This repository contains a GitHub Actions workflow and helper scripts to:
- Import Apple Developer signing certificates into a keychain using
apple-actions/import-codesign-certs. - Automatically sync Development provisioning profiles with all registered devices via App Store Connect API.
- Read a TOML config of signing tasks.
- For each task: download the IPA (from direct URL or GitHub Release), re-sign with Fastlane
resignusing synced profiles, and upload to an Assets server viascp. - Intelligent caching: Only rebuild IPAs when releases are updated or devices change, reducing workflow runtime and costs.
.github/workflows/sign-and-upload.yml— the workflow (manual, webhook, and scheduled triggers)scripts/sync_profiles.rb— syncs provisioning profiles with all devices via App Store Connect APIscripts/run_signing.py— processesconfigs/tasks.toml, re-signs, uploads (with GitHub API integration)scripts/check_changes.py— detects changes to determine which tasks need rebuildingconfigs/tasks.toml— TOML config defining signing tasksconfigs/tasks.toml.example— example configuration file.env.example— example environment variablesGemfile— Ruby dependencies (spaceship, toml-rb)
Set these at Repository → Settings → Secrets and variables → Actions:
APPLE_DEV_CERT_P12_ENCODED— Base64-encoded Apple Developer signing P12 certificateAPPLE_DEV_CERT_PASSWORD— Password for the P12 certificate
ASC_KEY_ID— App Store Connect API Key ID (e.g.,ABC123XYZ)ASC_ISSUER_ID— App Store Connect API Issuer ID (UUID format)ASC_PRIVATE_KEY— Base64-encoded.p8private key content
Generate API keys at: https://appstoreconnect.apple.com/access/api (requires "Developer" role)
ASSETS_SERVER_IP— SSH server IP or hostnameASSETS_SERVER_USER— SSH usernameASSETS_SERVER_CREDENTIALS— SSH password
DEBUG_SSH_PUBLIC_KEY— SSH public key for debug mode (only required whendebug=true)
The workflow automatically creates/updates Development provisioning profiles via App Store Connect API, including:
- All registered iOS and macOS devices
- All available Development certificates
Provisioning profiles are downloaded to work/profiles/ and used directly for signing. If profile sync fails, the entire workflow will fail.
Edit configs/tasks.toml and add entries like:
[[tasks]]
task_name = "MyApp"
app_name = "My App"
bundle_id = "com.example.myapp"
ipa_url = "https://example.com/path/to/MyApp.ipa"
asset_server_path = "/var/www/assets/ipas/"[[tasks]]
task_name = "AnotherApp"
app_name = "Another App"
bundle_id = "com.example.anotherapp"
repo_url = "https://github.com/owner/repo"
release_glob = "*.ipa" # Optional, default: "*.ipa"
use_prerelease = false # Optional, default: false
asset_server_path = "/var/www/assets/"Required fields:
task_name— Identifier for this task (used for profile lookup)app_name— Human-friendly name (used for profile naming: "{app_name} Dev")bundle_id— iOS Bundle Identifier (must exist in Apple Developer Portal)- Either
ipa_urlORrepo_url(mutually exclusive)ipa_url— Direct download URL of the IPA (always rebuilds)repo_url— GitHub repository URL (enables version tracking and caching)
asset_server_path— Destination path on asset server (if ends with/, filename is appended)
Optional fields for GitHub Release tracking:
release_glob— Pattern to match release assets (default:*.ipa)use_prerelease— Whether to use prerelease versions (default:false)- If
true, fetches latest prerelease; falls back to latest stable if none exist - If
false, fetches only latest stable release
- If
See configs/tasks.toml.example for more details.
- Scheduled: Daily at 02:00 UTC (keeps cache fresh and auto-processes new releases)
- Manual: Workflow Dispatch inputs:
debug— Enable Cloudflare Tunnel for SSH debugging (default:false)force_rebuild— Force full rebuild ignoring cache (default:false)
- Webhook:
repository_dispatchwith typesign_ipas
Example repository_dispatch payload:
{
"event_type": "sign_ipas",
"client_payload": {}
}- Restore Cache: Restores cached device lists and release versions from previous runs
- Import Certificates: Uses
apple-actions/import-codesign-certsto import P12 into temporary keychain - Check Entitlements Profile: Ruby script (
sync_profiles.rb check) via Spaceship:- Fetches all enabled iOS devices
- Saves device list snapshot to cache for change detection
- Compares with cached device list to detect changes
- Verifies
tasks.tomlapps have corresponding provisioning profiles
- Check App Version: Python script (
check_changes.py):- Uses device-change status +
force_rebuildto decide whether to rebuild all - Checks GitHub release versions vs cache to decide which tasks need rebuilding
- Uses device-change status +
- Sync Entitlements Profile: Ruby script (
sync_profiles.rb) via Spaceship:- If device list changed → regenerates all provisioning profiles and downloads them
- If device list unchanged → downloads existing profiles and creates missing ones if needed
- Sign IPAs: Python script (
run_signing.py):- For
ipa_urltasks: Always downloads and rebuilds - For
repo_urltasks:- Fetches latest release via authenticated GitHub API
- Compares version with cache
- Only rebuilds if version or publish timestamp changed
- Re-signs with Fastlane using synced profile
- Uploads to asset server via
scp - Updates release cache with new versions
- For
- Save Cache: Saves updated cache state for next run
The workflow uses GitHub Actions cache to minimize unnecessary work:
-
Cache Storage:
work/cache/directory containing:device-list.json— Snapshot of registered devices with checksumrelease-versions.json— Tracked release versions and timestamps
-
Cache Lifetime: 7 days of inactivity (refreshed by daily scheduled runs)
-
Change Detection Logic:
- Device list changes → Full rebuild (all profiles regenerated, all IPAs re-signed)
- Release version changes → Rebuild only affected IPA
- Direct URL (
ipa_url) tasks → Always rebuild (no version tracking) - New tasks or first run → Always rebuild
-
Force Rebuild: Use the
force_rebuildinput to bypass cache and rebuild everything
- Runner:
macos-latest - Tools installed:
fastlane,sshpass,curl(Homebrew), Ruby gems (spaceship,toml-rb) - Signing: Uses Fastlane
resignaction with discovered identity and synced profile - Upload: Password-based
scpto asset server - Bundle IDs: Must be pre-registered in Apple Developer Portal
- GitHub Token: Workflow automatically uses
GITHUB_TOKENfor authenticated API access- Provides 1,000 requests/hour per repository (vs 60/hour unauthenticated)
- Avoids shared runner IP rate limiting
- No additional configuration required (default
contents: readpermission)
If debug is enabled for a manual run (workflow_dispatch), the workflow will:
- Enable macOS SSH service on port 22.
- Disable password authentication and enable public key authentication.
- Write the provided
DEBUG_SSH_PUBLIC_KEYto~runner/.ssh/authorized_keys. - Install
cloudflaredand runcloudflared --no-autoupdate --url ssh://localhost:22in the foreground.
Use the private key that corresponds to DEBUG_SSH_PUBLIC_KEY to connect to the printed trycloudflare.com hostname. The tunnel runs in the foreground and keeps the job alive until you exit or cancel the run.
This project uses uv for Python dependency management.
- Python 3.11+
- uv installed
-
Install uv (if not already installed):
curl -LsSf https://astral.sh/uv/install.sh | sh -
Sync dependencies:
uv sync
This will:
- Create a virtual environment at
.venv/ - Install all project dependencies
- Install development dependencies (pytest, mypy, black, isort)
- Create a virtual environment at
-
Run scripts:
# Run check_changes.py uv run python scripts/check_changes.py # Run run_signing.py uv run python scripts/run_signing.py
-
Run tests (when available):
uv run pytest
-
Format code:
# Format with black uv run black scripts/ # Sort imports with isort uv run isort scripts/ # Type check with mypy uv run mypy scripts/
- Fast: 10-100x faster than pip
- Reliable: Lockfile ensures reproducible installs
- Simple: Single tool for virtual environments and dependencies
- Compatible: Works with standard
pyproject.toml
actions/checkout@v5astral-sh/setup-uv@v5apple-actions/import-codesign-certs@v2
These are selected based on current docs and should be kept up to date.