This repository contains a GitHub Actions workflow and helper scripts to:
- Automatically sync Development provisioning profiles with all registered devices via App Store Connect CLI (
asc). - Read a TOML config of signing tasks.
- For each task: download the IPA (from direct URL or GitHub Release), re-sign with
zsign(directly from the P12 certificate, no Keychain) using synced profiles, and upload to an Assets server viascp. - Generate the itms-services manifest: after each re-sign, read the signed IPA's actual bundle id + version and emit
itms.plistnext to the IPA. - Refresh the download page: update existing app versions / add new app cards in
site/apps.js, bump the Cloudflare cache-buster, deploy the page, and commit the change back. - 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_asc.py— syncs provisioning profiles with all devices via App Store Connect CLIscripts/run_signing.py— processesconfigs/tasks.toml, re-signs, generatesitms.plist, uploads, refreshes the download pagescripts/site_update.py— merges signing results into the download page data (site/apps.js) and bumps cache-busting versionsscripts/check_changes.py— detects changes to determine which tasks need rebuildingconfigs/tasks.toml— TOML config defining signing tasks (and the optional[site]publishing settings)configs/tasks.toml.example— example configuration file.env.example— example environment variablessite/— the ITMS download page (auto-maintained by the pipeline; seesite/README.md)
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 CLI (asc), including:
- All enabled iOS devices (iPhone and iPad classes)
- 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
- Install zsign: Downloads
zsign's official prebuilt Linux binary (pinned viaZSIGN_VERSION, checksum-verified). The staticmuslbuild has no runtime dependencies, andzsignsigns straight from the P12 — no Keychain involved - Check Entitlements Profile: Python script (
sync_profiles_asc.py check) via App Store Connect CLI:- 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: Python script (
sync_profiles_asc.py) via App Store Connect CLI:- 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
zsignusing the P12 certificate and synced profile - Uploads the signed IPA to the asset server via
scp - Generates
itms.plistfrom the signed IPA's actual bundle id + version and uploads it next to the IPA - Refreshes the download page (
site/apps.js+index.htmlcache-buster) and deployssite/to the docroot - Updates release cache with new versions
- For
- Commit Download Page: Commits the refreshed
site/apps.js/index.htmlback to the repo (keeps it the single source of truth) - 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:
ubuntu-latest—zsignsigns via OpenSSL (not Apple'scodesign/Security.framework), so the whole pipeline runs on Linux (≈10× cheaper than a macOS runner) - Tools installed:
zsign(prebuilt static Linux binary),asc(App Store Connect CLI, prebuilt Linux binary),sshpass(viaapt) — all checksum-verified where downloaded - Signing: Uses
zsignwith the P12 certificate and synced profile (no Keychain / codesign identity required) - 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:
- Write the provided
DEBUG_SSH_PUBLIC_KEYto~runner/.ssh/authorized_keys. - Start a throwaway
dropbearSSH server on127.0.0.1:2222— public-key auth only (password auth disabled), with a per-run host key. - Download
cloudflaredand runcloudflared --no-autoupdate --url ssh://localhost:2222in the foreground, which prints atrycloudflare.comhostname.
Connect with the private key matching DEBUG_SSH_PUBLIC_KEY, tunnelling raw TCP through Cloudflare (end-to-end encrypted, no third-party SSH relay):
ssh -o ProxyCommand='cloudflared access tcp --hostname <printed-host>.trycloudflare.com' runner@localhostThe 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@v6astral-sh/setup-uv@v8actions/cache@v5
These are selected based on current docs and should be kept up to date.