Skip to content

profiles and signing: EAS-style build profiles, one secret set per distribution, automatic provisioning - #23

Closed
Interlap01 wants to merge 81 commits into
mainfrom
feat/signing-sets
Closed

Interlap01 wants to merge 81 commits into
mainfrom
feat/signing-sets

Conversation

@Interlap01

@Interlap01 Interlap01 commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

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:

"profiles": {
  "development": { "distribution": "development" },
  "preview":     { "distribution": "internal" },
  "production":  { "distribution": "store" }
}
  • distribution is development, ad-hoc (alias internal), store or enterprise; omitted means an unsigned build. configuration derives from it (development → Debug, else Release) unless set. The profile signing field is gone; top-level ios.signing remains only for the legacy no-profile path with unsuffixed secrets.
  • One GitHub secret set per distribution: IOS_CERTIFICATE_<SET>, IOS_CERTIFICATE_PASSWORD_<SET>, IOS_PROVISIONING_PROFILE_<SET> with DEVELOPMENT, AD_HOC, STORE, ENTERPRISE. A repo can hold a development and a store set at once.
  • builder signing setup --certificate x.p12 --profile x.mobileprovision reads the type from the profile, uploads that set and writes the build profile into builder.json (--name, default the distribution). Without files it provisions through the App Store Connect API (bundle ID, certificate from a local CSR, devices from --device or --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 X on 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 naming builder auth apple and signing setup. The check follows the profile's or --provider's provider, not the top-level one.
  • Runner: use_signing is 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.
  • Order of operations in provisioning avoids orphaned certificates: devices are checked and the key is written before the certificate request. Nothing is ever revoked; Apple's quota errors get hints. Cloud MobAI devices are skipped and UDID shape is enforced.
  • Removed: --type, profile signing, app-store as a value (rejected with a hint to use store).

Test plan

  • go build ./... && go vet ./... && go test ./..., gofmt -l . clean, golangci-lint v2.12.2 0 issues
  • Repo with only legacy secrets and no profiles builds unchanged
  • signing setup --distribution development --devices-from-mobai then ios build --profile development gives a signed development IPA
  • ios build --profile production with no store secrets and an ASC key provisions the store set and builds a Release IPA that ios upload accepts
  • Same command without an ASC key fails before dispatch with the named commands
  • Uploading a development profile as the store set fails early with the type mismatch

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.
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.
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.
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.
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.
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.
@Interlap01

Copy link
Copy Markdown
Collaborator Author

Folded into #22, whose branch carries these commits merged with main.

@Interlap01 Interlap01 closed this Sep 17, 2026
@Interlap01
Interlap01 deleted the feat/signing-sets branch September 17, 2026 18:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant