Build unsigned iOS applications, or deploy signed releases to TestFlight, from Linux, WSL, or Windows using a narrowly scoped remote macOS build. This is a truthful open-source remote-build/orchestration project derived from MobAI-App/ios-builder, not a generic compute service or a disguised workload.
The central backend lets multiple private application repositories use one public builder repository without committing private source or publishing plaintext output:
private app working tree
-> temporary private refs/ios-builder/jobs/<private-namespace>/<uuid>
-> public dispatch with opaque project ID only
-> secret registry resolution + immediate metadata masks
-> repository-scoped GitHub App checkout
-> unsigned iOS build with private console redirection
-> AGE-encrypted IPA + log artifact
-> local download, decryption, IPA validation, and cleanup
The optional TestFlight path adds two clean hosted-runner boundaries. The build
job encrypts untrusted project output to an isolated packaging job. That job
executes no project code, recreates the IPA under a trusted temporary root, and
uses GitHub Sigstore to attest the exact ciphertext and provenance manifest.
The apple-production signing job starts automatically, verifies that
attestation before any Apple credential is injected, then signs and uploads
directly to App Store Connect. GitHub stores no signed IPA artifact.
The original repository backend remains available for existing MobAI users and repository-local builds. Simulator sharing, MobAI integration, Flutter/React Native/KMP development commands, framework detection, working-tree snapshots, signing tools, and public Go wrappers are retained. See the upstream relationship.
Important
GitHub's hosted-runner terms contain a repository-association limitation, and GitHub has not explicitly approved this exact cross-repository architecture. Read COMPLIANCE.md before using the central hosted-runner backend. The backend boundary is intentionally replaceable by a self-hosted Mac, Codemagic, or another macOS CI implementation.
- Private source is pushed only to the private source repository, under a temporary non-branch ref.
- The GitHub App has Metadata read and Contents read only and is installed on explicitly selected repositories.
- The build job requests an installation token for exactly one registry-authorized source repository, checks out with
persist-credentials: false, then revokes the token before project code runs. - Unsigned build remains the default; Apple credentials are available only to the branch-restricted
apple-productionjob. - The signing job never checks out private source and never runs project scripts or dependencies.
- Detailed dependency/compiler output is redirected to a private log from process start.
- Build logs and locally downloaded IPAs are encrypted to the caller's local-only AGE identity. TestFlight uses distinct build-to-package and package-to-signing AGE identities.
- Public artifacts contain exact allowlists of ciphertext/manifest files, are retained for one day, and are deleted early when the caller completes cleanup. No plaintext or signed IPA is uploaded.
- Central mode creates no Actions caches and uploads no DerivedData, dSYM, archive, source, or plaintext diagnostics.
- The public schema contains only build ID, opaque project ID, operation, and public artifact recipient. Registry values are validated and masked before credential creation; build commands use fixed argv arrays, never
evalor user-provided scripts. - Full UUIDv4 correlation plus authenticated project ID, operation, builder commit, and workflow ref bind the TestFlight artifact to one trusted packaging execution.
These controls protect against accidental public disclosure; they do not sandbox intentionally malicious private project code or hide plaintext from GitHub's active runner. Read the full threat model.
Local workstation (Linux/WSL/macOS; Windows through PowerShell/WSL):
- Git 2.30+
- GitHub CLI (
gh) authenticated to the source and builder repositories, recommended - A Git remote using an explicit
github.comHTTPS or SSH URL - Builder CLI
The public builder workflow uses the stable macos-15 runner image. No local Mac, Xcode, certificate, or provisioning profile is required for an unsigned central build. TestFlight deployment requires an Apple Distribution certificate, one or more App Store distribution provisioning profiles, and an App Store Connect API key.
From a published release:
INSTALLER_COMMIT=4138a8cedfd986540035e5e284e9118f88af4f8c
curl -fsSLo install-builder.sh \
"https://raw.githubusercontent.com/ori2015/ios-cloud-builder/$INSTALLER_COMMIT/install.sh"
echo 'c4cc7d0f927fabee7b0087cc8e119d0627315250b55723c3627e8c5ebcb170cc install-builder.sh' \
| sha256sum -c -
bash install-builder.sh
rm install-builder.shOn macOS, use shasum -a 256 -c - in place of sha256sum -c -.
Or build from source with Go 1.24 or newer:
git clone https://github.com/ori2015/ios-cloud-builder.git
cd ios-cloud-builder
go build -o builder ./cmd/builder
install -m 0755 builder ~/.local/bin/builderWindows users can download builder-windows-amd64.exe from Releases and place it on PATH.
Authentication resolution order is BUILDER_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN, an authenticated gh, then the legacy Builder credential store. Tokens are never written to builder.json or logged. Usually this is enough:
gh auth login
gh auth statusThe legacy device flow remains available for repository-mode compatibility:
builder auth githubThis repository itself is the public builder. Fork it publicly without rewriting history, retain .github/workflows/ios-build.yml, and restrict write access to trusted operators.
Create a dedicated GitHub App in Settings -> Developer settings -> GitHub Apps -> New GitHub App with these exact settings:
| Setting | Value |
|---|---|
| GitHub App name | ios-cloud-builder-<your-account> (globally unique) |
| Homepage URL | URL of your public builder repository |
| Webhook | Inactive |
| Repository permissions: Contents | Read-only |
| Repository permissions: Metadata | Read-only (implicit) |
| Every other repository permission | No access |
| Organization permissions | No access |
| Where can this GitHub App be installed? | Only on this account |
Generate one private key. In the public builder repository, configure:
Repository variable APP_CLIENT_ID = GitHub App client ID
Repository secret APP_PRIVATE_KEY = complete generated PEM private key
Install the App using Only select repositories and choose only private applications this builder may read. Do not grant Actions, Issues, Pull requests, Administration, or write permissions. Delete the downloaded PEM after the repository secret is verified, or retain a recovery copy only in an appropriate secrets manager. Never commit it.
Protect the builder's default branch, require review for CODEOWNERS paths, keep administrators minimal, and leave the default workflow token permission at read-only.
Create a GitHub Environment named apple-production in the public builder.
Do not configure required reviewers or a wait timer. Restrict deployment to the
protected default branch and do not allow unreviewed workflow changes. After a
successful trusted packaging job, signing and TestFlight deployment start
automatically. Apple credentials remain scoped to the Environment and are made
available only to the isolated signing job.
Generate two dedicated AGE identities: one for the untrusted build-to-package
transport and one for package-to-signing transport. Put the first public
recipient in repository variable PACKAGING_RECIPIENT and its identity in
repository secret PACKAGING_AGE_IDENTITY. Put the second public recipient in
APPLE_SIGNING_RECIPIENT and its identity only in the protected Environment
secret APPLE_SIGNING_AGE_IDENTITY. These identities must be distinct from one
another and from every caller's local artifact identity.
The isolated trusted-package job has no private checkout and executes no
project code. It decrypts and validates project output, copies the .app into a
fresh temporary root, creates the exact unsigned IPA, hashes plaintext and
ciphertext, and emits provenance.json. GitHub's pinned actions/attest action
then signs both ciphertext and manifest using a short-lived Sigstore certificate
issued from that job's OIDC identity. No long-lived provenance signing key is
required. The protected signing job verifies repository, workflow, commit, ref,
hosted-runner origin, manifest fields, artifact allowlist, and both digests
before its Apple-secret step can run.
Put the Apple transport identity's
public recipient in the repository variable APPLE_SIGNING_RECIPIENT; put the
identity itself only in the Environment secret APPLE_SIGNING_AGE_IDENTITY.
Never reuse the caller's local AGE identity and never commit the transport
identity. For example, on a trusted workstation with age-keygen installed:
umask 077
age-keygen -o /tmp/apple-signing.agekey
age-keygen -y /tmp/apple-signing.agekeyCopy the printed age1... recipient into the repository variable. Copy the
complete AGE-SECRET-KEY-... line into the Environment secret, then securely
remove the temporary file.
Configure these values in apple-production:
| Kind | Name | Format |
|---|---|---|
| Environment variable | APPLE_TEAM_ID |
10-character Apple Team ID |
| Environment secret | TESTFLIGHT_BETA_GROUPS |
optional JSON map of Bundle IDs to existing beta groups; secret-scoped so GitHub masks the map before the signing step starts |
| Environment secret | APPLE_SIGNING_AGE_IDENTITY |
dedicated AGE identity |
| Environment secret | APPLE_DISTRIBUTION_P12 |
base64-encoded .p12 |
| Environment secret | APPLE_DISTRIBUTION_P12_PASSWORD |
.p12 password |
| Environment secret (optional fallback) | APPLE_PROVISIONING_PROFILES |
base64-encoded ZIP of App Store .mobileprovision files |
| Environment secret (legacy optional fallback) | APPLE_PROVISIONING_PROFILE |
base64-encoded single App Store .mobileprovision; used when the bundle secret is absent |
| Environment secret | ASC_API_KEY_P8 |
complete .p8 PEM or its base64 encoding |
| Environment secret | ASC_KEY_ID |
App Store Connect API key ID |
| Environment secret | ASC_ISSUER_ID |
App Store Connect issuer UUID for a Team key; omit for an Individual key |
The central TestFlight path requires a Team App Store Connect key with
ASC_ISSUER_ID. Inside the protected apple-production job, the signer lists
active App Store profiles for the exact Bundle ID, verifies them against the
imported distribution-certificate fingerprint, and creates an IOS_APP_STORE
profile through Apple's API when none is usable. The downloaded profile exists
only in the disposable signing workspace. Stored profile secrets remain optional
fallbacks for API incidents or migrations. The Team key applies across apps, so
keep the exact Environment branch allowlist in place.
Revoke and replace any certificate, API key, or GitHub App key that was ever
committed, pasted into a public log, or otherwise exposed; moving an exposed
credential into a secret does not make the old credential safe.
If API-managed provisioning is temporarily unavailable, create the optional
multi-application profile bundle on a trusted workstation. Filenames are only
labels: the protected runner selects a profile from its signed
application-identifier entitlement and requires an exact Bundle ID match.
Keep only App Store distribution profiles for APPLE_TEAM_ID in this ZIP.
To wait for processing and publish selected applications to existing TestFlight
groups, configure the TESTFLIGHT_BETA_GROUPS Environment secret in
apple-production. It must be a secret rather than an Actions variable because
GitHub renders step environment variables before in-step masking can run. Both
immutable group ID and public-link ID are required and verified before mutation:
{
"com.example.app": {
"group_id": "00000000-0000-0000-0000-000000000000",
"public_link_id": "abcd1234",
"submit_beta_review": true
}
}Applications absent from the map retain upload-only behavior. Configured builds are selected by exact app, marketing version, and build number; the runner waits up to 40 minutes for Apple processing, updates What to Test, attaches the build additively, preserves the public link, and submits external builds for Beta App Review when prerequisites are complete.
umask 077
zip -j /tmp/apple-provisioning-profiles.zip /trusted/profiles/*.mobileprovision
base64 < /tmp/apple-provisioning-profiles.zip | gh secret set APPLE_PROVISIONING_PROFILES --env apple-production --repo YOUR_ACCOUNT/ios-cloud-builderSecurely remove the temporary ZIP after setting the secret. During migration,
both profile secrets may exist; candidates from both are considered. Once at
least one deployment per configured Bundle ID succeeds, the legacy
APPLE_PROVISIONING_PROFILE secret can be removed. The bundle is limited by
GitHub's Environment-secret size limit; split certificates/teams across
separate protected builders if the compressed profiles no longer fit.
Verify metadata without reading secret values:
builder central doctor --testflightbuilder ios test (central backend, native Xcode or XcodeGen projects) builds the private snapshot for the newest installed
iOS Simulator and runs xcodebuild test. Nothing is signed, no IPA is produced, and the encrypted log is decrypted to dist/.
The deployment target is lowered to the simulator runtime for this run only, so projects targeting a newer OS than the runner's
Xcode can still execute their tests.
From each authorized private application:
cd /path/to/private-app
builder central setup --builder YOUR_ACCOUNT/ios-cloud-builder
builder central register
builder central doctorcentral setup detects the source GitHub repository, project type, and common iOS path; creates/reuses a local AGE identity; and writes a configuration like:
{
"project": "MyApp",
"project_id": "p_0123456789abcdef0123456789abcdef",
"snapshot_namespace": "11111111111111111111111111111111",
"platform": "ios",
"backend": "central",
"github": { "owner": "SOURCE_OWNER", "repo": "PRIVATE_SOURCE_REPO" },
"builder": {
"owner": "YOUR_ACCOUNT",
"repo": "ios-cloud-builder",
"workflow": "ios-build.yml"
},
"security": { "recipient": "age1..." },
"ios": { "path": "ios", "scheme": "", "configuration": "Debug" }
}central register creates a random 128-bit opaque handle and a separate private
128-bit snapshot namespace, updates the protected
PROJECT_REGISTRY Actions secret, and keeps a mode-0600 local registry backup
so additional private projects can be added without manually editing secret
JSON. Never commit that backup. GitHub cannot return secret plaintext, so retain
the backup when managing multiple projects; losing it requires rebuilding and
re-registering the complete mapping before replacing the secret.
Only the public AGE recipient, opaque handle, and private snapshot namespace are
stored in the private application's builder.json. The namespace never appears
in public dispatch inputs and prevents the public build UUID from revealing the
temporary Git ref.
The private identity is kept in the OS keyring when usable or under the user's
configuration directory with 0600 permissions. To initialize it explicitly:
builder security initExisting upstream builder.json files migrate automatically to backend: repository. Adding valid builder and security fields without a backend migrates to central. builder init --backend central --builder OWNER/REPO is the interactive alternative and deliberately does not create .github/workflows in the private repository.
cd /path/to/any/authorized/private-ios-project
builder ios buildThe command snapshots staged, unstaged, and untracked non-ignored files without modifying the branch, real index, or working tree. It pushes only the temporary private ref, dispatches the public builder, shows high-level progress, downloads ciphertext, decrypts locally, validates the IPA ZIP and app structure, and writes:
Regular files larger than 45 MiB are represented in that temporary ref as verified 40 MiB transport chunks. The trusted runner reconstructs them after revoking the private-repository token, so generated Unity/Xcode files can exceed GitHub's 100 MB per-blob limit without Git LFS. The transport namespace is removed before framework detection or project code runs.
./dist/MyApp.ipa
The private ref and encrypted public artifact are deleted best-effort. A failure automatically downloads and decrypts diagnostics under dist/. If retrieval was interrupted, retry while the one-day artifact still exists:
builder ios logs <build-uuid>Remove abandoned refs older than the default 24 hours:
builder cleanup
builder cleanup --older-than 48hSnapshot creation respects .gitignore. An untracked secret that is not ignored will be included in the temporary private snapshot, so review ignore rules before building.
To create a Release build and upload it to TestFlight:
builder ios deploy
# equivalent:
builder ios build --testflightThe command's default three-hour timeout includes build, signing, upload, and
configured App Store Connect processing; it can be changed with --timeout.
No signed IPA is downloaded or retained as a GitHub artifact.
The protected job queries App Store Connect for the application's existing builds
under the current marketing version, increments the highest leading build-number
component, and replaces only CFBundleVersion before signing. This preserves one
monotonic sequence across legacy and central workflows instead of depending on a
workflow-local GitHub run counter. The application's
CFBundleShortVersionString is preserved. Automatic numbering requires
ASC_ISSUER_ID. The job validates the signed IPA with App Store Connect before
uploading it.
- Native Swift/Objective-C iOS projects and workspaces
- Flutter
- React Native and ejected Expo
- Kotlin Multiplatform iOS applications
- Cordova/Ionic generated iOS projects
- XcodeGen manifests
Schemes and workspaces/projects are detected where possible. The source build always passes CODE_SIGNING_ALLOWED=NO and packages the device .app as an unsigned IPA. In TestFlight mode, the separate protected job manually signs that app with the matching App Store provisioning profile from the protected multi-application bundle. Embedded bundles are signed inside-out in the same job: frameworks and dylibs with the distribution identity only, then app extensions (PlugIns/*.appex), Watch apps (Watch/*.app, including their own extensions), App Clips (AppClips/*.app) and XPC services (XPCServices/*.xpc), then the main app last. Each extension, Watch app and App Clip gets its own provisioning profile for its exact Bundle ID (<registered app id>.<suffix>; the registered Bundle ID remains the only identity named in the registry), selected, verified against the imported distribution certificate, or created through the App Store Connect API exactly as for the main app, and its entitlements come from that profile. The Bundle IDs of the extensions must already exist in the Apple developer portal with the capabilities they need (for example the same App Group as the main app). Any bundle type or location that is not understood, a nested identifier outside the registered prefix, a symlink, or an unbounded tree is rejected rather than partially signed.
builder-runner plan-signing --ipa /absolute/path/App.ipa [--expected-bundle-id ID] [--adhoc] [--json] extracts an unsigned IPA into a private temporary directory and prints every nested bundle, its identifier, the profiles that would be required and the signing order. It reads no credential, makes no network request and runs nothing from the IPA. The output names Bundle IDs, so run it locally and do not paste it into public logs for a private application.
Set "backend": "repository" to retain the original behavior where workflows live in the application repository. This mode continues to support repository-local signing and simulator sharing.
builder init
builder ios build
builder ios share
builder signing csr
builder signing p12
builder signing setup
builder dev flutter
builder dev rn
builder dev kmp
builder mobai ping
builder updatebuilder ios share and builder signing setup remain repository-backend features. Central TestFlight credentials are configured only in the public builder's protected Environment; the CLI never downloads them.
builder central doctor verifies, without printing credentials:
- Git and local GitHub authentication source
- config schema and source/builder separation
- public builder and private source API access
- central workflow availability
APP_CLIENT_IDvariable andAPP_PRIVATE_KEYsecret metadataPROJECT_REGISTRYsecret metadata- matching local AGE identity
- explicit GitHub source remote
- dry-run permission to push the temporary snapshot namespace
With --testflight, it also requires the Team-key ASC_ISSUER_ID; provisioning
profiles themselves are fetched or created by the protected signer and are not
required as GitHub secrets.
With --testflight, doctor additionally verifies metadata for
PACKAGING_RECIPIENT, PACKAGING_AGE_IDENTITY,
APPLE_SIGNING_RECIPIENT, the apple-production Environment, APPLE_TEAM_ID,
every required Environment secret, either APPLE_PROVISIONING_PROFILES or the
legacy APPLE_PROVISIONING_PROFILE, the absence of a manual approval rule, and
an exact custom branch allowlist containing only the builder's default branch.
It cannot verify secret values or profile coverage.
See docs/UPSTREAM.md for the preserved upstream baseline and merge procedure. Before submitting:
go test ./...
go test -race ./...
go vet ./...
go build ./cmd/builder
go build ./cmd/builder-runner- A one-time GitHub App browser setup and private-repository selection cannot be completed safely by the CLI alone.
- The opaque project ID, random build ID, operation, and caller AGE public recipient are public workflow inputs. Private repository identity, private snapshot namespace and ref, iOS path, scheme, workspace/project name, and Bundle ID are not dispatch inputs; trusted code masks resolved values before GitHub-native actions use them.
- When the private source and public builder share one GitHub account, that account name is unavoidably public as the builder repository owner. Use a separate public-builder account if the owner name itself must be hidden; the private repository name and full owner/repository pair remain protected.
- A malicious project or dependency runs as the runner user and is not strongly sandboxed; it can intentionally leak or alter its own pre-boundary output. The clean packaging job prevents post-boundary substitution but does not prove that source/dependencies were benign.
- The central hosted-runner design has the policy caveat described in COMPLIANCE.md.
- Central TestFlight and ad hoc signing support embedded app extensions, Watch apps, App Clips, frameworks and XPC services, but this has not yet been verified end to end on a real macOS runner against App Store Connect; it is covered by unit tests with a faked
codesign/security/ASC API only. Nested bundles are signed with exactly the entitlements their provisioning profile grants (plusget-task-allow=falseand the team identifier); entitlements declared only in the project, including App Groups that are not on the Bundle ID's profile, are not added, and the project-requested Associated Domains narrowing applies to the main application only. Bundle IDs of nested bundles must be registered in the developer portal beforehand (the builder creates profiles, not Bundle IDs or capabilities). Watch apps use iOS-platform App Store profiles; that assumption is unverified. Every nested bundle'sCFBundleVersionis set to the TestFlight build number;CFBundleShortVersionStringis not modified. - Applications without
TESTFLIGHT_BETA_GROUPSretain upload-only semantics: acceptance does not mean Apple's asynchronous processing or review has completed. - Private GitHub SSH aliases are rejected in central mode because the CLI cannot prove an alias resolves to GitHub; use an explicit
git@github.com:OWNER/REPO.gitorhttps://github.com/OWNER/REPO.gitremote. - Failed artifact deletion is non-fatal; ciphertext expires after one day.
MIT. The original license and Git history are retained. See LICENSE and docs/UPSTREAM.md.