Skip to content

workflow: derive the IPA export method from the provisioning profile - #17

Closed
Interlap01 wants to merge 5 commits into
mainfrom
feat/export-method
Closed

Interlap01 wants to merge 5 commits into
mainfrom
feat/export-method

Conversation

@Interlap01

@Interlap01 Interlap01 commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

The export step hardcoded method = development in ExportOptions.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.

  • Decode the provisioning profile on the runner and pick the method: ProvisionsAllDevicesenterprise; ProvisionedDevices + get-task-allowdevelopment; ProvisionedDevices without it → ad-hoc; no devices → app-store. Same function in ios-build.yml (GitHub Actions) and runner.sh (Codemagic/Bitrise); a test asserts the two copies are identical.
  • Legacy method names, since Xcode 16 accepts them and older provider Xcodes reject the new ones.
  • Non-development exports set manageAppVersionAndBuildNumber = false so Xcode leaves version numbers alone.
  • A Debug archive carries get-task-allow = true, which distribution profiles do not allow. The job now fails early with an error naming ios.configurationRelease instead of failing deep in the export.
  • Tests run the extracted shell function against synthetic profiles (darwin only) and parse both workflow YAMLs; docs updated in README, CLAUDE.md and docs/provider-secrets.md.

Test plan

  • go build ./... && go vet ./... && go test ./...
  • Run a signed build with a Development profile and confirm the log shows development
  • Run a signed Release build with an App Store profile and confirm the IPA exports with app-store
  • Run a signed Debug build with an App Store profile and confirm the early error

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

Copy link
Copy Markdown
Collaborator Author

Folded into #23, which carries these commits.

@Interlap01 Interlap01 closed this Sep 16, 2026
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
@Interlap01
Interlap01 deleted the feat/export-method 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