profiles and signing: EAS-style build profiles, one secret set per distribution, automatic provisioning - #23
Closed
Interlap01 wants to merge 81 commits into
Closed
Interlap01 wants to merge 81 commits into
Interlap01 wants to merge 81 commits into
Conversation
The export step wrote method = development into every ExportOptions.plist, so an Ad Hoc profile failed the export and App Store Connect rejected the IPA. Both templates now read the decoded profile they already parse for the team and profile name: ProvisionsAllDevices means enterprise, ProvisionedDevices with get-task-allow means development and without it ad-hoc, and a profile with no devices is app-store. The legacy method names are used because Xcode 16 still accepts them while older Xcodes reject the Xcode 15.3+ spellings. Distribution exports also set manageAppVersionAndBuildNumber = false so Xcode leaves the archive's version numbers alone, and a distribution profile combined with the Debug configuration now fails in the signing step: a Debug archive carries get-task-allow, which those profiles do not grant. The decision is duplicated verbatim in both templates, so the test compares the two function bodies and runs the shared one against synthetic profile plists.
The conditional printf argument emitted a blank line inside <dict> on every development export. XML tolerates it, but the plist no longer needs the indirection: write the fixed options, then insert the key with plutil, which also lints the file on the way. The result is byte-identical in content to the plist runner.sh builds with plistlib. Also stop claiming the runner's signing check fails "in seconds" — prepare() has already installed pods and dependencies by then; it fails before the compile, which is the point. Extend the template test so it fails if either template stops feeding the detected method into ExportOptions.plist, not only if someone re-hardcodes the string "development".
provider-secrets.md still told readers the generated runner exports development-signed IPAs and that App Store or Ad Hoc export "requires a corresponding change to the generated runner's export settings". Both are now wrong: the runner follows the profile. Point at the Release configuration requirement instead, and shorten the CLAUDE.md bullet.
A profiles map in builder.json, keyed by name, overrides ios.configuration, ios.scheme, ios.signing and provider, and adds env and the reserved distribution field. ResolveProfile applies the named profile, or defaultProfile, over the top-level settings; with neither the result is the top-level settings unchanged. Unknown names list the available profiles, and env names that are not identifiers or clash with the runner's own parameters are rejected. Signing is a *bool so a profile's false can override a top-level true.
The coordinator resolves the profile, layers --unsigned and --provider on top, and prints the resolved settings before anything is dispatched. Input assembly moves into buildInputs/workflowInputs (GitHub) and inputs (Codemagic/Bitrise) so the mapping is testable. The profile reaches GitHub as one JSON input, profile, sent only when a profile is selected so older workflow files keep working; runner.sh receives BUILD_ENV and DISTRIBUTION variables. The CLI resolves the effective provider first so the GitHub client and signal handling follow a profile that names a provider.
Resolve parameters takes the profile input on dispatch and, on a tag push, the profile named by defaultProfile in builder.json, letting its fields override ios.* (with an explicit null test for signing, since jq's // treats false as missing). The profile's env is written to GITHUB_ENV before the dependency and build steps, and runner.sh exports BUILD_ENV at the start of prepare(). Keys and values are base64 per entry: jq drops NUL bytes, and a key containing a space must not split into a valid name. distribution is validated and exposed as an output for the export step.
…ile env PATH, HOME, DEVELOPER_DIR, the IOS_* signing secrets, MOBAI_API_KEY and the GITHUB_/RUNNER_/ACTIONS_/CM_/FCI_/BITRISE_/BUILDER_ prefixes are rejected alongside the runner parameters: runner.sh exports the profile env before install_signing reads its secrets from the environment. A defaultProfile that names a missing profile now says so instead of reading as a --profile typo.
…input GitHub answers 422 'Unexpected inputs provided' when the committed workflow predates profiles; the error now says to run builder init and push. ios share drops distribution along with configuration and signing, since the simulator job ignores it. Pin the no-profile shape of the runner variables and the share inputs against the pre-profile code.
A value line equal to the fixed __BUILDER_ENV__ delimiter ended the value early and let the rest be read as new variables. A non-object env failed jq inside a process substitution, which set -e cannot see, so nothing was exported and the job carried on; Resolve parameters and runner.sh now fail with a message.
ES256 JWT auth from the .p8 key (15 minute tokens, cached and refreshed before expiry), generic JSON:API documents with pagination over links.next, typed decoding of the errors[] array, and retries on 429 for every method and on 5xx for idempotent ones only. Typed helpers cover what upload and submit need: apps by bundle ID, builds (list/filter, processing state, export compliance, beta group linkage), the buildUploads/buildUploadFiles chunked delivery with state polling, beta groups, beta build localizations, beta app review submissions, App Store versions and review submissions. Everything runs from the developer's machine; the runner is not involved.
builder auth apple saves the issuer ID, key ID and .p8 key as one secret through the same keyring/file storage the CI tokens use, after checking the key against the API. Flags left out are prompted for on a terminal; without one the command asks for the flags or the ASC_ISSUER_ID, ASC_KEY_ID and ASC_PRIVATE_KEY/ASC_KEY_PATH variables, which always take precedence so CI jobs and agents need no keychain. auth status and auth logout apple cover the new login.
builder ios upload reads the bundle ID, version, build number and ITSAppUsesNonExemptEncryption from the newest IPA in dist/ (or --ipa), resolves the app and runs the buildUploads flow: create the delivery, reserve the file, PUT the chunks to the presigned URLs with their request headers, commit. With --wait it polls the delivery until COMPLETE, then the build until VALID, surfacing App Store Connect's error details on failure, and answers the export compliance question when the plist declares no non-exempt encryption or --no-encryption is given. --json prints the result for agents. Info.plist reading moves from internal/dev into internal/ipa so both the dev session and the upload share it.
builder ios submit --testflight picks the newest VALID build (or --build-number), sets the What to Test notes in the app's primary locale, submits the build for beta review when a chosen group is external and adds it to the named groups; without --group it reports the build and lists the groups. --wait follows the beta review decision. builder ios submit --app-store finds or creates the App Store version for the marketing version, attaches the build, sets the release type, reuses an open review submission or creates one, adds the version and submits it. App Store Connect's 409/422 state errors, nearly always incomplete metadata, are rewritten with a hint to finish it in App Store Connect or with asc-cli.
README gets a TestFlight and App Store section after Code Signing covering the API key, upload, TestFlight and App Review steps, the build number and export compliance rules, and credits asc-cli as the reference that proved the Mac-free buildUploads path. CLAUDE.md documents the asc, distribute and ipa packages and the client, credential, upload, compliance and submit-order patterns.
gocritic's hugeParam flags BuildSettings (96 bytes) and BuildOptions (80 bytes) everywhere they are passed by value, and the repo config treats it as an error. Take *config.BuildSettings in the inputs, progress and remote helpers, give EnvJSON/ProfileInput pointer receivers, and have Coordinator.settings hand back a pointer; Build and buildRemote now take *BuildOptions. Both take a copy before filling in their defaults, so the caller's BuildOptions is left exactly as it was passed.
Every retry and poll now sleeps through Client.sleep, so the 429 test asserts the Retry-After it was handed instead of waiting a real second, and status polls grow 1.5x per round up to 4x the base interval. Remove ListApps, GetBuildUploadFile and Error.HasCode, which nothing called, and parse the private key once instead of twice on NewClient. The beta review wait moves into asc as WaitForBetaAppReview beside the other waits. Options structs over 80 bytes are passed by pointer, and the tests check their JSON type assertions, both of which golangci-lint v2.12.2 flags in CI.
finish took the result as any, so a typed nil pointer on failure was encoded as "null" on stdout in --json mode; a generic finish[T] sees the nil. The missing-credentials error now names the environment variables next to builder auth apple, and the key-rejected error is lower-case for staticcheck.
The provisioning resources behind automatic signing: find/register App IDs (filter[identifier] also matches prefixes, so the exact identifier is checked), list/issue certificates with the CSR as csrContent and the DER decoded from certificateContent, list/register iOS devices, and list by name, create and delete profiles. Profile membership is read from the paginated relationships endpoints rather than include=, which caps the linkage arrays.
… API signing.Auto finds or registers the App ID, reuses a valid certificate only when the matching private key is on this machine (otherwise no .p12 can be built, so a new one is issued; nothing is ever revoked), registers missing devices and puts every enabled iOS device into the profile, and recreates the Builder-managed profile only when it is missing, INVALID, expired, forced, or its certificate or device set changed. Apple's quota refusals for certificates and devices get an explanatory hint. CreateCSR is split out of GenerateKeyAndCSR so a CSR can be made for an existing key, and KeyMatchesCertificate exposes the check BuildP12 does.
builder signing setup without --certificate/--profile now provisions everything through signing.Auto: --bundle-id (else ios.bundleId, else the newest IPA in ./dist, else a prompt on a TTY), --type development|ad-hoc| app-store, --device / --devices-from-mobai, --key or the ios-signing.key a previous run left in --out-dir, --force, --yes, --password and --json. One confirmation shows the plan before anything is created; without a TTY --yes is required and the .p12 password is generated and printed once. GitHub gets the three IOS_* secrets and ios.signing flips as before; Codemagic and Bitrise get the file paths and docs/provider-secrets.md. The resolved bundle ID is saved as ios.bundleId, which init now also fills from PRODUCT_BUNDLE_IDENTIFIER when the Xcode project has exactly one app target. The manual --certificate/--profile path is unchanged.
README's Code Signing section leads with builder signing setup through the App Store Connect API (bundle ID resolution, certificate reuse rule, device and profile handling, idempotent reruns, --type app-store) and keeps the portal steps as the manual fallback. CLAUDE.md gains the command, flow diagram, module notes, the ios.bundleId field and the still-hardcoded development export method; provider-secrets.md points Codemagic/Bitrise users at automatic setup with --out-dir.
Auto issued the certificate before looking at devices and wrote the private key only after the profile existed. A development run with no device to cover, or any failure between the certificate POST and the file write, left a certificate on the account whose key was gone: Builder never revokes, so it occupied one of the two Development slots for a year. Devices are now resolved first, and a generated key is on disk before the CSR goes to Apple, so a failed run can be retried with the same key and the certificate it may have produced is reused.
MobAI also lists cloud farm devices (cloud: true) as physical iOS devices; their IDs are farm handles like awsdevicefarm:Apple_iPhone_16:26.0, which --devices-from-mobai would have sent to Apple as UDIDs. Decode the cloud flag, skip those, and require a UDID shape (40 hex, or 8-16 hex) for both MobAI-sourced and --device values so a typo fails here, not as an ASC 409.
The note described the hardcoded development export method as current; PR #17 derives it from the profile, so say what must hold and point there.
…ertificate count from the quota hint
A store build archived with DEVELOPMENT_TEAM, CODE_SIGN_STYLE=Manual and PROVISIONING_PROFILE_SPECIFIER but no CODE_SIGN_IDENTITY kept the project's default identity, and Xcode refused to pair it with the App Store profile: "No signing certificate iOS Development found". Development builds only worked because the default happened to match. Both templates now map the export method to the identity the profile needs (signing_identity: development -> Apple Development, ad-hoc/app-store/ enterprise -> Apple Distribution), export it the way EXPORT_METHOD and DEVELOPMENT_TEAM already travel, and pass it to every manually signed archive command. security find-identity right after security import fails the job by name when the set's certificate is not that kind, instead of after a five-minute archive.
Apple renamed the certificates in 2021, but a keychain may still hold an iPhone Distribution or iPhone Developer one, and those sign exactly the same profiles. Demanding the current name rejected a working certificate. The identity is now chosen from what the imported certificate actually goes by: signing_identities lists the names a profile type accepts, current one first, and signing_identity takes the first that appears in security find-identity output. Only a set holding neither form fails, with the ::error:: naming both names it looked for.
…ng on demand ios build --profile provisioned a missing set with the key from the working directory only, so after signing setup --out-dir ~/signing/app it found nothing, requested a second certificate and Apple answered 409 (one Development certificate per team). signing setup now records an --out-dir other than . as signing.dir in builder.json, as given with the tilde kept, and ensureSigningSecrets looks there first, then in ., and writes the material next to the key. When Apple refuses the certificate and no key was found, the error names the directories searched for ios-signing-<distribution>.key and suggests signing setup --distribution <d> --key <path> or --out-dir.
writeSigningKey checks the type assertion on the generated key, and signingSetupCommand loses its storeErr parameter, which every caller passed as nil.
A failed GitHub Actions run ended in "workflow failed with conclusion:
failure" and nothing else. PollForArtifact now returns a
github.RunFailedError carrying the first failed job and step from
ListRunJobs and the job's failure-level check-run annotations (GET
/repos/{o}/{r}/check-runs/{job_id}/annotations, a job ID being its check
run ID), which are the runner's ::error:: lines; they are printed under
the conclusion. Reading the details is best-effort, so the conclusion is
still reported when the annotations endpoint fails.
4 tasks
On Linux and WSL the login is written to a 0600 file in the config dir, not a keychain, so the auth apple confirmation was wrong there. Word it like the other provider logins instead.
Drop comments that only restated the function below them (getOne, post, patch, sleep, utiFor, logf, pollInterval, resolveIPA, getASCClient), cut the package doc for asc to what is not already in CLAUDE.md, and compress the six CLAUDE.md bullets this branch added to three lines each.
Every comment block this branch added is at most two sentences and says why, not what the next line does; the shell functions shared by ios-build.yml and runner.sh keep identical bodies. CLAUDE.md's new bullets are three lines of facts and gotchas each, and the README paragraphs that ran past eight lines are tightened.
# Conflicts: # CLAUDE.md
An app with a widget, share, notification, intents or watch extension
failed at the archive: only the app target was signed, and the extension
targets had no profile. Each extension is its own App ID at Apple and
needs a profile of its own.
ios.extensions in builder.json lists the extension bundle ids. init and
signing setup append what internal/xcodeproj finds in the local project
(every PBXNativeTarget of an extension product type, reading
PRODUCT_BUNDLE_IDENTIFIER from its configurations and skipping $(...)
values); a managed Expo project has no project locally, so its ids are
listed by hand.
signing.Auto registers an App ID and creates Builder <distribution>
<bundle id> for each entry, with the app's certificate and devices, and
reuses them on the next run like the app's. Manual mode takes one
--extension-profile per extension, matched to ios.extensions by the app
id inside each file (exact or wildcard, the more specific one winning),
of the app profile's type, and errors naming any extension without a
profile or profile without an extension.
The profiles travel in a fourth secret per set,
IOS_EXTENSION_PROFILES_<SET>: a JSON object of bundle id to base64
.mobileprovision, always written ({} for none) so a removed extension
leaves nothing behind. missingSigningSecrets requires it only when
ios.extensions is non-empty, and since a secret's contents cannot be read
back, ios build also re-provisions when the project has an extension the
config did not list yet.
Both runners now decode IOS_EXTENSION_PROFILES_<SET> (optional; the legacy path reads the unsuffixed name) in install_extension_profiles, install every profile next to the app's and hand EXTENSION_PROFILES, a JSON object of bundle id to profile name, to the build. apply_signing_to_app_target keeps the app path as it was and also writes the four manual settings into every extension-type target (the same product types internal/xcodeproj lists, checked by the test), choosing the longest EXTENSION_PROFILES entry whose app id covers the target's PRODUCT_BUNDLE_IDENTIFIER. An extension without one fails before the archive with ::error:: naming each target and bundle id, telling the user to add them to ios.extensions and rerun builder signing setup --distribution <d>; on a managed Expo project that runs on the project expo prebuild generated, so the ids to list come from the runner. write_export_options replaces the printf'd ExportOptions.plist in the GitHub workflow with the plistlib writer runner.sh had, shared verbatim, and adds one provisioningProfiles entry per extension. The workflow's fail now writes to stderr so a failure inside a command substitution is still seen.
Collaborator
Author
|
Folded into #22, whose branch carries these commits merged with main. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Includes #17, #19 and #21 (all closed, folded in here) and depends on #20. The branch starts from a merge of those; once #20 lands, this PR's diff shrinks to the profiles, export-method and signing commits.
One signing field, EAS-style:
distributionisdevelopment,ad-hoc(aliasinternal),storeorenterprise; omitted means an unsigned build.configurationderives from it (development → Debug, else Release) unless set. The profilesigningfield is gone; top-levelios.signingremains only for the legacy no-profile path with unsuffixed secrets.IOS_CERTIFICATE_<SET>,IOS_CERTIFICATE_PASSWORD_<SET>,IOS_PROVISIONING_PROFILE_<SET>withDEVELOPMENT,AD_HOC,STORE,ENTERPRISE. A repo can hold a development and a store set at once.builder signing setup --certificate x.p12 --profile x.mobileprovisionreads the type from the profile, uploads that set and writes the build profile intobuilder.json(--name, default the distribution). Without files it provisions through the App Store Connect API (bundle ID, certificate from a local CSR, devices from--deviceor--devices-from-mobai, profile), uploads and writes the profile. Codemagic/Bitrise: prints the names and paths and still writes the profile.builder ios build --profile Xon GitHub lists the repository's secret names. Missing set plus an ASC key: provisions non-interactively with the same function, uploads, then builds. No key: fails before the snapshot push namingbuilder auth appleandsigning setup. The check follows the profile's or--provider's provider, not the top-level one.use_signingis true whenever the profile has a distribution; the signing step selects the set by indirect expansion with the unsuffixed names as the legacy fallback, decodes the profile and checks its type against the requested distribution before anything touches the keychain, then exports with the method the profile calls for (development,ad-hoc,app-store,enterprise). A Debug configuration with a distribution profile fails early naming the setting.--type, profilesigning,app-storeas a value (rejected with a hint to usestore).Test plan
go build ./... && go vet ./... && go test ./...,gofmt -l .clean, golangci-lint v2.12.2 0 issuessigning setup --distribution development --devices-from-mobaithenios build --profile developmentgives a signed development IPAios build --profile productionwith no store secrets and an ASC key provisions the store set and builds a Release IPA thatios uploadaccepts