workflow: derive the IPA export method from the provisioning profile - #17
Closed
Interlap01 wants to merge 5 commits into
Closed
Interlap01 wants to merge 5 commits into
Interlap01 wants to merge 5 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.
Collaborator
Author
|
Folded into #23, which carries these commits. |
Interlap01
added a commit
that referenced
this pull request
Sep 17, 2026
…matic provisioning, extensions, ios release (#22) * workflow: derive the export method from the provisioning profile 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. * docs: explain that the export method follows the profile * workflow: set manageAppVersionAndBuildNumber with plutil 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". * docs: correct the provider signing doc for the export method 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. * config: add build profiles and their resolution 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. * build: select a profile with --profile on ios build and ios share 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. * workflows: apply the profile on the runner and export its env 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. * docs: describe build profiles * config: reserve the runner's secrets, shell and CI namespaces in profile 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. * build: explain a dispatch rejected by a workflow without the profile 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. * workflows: random GITHUB_ENV heredoc delimiter and a type check on env 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. * docs: defaultProfile also needs refreshed workflows; list reserved env names * workflow: tolerate CRLF checkouts in the export-method test * asc: add App Store Connect API client 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. * auth: store App Store Connect API keys 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. * ios: upload IPAs to App Store Connect 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. * ios: submit builds to TestFlight and App Review 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. * docs: describe the TestFlight and App Store commands 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. * build: pass profile settings and build options by pointer 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. * asc: make waits injectable, back off status polls, drop unused helpers 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. * ios: print no JSON on a nil result and name the ASC_* variables 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. * docs: Release configuration prerequisite, drop roadmap item numbers * workflow: stamp a build number on the runner A new build_number dispatch input (BUILD_NUMBER for Codemagic/Bitrise) carries N, or X.Y.Z+N to set the marketing version too. An identical apply_build_number function in ios-build.yml and runner.sh validates it, passes --build-number/--build-name to flutter build, adds CURRENT_PROJECT_VERSION/MARKETING_VERSION to every xcodebuild, and rewrites an Info.plist that hardcodes CFBundleVersion, which the build setting never reaches. Tag-triggered runs get no build number. The test extracts the function from both templates, checks they match, and runs it under xcodebuild/plutil stubs. * build: pass a build number and version to the runner BuildOptions.BuildNumber/Version travel as the build_number dispatch input or the BUILD_NUMBER variable. A 422 for an unexpected input names the stale workflow file. ios.bundleId in builder.json identifies the App Store Connect app before any IPA exists. * asc: bundle ID, certificate, device and profile endpoints 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. * signing: provision certificates, devices and profiles through the ASC 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. * signing: make setup automatic with the App Store Connect API key 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. * ios: release command and build --submit builder ios release builds with the next build number, verifies the downloaded IPA carries it, uploads to App Store Connect, waits for processing and hands the build to TestFlight groups (default) or App Review (--app-store --release). ios build --submit is the TestFlight release with no groups. Both run release.Run, which checks ios.signing and ios.configuration=Release before dispatching, resolves the bundle ID (--bundle-id, ios.bundleId, newest dist IPA), lists the app's builds across every version and increments the largest CFBundleVersion. The build package now passes one BuildNumber string through (N, or X.Y.Z+N when --version is given; the release package encodes it) and Coordinator.Build takes *BuildOptions, since the struct passed gocritic's by-value size limit. * docs: describe ios release and automatic build numbers * docs: automatic signing setup is the primary path 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. * signing: check devices and write the key before requesting a certificate 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. * signing: register only devices whose MobAI ID is a UDID 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. * docs: export method must follow the profile type 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. * workflow: keep Codemagic's BUILD_NUMBER off plain builds Codemagic predefines BUILD_NUMBER as its per-workflow build counter, so runner.sh's BUILD_NUMBER="${BUILD_NUMBER:-}" default stamped that counter on every plain `ios build` there, rewriting a hardcoded Info.plist along the way. The CLI now sends the stamp as BUILDER_BUILD_NUMBER and the runner maps it onto the BUILD_NUMBER that apply_build_number reads, so an unset value once again leaves the build untouched. The runner test sets BUILD_NUMBER=17 in the environment and asserts nothing is stamped. apply_build_number also leaves ${CURRENT_PROJECT_VERSION}-style plist references alone, the same as the $(...) spelling. * config: map a profile's distribution to its signing set A build profile's distribution now names the IOS_* secrets the runner reads: IOS_CERTIFICATE_<SET>, IOS_CERTIFICATE_PASSWORD_<SET> and IOS_PROVISIONING_PROFILE_<SET>, where the set is DEVELOPMENT, AD_HOC, APP_STORE or ENTERPRISE, and no distribution means DEVELOPMENT. The mapping lives in config.SigningSet, the secret names in config.SigningSecretNames, and the resolved-settings block prints the set of a signed build. The suffixed secret names and the runner's SIGNING_SET variables join the env names a profile may not set. * signing: read the profile type locally and name files by type ProfileType decodes a .mobileprovision without a Mac: the plist inside the CMS wrapper is parsed and classified by the rules the runner's detect_export_method applies, so setup can tell which signing set a profile belongs to. Enterprise joins the types (Auto refuses it, since in-house profiles are not issued through the ASC API). The key and .p12 are written as ios-signing-<type>.key/.p12, so setting up a second type in the same directory keeps the first type's material. * signing: upload setup material to the set of its type signing setup writes IOS_CERTIFICATE_<SET>, IOS_CERTIFICATE_PASSWORD_<SET> and IOS_PROVISIONING_PROFILE_<SET> for the type it produced or was given. Manual mode (--certificate/--profile) reads the type from the profile, --type overrides it; automatic mode already knew it. Neither deletes the unsuffixed secrets, which remain the runner's fallback, so running setup for two types leaves both sets in place. The key of a previous run is found under ios-signing-<type>.key, then the legacy ios-signing.key. * workflow: select the signing set by distribution and check the profile type Resolve parameters emits signing_set (DEVELOPMENT, AD_HOC, APP_STORE, ENTERPRISE) from the profile's distribution, and the signing step receives every set's IOS_* secrets. A shared select_signing_set picks IOS_CERTIFICATE_<SET> and friends by indirect expansion, falls back to the unsuffixed secrets, and fails by name when neither exists; check_signing_set then compares the detected export method with the requested distribution before anything long runs. Legacy secrets with no distribution requested pass as they did. runner.sh derives SIGNING_SET from DISTRIBUTION and carries the same three functions, which a test compares between the templates and runs with stub secrets. * docs: signing sets README: the secret-name table with the four suffixes and the legacy fallback, a development + production example that uses two sets, the profile table's distribution now applied, and the manual and automatic setup steps naming the set they write. Provider docs carry the suffixed names for Codemagic and Bitrise. CLAUDE.md gets the Signing Sets key pattern and drops the pending-PR note on the export method. * workflow: require a complete signing set and check the profile before the import select_signing_set treated a suffixed set as present when either its certificate or its profile was set, took an empty password as fine, and let a set consisting only of a password fall back to the unsuffixed names. Builder never writes a set without a password (signing setup and Auto both refuse an empty one), so a suffixed set now needs all three secrets and a partial set fails naming exactly the missing ones. The unsuffixed password stays optional, as it was before signing sets; the Codemagic/Bitrise docs said "must exist" and "may be empty" for every set and now say which is which. Both templates decoded the profile and ran check_signing_set and the Debug guard only after security import. The profile is now read and checked first, so a wrong set, type or configuration fails before any keychain exists. TestSigningSetSelection runs under set -euo pipefail like runner.sh, covers the empty and lone password and an unset legacy password, and asserts the selection, check and import order in both templates. TestExportMethodFollows Profile runs signing.ProfileType on the same plist table as the shell detect_export_method on every platform, with explicit ProvisionsAllDevices false and missing Entitlements cases, so the two rule sets cannot drift. * workflow: tolerate CRLF checkouts in the signing set test * docs: explain signing sets in the provider secrets guide * github: list repository secret names GET /repos/{owner}/{repo}/actions/secrets, following the pages, so ios build can tell whether a profile's signing set is in the repository before it dispatches. * config: distribution is the only signing field of a profile The profile's signing field is gone. A profile signs exactly when it has a distribution: development, ad-hoc (alias internal, canonical ad-hoc), store or enterprise; omitted means unsigned. Its configuration is derived when not set (Debug for development, Release for the rest), and an explicit one still wins. Secret set suffixes are the canonical name upper-cased (_STORE, _AD_HOC, _DEVELOPMENT, _ENTERPRISE); no distribution has no set, that is the legacy ios.signing path with the unsuffixed secrets, which profiles never fall back to. * signing: name types after distributions and share the test portal signing.Type takes its values from config's canonical distributions (store replaces app-store, internal parses as ad-hoc), so files and profile names follow them. The no-devices error names signing setup --distribution --devices-from-mobai, which is the way out from ios build as well. The in-memory ASC portal moves to internal/signing/signingtest so the CLI's on-demand provisioning tests can use it. * build: print the signing set on the signing line Signed builds show their set, or that the unsuffixed legacy secrets apply; the profiled test config selects signing through its distribution. * signing: setup writes a build profile and ios build provisions a missing set signing setup takes --distribution (replacing --type) or reads it from the --name profile, defaults to development, and after uploading the set writes profiles.<name>.distribution to builder.json instead of ios.signing; with --certificate/--profile the distribution is what the .mobileprovision says, and a disagreeing --distribution is an error. Codemagic/Bitrise get the secret names and paths printed on both paths. ios build --profile X on GitHub lists the repository's secret names before dispatching: with the three suffixed secrets present it dispatches as before; otherwise, with an App Store Connect key, it runs the same provisioning as signing setup without prompts, uploads the set and builds; without one it stops before pushing anything and names builder auth apple and signing setup --certificate/--profile. --unsigned skips the check, as do Codemagic/Bitrise. * workflow: derive signing from the profile's distribution Tag builds resolve use_signing as "the selected profile has a distribution" (ios.signing only without a profile) and configuration from the distribution when the profile sets none. The signing_set table uses the new suffixes (STORE, AD_HOC also for internal); select_signing_set reads the unsuffixed secrets only with no distribution and never falls back to them for a set; check_signing_set compares canonical names (app-store is store, internal is ad-hoc). Messages point at --distribution and the derived configuration. * docs: signing profiles with distribution README Code Signing rewritten around profiles with a distribution, the two modes of signing setup, on-demand provisioning from ios build, the secret names table and the legacy path; Configuration, the provider guides and CLAUDE.md follow. * github: explain 403 and 404 when listing secrets A token without the repo scope gets 404 from the secrets listing and a non-admin 403; both read like a repository with no secrets. ListSecretNames now names the repository and what the token lacks. APIError.Status is filled from the HTTP status when the body has none, so the check does not depend on GitHub's optional field. Adds the package's first tests: pagination, an empty repository, and the two access failures, against httptest. * config: point app-store at store The old distribution name gets its own message naming the new one instead of the generic list. * build: check the signing set of the provider that runs the job runBuild decided whether to check GitHub secrets from the top-level provider, while the coordinator runs the job on the profile's provider (or --provider). A profile with provider codemagic and a distribution was checked against GitHub, and with an ASC key would have provisioned a set into a repository the build never reads. ensureSigningSecrets now resolves the provider the same way and returns early unless it is GitHub. The listing error already names the repository, so it is no longer wrapped a second time. * signing: keep a profile's spelling and report a replaced distribution writeSigningProfile overwrote the distribution silently: --name at a profile with another distribution changed what its builds sign with without a word, and a profile spelled internal came back as ad-hoc. The same distribution now leaves the field as the user wrote it, a different one is replaced and the old value is part of the Updated line; the --name help says so. * docs: keep README line endings, say what signing setup leaves alone The docs commit rewrote README.md from CRLF to LF, so its diff was 1657 lines for a 255-line edit and would conflict with any README change on main. Lines that survived the edit get their previous ending back. The setup step in the README now says that a different distribution in the --name profile is replaced and that defaultProfile is not set; CLAUDE.md records the same and the provider rule of the on-demand check. * signing: --provider on setup and a hint when a build's secrets cannot be checked * signing: always print the secret values and treat a failed GitHub upload as non-fatal `signing setup` no longer has --provider and never reads the `provider` field: both modes upload the distribution's set to the GitHub repository in builder.json, and both print the three secret names with where their values come from, so the same run serves Codemagic, Bitrise, or a repository this login cannot write to. A GitHub client that cannot be built, or an upload that fails, is an `Error:` line on stderr and nothing more: the files, the values and the build profile are written and printed anyway, and only the exit code at the end says it failed (`github_upload` in --json). On-demand provisioning from `ios build` is unchanged and still GitHub-only; its hint for the other providers drops --provider. * asc: match MobAI's key check, error window and profile delete - auth apple verifies the key with GET /v1/certificates?limit=1 (MobAI's Verify) instead of apps?limit=1: apps answers 200 for a key of any role, certificates demands the Certificates, IDs & Profiles access signing needs. - Any non-2xx response is an ASC error, not only >= 400. - DeleteProfile treats 404 as already deleted, so a profile that vanished between the lookup and the delete does not abort a recreate. - The bad-.p8 error names the AuthKey_*.p8 file and the parsed key type. * signing: write the private key as PKCS#8, read PKCS#1 too MobAI's signer writes the key as a PKCS#8 "PRIVATE KEY" block, the form openssl and zsign read without a legacy flag; Builder wrote PKCS#1. Keys from earlier runs (ios-signing*.key) still open, so no certificate slot is lost. * docs: walk through creating the App Store Connect API key; drop the certificate count from the quota hint * release: require an App Store build profile and provision its set ios release and ios build --submit take --profile. Preflight resolves the selected profile (or defaultProfile) and refuses anything but distribution store with an effective Release configuration; an explicit Debug is refused with the hint, and without a store profile the error names builder signing setup --distribution store and --profile store. The ios.signing/ios.configuration preflight is gone. The command runs the check before the GitHub client is built and, on GitHub, provisions a missing STORE set through ensureSigningSecrets, the path ios build --profile uses, before the snapshot is pushed. --unsigned with --submit stays refused. Tests, the README release subsection and CLAUDE.md follow. * auth: say what auth apple saved * workflow: sign the archive with the identity the profile type needs 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. * workflow: accept legacy iPhone Distribution and Developer certificates 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. * docs: explain creating the App Store Connect app record * asc: beta group, tester, team user and build management endpoints Adds typed helpers for what the asc commands need: ListApps; CreateBetaGroup, DeleteBetaGroup and the betaTesters linkage calls; ListBetaTesters, FindBetaTester, CreateBetaTester, AddBetaTester (409 on a known address falls back to finding the record and adding it to the groups) and DeleteBetaTester; ListUsers, FindUser, FindUserInvitation and InviteUser; ExpireBuild and a BuildFilter.Details mode that includes preReleaseVersion and betaGroups. BetaGroup now carries hasAccessToAllBuilds and publicLink. The JSON:API plumbing parses the included block (collect/includedAttr) and to-many linkages (Relationships.Many); ListBuilds with a limit above the page size follows pages and cuts. Apple's email/username filters are substring matches, so the Find helpers compare exactly. * ios: create missing TestFlight groups on submit, skip automatic-distribution groups A --group name the app has no group for is created (internal by default, external with --external); existing groups keep their type. The result marks created groups and the log says "Created TestFlight group X (internal)". Without --group, an app with no groups prints "Available groups: (none)". An internal group with hasAccessToAllBuilds already receives every build and App Store Connect answers 422 when one is added by hand, so such groups are skipped with a note (GroupRef.AutoBuilds) and the command exits 0. * asc: builder asc command group for apps, builds, groups, testers and users builder asc apps | builds [expire] | groups [create|delete|add-build] | testers [add|remove] | users [invite], all non-interactive, all with --json. The app comes from --bundle-id, else ios.bundleId in builder.json, else the newest IPA in ./dist. Human output is aligned columns; --json prints arrays of snake_case objects. distribute.AddTester routes by group type: external groups create the tester record in the group or add the existing one; internal groups take team members only, so a member's record joins the group and a stranger is invited to the team (POST userInvitations, CUSTOMER_SUPPORT with only this app visible, --role to override) with a note that the invitation must be accepted first. groups create sends hasAccessToAllBuilds for internal groups unless --no-auto-builds; groups lists such groups as "internal, all builds". getASCClient is a package variable so command tests can point it at an httptest server. * docs: describe the asc management commands and group auto-create * asc: invite beta testers, lowercase the email filter, refuse ambiguous group names POST betaTesterInvitations (relationships app + betaTester) sends or resends the TestFlight email; GetBetaTester reads the state back, since the invitation resource carries none. filter[email] goes out lowercased because App Store Connect stores addresses that way. MatchBetaGroup is the one case-insensitive name lookup for the command layer and distribute, and it errors, listing the candidates, when several groups fold to the same name. * distribute: invite NOT_INVITED testers after a group add, share the group matcher A team member put into an internal group keeps a NOT_INVITED tester record and never gets an email, so AddTester reads the state back after the add and sends the TestFlight invitation when it is still NOT_INVITED; the result now carries the state. InviteTester is the reusable half for the command layer. findOrCreateGroup goes through asc.MatchBetaGroup, so a name that matches two groups case-insensitively fails instead of picking the first. * asc: testers invite, confirmed deletes with previews, one app resolver asc testers invite <email>... sends or resends the TestFlight email to NOT_INVITED and INVITED testers and reports the new state; asc testers marks NOT_INVITED rows with a hint pointing at it. groups delete always needs --yes and says what it will delete first; testers remove resolves every address before the first deletion and previews team-wide removals. Group names go through asc.MatchBetaGroup, so Team/team duplicates are refused. resolveApp (--bundle-id, --ipa, ios.bundleId, newest dist IPA) serves ios submit and every asc command; runTestFlight is the shared submit-and-print step of ios submit --testflight and asc groups add-build, writing to the command's stdout. Command tests reset cobra flags between runs, since values otherwise carry over on the shared command tree. * docs: asc testers invite, NOT_INVITED testers, confirmed deletes and group matching * share: drop --profile; the simulator build takes no profile * asc: gofmt * upload: show fractional megabytes, or kilobytes, in the progress line A small IPA printed "100% (0/0 MB)" because the sizes were shifted to whole megabytes. progressSize prints one decimal ("0.2/0.2 MB") and falls back to kilobytes while the whole upload is under a megabyte. * asc: explain a refused invitation when the group has no build Inviting a tester whose groups hold no build makes App Store Connect answer 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS, which asc testers invite printed raw. InviteTester now returns a noBuildError that says to add a build to the group first (asc groups add-build) and that external groups also need Beta App Review; the ASC error stays wrapped. AddTester, which sends the invitation itself after a group add, treats the same refusal as "Added <email> to <group> (invite goes out once the group has a build)" with status added and the NOT_INVITED state, instead of claiming an invitation went out. asc.HasCode matches ASC error codes. * signing: reuse the key from signing setup's --out-dir when provisioning 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. * signing: satisfy errcheck and unparam in the setup tests writeSigningKey checks the type assertion on the generated key, and signingSetupCommand loses its storeErr parameter, which every caller passed as nil. * build: print the failed step and the runner's errors when a run fails 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. * release: default to the only store profile `ios release` and `ios build --submit` refused to run whenever neither --profile nor defaultProfile named a profile with "distribution": "store", which is exactly what `signing setup --distribution store` leaves behind: it writes the store profile but does not touch defaultProfile. Preflight now returns the profile the release must build with. When nothing selects an App Store profile and builder.json holds exactly one, it picks that one and logs `Using profile <name> (the only App Store profile)`. With two or more the error names them so the caller can pick with --profile; with none the message is unchanged and still points at `signing setup --distribution store`. An explicit --profile is never second-guessed. The command keeps what Preflight chose, so the provisioned signing set, the provider and the build all use that profile rather than the empty one. * workflow: set manual signing on the app target only, not on every Pods target * workflow: match the Flutter build line by prefix in the signing test * ci: say why Codemagic or Bitrise rejected a request * asc: stop claiming the Apple key went to the keychain 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. * asc: trim comments and review fixes 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. * distribute: name only the groups the build was really added to SubmitTestFlight logged strings.Join(opts.Groups), so a run mixing an automatic-distribution group with a manual one claimed the build had been added to the group it had just skipped. * release: trim comments and review fixes * asc: trim comments and review fixes Cut the comment blocks this branch added down to what they explain: the CLAUDE.md bullets on internal testers, invitations and the command layer split into one fact each, the README's TestFlight section tightened, and the longest Go and test comments shortened. * auth: drop the unrelated tail from the Apple key message * release: report a build timeout as the build's, not App Store Connect's finish translates every context.DeadlineExceeded into "timed out waiting for App Store Connect; processing continues server-side", but PollForArtifact returns ctx.Err() when --timeout expires during the build, so a short timeout claimed an upload that never happened. Keep that message for a deadline hit after the IPA exists and name the build otherwise. * docs: keep README CRLF line endings 152608a rewrote README.md from CRLF to LF again, undoing 2d62ef9: the branch diff was the whole file for a 300-line edit. Lines get their CRLF back. * signing: trim comments and review fixes 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. * asc: page through the app lookup instead of trusting a two-item filter * docs: extension targets are not signed yet * signing: provision and upload profiles for extension targets 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. * workflow: sign extension targets with their own profiles 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. * docs: extension targets are signed with their own profiles * cmd: set USERPROFILE with HOME in tests so ~ expands on Windows
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
The export step hardcoded
method = developmentinExportOptions.plist, so IPAs from Ad Hoc, Enterprise or App Store profiles either failed to export or were rejected by App Store Connect. This is the prerequisite for the App Store Connect integration on the roadmap.ProvisionsAllDevices→enterprise;ProvisionedDevices+get-task-allow→development;ProvisionedDeviceswithout it →ad-hoc; no devices →app-store. Same function inios-build.yml(GitHub Actions) andrunner.sh(Codemagic/Bitrise); a test asserts the two copies are identical.manageAppVersionAndBuildNumber = falseso Xcode leaves version numbers alone.get-task-allow = true, which distribution profiles do not allow. The job now fails early with an error namingios.configuration→Releaseinstead of failing deep in the export.docs/provider-secrets.md.Test plan
go build ./... && go vet ./... && go test ./...developmentapp-store