From aa25ddc93acf6ad667b0555e46fd37c533bad43b Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 13:54:32 +0200 Subject: [PATCH 01/75] 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. --- internal/workflow/providers_test.go | 122 ++++++++++++++++++++++ internal/workflow/templates/ios-build.yml | 44 +++++++- internal/workflow/templates/runner.sh | 42 +++++++- 3 files changed, 202 insertions(+), 6 deletions(-) diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index 3139430..88983c0 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -187,6 +187,128 @@ fi } } +// shellFunc pulls a shell function body out of a template so the same code the +// runner executes can be exercised here. Works for runner.sh and for the +// indented run: blocks of the workflow YAML. +func shellFunc(t *testing.T, template, name string) string { + t.Helper() + lines := strings.Split(template, "\n") + start := -1 + indent := "" + for i, line := range lines { + if strings.TrimSpace(line) == name+"() {" { + start = i + indent = line[:len(line)-len(strings.TrimLeft(line, " "))] + break + } + } + if start < 0 { + t.Fatalf("%s not found", name) + } + for i := start; i < len(lines); i++ { + if i > start && lines[i] == indent+"}" { + body := lines[start : i+1] + for j, line := range body { + body[j] = strings.TrimPrefix(line, indent) + } + return strings.Join(body, "\n") + } + } + t.Fatalf("%s not terminated", name) + return "" +} + +func TestExportMethodFollowsProfile(t *testing.T) { + workflowTemplate, err := GetWorkflowTemplate() + if err != nil { + t.Fatal(err) + } + runner, err := GetTemplate("runner.sh") + if err != nil { + t.Fatal(err) + } + fromWorkflow := shellFunc(t, string(workflowTemplate), "detect_export_method") + fromRunner := shellFunc(t, string(runner), "detect_export_method") + if fromWorkflow != fromRunner { + t.Fatalf("templates disagree on the export method:\n%s\n---\n%s", fromWorkflow, fromRunner) + } + // Both must refuse a Debug distribution build, whose get-task-allow + // entitlement no distribution profile grants. + for name, data := range map[string]string{"ios-build.yml": string(workflowTemplate), "runner.sh": string(runner)} { + if !strings.Contains(data, `configuration\": \"Release`) { + t.Errorf("%s: no Debug + distribution guard", name) + } + if strings.Contains(data, "development") || strings.Contains(data, "'method': 'development'") { + t.Errorf("%s: export method still hardcoded", name) + } + } + + if runtime.GOOS != "darwin" { + t.Skip("plutil is macOS only") + } + profile := func(body string) string { + return ` + +` + body + `` + } + devices := "ProvisionedDevices00008030-001" + allow := func(v bool) string { + if v { + return "Entitlementsget-task-allow" + } + return "Entitlementsget-task-allow" + } + cases := []struct{ name, plist, want string }{ + {"development", profile(devices + allow(true)), "development"}, + {"adhoc", profile(devices + allow(false)), "ad-hoc"}, + {"appstore", profile(allow(false)), "app-store"}, + {"enterprise", profile("ProvisionsAllDevices" + allow(false)), "enterprise"}, + // An enterprise profile ships devices too on some accounts; it still wins. + {"enterprise with devices", profile("ProvisionsAllDevices" + devices + allow(false)), "enterprise"}, + } + dir := t.TempDir() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(dir, tc.name+".plist") + if err := os.WriteFile(path, []byte(tc.plist), 0644); err != nil { + t.Fatal(err) + } + out, err := exec.Command("bash", "-c", fromRunner+"\ndetect_export_method \"$1\"", "bash", path).CombinedOutput() + if err != nil { + t.Fatalf("%s %v", out, err) + } + if got := strings.TrimSpace(string(out)); got != tc.want { + t.Fatalf("method = %q, want %q", got, tc.want) + } + }) + } +} + +func TestWorkflowTemplatesParse(t *testing.T) { + for _, name := range []string{"ios-build.yml", "ios-share.yml"} { + data, err := GetTemplate(name) + if err != nil { + t.Fatal(err) + } + var parsed struct { + Jobs map[string]struct { + Steps []map[string]any + } + } + if err := yaml.Unmarshal(data, &parsed); err != nil { + t.Fatalf("%s: %v", name, err) + } + if len(parsed.Jobs) == 0 { + t.Fatalf("%s: no jobs", name) + } + for job, spec := range parsed.Jobs { + if len(spec.Steps) == 0 { + t.Fatalf("%s: job %s has no steps", name, job) + } + } + } +} + func TestBitriseSSHActivationRequiresKey(t *testing.T) { data, err := GetTemplate("bitrise.yml") if err != nil { diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index e98bc2b..b87a52c 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -275,9 +275,29 @@ jobs: IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} + CONFIGURATION: ${{ steps.params.outputs.configuration }} run: | set -e + # The export method has to match the profile, or -exportArchive fails + # and App Store Connect rejects the IPA. Xcode 15.3+ also accepts + # debugging/release-testing/app-store-connect, but these legacy names + # still work in Xcode 16 and are the only ones older Xcodes (pinned or + # self-hosted runners) understand, so both templates use them. + detect_export_method() { + if [ "$(plutil -extract ProvisionsAllDevices raw -o - "$1" 2>/dev/null)" = "true" ]; then + echo enterprise + elif plutil -extract ProvisionedDevices xml1 -o /dev/null "$1" >/dev/null 2>&1; then + if [ "$(plutil -extract Entitlements.get-task-allow raw -o - "$1" 2>/dev/null)" = "true" ]; then + echo development + else + echo ad-hoc + fi + else + echo app-store + fi + } + # Create temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db KEYCHAIN_PASSWORD=$(openssl rand -base64 32) @@ -311,13 +331,25 @@ jobs: APP_ID=$(plutil -extract Entitlements.application-identifier raw -o - "$PROFILE_PLIST") PROFILE_BUNDLE_ID=${APP_ID#"$TEAM_ID".} + EXPORT_METHOD=$(detect_export_method "$PROFILE_PLIST") + + # A Debug archive carries get-task-allow=true, which no distribution + # profile grants: the export fails, or an IPA that App Store Connect + # rejects comes out. Say so now instead of after the whole build. + if [ "$EXPORT_METHOD" != "development" ] && [ "$CONFIGURATION" = "Debug" ]; then + echo "::error::The provisioning profile is an $EXPORT_METHOD profile, but the build configuration is Debug. A Debug build is signed with get-task-allow, which distribution profiles do not allow and App Store Connect rejects. Set \"configuration\": \"Release\" under \"ios\" in builder.json, or use a development profile." + exit 1 + fi + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV echo "DEVELOPMENT_TEAM=$TEAM_ID" >> $GITHUB_ENV echo "PROVISIONING_PROFILE_NAME=$PROFILE_NAME" >> $GITHUB_ENV + echo "EXPORT_METHOD=$EXPORT_METHOD" >> $GITHUB_ENV echo "Certificate and provisioning profile installed" echo " team: $TEAM_ID" echo " profile: $PROFILE_NAME ($PROFILE_UUID)" echo " app id: $PROFILE_BUNDLE_ID" + echo " export: $EXPORT_METHOD" echo "If the build fails on a provisioning mismatch, the app's PRODUCT_BUNDLE_IDENTIFIER must match the app id above." - name: Build IPA @@ -533,7 +565,14 @@ jobs: # The export has to use the same manual signing as the archive, and # it needs the app's real bundle id to map it to the profile. APP_BUNDLE_ID=$(plutil -extract ApplicationProperties.CFBundleIdentifier raw -o - build/App.xcarchive/Info.plist) - echo "Exporting $APP_BUNDLE_ID with profile '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM)" + echo "Exporting $APP_BUNDLE_ID with profile '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM), method $EXPORT_METHOD" + + # Distribution exports keep the version numbers the archive was + # built with; Xcode would otherwise renumber the build on export. + MANAGE_VERSION="" + if [ "$EXPORT_METHOD" != "development" ]; then + MANAGE_VERSION=$(printf ' manageAppVersionAndBuildNumber\n ') + fi printf '%s\n' \ '' \ @@ -541,7 +580,8 @@ jobs: '' \ '' \ ' method' \ - ' development' \ + " ${EXPORT_METHOD}" \ + "${MANAGE_VERSION}" \ ' signingStyle' \ ' manual' \ ' teamID' \ diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 7ed603e..92c0f92 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -128,6 +128,25 @@ cleanup_signing() { if [ -n "${signing_dir:-}" ]; then rm -rf "$signing_dir"; fi } +# The export method has to match the profile, or -exportArchive fails and App +# Store Connect rejects the IPA. Xcode 15.3+ also accepts +# debugging/release-testing/app-store-connect, but these legacy names still work +# in Xcode 16 and are the only ones older Xcodes (pinned or self-hosted runners) +# understand, so both templates use them. +detect_export_method() { + if [ "$(plutil -extract ProvisionsAllDevices raw -o - "$1" 2>/dev/null)" = "true" ]; then + echo enterprise + elif plutil -extract ProvisionedDevices xml1 -o /dev/null "$1" >/dev/null 2>&1; then + if [ "$(plutil -extract Entitlements.get-task-allow raw -o - "$1" 2>/dev/null)" = "true" ]; then + echo development + else + echo ad-hoc + fi + else + echo app-store + fi +} + install_signing() { : "${IOS_CERTIFICATE:?Set the IOS_CERTIFICATE secret on this provider}" : "${IOS_CERTIFICATE_PASSWORD?Set IOS_CERTIFICATE_PASSWORD on this provider (may be empty)}" @@ -151,9 +170,20 @@ install_signing() { mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" profile_dest="$HOME/Library/MobileDevice/Provisioning Profiles/$profile_uuid.mobileprovision" cp "$signing_dir/profile.mobileprovision" "$profile_dest" + export EXPORT_METHOD="$(detect_export_method "$signing_dir/profile.plist")" + echo "Signing with '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM), export method $EXPORT_METHOD" + # A Debug archive carries get-task-allow=true, which no distribution profile + # grants: the export fails, or an IPA that App Store Connect rejects comes + # out. Say so now instead of after the whole build. + if [ "$EXPORT_METHOD" != development ] && [ "$CONFIGURATION" = Debug ]; then + echo "The provisioning profile is an $EXPORT_METHOD profile, but the build configuration is Debug. A Debug build is signed with get-task-allow, which distribution profiles do not allow and App Store Connect rejects. Set \"configuration\": \"Release\" under \"ios\" in builder.json, or use a development profile." >&2 + exit 1 + fi } build_ipa() { + # Before the build, so a profile/configuration mismatch fails in seconds. + if [ "$USE_SIGNING" = true ]; then install_signing; fi if [ "$project_type" = flutter ]; then cd "$BUILDER_WORKSPACE" case "$CONFIGURATION" in Debug) flutter build ios --debug --no-codesign ;; *) flutter build ios --release --no-codesign ;; esac @@ -163,16 +193,20 @@ build_ipa() { -derivedDataPath "$BUILDER_WORKSPACE/DerivedData" COMPILER_INDEX_STORE_ENABLE=NO) mkdir -p "$BUILDER_WORKSPACE/build" if [ "$USE_SIGNING" = true ]; then - install_signing xcodebuild "${args[@]}" DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" CODE_SIGN_STYLE=Manual \ PROVISIONING_PROFILE_SPECIFIER="$PROVISIONING_PROFILE_NAME" -archivePath "$BUILDER_WORKSPACE/build/App.xcarchive" archive export APP_BUNDLE_ID="$(plutil -extract ApplicationProperties.CFBundleIdentifier raw -o - "$BUILDER_WORKSPACE/build/App.xcarchive/Info.plist")" python3 - "$signing_dir/ExportOptions.plist" <<'PY' import os, plistlib, sys +options = {'method': os.environ['EXPORT_METHOD'], 'signingStyle': 'manual', + 'teamID': os.environ['DEVELOPMENT_TEAM'], + 'provisioningProfiles': {os.environ['APP_BUNDLE_ID']: os.environ['PROVISIONING_PROFILE_NAME']}} +# Distribution exports keep the version numbers the archive was built with; +# Xcode would otherwise renumber the build on export. +if options['method'] != 'development': + options['manageAppVersionAndBuildNumber'] = False with open(sys.argv[1], 'wb') as out: - plistlib.dump({'method': 'development', 'signingStyle': 'manual', - 'teamID': os.environ['DEVELOPMENT_TEAM'], - 'provisioningProfiles': {os.environ['APP_BUNDLE_ID']: os.environ['PROVISIONING_PROFILE_NAME']}}, out) + plistlib.dump(options, out) PY xcodebuild -exportArchive -archivePath "$BUILDER_WORKSPACE/build/App.xcarchive" \ -exportOptionsPlist "$signing_dir/ExportOptions.plist" -exportPath "$signing_dir/export" From b5873203293ed6bbf359b934aebdf711633ba896 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 13:54:32 +0200 Subject: [PATCH 02/75] docs: explain that the export method follows the profile --- CLAUDE.md | 8 ++++++++ README.md | 13 +++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b15fd87..a90f305 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,6 +203,14 @@ The embedded workflow template (`internal/workflow/templates/ios-build.yml`): - Flutter: uses `Runner` scheme, runs `flutter pub get` - Installs CocoaPods if Podfile exists - Builds unsigned IPA with `CODE_SIGNING_ALLOWED=NO` +- Signed builds derive the `ExportOptions.plist` `method` from the profile itself + (`ProvisionsAllDevices` → `enterprise`, `ProvisionedDevices` plus `get-task-allow` → + `development`, `ProvisionedDevices` without it → `ad-hoc`, neither → `app-store`), using the + legacy method names because older Xcodes reject the Xcode 15.3+ ones. Distribution exports also + set `manageAppVersionAndBuildNumber = false`, and a distribution profile with `CONFIGURATION` + `Debug` fails the job in the signing step — a Debug archive's `get-task-allow` is not allowed by + those profiles. `detect_export_method` is duplicated verbatim in `ios-build.yml` and `runner.sh`; + a test compares the two bodies and runs it against synthetic profile plists - Uploads IPA as GitHub artifact with 7-day retention ## Flutter Dev Requirements diff --git a/README.md b/README.md index d99627b..94c7677 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ gitignored files are also excluded from build snapshots). ### 2. Create the certificate 1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal -2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc) +2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc). TestFlight and App Store uploads need **Apple Distribution** together with an App Store profile in step 4 3. Upload `ios-signing.csr` and download the resulting `.cer` file ### 3. Assemble the .p12 @@ -318,7 +318,16 @@ On the portal: 1. **Identifiers** → register an App ID matching your app's bundle identifier 2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page) -3. **Profiles** → create an **iOS App Development** (or Ad Hoc) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file +3. **Profiles** → create an **iOS App Development** (or Ad Hoc, App Store) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file + +The build reads the profile and exports the IPA with the matching method, so the +profile type alone decides what the IPA is good for: development, ad-hoc, +enterprise or App Store. Everything except a development profile is a +distribution build, and those must be built with the **Release** configuration +(`"configuration": "Release"` under `ios` in `builder.json`) — a Debug build is +signed with `get-task-allow`, which distribution profiles do not allow and App +Store Connect rejects. The build fails early with that message if the two +disagree. ### 5. Upload the signing secrets From c35ddfb5b2742708209881d2c19ab1aa5c63f89f Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:01:52 +0200 Subject: [PATCH 03/75] workflow: set manageAppVersionAndBuildNumber with plutil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conditional printf argument emitted a blank line inside 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". --- internal/workflow/providers_test.go | 20 +++++++++++++++++++- internal/workflow/templates/ios-build.yml | 15 +++++++-------- internal/workflow/templates/runner.sh | 3 ++- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index 88983c0..042ead6 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -233,7 +233,20 @@ func TestExportMethodFollowsProfile(t *testing.T) { t.Fatalf("templates disagree on the export method:\n%s\n---\n%s", fromWorkflow, fromRunner) } // Both must refuse a Debug distribution build, whose get-task-allow - // entitlement no distribution profile grants. + // entitlement no distribution profile grants, and both must feed the + // detected method — not a constant — into ExportOptions.plist. + wiring := map[string][]string{ + "ios-build.yml": { + `EXPORT_METHOD=$(detect_export_method "$PROFILE_PLIST")`, + `" ${EXPORT_METHOD}"`, + "plutil -insert manageAppVersionAndBuildNumber -bool NO", + }, + "runner.sh": { + `detect_export_method "$signing_dir/profile.plist"`, + `'method': os.environ['EXPORT_METHOD']`, + "options['manageAppVersionAndBuildNumber'] = False", + }, + } for name, data := range map[string]string{"ios-build.yml": string(workflowTemplate), "runner.sh": string(runner)} { if !strings.Contains(data, `configuration\": \"Release`) { t.Errorf("%s: no Debug + distribution guard", name) @@ -241,6 +254,11 @@ func TestExportMethodFollowsProfile(t *testing.T) { if strings.Contains(data, "development") || strings.Contains(data, "'method': 'development'") { t.Errorf("%s: export method still hardcoded", name) } + for _, want := range wiring[name] { + if !strings.Contains(data, want) { + t.Errorf("%s: export options no longer wired to the profile, missing %q", name, want) + } + } } if runtime.GOOS != "darwin" { diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index b87a52c..5755679 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -567,13 +567,6 @@ jobs: APP_BUNDLE_ID=$(plutil -extract ApplicationProperties.CFBundleIdentifier raw -o - build/App.xcarchive/Info.plist) echo "Exporting $APP_BUNDLE_ID with profile '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM), method $EXPORT_METHOD" - # Distribution exports keep the version numbers the archive was - # built with; Xcode would otherwise renumber the build on export. - MANAGE_VERSION="" - if [ "$EXPORT_METHOD" != "development" ]; then - MANAGE_VERSION=$(printf ' manageAppVersionAndBuildNumber\n ') - fi - printf '%s\n' \ '' \ '' \ @@ -581,7 +574,6 @@ jobs: '' \ ' method' \ " ${EXPORT_METHOD}" \ - "${MANAGE_VERSION}" \ ' signingStyle' \ ' manual' \ ' teamID' \ @@ -593,6 +585,13 @@ jobs: ' ' \ '' \ '' > ExportOptions.plist + + # Distribution exports keep the version numbers the archive was + # built with; Xcode would otherwise renumber the build on export. + if [ "$EXPORT_METHOD" != "development" ]; then + plutil -insert manageAppVersionAndBuildNumber -bool NO ExportOptions.plist + fi + xcodebuild -exportArchive \ -archivePath build/App.xcarchive \ -exportOptionsPlist ExportOptions.plist \ diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 92c0f92..fd60bba 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -182,7 +182,8 @@ install_signing() { } build_ipa() { - # Before the build, so a profile/configuration mismatch fails in seconds. + # Before the compile, so a profile/configuration mismatch fails without + # waiting for the archive. if [ "$USE_SIGNING" = true ]; then install_signing; fi if [ "$project_type" = flutter ]; then cd "$BUILDER_WORKSPACE" From 94eda378ea0407a94df7e67ea097f7afbe7c8435 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:01:59 +0200 Subject: [PATCH 04/75] 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. --- CLAUDE.md | 16 ++++++++-------- docs/provider-secrets.md | 9 ++++++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a90f305..960576f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,14 +203,14 @@ The embedded workflow template (`internal/workflow/templates/ios-build.yml`): - Flutter: uses `Runner` scheme, runs `flutter pub get` - Installs CocoaPods if Podfile exists - Builds unsigned IPA with `CODE_SIGNING_ALLOWED=NO` -- Signed builds derive the `ExportOptions.plist` `method` from the profile itself - (`ProvisionsAllDevices` → `enterprise`, `ProvisionedDevices` plus `get-task-allow` → - `development`, `ProvisionedDevices` without it → `ad-hoc`, neither → `app-store`), using the - legacy method names because older Xcodes reject the Xcode 15.3+ ones. Distribution exports also - set `manageAppVersionAndBuildNumber = false`, and a distribution profile with `CONFIGURATION` - `Debug` fails the job in the signing step — a Debug archive's `get-task-allow` is not allowed by - those profiles. `detect_export_method` is duplicated verbatim in `ios-build.yml` and `runner.sh`; - a test compares the two bodies and runs it against synthetic profile plists +- **Export Method**: `detect_export_method` reads the profile — `ProvisionsAllDevices` → + `enterprise`, `ProvisionedDevices` with `get-task-allow` → `development`, without → `ad-hoc`, + neither → `app-store` — and that method goes into `ExportOptions.plist` (legacy names, since + older Xcodes reject the 15.3+ ones). Non-development exports add + `manageAppVersionAndBuildNumber = false`, and a distribution profile with configuration `Debug` + fails in the signing step, before the build. The function is duplicated verbatim in + `ios-build.yml` and `runner.sh`; a test compares the two bodies and runs one against + synthetic profile plists - Uploads IPA as GitHub artifact with 7-day retention ## Flutter Dev Requirements diff --git a/docs/provider-secrets.md b/docs/provider-secrets.md index e47a643..5a9bfb3 100644 --- a/docs/provider-secrets.md +++ b/docs/provider-secrets.md @@ -16,7 +16,8 @@ Existing GitHub secret values cannot be downloaded for copying to another servic ## 1. Prepare your signing files -Builder's generated provider runner exports development-signed IPAs. Prepare: +The generated runner exports the IPA with the method the profile calls for, so +the profile you upload decides what the build is. For on-device testing prepare: - An **Apple Development** certificate in a `.p12` file, including its matching private key, and the P12 password. @@ -27,8 +28,10 @@ Builder's generated provider runner exports development-signed IPAs. Prepare: Use [Apple Certificates](https://developer.apple.com/account/resources/certificates/list) and [Apple Profiles](https://developer.apple.com/account/resources/profiles/list). Apple's [development profile guide](https://developer.apple.com/help/account/provisioning-profiles/create-a-development-provisioning-profile) -explains selecting the App ID, certificate, and devices. App Store/Ad Hoc export -requires a corresponding change to the generated runner's export settings. +explains selecting the App ID, certificate, and devices. An Ad Hoc, In House or +App Store profile works too — pair it with an **Apple Distribution** certificate +and set `"configuration": "Release"` under `ios` in `builder.json`, since those +profiles reject the `get-task-allow` a Debug build is signed with. If you already have the P12 and profile, reuse them. If you have no certificate, follow Builder's [certificate creation instructions](../README.md#1-create-a-certificate-signing-request). From 3833980cc69701fc0ccd3726fe3c58e8c39b9965 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:04:49 +0200 Subject: [PATCH 05/75] 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. --- internal/config/profile.go | 133 ++++++++++++++++++++++++++++++++ internal/config/profile_test.go | 129 +++++++++++++++++++++++++++++++ internal/config/types.go | 18 +++++ 3 files changed, 280 insertions(+) create mode 100644 internal/config/profile.go create mode 100644 internal/config/profile_test.go diff --git a/internal/config/profile.go b/internal/config/profile.go new file mode 100644 index 0000000..581350e --- /dev/null +++ b/internal/config/profile.go @@ -0,0 +1,133 @@ +package config + +import ( + "encoding/json" + "fmt" + "regexp" + "slices" + "sort" + "strings" +) + +// BuildSettings is what a build runs with once a profile has been applied over +// the top-level settings. Command flags (--unsigned, --provider) are applied by +// the caller on top of this. +type BuildSettings struct { + Profile string // selected profile name, empty when none applies + Configuration string + Scheme string + Signing bool + Provider string // profile provider, else the top-level provider; may be empty (GitHub) + Env map[string]string + Distribution string +} + +// Distributions are the accepted values of a profile's distribution field. +var Distributions = []string{"development", "ad-hoc", "app-store", "enterprise"} + +// reservedEnv names the variables the runners read their parameters from. A +// profile that set one of these would silently change the build. +var reservedEnv = []string{ + "BUILD_ID", "SNAPSHOT_REF", "SNAPSHOT_SHA", "IOS_PATH", "SCHEME", "CONFIGURATION", + "USE_SIGNING", "FLUTTER_VERSION", "JDK_VERSION", "BUILD_ENV", "DISTRIBUTION", + "BUILDER_REPOSITORY", "BUILDER_WORKSPACE", "DURATION", "PROJECT_TYPE", +} + +var envNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// ProfileNames lists the configured profiles, sorted. +func (c *Config) ProfileNames() []string { + names := make([]string, 0, len(c.Profiles)) + for n := range c.Profiles { + names = append(names, n) + } + sort.Strings(names) + return names +} + +// ResolveProfile applies the named profile, or defaultProfile when name is +// empty, over the top-level ios.* and provider settings. With neither, the +// result is the top-level settings unchanged, so projects without profiles +// build exactly as before. +func (c *Config) ResolveProfile(name string) (BuildSettings, error) { + s := BuildSettings{ + Configuration: c.IOS.Configuration, + Scheme: c.IOS.Scheme, + Signing: c.IOS.Signing, + Provider: c.Provider, + } + if name == "" { + name = c.DefaultProfile + } + if name == "" { + return s, nil + } + p, ok := c.Profiles[name] + if !ok { + if len(c.Profiles) == 0 { + return s, fmt.Errorf("profile %q is not defined; builder.json has no profiles", name) + } + return s, fmt.Errorf("profile %q is not defined; available profiles: %s", name, strings.Join(c.ProfileNames(), ", ")) + } + if p.Distribution != "" && !slices.Contains(Distributions, p.Distribution) { + return s, fmt.Errorf("profile %q: distribution %q must be one of %s", name, p.Distribution, strings.Join(Distributions, ", ")) + } + for k := range p.Env { + if !envNameRe.MatchString(k) { + return s, fmt.Errorf("profile %q: env name %q is not a valid environment variable name", name, k) + } + if slices.Contains(reservedEnv, k) { + return s, fmt.Errorf("profile %q: env name %q is reserved for the runner's own parameters", name, k) + } + } + s.Profile = name + if p.Configuration != "" { + s.Configuration = p.Configuration + } + if p.Scheme != "" { + s.Scheme = p.Scheme + } + if p.Signing != nil { + s.Signing = *p.Signing + } + if p.Provider != "" { + s.Provider = p.Provider + } + if len(p.Env) > 0 { + s.Env = p.Env + } + s.Distribution = p.Distribution + return s, nil +} + +// EnvJSON encodes the profile's environment as a JSON object, which is how it +// travels to the runner: workflow inputs and CI variables are strings, and JSON +// survives values with spaces, quotes and newlines. Empty when there is none. +func (s BuildSettings) EnvJSON() string { + if len(s.Env) == 0 { + return "" + } + data, _ := json.Marshal(s.Env) // a map[string]string cannot fail to marshal + return string(data) +} + +// ProfileInput encodes the parts of the profile that are not workflow inputs of +// their own (name, env, distribution) as the single `profile` dispatch input, +// keeping the workflow under GitHub's limit of ten inputs. Empty when no +// profile is selected, so older workflow files keep receiving the inputs they +// declare. +func (s BuildSettings) ProfileInput() string { + if s.Profile == "" { + return "" + } + env := s.Env + if env == nil { + env = map[string]string{} + } + data, _ := json.Marshal(struct { + Name string `json:"name"` + Env map[string]string `json:"env"` + Distribution string `json:"distribution"` + }{s.Profile, env, s.Distribution}) + return string(data) +} diff --git a/internal/config/profile_test.go b/internal/config/profile_test.go new file mode 100644 index 0000000..f93bbe5 --- /dev/null +++ b/internal/config/profile_test.go @@ -0,0 +1,129 @@ +package config + +import ( + "encoding/json" + "strings" + "testing" +) + +func boolPtr(b bool) *bool { return &b } + +func profileConfig() *Config { + return &Config{ + Provider: "github", + IOS: IOSConfig{Path: "ios", Scheme: "Top", Signing: true, Configuration: "Debug"}, + Profiles: map[string]Profile{ + "development": {Configuration: "Debug", Signing: boolPtr(false)}, + "preview": {Configuration: "Release", Env: map[string]string{"API_URL": "https://staging.example.com"}}, + "production": {Configuration: "Release", Scheme: "MyApp", Provider: "codemagic", Distribution: "app-store"}, + }, + } +} + +func TestResolveProfile(t *testing.T) { + cfg := profileConfig() + for _, tt := range []struct { + name, profile string + want BuildSettings + }{ + {"no profile keeps top-level settings", "", BuildSettings{Configuration: "Debug", Scheme: "Top", Signing: true, Provider: "github"}}, + {"false overrides true", "development", BuildSettings{Profile: "development", Configuration: "Debug", Scheme: "Top", Signing: false, Provider: "github"}}, + {"unset fields inherit", "preview", BuildSettings{Profile: "preview", Configuration: "Release", Scheme: "Top", Signing: true, Provider: "github", Env: map[string]string{"API_URL": "https://staging.example.com"}}}, + {"every field overrides", "production", BuildSettings{Profile: "production", Configuration: "Release", Scheme: "MyApp", Signing: true, Provider: "codemagic", Distribution: "app-store"}}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := cfg.ResolveProfile(tt.profile) + if err != nil { + t.Fatal(err) + } + if got.Profile != tt.want.Profile || got.Configuration != tt.want.Configuration || got.Scheme != tt.want.Scheme || + got.Signing != tt.want.Signing || got.Provider != tt.want.Provider || got.Distribution != tt.want.Distribution || + len(got.Env) != len(tt.want.Env) || got.Env["API_URL"] != tt.want.Env["API_URL"] { + t.Fatalf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestResolveProfileDefault(t *testing.T) { + cfg := profileConfig() + cfg.DefaultProfile = "preview" + s, err := cfg.ResolveProfile("") + if err != nil || s.Profile != "preview" || s.Configuration != "Release" { + t.Fatalf("default profile not applied: %+v %v", s, err) + } + // An explicit --profile beats defaultProfile. + s, err = cfg.ResolveProfile("production") + if err != nil || s.Profile != "production" { + t.Fatalf("explicit profile lost to default: %+v %v", s, err) + } + cfg.DefaultProfile = "nightly" + if _, err := cfg.ResolveProfile(""); err == nil || !strings.Contains(err.Error(), `"nightly"`) { + t.Fatalf("unknown defaultProfile accepted: %v", err) + } +} + +func TestResolveProfileErrors(t *testing.T) { + cfg := profileConfig() + _, err := cfg.ResolveProfile("staging") + if err == nil || !strings.Contains(err.Error(), "development, preview, production") { + t.Fatalf("unknown profile should list the available names: %v", err) + } + if _, err := (&Config{}).ResolveProfile("staging"); err == nil || !strings.Contains(err.Error(), "no profiles") { + t.Fatalf("missing profiles section: %v", err) + } + for name, p := range map[string]Profile{ + "bad distribution": {Distribution: "adhoc"}, + "bad env name": {Env: map[string]string{"API-URL": "x"}}, + "env with equals": {Env: map[string]string{"A=B": "x"}}, + "reserved env": {Env: map[string]string{"SCHEME": "Other"}}, + } { + cfg.Profiles["bad"] = p + if _, err := cfg.ResolveProfile("bad"); err == nil { + t.Errorf("%s accepted", name) + } + } +} + +func TestProfileJSONRoundTrip(t *testing.T) { + raw := `{"project":"App","github":{"owner":"o","repo":"r"},"defaultProfile":"preview", + "profiles":{"preview":{"configuration":"Release","signing":false,"env":{"API_URL":"https://staging.example.com"},"distribution":"ad-hoc"}}}` + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatal(err) + } + p := cfg.Profiles["preview"] + if p.Signing == nil || *p.Signing || p.Distribution != "ad-hoc" || cfg.DefaultProfile != "preview" { + t.Fatalf("parsed %+v", cfg) + } + out, err := json.Marshal(&Config{Project: "App"}) + if err != nil || strings.Contains(string(out), "profiles") || strings.Contains(string(out), "defaultProfile") { + t.Fatalf("empty profiles should be omitted: %s %v", out, err) + } +} + +func TestProfileEncodings(t *testing.T) { + s := BuildSettings{} + if s.EnvJSON() != "" || s.ProfileInput() != "" { + t.Fatal("no profile must produce no inputs") + } + s = BuildSettings{Profile: "preview", Env: map[string]string{"MSG": "line one\nline \"two\""}, Distribution: "ad-hoc"} + var env map[string]string + if err := json.Unmarshal([]byte(s.EnvJSON()), &env); err != nil || env["MSG"] != s.Env["MSG"] { + t.Fatalf("env encoding: %q %v", s.EnvJSON(), err) + } + var input struct { + Name string + Env map[string]string + Distribution string + } + if err := json.Unmarshal([]byte(s.ProfileInput()), &input); err != nil || input.Name != "preview" || input.Distribution != "ad-hoc" || input.Env["MSG"] != s.Env["MSG"] { + t.Fatalf("profile input: %q %v", s.ProfileInput(), err) + } + if strings.Contains(s.ProfileInput(), "\n") { + t.Fatal("profile input must be a single line") + } + if got := (BuildSettings{Profile: "development"}).ProfileInput(); !strings.Contains(got, `"env":{}`) { + t.Fatalf("env should be an object even when empty: %s", got) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index d67914d..16c39d3 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -18,6 +18,24 @@ type Config struct { ReactNative ReactNativeConfig `json:"reactNative,omitempty"` KMP KMPConfig `json:"kmp,omitempty"` MobAI MobAIConfig `json:"mobai,omitempty"` + // DefaultProfile is used when a command is run without --profile. Tag-triggered + // runs have no flags, so it is also the only way they can select a profile. + DefaultProfile string `json:"defaultProfile,omitempty"` + Profiles map[string]Profile `json:"profiles,omitempty"` +} + +// Profile is a named set of build settings, selected with --profile. Every +// field is optional and overrides the matching top-level setting; unset fields +// keep the top-level value. Runner and submit settings are planned here too. +type Profile struct { + Configuration string `json:"configuration,omitempty"` // overrides ios.configuration + Scheme string `json:"scheme,omitempty"` // overrides ios.scheme + Signing *bool `json:"signing,omitempty"` // overrides ios.signing; a pointer so false can override true + Provider string `json:"provider,omitempty"` // overrides provider + Env map[string]string `json:"env,omitempty"` // exported on the runner before dependencies and the build + // Distribution is reserved for the export step (development, ad-hoc, app-store, + // enterprise). It is validated and passed to the runner but not applied yet. + Distribution string `json:"distribution,omitempty"` } // CIConfig identifies an app already connected to the project's GitHub repository. From cd92fbb4e23a575baeaa2787c6264b54eaa77d7a Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:04:49 +0200 Subject: [PATCH 06/75] 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. --- cmd/builder/root.go | 32 +++++++- internal/build/coordinator.go | 100 ++++++++++++++++-------- internal/build/inputs_test.go | 140 ++++++++++++++++++++++++++++++++++ internal/build/progress.go | 37 ++++++++- internal/build/remote.go | 41 ++++++---- internal/build/share.go | 29 +++---- 6 files changed, 310 insertions(+), 69 deletions(-) create mode 100644 internal/build/inputs_test.go diff --git a/cmd/builder/root.go b/cmd/builder/root.go index cfda5b9..10f7b5a 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -551,15 +551,31 @@ func init() { iosBuildCmd.Flags().Bool("unsigned", false, "Build unsigned IPA (skip code signing even if configured)") iosBuildCmd.Flags().StringP("remote", "r", "origin", "Git remote to push the working-tree snapshot to") iosBuildCmd.Flags().String("provider", "", "Override CI provider (default github or builder.json provider)") + iosBuildCmd.Flags().String("profile", "", "Build profile from builder.json (default: defaultProfile, else the top-level ios settings)") iosCmd.AddCommand(iosBuildCmd) // iOS share command flags iosShareCmd.Flags().Duration("duration", 30*time.Minute, "How long the simulator stays available while unused") iosShareCmd.Flags().StringP("remote", "r", "origin", "Git remote to push the working-tree snapshot to") iosShareCmd.Flags().String("provider", "", "Override CI provider (default github or builder.json provider)") + iosShareCmd.Flags().String("profile", "", "Build profile from builder.json; its scheme, provider and env apply to the simulator build") iosCmd.AddCommand(iosShareCmd) } +// effectiveProvider is the --provider flag, else the selected profile's +// provider, else builder.json's. The coordinator resolves the same chain; this +// exists so the GitHub client and signal handling agree with it. +func effectiveProvider(cfg *config.Config, profile, flag string) (string, error) { + if flag != "" { + return flag, nil + } + s, err := cfg.ResolveProfile(profile) + if err != nil { + return "", err + } + return s.Provider, nil +} + func runIOSBuild(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { @@ -574,13 +590,18 @@ func runIOSBuild(cmd *cobra.Command, args []string) error { timeout, _ := cmd.Flags().GetDuration("timeout") unsigned, _ := cmd.Flags().GetBool("unsigned") remote, _ := cmd.Flags().GetString("remote") - provider, _ := cmd.Flags().GetString("provider") + providerFlag, _ := cmd.Flags().GetString("provider") + profile, _ := cmd.Flags().GetString("profile") ctx := cmd.Context() if ctx == nil { ctx = context.Background() } + provider, err := effectiveProvider(cfg, profile, providerFlag) + if err != nil { + return err + } name, err := cfg.ProviderName(provider) if err != nil { return err @@ -592,6 +613,7 @@ func runIOSBuild(cmd *cobra.Command, args []string) error { } return runBuild(ctx, cfg, build.BuildOptions{ Provider: provider, + Profile: profile, OutputDir: outputDir, Timeout: timeout, Unsigned: unsigned, @@ -610,7 +632,8 @@ func runIOSShare(cmd *cobra.Command, args []string) error { duration, _ := cmd.Flags().GetDuration("duration") remote, _ := cmd.Flags().GetString("remote") - provider, _ := cmd.Flags().GetString("provider") + providerFlag, _ := cmd.Flags().GetString("provider") + profile, _ := cmd.Flags().GetString("profile") ctx := cmd.Context() if ctx == nil { @@ -622,12 +645,17 @@ func runIOSShare(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() + provider, err := effectiveProvider(cfg, profile, providerFlag) + if err != nil { + return err + } ghClient, err := clientForProvider(cfg, provider) if err != nil { return err } result, err := build.NewCoordinator(cfg, ghClient).Share(ctx, build.ShareOptions{ Provider: provider, + Profile: profile, Duration: duration, Remote: remote, }) diff --git a/internal/build/coordinator.go b/internal/build/coordinator.go index 4dfc97f..a72b23b 100644 --- a/internal/build/coordinator.go +++ b/internal/build/coordinator.go @@ -56,13 +56,77 @@ func NewCoordinatorWithOutput(cfg *config.Config, gh *github.Client, w io.Writer // BuildOptions contains options for a build type BuildOptions struct { - Provider string // Override the configured CI provider + Provider string // Override the configured CI provider (and the profile's) + Profile string // builder.json profile to build with; empty uses defaultProfile, else the top-level settings OutputDir string Timeout time.Duration Unsigned bool // Skip code signing even if configured Remote string // Git remote to push the working-tree snapshot to } +// settings applies the selected profile, then the command flags, over +// builder.json. The returned name is the provider that will run the job. +func (c *Coordinator) settings(profile, provider string, unsigned bool) (config.BuildSettings, string, error) { + s, err := c.config.ResolveProfile(profile) + if err != nil { + return s, "", err + } + if provider != "" { + s.Provider = provider + } + name, err := c.config.ProviderName(s.Provider) + if err != nil { + return s, "", err + } + if unsigned { + s.Signing = false + } + return s, name, nil +} + +// workflowInputs maps the settings onto the workflow_dispatch inputs both +// GitHub workflows share. Empty values are left out so the declared defaults +// apply, and `profile` is only sent when one is selected: a workflow file from +// before profiles rejects a dispatch carrying an input it does not declare. +func (c *Coordinator) workflowInputs(buildID, ref string, s config.BuildSettings) map[string]string { + inputs := map[string]string{ + "build_id": buildID, + "snapshot_ref": ref, + } + if c.config.IOS.Path != "" { + inputs["ios_path"] = c.config.IOS.Path + } + if s.Scheme != "" { + inputs["scheme"] = s.Scheme + } + // Pass Flutter version if configured (ensures SDK version match for hot reload) + if c.config.Flutter.Version != "" { + inputs["flutter_version"] = c.config.Flutter.Version + } + // Pass JDK version for Kotlin Multiplatform Gradle builds + if c.config.KMP.JDKVersion != "" { + inputs["jdk_version"] = c.config.KMP.JDKVersion + } + if p := s.ProfileInput(); p != "" { + inputs["profile"] = p + } + return inputs +} + +// buildInputs are the ios-build.yml inputs: the shared ones plus signing and +// configuration, which the simulator workflow has no use for. +func (c *Coordinator) buildInputs(buildID, ref string, s config.BuildSettings) map[string]string { + inputs := c.workflowInputs(buildID, ref, s) + if s.Signing { + inputs["use_signing"] = "true" + } + // Pass build configuration (Debug is faster, Release for production) + if s.Configuration != "" { + inputs["configuration"] = s.Configuration + } + return inputs +} + // BuildResult contains the result of a build type BuildResult struct { BuildID string @@ -74,12 +138,12 @@ type BuildResult struct { // Build triggers a remote build and downloads the IPA artifact func (c *Coordinator) Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) { - name, err := c.config.ProviderName(opts.Provider) + settings, name, err := c.settings(opts.Profile, opts.Provider, opts.Unsigned) if err != nil { return nil, err } if name != "github" || c.provider != nil { - return c.buildRemote(ctx, opts) + return c.buildRemote(ctx, opts, settings) } if c.github == nil { return nil, fmt.Errorf("GitHub client is required") @@ -98,6 +162,7 @@ func (c *Coordinator) Build(ctx context.Context, opts BuildOptions) (*BuildResul // Generate build ID buildID := uuid.New().String()[:8] c.progress.Start(buildID) + c.progress.Settings(settings, name) // Step 1: Snapshot the working tree so the build matches what's on disk c.progress.Update(PhaseSnapshot, "Snapshotting working tree...") @@ -117,34 +182,7 @@ func (c *Coordinator) Build(ctx context.Context, opts BuildOptions) (*BuildResul // Step 2: Trigger workflow c.progress.Update(PhaseTriggering, "Triggering GitHub Actions build...") - inputs := map[string]string{ - "build_id": buildID, - "snapshot_ref": ref, - } - // Add iOS-specific inputs if configured - if c.config.IOS.Path != "" { - inputs["ios_path"] = c.config.IOS.Path - } - if c.config.IOS.Scheme != "" { - inputs["scheme"] = c.config.IOS.Scheme - } - // Determine signing: use signing if configured and not explicitly disabled - useSigning := c.config.IOS.Signing && !opts.Unsigned - if useSigning { - inputs["use_signing"] = "true" - } - // Pass build configuration (Debug is faster, Release for production) - if c.config.IOS.Configuration != "" { - inputs["configuration"] = c.config.IOS.Configuration - } - // Pass Flutter version if configured (ensures SDK version match for hot reload) - if c.config.Flutter.Version != "" { - inputs["flutter_version"] = c.config.Flutter.Version - } - // Pass JDK version for Kotlin Multiplatform Gradle builds - if c.config.KMP.JDKVersion != "" { - inputs["jdk_version"] = c.config.KMP.JDKVersion - } + inputs := c.buildInputs(buildID, ref, settings) if err := c.github.TriggerWorkflow(ctx, c.config.GitHub.Owner, c.config.GitHub.Repo, WorkflowFile, inputs); err != nil { c.progress.Error(PhaseTriggering, err) return nil, fmt.Errorf("failed to trigger workflow: %w", err) diff --git a/internal/build/inputs_test.go b/internal/build/inputs_test.go new file mode 100644 index 0000000..570c64e --- /dev/null +++ b/internal/build/inputs_test.go @@ -0,0 +1,140 @@ +package build + +import ( + "bytes" + "encoding/json" + "io" + "reflect" + "strings" + "testing" + + "github.com/MobAI-App/ios-builder/internal/config" +) + +func profiledConfig() *config.Config { + signed := true + return &config.Config{ + Project: "App", + GitHub: config.GitHubConfig{Owner: "owner", Repo: "repo"}, + IOS: config.IOSConfig{Path: "ios", Scheme: "App", Configuration: "Debug"}, + Flutter: config.FlutterConfig{Version: "3.24.0"}, + Profiles: map[string]config.Profile{ + "preview": { + Configuration: "Release", Signing: &signed, Scheme: "AppPreview", Distribution: "ad-hoc", + Env: map[string]string{"API_URL": "https://staging.example.com", "FLAGS": "a b"}, + }, + "ci": {Provider: "codemagic"}, + }, + Codemagic: config.CIConfig{AppID: "app", Branch: "main"}, + } +} + +func TestSettingsPrecedence(t *testing.T) { + c := NewCoordinatorWithOutput(profiledConfig(), nil, io.Discard) + s, name, err := c.settings("", "", false) + if err != nil || name != "github" || s.Profile != "" || s.Configuration != "Debug" || s.Signing { + t.Fatalf("top-level settings: %+v %s %v", s, name, err) + } + s, name, err = c.settings("preview", "", false) + if err != nil || name != "github" || !s.Signing || s.Configuration != "Release" { + t.Fatalf("profile settings: %+v %s %v", s, name, err) + } + // --unsigned beats the profile's signing. + if s, _, err = c.settings("preview", "", true); err != nil || s.Signing { + t.Fatalf("--unsigned ignored: %+v %v", s, err) + } + // The profile's provider applies, and --provider beats it. + if _, name, err = c.settings("ci", "", false); err != nil || name != "codemagic" { + t.Fatalf("profile provider: %s %v", name, err) + } + if _, name, err = c.settings("ci", "bitrise", false); err != nil || name != "bitrise" { + t.Fatalf("--provider ignored: %s %v", name, err) + } + if _, _, err = c.settings("nope", "", false); err == nil || !strings.Contains(err.Error(), "ci, preview") { + t.Fatalf("unknown profile: %v", err) + } +} + +func TestGitHubInputsMapping(t *testing.T) { + c := NewCoordinatorWithOutput(profiledConfig(), nil, io.Discard) + + s, _, _ := c.settings("", "", false) + got := c.buildInputs("abcdef12", "refs/ios-builder/jobs/abcdef12", s) + want := map[string]string{ + "build_id": "abcdef12", "snapshot_ref": "refs/ios-builder/jobs/abcdef12", + "ios_path": "ios", "scheme": "App", "configuration": "Debug", "flutter_version": "3.24.0", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("without a profile the inputs must be unchanged:\n got %v\nwant %v", got, want) + } + + s, _, _ = c.settings("preview", "", false) + got = c.buildInputs("abcdef12", "ref", s) + if got["scheme"] != "AppPreview" || got["configuration"] != "Release" || got["use_signing"] != "true" { + t.Fatalf("profile not mapped: %v", got) + } + var profile struct { + Name string + Env map[string]string + Distribution string + } + if err := json.Unmarshal([]byte(got["profile"]), &profile); err != nil { + t.Fatalf("profile input is not JSON: %q %v", got["profile"], err) + } + if profile.Name != "preview" || profile.Distribution != "ad-hoc" || profile.Env["FLAGS"] != "a b" || len(profile.Env) != 2 { + t.Fatalf("profile input: %+v", profile) + } + if len(got) > 10 { + t.Fatalf("workflow_dispatch allows at most 10 inputs, sending %d", len(got)) + } + + share := c.workflowInputs("abcdef12", "ref", s) + for _, k := range []string{"use_signing", "configuration"} { + if _, ok := share[k]; ok { + t.Fatalf("simulator workflow does not declare %s", k) + } + } + if share["profile"] == "" || share["scheme"] != "AppPreview" { + t.Fatalf("share inputs: %v", share) + } +} + +func TestRemoteInputsMapping(t *testing.T) { + c := NewCoordinatorWithOutput(profiledConfig(), nil, io.Discard) + + s, _, _ := c.settings("", "", false) + got := c.inputs("abcdef12", "ref", "sha", s) + for _, k := range []string{"BUILD_ENV", "DISTRIBUTION"} { + if _, ok := got[k]; ok { + t.Fatalf("%s must be absent without a profile: %v", k, got) + } + } + if got["USE_SIGNING"] != "false" || got["SCHEME"] != "App" || got["CONFIGURATION"] != "Debug" { + t.Fatalf("top-level mapping: %v", got) + } + + s, _, _ = c.settings("preview", "", false) + got = c.inputs("abcdef12", "ref", "sha", s) + if got["USE_SIGNING"] != "true" || got["SCHEME"] != "AppPreview" || got["CONFIGURATION"] != "Release" || got["DISTRIBUTION"] != "ad-hoc" { + t.Fatalf("profile mapping: %v", got) + } + var env map[string]string + if err := json.Unmarshal([]byte(got["BUILD_ENV"]), &env); err != nil || env["API_URL"] != "https://staging.example.com" { + t.Fatalf("BUILD_ENV: %q %v", got["BUILD_ENV"], err) + } +} + +func TestSettingsPrinted(t *testing.T) { + var out bytes.Buffer + p := NewProgress(&out) + p.Start("abcdef12") + p.Settings(config.BuildSettings{Profile: "preview", Configuration: "Release", Signing: true, Env: map[string]string{"B": "2", "A": "1"}, Distribution: "ad-hoc"}, "github") + for _, want := range []string{"Profile: preview", "Configuration: Release", "Scheme: (auto-detected)", "Signing: signed", "Provider: github", "Env: A, B", "Distribution: ad-hoc"} { + if !strings.Contains(out.String(), want) { + t.Errorf("missing %q in:\n%s", want, out.String()) + } + } + if strings.Contains(out.String(), "staging") || strings.Contains(out.String(), "=1") { + t.Fatal("env values should not be printed, only names") + } +} diff --git a/internal/build/progress.go b/internal/build/progress.go index 956eb77..5d1cf53 100644 --- a/internal/build/progress.go +++ b/internal/build/progress.go @@ -3,9 +3,13 @@ package build import ( "fmt" "io" + "maps" + "slices" "strings" "sync" "time" + + "github.com/MobAI-App/ios-builder/internal/config" ) // Phase represents a build phase @@ -63,7 +67,38 @@ func (p *Progress) Start(buildID string) { fmt.Fprintf(p.writer, "\n") fmt.Fprintf(p.writer, "🏗️ Builder - Remote iOS Build\n") - fmt.Fprintf(p.writer, " Build ID: %s\n", buildID) + fmt.Fprintf(p.writer, " Build ID: %s\n", buildID) +} + +// Settings prints what the job will run with, before anything is dispatched, +// so a wrong profile or flag is visible without opening the provider's logs. +// It completes the header that Start begins. +func (p *Progress) Settings(s config.BuildSettings, provider string) { + p.mu.Lock() + defer p.mu.Unlock() + + orDefault := func(v, d string) string { + if v == "" { + return d + } + return v + } + signing := "unsigned" + if s.Signing { + signing = "signed" + } + fmt.Fprintf(p.writer, " Profile: %s\n", orDefault(s.Profile, "(none)")) + fmt.Fprintf(p.writer, " Configuration: %s\n", orDefault(s.Configuration, "Debug")) + fmt.Fprintf(p.writer, " Scheme: %s\n", orDefault(s.Scheme, "(auto-detected)")) + fmt.Fprintf(p.writer, " Signing: %s\n", signing) + fmt.Fprintf(p.writer, " Provider: %s\n", provider) + if len(s.Env) > 0 { + keys := slices.Sorted(maps.Keys(s.Env)) + fmt.Fprintf(p.writer, " Env: %s\n", strings.Join(keys, ", ")) + } + if s.Distribution != "" { + fmt.Fprintf(p.writer, " Distribution: %s\n", s.Distribution) + } fmt.Fprintf(p.writer, "\n") } diff --git a/internal/build/remote.go b/internal/build/remote.go index 8d35313..f1b6cdc 100644 --- a/internal/build/remote.go +++ b/internal/build/remote.go @@ -61,10 +61,14 @@ func (c *Coordinator) remote(override string) (ci.Provider, config.CIConfig, err return RemoteProvider(c.config, override) } -func (c *Coordinator) inputs(buildID, ref, sha string) map[string]string { +// inputs are the variables runner.sh reads on Codemagic and Bitrise. The +// profile's env travels as one JSON object in BUILD_ENV, which the runner +// exports before installing dependencies; DISTRIBUTION is passed through for +// the export step. Both are only set when the profile provides them. +func (c *Coordinator) inputs(buildID, ref, sha string, s config.BuildSettings) map[string]string { v := map[string]string{"BUILD_ID": buildID, "SNAPSHOT_REF": ref, "SNAPSHOT_SHA": sha, - "IOS_PATH": c.config.IOS.Path, "SCHEME": c.config.IOS.Scheme, - "CONFIGURATION": c.config.IOS.Configuration, "FLUTTER_VERSION": c.config.Flutter.Version, + "IOS_PATH": c.config.IOS.Path, "SCHEME": s.Scheme, + "CONFIGURATION": s.Configuration, "FLUTTER_VERSION": c.config.Flutter.Version, "JDK_VERSION": c.config.KMP.JDKVersion, "USE_SIGNING": "false", "BUILDER_REPOSITORY": c.config.GitHub.Owner + "/" + c.config.GitHub.Repo} if v["IOS_PATH"] == "" { @@ -76,11 +80,21 @@ func (c *Coordinator) inputs(buildID, ref, sha string) map[string]string { if v["JDK_VERSION"] == "" { v["JDK_VERSION"] = "17" } + if s.Signing { + v["USE_SIGNING"] = "true" + } + if env := s.EnvJSON(); env != "" { + v["BUILD_ENV"] = env + } + if s.Distribution != "" { + v["DISTRIBUTION"] = s.Distribution + } return v } -func (c *Coordinator) pushSnapshot(ctx context.Context, remote, buildID string) (string, string, error) { +func (c *Coordinator) pushSnapshot(ctx context.Context, remote, buildID string, s config.BuildSettings, provider string) (string, string, error) { c.progress.Start(buildID) + c.progress.Settings(s, provider) c.progress.Update(PhaseSnapshot, "Snapshotting working tree...") sha, err := snapshot.Create(ctx, fmt.Sprintf("ios-builder snapshot %s", buildID)) if err != nil { @@ -94,8 +108,8 @@ func (c *Coordinator) pushSnapshot(ctx context.Context, remote, buildID string) return ref, sha, nil } -func (c *Coordinator) buildRemote(ctx context.Context, opts BuildOptions) (*BuildResult, error) { - p, cfgCI, err := c.remote(opts.Provider) +func (c *Coordinator) buildRemote(ctx context.Context, opts BuildOptions, s config.BuildSettings) (*BuildResult, error) { + p, cfgCI, err := c.remote(s.Provider) if err != nil { return nil, err } @@ -115,14 +129,11 @@ func (c *Coordinator) buildRemote(ctx context.Context, opts BuildOptions) (*Buil defer cancel() started := time.Now() buildID := uuid.New().String()[:8] - ref, sha, err := c.pushSnapshot(ctx, opts.Remote, buildID) + ref, sha, err := c.pushSnapshot(ctx, opts.Remote, buildID, s, p.Name()) if err != nil { return nil, err } - v := c.inputs(buildID, ref, sha) - if c.config.IOS.Signing && !opts.Unsigned { - v["USE_SIGNING"] = "true" - } + v := c.inputs(buildID, ref, sha, s) c.progress.Update(PhaseTriggering, "Triggering "+p.Name()+" build...") run, err := p.Start(ctx, ci.Request{Workflow: cfgCI.BuildWorkflow, Variables: v}) if err != nil { @@ -263,8 +274,8 @@ func saveRemoteIPA(ctx context.Context, p ci.Provider, run ci.Run, a ci.Artifact return dest, n, nil } -func (c *Coordinator) shareRemote(ctx context.Context, opts ShareOptions) (*ShareResult, error) { - p, cfgCI, err := c.remote(opts.Provider) +func (c *Coordinator) shareRemote(ctx context.Context, opts ShareOptions, s config.BuildSettings) (*ShareResult, error) { + p, cfgCI, err := c.remote(s.Provider) if err != nil { return nil, err } @@ -286,11 +297,11 @@ func (c *Coordinator) shareRemote(ctx context.Context, opts ShareOptions) (*Shar ctx, cancel := context.WithTimeout(ctx, opts.Timeout) defer cancel() buildID := uuid.New().String()[:8] - ref, sha, err := c.pushSnapshot(ctx, opts.Remote, buildID) + ref, sha, err := c.pushSnapshot(ctx, opts.Remote, buildID, s, p.Name()) if err != nil { return nil, err } - v := c.inputs(buildID, ref, sha) + v := c.inputs(buildID, ref, sha, s) v["DURATION"] = opts.Duration.String() run, err := p.Start(ctx, ci.Request{Workflow: cfgCI.ShareWorkflow, Variables: v}) if err != nil { diff --git a/internal/build/share.go b/internal/build/share.go index 9e931b6..0d4d610 100644 --- a/internal/build/share.go +++ b/internal/build/share.go @@ -16,7 +16,8 @@ const ShareWorkflowFile = "ios-share.yml" // ShareOptions configures a simulator session. type ShareOptions struct { - Provider string // Override the configured CI provider + Provider string // Override the configured CI provider (and the profile's) + Profile string // builder.json profile; only its scheme, provider and env apply to a simulator build // Duration is how long the simulator stays available while unused. Using // it keeps it open past this. Duration time.Duration @@ -44,12 +45,14 @@ const sharePublishGrace = 30 * time.Second // Share builds the working tree for the simulator and publishes it to the // account's MobAI app, then returns while the job outlives the command. func (c *Coordinator) Share(ctx context.Context, opts ShareOptions) (*ShareResult, error) { - name, err := c.config.ProviderName(opts.Provider) + settings, name, err := c.settings(opts.Profile, opts.Provider, true) if err != nil { return nil, err } + // Simulator builds are always Debug and never signed, whatever the profile says. + settings.Configuration = "Debug" if name != "github" || c.provider != nil { - return c.shareRemote(ctx, opts) + return c.shareRemote(ctx, opts, settings) } if c.github == nil { return nil, fmt.Errorf("GitHub client is required") @@ -65,6 +68,7 @@ func (c *Coordinator) Share(ctx context.Context, opts ShareOptions) (*ShareResul buildID := uuid.New().String()[:8] c.progress.Start(buildID) + c.progress.Settings(settings, name) c.progress.Update(PhaseSnapshot, "Snapshotting working tree...") sha, err := snapshot.Create(ctx, fmt.Sprintf("ios-builder snapshot %s", buildID)) @@ -82,23 +86,8 @@ func (c *Coordinator) Share(ctx context.Context, opts ShareOptions) (*ShareResul c.progress.Complete(PhaseSnapshot, fmt.Sprintf("Pushed %s", sha[:7])) c.progress.Update(PhaseTriggering, "Starting the simulator session...") - inputs := map[string]string{ - "build_id": buildID, - "snapshot_ref": ref, - "duration": opts.Duration.String(), - } - if c.config.IOS.Path != "" { - inputs["ios_path"] = c.config.IOS.Path - } - if c.config.IOS.Scheme != "" { - inputs["scheme"] = c.config.IOS.Scheme - } - if c.config.Flutter.Version != "" { - inputs["flutter_version"] = c.config.Flutter.Version - } - if c.config.KMP.JDKVersion != "" { - inputs["jdk_version"] = c.config.KMP.JDKVersion - } + inputs := c.workflowInputs(buildID, ref, settings) + inputs["duration"] = opts.Duration.String() if err := c.github.TriggerWorkflow(ctx, c.config.GitHub.Owner, c.config.GitHub.Repo, ShareWorkflowFile, inputs); err != nil { c.progress.Error(PhaseTriggering, err) return nil, fmt.Errorf("failed to trigger workflow: %w", err) From 63da3b65f577d905193623e3ad6b2fe70f1eb6b3 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:04:49 +0200 Subject: [PATCH 07/75] 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. --- internal/workflow/profile_test.go | 194 ++++++++++++++++++++++ internal/workflow/providers_test.go | 10 +- internal/workflow/templates/ios-build.yml | 69 +++++++- internal/workflow/templates/ios-share.yml | 51 +++++- internal/workflow/templates/runner.sh | 17 ++ 5 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 internal/workflow/profile_test.go diff --git a/internal/workflow/profile_test.go b/internal/workflow/profile_test.go new file mode 100644 index 0000000..9639649 --- /dev/null +++ b/internal/workflow/profile_test.go @@ -0,0 +1,194 @@ +package workflow + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "go.yaml.in/yaml/v3" +) + +// resolveStep returns the shell of the "Resolve parameters" step of a workflow +// template, which is where builder.json profiles are applied on the runner. +func resolveStep(t *testing.T, file string) string { + t.Helper() + data, err := GetTemplate(file) + if err != nil { + t.Fatal(err) + } + var wf struct { + Jobs map[string]struct { + Steps []struct { + Name string `yaml:"name"` + Run string `yaml:"run"` + } `yaml:"steps"` + } `yaml:"jobs"` + } + if err := yaml.Unmarshal(data, &wf); err != nil { + t.Fatal(err) + } + for _, job := range wf.Jobs { + for _, step := range job.Steps { + if step.Name == "Resolve parameters" { + return step.Run + } + } + } + t.Fatalf("%s has no Resolve parameters step", file) + return "" +} + +type resolved struct { + outputs map[string]string + env map[string]string + log string + err error +} + +// runResolve executes the step the way the runner does: bash, GITHUB_OUTPUT and +// GITHUB_ENV files, builder.json in the working directory. +func runResolve(t *testing.T, script, builderJSON string, env map[string]string) resolved { + t.Helper() + dir := t.TempDir() + if builderJSON != "" { + if err := os.WriteFile(filepath.Join(dir, "builder.json"), []byte(builderJSON), 0644); err != nil { + t.Fatal(err) + } + } + scriptPath := filepath.Join(dir, "resolve.sh") + if err := os.WriteFile(scriptPath, []byte(script), 0644); err != nil { + t.Fatal(err) + } + outPath, envPath := filepath.Join(dir, "output"), filepath.Join(dir, "env") + cmd := exec.Command("bash", "-e", scriptPath) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GITHUB_OUTPUT="+outPath, "GITHUB_ENV="+envPath, "GITHUB_REF_NAME=ios-build/abcdef12") + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + out, err := cmd.CombinedOutput() + r := resolved{outputs: map[string]string{}, env: map[string]string{}, log: string(out), err: err} + if data, err := os.ReadFile(outPath); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if k, v, ok := strings.Cut(line, "="); ok { + r.outputs[k] = v + } + } + } + if data, err := os.ReadFile(envPath); err == nil { + lines := strings.Split(string(data), "\n") + for i := 0; i < len(lines); i++ { + name, ok := strings.CutSuffix(lines[i], "<<__BUILDER_ENV__") + if !ok { + continue + } + var value []string + for i++; i < len(lines) && lines[i] != "__BUILDER_ENV__"; i++ { + value = append(value, lines[i]) + } + r.env[name] = strings.Join(value, "\n") + } + } + return r +} + +const profiledBuilderJSON = `{ + "project": "App", "github": {"owner": "o", "repo": "r"}, + "ios": {"path": "ios", "scheme": "Top", "signing": true, "configuration": "Debug"}, + "defaultProfile": "preview", + "profiles": { + "preview": {"configuration": "Release", "signing": false, "distribution": "ad-hoc", + "env": {"API_URL": "https://staging.example.com", "NOTES": "line one\nline \"two\""}} + } +}` + +func TestResolveParametersApplyProfiles(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell test") + } + for _, tool := range []string{"bash", "jq", "base64"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s unavailable", tool) + } + } + build := resolveStep(t, "ios-build.yml") + share := resolveStep(t, "ios-share.yml") + + t.Run("tag build applies defaultProfile", func(t *testing.T) { + r := runResolve(t, build, profiledBuilderJSON, map[string]string{"GITHUB_EVENT_NAME": "push"}) + if r.err != nil { + t.Fatalf("%v\n%s", r.err, r.log) + } + want := map[string]string{"build_id": "abcdef12", "ios_path": "ios", "scheme": "Top", "use_signing": "false", + "configuration": "Release", "profile": "preview", "distribution": "ad-hoc", "jdk_version": "17"} + for k, v := range want { + if r.outputs[k] != v { + t.Errorf("%s = %q, want %q\n%s", k, r.outputs[k], v, r.log) + } + } + if r.env["API_URL"] != "https://staging.example.com" || r.env["NOTES"] != "line one\nline \"two\"" { + t.Fatalf("env not exported verbatim: %q\n%s", r.env, r.log) + } + }) + + t.Run("tag build without profiles is unchanged", func(t *testing.T) { + plain := `{"ios": {"scheme": "Top", "signing": true}}` + r := runResolve(t, build, plain, map[string]string{"GITHUB_EVENT_NAME": "push"}) + if r.err != nil { + t.Fatalf("%v\n%s", r.err, r.log) + } + if r.outputs["scheme"] != "Top" || r.outputs["use_signing"] != "true" || r.outputs["configuration"] != "Debug" || r.outputs["profile"] != "" || len(r.env) != 0 { + t.Fatalf("outputs %v env %v\n%s", r.outputs, r.env, r.log) + } + }) + + t.Run("dispatch uses the profile input", func(t *testing.T) { + env := map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_BUILD_ID": "12345678", "IN_SCHEME": "Dispatched", + "IN_USE_SIGNING": "true", "IN_CONFIGURATION": "Release", + "IN_PROFILE": `{"name":"production","env":{"API_URL":"https://api.example.com"},"distribution":"app-store"}`} + // builder.json on disk must be ignored for a dispatch. + r := runResolve(t, build, profiledBuilderJSON, env) + if r.err != nil { + t.Fatalf("%v\n%s", r.err, r.log) + } + if r.outputs["build_id"] != "12345678" || r.outputs["scheme"] != "Dispatched" || r.outputs["use_signing"] != "true" || + r.outputs["profile"] != "production" || r.outputs["distribution"] != "app-store" || r.env["API_URL"] != "https://api.example.com" { + t.Fatalf("outputs %v env %v\n%s", r.outputs, r.env, r.log) + } + // Without a selected profile the input carries its default. + env["IN_PROFILE"] = "{}" + r = runResolve(t, build, "", env) + if r.err != nil || r.outputs["profile"] != "" || r.outputs["distribution"] != "" || len(r.env) != 0 { + t.Fatalf("default profile input: %v %v %v\n%s", r.err, r.outputs, r.env, r.log) + } + }) + + t.Run("share exports env and profile scheme", func(t *testing.T) { + withScheme := strings.Replace(profiledBuilderJSON, `"configuration": "Release",`, `"configuration": "Release", "scheme": "Preview",`, 1) + r := runResolve(t, share, withScheme, map[string]string{"GITHUB_EVENT_NAME": "push"}) + if r.err != nil { + t.Fatalf("%v\n%s", r.err, r.log) + } + if r.outputs["scheme"] != "Preview" || r.outputs["profile"] != "preview" || r.outputs["duration"] != "30m" || r.env["API_URL"] == "" { + t.Fatalf("outputs %v env %v\n%s", r.outputs, r.env, r.log) + } + }) + + t.Run("bad profiles fail the job", func(t *testing.T) { + for name, tt := range map[string]struct { + json string + env map[string]string + }{ + "unknown defaultProfile": {`{"defaultProfile": "nightly", "profiles": {"preview": {}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}}, + "bad distribution": {`{"defaultProfile": "p", "profiles": {"p": {"distribution": "adhoc"}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}}, + "bad env name": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `{"name":"p","env":{"A B":"x"}}`}}, + } { + if r := runResolve(t, build, tt.json, tt.env); r.err == nil { + t.Errorf("%s accepted:\n%s", name, r.log) + } + } + }) +} diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index 3139430..dc458d8 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -149,6 +149,7 @@ for arg in "$@"; do if [ "$arg" = "-showBuildSettings" ]; then settings=true; fi prev="$arg" done +printf '%s' "${API_URL:-}|${NOTES:-}|${DISTRIBUTION:-}" > "$ENV_LOG" app="$dd/Build/Products/Debug-iphoneos/App.app" if [ "$settings" = true ]; then python3 - "$dd/Build/Products/Debug-iphoneos" <<'PY' @@ -166,10 +167,17 @@ fi scheme := `App's $(touch should-not-exist)` cmd := exec.Command("/bin/bash", script, "build") cmd.Dir = clone - cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "SNAPSHOT_REF="+ref, "SNAPSHOT_SHA="+sha, "BUILD_ID=abcdef12", "IOS_PATH=.", "USE_SIGNING=false", "CONFIGURATION=Debug", "SCHEME="+scheme, "SCHEME_LOG="+filepath.Join(dir, "scheme.log"), "BUILDER_CI_DIR="+filepath.Join(dir, "state")) + // The profile env arrives as one JSON object and must reach the build + // tools as ordinary variables, values intact. + buildEnv := `{"API_URL":"https://staging.example.com","NOTES":"line one\nline \"two\""}` + cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "SNAPSHOT_REF="+ref, "SNAPSHOT_SHA="+sha, "BUILD_ID=abcdef12", "IOS_PATH=.", "USE_SIGNING=false", "CONFIGURATION=Debug", "SCHEME="+scheme, "SCHEME_LOG="+filepath.Join(dir, "scheme.log"), "BUILDER_CI_DIR="+filepath.Join(dir, "state"), + "BUILD_ENV="+buildEnv, "DISTRIBUTION=ad-hoc", "ENV_LOG="+filepath.Join(dir, "env.log")) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("runner: %s %v", out, err) } + if data, err := os.ReadFile(filepath.Join(dir, "env.log")); err != nil || string(data) != "https://staging.example.com|line one\nline \"two\"|ad-hoc" { + t.Fatalf("profile env did not reach the build: %q %v", data, err) + } if _, err := os.Stat(filepath.Join(clone, "build", "abcdef12.ipa")); err != nil { t.Fatal("runner produced no IPA:", err) } diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index e98bc2b..3fb470e 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -50,6 +50,13 @@ on: required: false type: string default: '17' + # One input for the profile fields that are not inputs of their own, so + # the workflow stays under the ten-input limit of workflow_dispatch. + profile: + description: 'Selected builder.json profile as JSON: {"name": "...", "env": {...}, "distribution": "..."}' + required: false + type: string + default: '{}' jobs: build: @@ -76,7 +83,8 @@ jobs: # Dispatch inputs arrive with their declared defaults. A tag push has no # inputs, so the values come from builder.json in the tagged commit and - # the build id is the tag name after the prefix. + # the build id is the tag name after the prefix. A tag build cannot pick + # a profile per run; it applies builder.json's defaultProfile, if any. - name: Resolve parameters id: params env: @@ -87,27 +95,74 @@ jobs: IN_CONFIGURATION: ${{ inputs.configuration }} IN_FLUTTER_VERSION: ${{ inputs.flutter_version }} IN_JDK_VERSION: ${{ inputs.jdk_version }} + IN_PROFILE: ${{ inputs.profile }} run: | set -e + PROFILE="" + if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] && [ -f builder.json ]; then + PROFILE=$(jq -r '.defaultProfile // empty' builder.json) + if [ -n "$PROFILE" ] && [ "$(jq -r --arg p "$PROFILE" '.profiles[$p] != null' builder.json)" != "true" ]; then + echo "::error::defaultProfile \"$PROFILE\" is not defined under profiles in builder.json" + exit 1 + fi + fi param() { # name dispatch-value jq-path default local v="" if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then v="$2" elif [ -f builder.json ]; then - v=$(jq -r "$3 // empty" builder.json) + v=$(jq -r --arg p "$PROFILE" "$3 // empty" builder.json) fi v="${v:-$4}" echo "$1=$v" >> "$GITHUB_OUTPUT" echo "$1=$v" } + # Profile fields override ios.*. signing needs the explicit null test: + # jq's // would let a profile's `false` fall through to ios.signing. param build_id "$IN_BUILD_ID" '""' "${GITHUB_REF_NAME##*/}" param ios_path "$IN_IOS_PATH" '.ios.path' '.' - param scheme "$IN_SCHEME" '.ios.scheme' '' - param use_signing "$IN_USE_SIGNING" '.ios.signing' 'false' - param configuration "$IN_CONFIGURATION" '.ios.configuration' 'Debug' + param scheme "$IN_SCHEME" '(.profiles[$p].scheme // .ios.scheme)' '' + param use_signing "$IN_USE_SIGNING" '(if .profiles[$p].signing != null then .profiles[$p].signing else .ios.signing end)' 'false' + param configuration "$IN_CONFIGURATION" '(.profiles[$p].configuration // .ios.configuration)' 'Debug' param flutter_version "$IN_FLUTTER_VERSION" '.flutter.version' '' param jdk_version "$IN_JDK_VERSION" '.kmp.jdkVersion' '17' + # The rest of the profile: name (for the summary), distribution (for + # the export step) and env, exported to every step from here on so + # dependency installs and the build see it. + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + PROFILE_JSON="$IN_PROFILE" + elif [ -f builder.json ]; then + PROFILE_JSON=$(jq -c --arg p "$PROFILE" '{name: $p, env: (.profiles[$p].env // {}), distribution: (.profiles[$p].distribution // "")}' builder.json) + fi + [ -n "${PROFILE_JSON:-}" ] || PROFILE_JSON='{}' + PROFILE=$(jq -r '.name // ""' <<< "$PROFILE_JSON") + DISTRIBUTION=$(jq -r '.distribution // ""' <<< "$PROFILE_JSON") + case "$DISTRIBUTION" in + ''|development|ad-hoc|app-store|enterprise) ;; + *) echo "::error::distribution \"$DISTRIBUTION\" must be development, ad-hoc, app-store or enterprise"; exit 1 ;; + esac + echo "profile=$PROFILE" >> "$GITHUB_OUTPUT" + echo "distribution=$DISTRIBUTION" >> "$GITHUB_OUTPUT" + echo "profile=${PROFILE:-(none)}" + echo "distribution=$DISTRIBUTION" + # Values are base64 per entry so newlines and quotes survive; the + # heredoc form of GITHUB_ENV then takes them verbatim. Names are + # checked so a value cannot smuggle in a second variable. + while IFS=' ' read -r key encoded; do + name=$(printf '%s' "$key" | base64 --decode) + if ! [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + echo "::error::env name \"$name\" is not a valid environment variable name"; exit 1 + fi + { + echo "$name<<__BUILDER_ENV__" + printf '%s' "$encoded" | base64 --decode + echo + echo "__BUILDER_ENV__" + } >> "$GITHUB_ENV" + echo "env: $name" + done < <(jq -r '.env // {} | to_entries[] | "\(.key | @base64) \(.value | tostring | @base64)"' <<< "$PROFILE_JSON") + - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: @@ -643,11 +698,15 @@ jobs: env: USE_SIGNING: ${{ steps.params.outputs.use_signing }} CONFIGURATION: ${{ steps.params.outputs.configuration }} + PROFILE: ${{ steps.params.outputs.profile }} run: | echo "## Build Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- **Build ID:** ${{ steps.params.outputs.build_id }}" >> $GITHUB_STEP_SUMMARY echo "- **Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + if [ -n "$PROFILE" ]; then + echo "- **Profile:** $PROFILE" >> $GITHUB_STEP_SUMMARY + fi echo "- **Configuration:** $CONFIGURATION" >> $GITHUB_STEP_SUMMARY echo "- **DerivedData Cache:** ${{ steps.cache-deriveddata.outputs.cache-hit == 'true' && 'Hit' || 'Miss' }}" >> $GITHUB_STEP_SUMMARY echo "- **Pods Cache:** ${{ steps.pods-cache.outputs.cache-hit == 'true' && 'Hit' || 'Miss' }}" >> $GITHUB_STEP_SUMMARY diff --git a/internal/workflow/templates/ios-share.yml b/internal/workflow/templates/ios-share.yml index 56f757f..655cac0 100644 --- a/internal/workflow/templates/ios-share.yml +++ b/internal/workflow/templates/ios-share.yml @@ -53,6 +53,13 @@ on: required: false type: string default: '17' + # Same encoding as ios-build.yml. Only the env applies here: simulator + # builds are always Debug and unsigned, so distribution is ignored. + profile: + description: 'Selected builder.json profile as JSON: {"name": "...", "env": {...}, "distribution": "..."}' + required: false + type: string + default: '{}' jobs: simulator: @@ -81,7 +88,8 @@ jobs: # Dispatch inputs arrive with their declared defaults. A tag push has no # inputs, so the values come from builder.json in the tagged commit and - # the build id is the tag name after the prefix. + # the build id is the tag name after the prefix. A tag build cannot pick + # a profile per run; it applies builder.json's defaultProfile, if any. - name: Resolve parameters id: params env: @@ -91,14 +99,23 @@ jobs: IN_DURATION: ${{ inputs.duration }} IN_FLUTTER_VERSION: ${{ inputs.flutter_version }} IN_JDK_VERSION: ${{ inputs.jdk_version }} + IN_PROFILE: ${{ inputs.profile }} run: | set -e + PROFILE="" + if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] && [ -f builder.json ]; then + PROFILE=$(jq -r '.defaultProfile // empty' builder.json) + if [ -n "$PROFILE" ] && [ "$(jq -r --arg p "$PROFILE" '.profiles[$p] != null' builder.json)" != "true" ]; then + echo "::error::defaultProfile \"$PROFILE\" is not defined under profiles in builder.json" + exit 1 + fi + fi param() { # name dispatch-value jq-path default local v="" if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then v="$2" elif [ -f builder.json ]; then - v=$(jq -r "$3 // empty" builder.json) + v=$(jq -r --arg p "$PROFILE" "$3 // empty" builder.json) fi v="${v:-$4}" echo "$1=$v" >> "$GITHUB_OUTPUT" @@ -106,11 +123,39 @@ jobs: } param build_id "$IN_BUILD_ID" '""' "${GITHUB_REF_NAME##*/}" param ios_path "$IN_IOS_PATH" '.ios.path' '.' - param scheme "$IN_SCHEME" '.ios.scheme' '' + param scheme "$IN_SCHEME" '(.profiles[$p].scheme // .ios.scheme)' '' param duration "$IN_DURATION" '""' '30m' param flutter_version "$IN_FLUTTER_VERSION" '.flutter.version' '' param jdk_version "$IN_JDK_VERSION" '.kmp.jdkVersion' '17' + # The profile's env is exported to every step from here on so the + # dependency installs and the build see it. + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + PROFILE_JSON="$IN_PROFILE" + elif [ -f builder.json ]; then + PROFILE_JSON=$(jq -c --arg p "$PROFILE" '{name: $p, env: (.profiles[$p].env // {})}' builder.json) + fi + [ -n "${PROFILE_JSON:-}" ] || PROFILE_JSON='{}' + PROFILE=$(jq -r '.name // ""' <<< "$PROFILE_JSON") + echo "profile=$PROFILE" >> "$GITHUB_OUTPUT" + echo "profile=${PROFILE:-(none)}" + # Values are base64 per entry so newlines and quotes survive; the + # heredoc form of GITHUB_ENV then takes them verbatim. Names are + # checked so a value cannot smuggle in a second variable. + while IFS=' ' read -r key encoded; do + name=$(printf '%s' "$key" | base64 --decode) + if ! [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + echo "::error::env name \"$name\" is not a valid environment variable name"; exit 1 + fi + { + echo "$name<<__BUILDER_ENV__" + printf '%s' "$encoded" | base64 --decode + echo + echo "__BUILDER_ENV__" + } >> "$GITHUB_ENV" + echo "env: $name" + done < <(jq -r '.env // {} | to_entries[] | "\(.key | @base64) \(.value | tostring | @base64)"' <<< "$PROFILE_JSON") + # Starts the simulator booting in the background (cached, so later runs # boot fast) while the app builds. - name: Install mobai-ci + boot simulator diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 7ed603e..61d9f6e 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -7,6 +7,22 @@ mkdir -p "$ci_dir" mode="${1:-build}" export IOS_PATH="${IOS_PATH:-.}" SCHEME="${SCHEME:-}" CONFIGURATION="${CONFIGURATION:-Debug}" export USE_SIGNING="${USE_SIGNING:-false}" JDK_VERSION="${JDK_VERSION:-17}" +# From the selected builder.json profile: DISTRIBUTION is reserved for the +# export step; BUILD_ENV is a JSON object exported by prepare(). +export DISTRIBUTION="${DISTRIBUTION:-}" BUILD_ENV="${BUILD_ENV:-}" + +# Exports the profile's env before any dependency install or build, as the +# GitHub workflows do. Values are base64 per entry so newlines and quotes +# survive; names are checked so a value cannot become a second variable. +export_build_env() { + [ -n "$BUILD_ENV" ] || return 0 + while IFS=' ' read -r key encoded; do + name=$(printf '%s' "$key" | base64 --decode) + if ! [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then echo "Invalid env name in BUILD_ENV: $name" >&2; exit 1; fi + export "$name=$(printf '%s' "$encoded" | base64 --decode)" + echo "env: $name" + done < <(jq -r 'to_entries[] | "\(.key | @base64) \(.value | tostring | @base64)"' <<< "$BUILD_ENV") +} snapshot_checkout() { case "${SNAPSHOT_REF:-}" in @@ -39,6 +55,7 @@ prepare() { fi echo "Project type: $project_type" if ! command -v jq >/dev/null; then brew install jq; fi + export_build_env # Match the GitHub workflows' committed xcconfig-template convention. find . -path ./DerivedData -prune -o -type f \ From 7ef5346adcee9edeb78e1c40462c264b2f15c27b Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:04:49 +0200 Subject: [PATCH 08/75] docs: describe build profiles --- CLAUDE.md | 38 +++++++++++++++++++++++++--- README.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++- docs/providers.md | 6 +++-- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b15fd87..b222fb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ go install ./cmd/builder ./builder auth github # Authenticate with GitHub (OAuth device flow) ./builder init # Set up workflow in current repo ./builder ios build # Trigger build and download IPA to ./dist/ +./builder ios build --profile production # Build with a builder.json profile ./builder dev flutter # Flutter hot reload with MobAI ./builder dev rn # React Native hot reload with MobAI ./builder dev kmp # Kotlin Multiplatform install + launch (no hot reload) @@ -134,6 +135,21 @@ internal/ submodule commit that only exists locally fails checkout on the runner. - **Run Correlation**: `run-name` carries the build ID so concurrent builds cannot adopt each other's runs +- **Build Profiles**: `profiles.` in `builder.json` overrides `ios.configuration`, `ios.scheme`, + `ios.signing` and `provider`, and adds `env` and the reserved `distribution`. `ios build` and + `ios share` take `--profile`; without it `defaultProfile` applies, and without that the top-level + settings are used unchanged. `config.ResolveProfile` does the merge, `Coordinator.settings` layers + `--unsigned`/`--provider` on top, and `Progress.Settings` prints the result before dispatch. + `Profile.Signing` is a `*bool` so a profile's `false` can override a top-level `true`; the jq in + `Resolve parameters` needs an explicit `!= null` test for the same reason, since `//` treats + `false` as missing. The runner receives env as one JSON object: the `profile` dispatch input + (`{"name","env","distribution"}`, one input to stay under the ten-input limit) on GitHub, and + `BUILD_ENV` plus `DISTRIBUTION` variables for `runner.sh`. Each entry is base64-encoded per + key and value on the runner (jq drops NUL bytes, and a key with a space must not split), names + are checked against `^[A-Za-z_][A-Za-z0-9_]*$`, and the runners' own parameter names are + rejected by `ResolveProfile`. `profile` is only sent when a profile is selected, because a + workflow file from before profiles rejects a dispatch with an input it does not declare. + `env` is build-time configuration, not secrets: it sits in `builder.json` and in the run's inputs - **Flutter Detection**: Auto-detects Flutter projects, runs `flutter pub get`, uses `Runner` scheme - **DerivedData Caching**: `restore` keys on `github.run_id` and only the prefix in `restore-keys` ever hits, so every run must pair with a `cache/save` step or later builds stay cold. `ios-share` @@ -178,14 +194,28 @@ internal/ "project": "MyApp", "platform": "ios", "github": { "owner": "username", "repo": "my-ios-app" }, - "ios": { "path": "ios", "scheme": "" } + "ios": { "path": "ios", "scheme": "" }, + "defaultProfile": "development", + "profiles": { + "development": { "configuration": "Debug", "signing": false }, + "preview": { "configuration": "Release", "signing": true, "env": { "API_URL": "https://staging.example.com" } }, + "production": { "configuration": "Release", "signing": true, "scheme": "MyApp", "provider": "codemagic", "distribution": "app-store" } + } } ``` +`profiles` and `defaultProfile` are optional. A profile's fields are `configuration`, `scheme`, +`signing`, `provider`, `env` (string map) and `distribution` (`development`, `ad-hoc`, `app-store`, +`enterprise`; reserved for the export step, passed through but not applied yet). `runner` and +`submit` are planned for the same struct (`config.Profile`) but not read. + ## Workflow Features The embedded workflow template (`internal/workflow/templates/ios-build.yml`): -- Triggered via `workflow_dispatch` with `build_id`, `snapshot_ref`, `ios_path`, `scheme` +- Triggered via `workflow_dispatch` with `build_id`, `snapshot_ref`, `ios_path`, `scheme`, + `use_signing`, `configuration`, `flutter_version`, `jdk_version` and `profile` (nine of the ten + inputs GitHub allows; the last slot is meant for item 5's `build_number`, so add nothing else + without combining) - Dispatch runs the workflow from the **default branch**, so edits to the workflow file itself only take effect once pushed there — unlike app sources, which come from the snapshot ref - Checks out `snapshot_ref` over the default-branch checkout when set @@ -193,7 +223,9 @@ The embedded workflow template (`internal/workflow/templates/ios-build.yml`): workflow) for environments without GitHub API access. Push events run the workflow file from the tagged commit, `inputs` are empty, so a `Resolve parameters` step reads `ios_path`, `scheme`, `use_signing`, `configuration`, `flutter_version` and `jdk_version` from `builder.json` in the - tagged tree; every later step reads `steps.params.outputs.*`, never `inputs.*`. The job deletes + tagged tree, applying the profile named by `defaultProfile` (a tag cannot pick one per run); + every later step reads `steps.params.outputs.*`, never `inputs.*`. The same step exports the + profile's `env` to `$GITHUB_ENV` and outputs `profile` and `distribution`. The job deletes the tag when it ends (`permissions: contents: write`). Any other workflow in the repo with an unfiltered `on: push` also fires on these tags. - Runs on `macos-latest` diff --git a/README.md b/README.md index d99627b..3e438f3 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,9 @@ The run is named after the tag. Build settings come from `builder.json` in the tagged commit (`ios.path`, `ios.scheme`, `ios.signing`, `ios.configuration`, `flutter.version`, `kmp.jdkVersion`), the simulator stays available for the default 30 minutes, and the tag is deleted when the run ends. The IPA is -attached to the run as an artifact. +attached to the run as an artifact. A tag carries no flags, so a tag build +cannot pick a [profile](#build-profiles) per run; it applies the profile named +by `defaultProfile`, if there is one. ## Additional macOS Providers @@ -174,6 +176,7 @@ builder update # Update builder to the latest release builder ios build # Trigger build and download IPA to ./dist/ builder ios build --unsigned # Build without code signing (if signing is configured) builder ios build --provider codemagic # Build on another provider (also: bitrise) +builder ios build --profile production # Build with a profile from builder.json # Simulator (free, needs a MOBAI_API_KEY secret) builder ios share # Try the build on a simulator in the MobAI app @@ -243,6 +246,64 @@ builder signing setup # Upload code signing secrets to GitHub | `ios.signing` | Sign the IPA with the uploaded certificate and profile | `false` | | `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` | +### Build Profiles + +Profiles are named sets of build settings, in the spirit of `eas.json`, selected +with `--profile` on `ios build` and `ios share`: + +```json +{ + "ios": { "path": "ios", "configuration": "Debug" }, + "defaultProfile": "development", + "profiles": { + "development": { "configuration": "Debug", "signing": false }, + "preview": { "configuration": "Release", "signing": true, + "env": { "API_URL": "https://staging.example.com" } }, + "production": { "configuration": "Release", "signing": true, "scheme": "MyApp", + "provider": "codemagic", "distribution": "app-store" } + } +} +``` + +```bash +builder ios build --profile preview +builder ios share --profile preview +``` + +| Field | Description | +|-------|-------------| +| `configuration` | Overrides `ios.configuration` | +| `scheme` | Overrides `ios.scheme` | +| `signing` | Overrides `ios.signing`; `false` in a profile turns signing off even when the top level has it on | +| `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) | +| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them | +| `distribution` | Reserved: one of `development`, `ad-hoc`, `app-store`, `enterprise`. Validated and passed to the runner; the export step does not act on it yet | + +How a build's settings are resolved: + +- Without `--profile`, the profile named by `defaultProfile` applies. With + neither, the top-level `ios.*` and `provider` settings are used exactly as + before, so existing projects are unaffected. +- A profile only overrides the fields it sets; everything else comes from the + top level. An unknown profile name is an error that lists the available ones. +- `--unsigned` and `--provider` on the command line override the profile. +- The resolved settings (profile, configuration, scheme, signing, provider, env + names) are printed before anything is dispatched. +- `ios share` only takes the profile's scheme, provider and env: simulator + builds are always Debug and unsigned. + +**`env` values are build-time configuration, not secrets.** They are stored in +`builder.json`, sent to the CI provider as plain workflow inputs, and shown in +its run details. Keep tokens and passwords in the provider's secrets instead +(`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets +guide](docs/provider-secrets.md)); the build reads those as environment +variables too. + +`--profile` needs the workflow files from this version of Builder, which +declare a `profile` input; run `builder init` again to refresh +`.github/workflows/ios-build.yml` and `ios-share.yml` (or `builder init +--provider ...` for `runner.sh`) in a project set up earlier. + ### MobAI Configuration | Field | Description | Default | diff --git a/docs/providers.md b/docs/providers.md index 34673cf..9ae7c37 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -112,8 +112,10 @@ builder ios build --provider bitrise --unsigned builder ios build --provider github # explicit override ``` -Provider selection is: command flag, then `builder.json`'s `provider`, then -`github`. Adding or logging into a provider does not change the default. +Provider selection is: command flag, then the selected build profile's +`provider` (see the README's Build Profiles section), then `builder.json`'s +`provider`, then `github`. Adding or logging into a provider does not change +the default. To change it, edit `provider`, or pass `--set-default` when configuring a provider. ```json From f5872afce001dc66c6fc95b2fe4d6b09a943bfae Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:12:42 +0200 Subject: [PATCH 09/75] 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. --- internal/config/profile.go | 38 ++++++++++++++++++++++++++------- internal/config/profile_test.go | 9 ++++++-- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/internal/config/profile.go b/internal/config/profile.go index 581350e..682c9a7 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -25,16 +25,37 @@ type BuildSettings struct { // Distributions are the accepted values of a profile's distribution field. var Distributions = []string{"development", "ad-hoc", "app-store", "enterprise"} -// reservedEnv names the variables the runners read their parameters from. A -// profile that set one of these would silently change the build. +// reservedEnv names the variables the runners read their parameters and +// secrets from, and the ones the shell and the CI services own. A profile that +// set one of these would silently change the build, or on runner.sh replace a +// provider secret, since the env is exported before the signing step reads it. var reservedEnv = []string{ "BUILD_ID", "SNAPSHOT_REF", "SNAPSHOT_SHA", "IOS_PATH", "SCHEME", "CONFIGURATION", "USE_SIGNING", "FLUTTER_VERSION", "JDK_VERSION", "BUILD_ENV", "DISTRIBUTION", - "BUILDER_REPOSITORY", "BUILDER_WORKSPACE", "DURATION", "PROJECT_TYPE", + "DURATION", "PROJECT_TYPE", "EXPORT_METHOD", + "IOS_CERTIFICATE", "IOS_CERTIFICATE_PASSWORD", "IOS_PROVISIONING_PROFILE", "MOBAI_API_KEY", + "PATH", "HOME", "USER", "SHELL", "TMPDIR", "DEVELOPER_DIR", "NODE_OPTIONS", } +// reservedEnvPrefixes cover the runners' own namespaces: Builder's, GitHub +// Actions' (GITHUB_*, RUNNER_*, ACTIONS_*), Codemagic's (CM_*, FCI_*) and +// Bitrise's. +var reservedEnvPrefixes = []string{"BUILDER_", "GITHUB_", "RUNNER_", "ACTIONS_", "CM_", "FCI_", "BITRISE_"} + var envNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) +func reservedEnvName(name string) bool { + if slices.Contains(reservedEnv, name) { + return true + } + for _, prefix := range reservedEnvPrefixes { + if strings.HasPrefix(name, prefix) { + return true + } + } + return false +} + // ProfileNames lists the configured profiles, sorted. func (c *Config) ProfileNames() []string { names := make([]string, 0, len(c.Profiles)) @@ -56,8 +77,9 @@ func (c *Config) ResolveProfile(name string) (BuildSettings, error) { Signing: c.IOS.Signing, Provider: c.Provider, } + source := "profile" if name == "" { - name = c.DefaultProfile + name, source = c.DefaultProfile, "defaultProfile" } if name == "" { return s, nil @@ -65,9 +87,9 @@ func (c *Config) ResolveProfile(name string) (BuildSettings, error) { p, ok := c.Profiles[name] if !ok { if len(c.Profiles) == 0 { - return s, fmt.Errorf("profile %q is not defined; builder.json has no profiles", name) + return s, fmt.Errorf("%s %q is not defined; builder.json has no profiles", source, name) } - return s, fmt.Errorf("profile %q is not defined; available profiles: %s", name, strings.Join(c.ProfileNames(), ", ")) + return s, fmt.Errorf("%s %q is not defined; available profiles: %s", source, name, strings.Join(c.ProfileNames(), ", ")) } if p.Distribution != "" && !slices.Contains(Distributions, p.Distribution) { return s, fmt.Errorf("profile %q: distribution %q must be one of %s", name, p.Distribution, strings.Join(Distributions, ", ")) @@ -76,8 +98,8 @@ func (c *Config) ResolveProfile(name string) (BuildSettings, error) { if !envNameRe.MatchString(k) { return s, fmt.Errorf("profile %q: env name %q is not a valid environment variable name", name, k) } - if slices.Contains(reservedEnv, k) { - return s, fmt.Errorf("profile %q: env name %q is reserved for the runner's own parameters", name, k) + if reservedEnvName(k) { + return s, fmt.Errorf("profile %q: env name %q is reserved for the runner", name, k) } } s.Profile = name diff --git a/internal/config/profile_test.go b/internal/config/profile_test.go index f93bbe5..112ad4c 100644 --- a/internal/config/profile_test.go +++ b/internal/config/profile_test.go @@ -58,8 +58,8 @@ func TestResolveProfileDefault(t *testing.T) { t.Fatalf("explicit profile lost to default: %+v %v", s, err) } cfg.DefaultProfile = "nightly" - if _, err := cfg.ResolveProfile(""); err == nil || !strings.Contains(err.Error(), `"nightly"`) { - t.Fatalf("unknown defaultProfile accepted: %v", err) + if _, err := cfg.ResolveProfile(""); err == nil || !strings.Contains(err.Error(), `defaultProfile "nightly"`) { + t.Fatalf("unknown defaultProfile accepted or not named as the source: %v", err) } } @@ -77,6 +77,11 @@ func TestResolveProfileErrors(t *testing.T) { "bad env name": {Env: map[string]string{"API-URL": "x"}}, "env with equals": {Env: map[string]string{"A=B": "x"}}, "reserved env": {Env: map[string]string{"SCHEME": "Other"}}, + "reserved secret": {Env: map[string]string{"IOS_CERTIFICATE": "x"}}, + "reserved PATH": {Env: map[string]string{"PATH": "/tmp"}}, + "GitHub namespace": {Env: map[string]string{"GITHUB_TOKEN": "x"}}, + "Codemagic space": {Env: map[string]string{"CM_BUILD_ID": "x"}}, + "Bitrise space": {Env: map[string]string{"BITRISE_GIT_BRANCH": "x"}}, } { cfg.Profiles["bad"] = p if _, err := cfg.ResolveProfile("bad"); err == nil { From 96449334de06b85997ef3bb7592b3cf4ebcf2ef4 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:12:42 +0200 Subject: [PATCH 10/75] 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. --- internal/build/coordinator.go | 14 +++++++++++++- internal/build/inputs_test.go | 32 ++++++++++++++++++++++++++------ internal/build/share.go | 7 +++++-- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/internal/build/coordinator.go b/internal/build/coordinator.go index a72b23b..6dcde6c 100644 --- a/internal/build/coordinator.go +++ b/internal/build/coordinator.go @@ -10,6 +10,7 @@ import ( "io" "os" "path/filepath" + "strings" "time" "github.com/MobAI-App/ios-builder/internal/ci" @@ -127,6 +128,16 @@ func (c *Coordinator) buildInputs(buildID, ref string, s config.BuildSettings) m return inputs } +// triggerError explains a rejected dispatch. GitHub answers 422 "Unexpected +// inputs provided" when the committed workflow file does not declare an input, +// which for `profile` means the file predates build profiles. +func triggerError(err error, inputs map[string]string, file string) error { + if _, ok := inputs["profile"]; ok && strings.Contains(err.Error(), "Unexpected inputs") { + return fmt.Errorf("failed to trigger workflow: the committed .github/workflows/%s does not declare the `profile` input; run `builder init` to refresh it, then commit and push the workflow to the default branch: %w", file, err) + } + return fmt.Errorf("failed to trigger workflow: %w", err) +} + // BuildResult contains the result of a build type BuildResult struct { BuildID string @@ -184,8 +195,9 @@ func (c *Coordinator) Build(ctx context.Context, opts BuildOptions) (*BuildResul c.progress.Update(PhaseTriggering, "Triggering GitHub Actions build...") inputs := c.buildInputs(buildID, ref, settings) if err := c.github.TriggerWorkflow(ctx, c.config.GitHub.Owner, c.config.GitHub.Repo, WorkflowFile, inputs); err != nil { + err = triggerError(err, inputs, WorkflowFile) c.progress.Error(PhaseTriggering, err) - return nil, fmt.Errorf("failed to trigger workflow: %w", err) + return nil, err } c.progress.Complete(PhaseTriggering, "Workflow triggered") diff --git a/internal/build/inputs_test.go b/internal/build/inputs_test.go index 570c64e..49744a5 100644 --- a/internal/build/inputs_test.go +++ b/internal/build/inputs_test.go @@ -3,6 +3,7 @@ package build import ( "bytes" "encoding/json" + "errors" "io" "reflect" "strings" @@ -97,6 +98,25 @@ func TestGitHubInputsMapping(t *testing.T) { if share["profile"] == "" || share["scheme"] != "AppPreview" { t.Fatalf("share inputs: %v", share) } + + s, _, _ = c.settings("", "", false) + share = c.workflowInputs("abcdef12", "ref", s) + want = map[string]string{"build_id": "abcdef12", "snapshot_ref": "ref", "ios_path": "ios", "scheme": "App", "flutter_version": "3.24.0"} + if !reflect.DeepEqual(share, want) { + t.Fatalf("without a profile the share inputs must be unchanged:\n got %v\nwant %v", share, want) + } +} + +func TestTriggerErrorExplainsOldWorkflow(t *testing.T) { + rejected := errors.New(`failed to trigger workflow (status 422): {"message":"Unexpected inputs provided: [\"profile\"]"}`) + err := triggerError(rejected, map[string]string{"profile": "{}"}, WorkflowFile) + if !strings.Contains(err.Error(), "builder init") || !strings.Contains(err.Error(), WorkflowFile) || !errors.Is(err, rejected) { + t.Fatalf("old workflow not explained: %v", err) + } + // Without the profile input the message is GitHub's, unchanged. + if err := triggerError(rejected, map[string]string{}, WorkflowFile); strings.Contains(err.Error(), "builder init") || !errors.Is(err, rejected) { + t.Fatalf("unrelated rejection rewritten: %v", err) + } } func TestRemoteInputsMapping(t *testing.T) { @@ -104,13 +124,13 @@ func TestRemoteInputsMapping(t *testing.T) { s, _, _ := c.settings("", "", false) got := c.inputs("abcdef12", "ref", "sha", s) - for _, k := range []string{"BUILD_ENV", "DISTRIBUTION"} { - if _, ok := got[k]; ok { - t.Fatalf("%s must be absent without a profile: %v", k, got) - } + want := map[string]string{ + "BUILD_ID": "abcdef12", "SNAPSHOT_REF": "ref", "SNAPSHOT_SHA": "sha", "IOS_PATH": "ios", "SCHEME": "App", + "CONFIGURATION": "Debug", "FLUTTER_VERSION": "3.24.0", "JDK_VERSION": "17", "USE_SIGNING": "false", + "BUILDER_REPOSITORY": "owner/repo", } - if got["USE_SIGNING"] != "false" || got["SCHEME"] != "App" || got["CONFIGURATION"] != "Debug" { - t.Fatalf("top-level mapping: %v", got) + if !reflect.DeepEqual(got, want) { + t.Fatalf("without a profile the runner variables must be unchanged:\n got %v\nwant %v", got, want) } s, _, _ = c.settings("preview", "", false) diff --git a/internal/build/share.go b/internal/build/share.go index 0d4d610..662c404 100644 --- a/internal/build/share.go +++ b/internal/build/share.go @@ -49,8 +49,10 @@ func (c *Coordinator) Share(ctx context.Context, opts ShareOptions) (*ShareResul if err != nil { return nil, err } - // Simulator builds are always Debug and never signed, whatever the profile says. + // Simulator builds are always Debug, never signed and never exported, + // whatever the profile says. settings.Configuration = "Debug" + settings.Distribution = "" if name != "github" || c.provider != nil { return c.shareRemote(ctx, opts, settings) } @@ -89,8 +91,9 @@ func (c *Coordinator) Share(ctx context.Context, opts ShareOptions) (*ShareResul inputs := c.workflowInputs(buildID, ref, settings) inputs["duration"] = opts.Duration.String() if err := c.github.TriggerWorkflow(ctx, c.config.GitHub.Owner, c.config.GitHub.Repo, ShareWorkflowFile, inputs); err != nil { + err = triggerError(err, inputs, ShareWorkflowFile) c.progress.Error(PhaseTriggering, err) - return nil, fmt.Errorf("failed to trigger workflow: %w", err) + return nil, err } c.progress.Complete(PhaseTriggering, "Session starting") From 3c06c58eb85cac9f9af767af4859ef7305434bca Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:12:42 +0200 Subject: [PATCH 11/75] 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. --- internal/workflow/profile_test.go | 13 ++++++++----- internal/workflow/templates/ios-build.yml | 13 +++++++++---- internal/workflow/templates/ios-share.yml | 13 +++++++++---- internal/workflow/templates/runner.sh | 1 + 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/internal/workflow/profile_test.go b/internal/workflow/profile_test.go index 9639649..7f199a2 100644 --- a/internal/workflow/profile_test.go +++ b/internal/workflow/profile_test.go @@ -81,12 +81,13 @@ func runResolve(t *testing.T, script, builderJSON string, env map[string]string) if data, err := os.ReadFile(envPath); err == nil { lines := strings.Split(string(data), "\n") for i := 0; i < len(lines); i++ { - name, ok := strings.CutSuffix(lines[i], "<<__BUILDER_ENV__") - if !ok { + // GitHub's heredoc form: NAME<> "$GITHUB_ENV" echo "env: $name" done < <(jq -r '.env // {} | to_entries[] | "\(.key | @base64) \(.value | tostring | @base64)"' <<< "$PROFILE_JSON") diff --git a/internal/workflow/templates/ios-share.yml b/internal/workflow/templates/ios-share.yml index 655cac0..636b2de 100644 --- a/internal/workflow/templates/ios-share.yml +++ b/internal/workflow/templates/ios-share.yml @@ -140,18 +140,23 @@ jobs: echo "profile=$PROFILE" >> "$GITHUB_OUTPUT" echo "profile=${PROFILE:-(none)}" # Values are base64 per entry so newlines and quotes survive; the - # heredoc form of GITHUB_ENV then takes them verbatim. Names are - # checked so a value cannot smuggle in a second variable. + # heredoc form of GITHUB_ENV then takes them verbatim, with a random + # delimiter so no value line can end it early. Names are checked so a + # value cannot smuggle in a second variable. + if [ "$(jq -r '.env // {} | type' <<< "$PROFILE_JSON")" != "object" ]; then + echo "::error::the profile's env must be a JSON object of variable names to string values"; exit 1 + fi while IFS=' ' read -r key encoded; do name=$(printf '%s' "$key" | base64 --decode) if ! [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then echo "::error::env name \"$name\" is not a valid environment variable name"; exit 1 fi + delim="BUILDER_ENV_${RANDOM}${RANDOM}${RANDOM}" { - echo "$name<<__BUILDER_ENV__" + echo "$name<<$delim" printf '%s' "$encoded" | base64 --decode echo - echo "__BUILDER_ENV__" + echo "$delim" } >> "$GITHUB_ENV" echo "env: $name" done < <(jq -r '.env // {} | to_entries[] | "\(.key | @base64) \(.value | tostring | @base64)"' <<< "$PROFILE_JSON") diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 61d9f6e..b713a63 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -16,6 +16,7 @@ export DISTRIBUTION="${DISTRIBUTION:-}" BUILD_ENV="${BUILD_ENV:-}" # survive; names are checked so a value cannot become a second variable. export_build_env() { [ -n "$BUILD_ENV" ] || return 0 + if [ "$(jq -r 'type' <<< "$BUILD_ENV" 2>/dev/null)" != "object" ]; then echo "BUILD_ENV must be a JSON object of variable names to string values" >&2; exit 1; fi while IFS=' ' read -r key encoded; do name=$(printf '%s' "$key" | base64 --decode) if ! [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then echo "Invalid env name in BUILD_ENV: $name" >&2; exit 1; fi From 3c883da99683790026985ca404eabbe5da6373b3 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:12:42 +0200 Subject: [PATCH 12/75] docs: defaultProfile also needs refreshed workflows; list reserved env names --- CLAUDE.md | 17 +++++++++++++---- README.md | 21 +++++++++++++-------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b222fb3..61982f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,10 +145,19 @@ internal/ `false` as missing. The runner receives env as one JSON object: the `profile` dispatch input (`{"name","env","distribution"}`, one input to stay under the ten-input limit) on GitHub, and `BUILD_ENV` plus `DISTRIBUTION` variables for `runner.sh`. Each entry is base64-encoded per - key and value on the runner (jq drops NUL bytes, and a key with a space must not split), names - are checked against `^[A-Za-z_][A-Za-z0-9_]*$`, and the runners' own parameter names are - rejected by `ResolveProfile`. `profile` is only sent when a profile is selected, because a - workflow file from before profiles rejects a dispatch with an input it does not declare. + key and value on the runner (jq drops NUL bytes, and a key with a space must not split), the + `$GITHUB_ENV` heredoc uses a random delimiter so no value line can end it early, names are + checked against `^[A-Za-z_][A-Za-z0-9_]*$`, and `ResolveProfile` rejects the names the runners + own (`reservedEnv` and `reservedEnvPrefixes` in `internal/config/profile.go`: the runner + parameters, the signing secrets, `PATH`/`HOME`/`DEVELOPER_DIR`, and the `GITHUB_`, `RUNNER_`, + `CM_`, `BITRISE_`, `BUILDER_` namespaces; keep that list in step with what `runner.sh` and the + workflows read). `profile` is only sent when a profile is selected (`--profile` or + `defaultProfile`), because a workflow file from before profiles rejects a dispatch with an input + it does not declare; `triggerError` turns that 422 into a "run `builder init`" message. On + GitHub the profile's env lands in `$GITHUB_ENV`, and step-level `env:` (the signing secrets, the + build parameters) takes precedence over it. `distribution` reaches the runner as the + `steps.params.outputs.distribution` output on GitHub and the `DISTRIBUTION` variable for + `runner.sh`; the export step is meant to consume it under those names. `env` is build-time configuration, not secrets: it sits in `builder.json` and in the run's inputs - **Flutter Detection**: Auto-detects Flutter projects, runs `flutter pub get`, uses `Runner` scheme - **DerivedData Caching**: `restore` keys on `github.run_id` and only the prefix in `restore-keys` diff --git a/README.md b/README.md index 3e438f3..f6d9b34 100644 --- a/README.md +++ b/README.md @@ -293,16 +293,21 @@ How a build's settings are resolved: builds are always Debug and unsigned. **`env` values are build-time configuration, not secrets.** They are stored in -`builder.json`, sent to the CI provider as plain workflow inputs, and shown in -its run details. Keep tokens and passwords in the provider's secrets instead -(`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets +`builder.json`, sent to the CI provider as plain workflow inputs, and visible in +the run's inputs and logs. Keep tokens and passwords in the provider's secrets +instead (`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets guide](docs/provider-secrets.md)); the build reads those as environment -variables too. - -`--profile` needs the workflow files from this version of Builder, which -declare a `profile` input; run `builder init` again to refresh +variables too. Names the runner owns are rejected: its own parameters +(`SCHEME`, `CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing +secrets, `PATH`, `HOME`, `DEVELOPER_DIR`, and anything starting with `GITHUB_`, +`RUNNER_`, `CM_`, `BITRISE_` or `BUILDER_`. + +Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow +files from this version of Builder, which declare a `profile` input; an older +committed workflow rejects the dispatch. Run `builder init` again to refresh `.github/workflows/ios-build.yml` and `ios-share.yml` (or `builder init ---provider ...` for `runner.sh`) in a project set up earlier. +--provider ...` for `runner.sh`) in a project set up earlier, then commit and +push them to the default branch. ### MobAI Configuration From 636fb523ea91810eb988b8d4a8e88d4e00d0d88d Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:14:01 +0200 Subject: [PATCH 13/75] workflow: tolerate CRLF checkouts in the export-method test --- internal/workflow/providers_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index 042ead6..5665dce 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -192,7 +192,9 @@ fi // indented run: blocks of the workflow YAML. func shellFunc(t *testing.T, template, name string) string { t.Helper() - lines := strings.Split(template, "\n") + // Windows checkouts may have CRLF line endings; the closing brace + // comparison below needs bare lines. + lines := strings.Split(strings.ReplaceAll(template, "\r\n", "\n"), "\n") start := -1 indent := "" for i, line := range lines { From 9880363524ebe778954c6ec22759fcd2570e0332 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:17:09 +0200 Subject: [PATCH 14/75] 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. --- internal/asc/apps.go | 62 ++++++ internal/asc/builds.go | 141 +++++++++++++ internal/asc/client.go | 277 +++++++++++++++++++++++++ internal/asc/client_test.go | 278 +++++++++++++++++++++++++ internal/asc/jsonapi.go | 148 +++++++++++++ internal/asc/jwt.go | 153 ++++++++++++++ internal/asc/jwt_test.go | 139 +++++++++++++ internal/asc/review.go | 145 +++++++++++++ internal/asc/testflight.go | 163 +++++++++++++++ internal/asc/uploads.go | 392 +++++++++++++++++++++++++++++++++++ internal/asc/uploads_test.go | 250 ++++++++++++++++++++++ internal/asc/versions.go | 113 ++++++++++ 12 files changed, 2261 insertions(+) create mode 100644 internal/asc/apps.go create mode 100644 internal/asc/builds.go create mode 100644 internal/asc/client.go create mode 100644 internal/asc/client_test.go create mode 100644 internal/asc/jsonapi.go create mode 100644 internal/asc/jwt.go create mode 100644 internal/asc/jwt_test.go create mode 100644 internal/asc/review.go create mode 100644 internal/asc/testflight.go create mode 100644 internal/asc/uploads.go create mode 100644 internal/asc/uploads_test.go create mode 100644 internal/asc/versions.go diff --git a/internal/asc/apps.go b/internal/asc/apps.go new file mode 100644 index 0000000..87c2c69 --- /dev/null +++ b/internal/asc/apps.go @@ -0,0 +1,62 @@ +package asc + +import ( + "context" + "fmt" + "net/url" +) + +// App is an App Store Connect app record. +type App struct { + ID string + BundleID string + Name string + SKU string + PrimaryLocale string +} + +type appAttributes struct { + BundleID string `json:"bundleId,omitempty"` + Name string `json:"name,omitempty"` + SKU string `json:"sku,omitempty"` + PrimaryLocale string `json:"primaryLocale,omitempty"` +} + +func toApp(r Resource[appAttributes]) App { + return App{ID: r.ID, BundleID: r.Attributes.BundleID, Name: r.Attributes.Name, SKU: r.Attributes.SKU, PrimaryLocale: r.Attributes.PrimaryLocale} +} + +// AppByBundleID finds the app record for a bundle identifier. +func (c *Client) AppByBundleID(ctx context.Context, bundleID string) (*App, error) { + q := url.Values{"filter[bundleId]": {bundleID}, "limit": {"2"}} + apps, err := getPage[appAttributes](ctx, c, "/v1/apps", q) + if err != nil { + return nil, err + } + for _, r := range apps { + if r.Attributes.BundleID == bundleID { + app := toApp(r) + return &app, nil + } + } + return nil, fmt.Errorf("no App Store Connect app has bundle ID %s; create the app record in App Store Connect (My Apps → +) with that bundle ID first, and check the API key can see it", bundleID) +} + +// ListApps lists the apps the key can see. +func (c *Client) ListApps(ctx context.Context) ([]App, error) { + rs, err := getAll[appAttributes](ctx, c, "/v1/apps", nil) + if err != nil { + return nil, err + } + apps := make([]App, 0, len(rs)) + for _, r := range rs { + apps = append(apps, toApp(r)) + } + return apps, nil +} + +// CheckAccess makes the cheapest authenticated call to verify the key works. +func (c *Client) CheckAccess(ctx context.Context) error { + _, err := getPage[appAttributes](ctx, c, "/v1/apps", url.Values{"limit": {"1"}}) + return err +} diff --git a/internal/asc/builds.go b/internal/asc/builds.go new file mode 100644 index 0000000..46582cd --- /dev/null +++ b/internal/asc/builds.go @@ -0,0 +1,141 @@ +package asc + +import ( + "context" + "net/url" + "strconv" + "time" +) + +// PlatformIOS is the App Store Connect platform value for iOS. +const PlatformIOS = "IOS" + +// Build processing states. +const ( + ProcessingStateProcessing = "PROCESSING" + ProcessingStateFailed = "FAILED" + ProcessingStateInvalid = "INVALID" + ProcessingStateValid = "VALID" +) + +// Build is a processed (or processing) build of an app. +type Build struct { + ID string + BuildNumber string // CFBundleVersion; ASC calls it "version" + ProcessingState string + UploadedDate time.Time + ExpirationDate time.Time + Expired bool + MinOSVersion string + // UsesNonExemptEncryption is nil while the export compliance question is + // unanswered ("Missing Compliance" in TestFlight). + UsesNonExemptEncryption *bool +} + +type buildAttributes struct { + Version string `json:"version,omitempty"` + UploadedDate *time.Time `json:"uploadedDate,omitempty"` + ExpirationDate *time.Time `json:"expirationDate,omitempty"` + Expired *bool `json:"expired,omitempty"` + MinOsVersion string `json:"minOsVersion,omitempty"` + ProcessingState string `json:"processingState,omitempty"` + UsesNonExemptEncryption *bool `json:"usesNonExemptEncryption,omitempty"` +} + +func toBuild(r Resource[buildAttributes]) Build { + b := Build{ + ID: r.ID, + BuildNumber: r.Attributes.Version, + ProcessingState: r.Attributes.ProcessingState, + MinOSVersion: r.Attributes.MinOsVersion, + UsesNonExemptEncryption: r.Attributes.UsesNonExemptEncryption, + } + if r.Attributes.UploadedDate != nil { + b.UploadedDate = *r.Attributes.UploadedDate + } + if r.Attributes.ExpirationDate != nil { + b.ExpirationDate = *r.Attributes.ExpirationDate + } + if r.Attributes.Expired != nil { + b.Expired = *r.Attributes.Expired + } + return b +} + +// BuildFilter narrows ListBuilds. Empty fields are not filtered on. +type BuildFilter struct { + AppID string + Platform string // e.g. PlatformIOS + Version string // marketing version (CFBundleShortVersionString) + BuildNumber string // CFBundleVersion + ProcessingState string + // ExcludeExpired drops builds past their 90-day TestFlight life. + ExcludeExpired bool + // Limit caps the result to the newest N builds; 0 returns every match. + Limit int +} + +// ListBuilds lists builds, newest first. +func (c *Client) ListBuilds(ctx context.Context, f BuildFilter) ([]Build, error) { + q := url.Values{"sort": {"-uploadedDate"}} + if f.AppID != "" { + q.Set("filter[app]", f.AppID) + } + if f.Platform != "" { + q.Set("filter[preReleaseVersion.platform]", f.Platform) + } + if f.Version != "" { + q.Set("filter[preReleaseVersion.version]", f.Version) + } + if f.BuildNumber != "" { + q.Set("filter[version]", f.BuildNumber) + } + if f.ProcessingState != "" { + q.Set("filter[processingState]", f.ProcessingState) + } + if f.ExcludeExpired { + q.Set("filter[expired]", "false") + } + var rs []Resource[buildAttributes] + var err error + if f.Limit > 0 { + q.Set("limit", strconv.Itoa(f.Limit)) + rs, err = getPage[buildAttributes](ctx, c, "/v1/builds", q) + } else { + rs, err = getAll[buildAttributes](ctx, c, "/v1/builds", q) + } + if err != nil { + return nil, err + } + builds := make([]Build, 0, len(rs)) + for _, r := range rs { + builds = append(builds, toBuild(r)) + } + return builds, nil +} + +// GetBuild fetches one build. +func (c *Client) GetBuild(ctx context.Context, id string) (*Build, error) { + r, err := getOne[buildAttributes](ctx, c, "/v1/builds/"+id, nil) + if err != nil { + return nil, err + } + b := toBuild(*r) + return &b, nil +} + +// SetUsesNonExemptEncryption answers the export compliance question for a build. +func (c *Client) SetUsesNonExemptEncryption(ctx context.Context, buildID string, uses bool) (*Build, error) { + req := Resource[buildAttributes]{Type: "builds", ID: buildID, Attributes: buildAttributes{UsesNonExemptEncryption: &uses}} + r, err := patch[buildAttributes, buildAttributes](ctx, c, "/v1/builds/"+buildID, req) + if err != nil { + return nil, err + } + b := toBuild(*r) + return &b, nil +} + +// AddBuildToBetaGroups makes the build available to the given TestFlight groups. +func (c *Client) AddBuildToBetaGroups(ctx context.Context, buildID string, groupIDs []string) error { + return c.Post(ctx, "/v1/builds/"+buildID+"/relationships/betaGroups", ToMany("betaGroups", groupIDs), nil) +} diff --git a/internal/asc/client.go b/internal/asc/client.go new file mode 100644 index 0000000..b86b903 --- /dev/null +++ b/internal/asc/client.go @@ -0,0 +1,277 @@ +package asc + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// DefaultBaseURL is the production App Store Connect API endpoint. +const DefaultBaseURL = "https://api.appstoreconnect.apple.com" + +// Client talks to the App Store Connect API. +type Client struct { + baseURL string + http *http.Client + upload *http.Client + tokens *tokenSource + retryDelay time.Duration + maxRetries int +} + +// Option configures a Client. +type Option func(*Client) + +// WithBaseURL points the client at another server, e.g. a test server. +func WithBaseURL(baseURL string) Option { + return func(c *Client) { c.baseURL = strings.TrimRight(baseURL, "/") } +} + +// WithHTTPClient replaces the HTTP client used for API calls. +func WithHTTPClient(h *http.Client) Option { + return func(c *Client) { c.http = h } +} + +// WithRetryDelay sets the base delay of the exponential backoff on 429/5xx. +func WithRetryDelay(d time.Duration) Option { + return func(c *Client) { c.retryDelay = d } +} + +// NewClient validates the credentials and returns a client. No network call is made. +func NewClient(creds Credentials, opts ...Option) (*Client, error) { + tokens, err := newTokenSource(creds) + if err != nil { + return nil, fmt.Errorf("App Store Connect credentials: %w", err) + } + c := &Client{ + baseURL: DefaultBaseURL, + http: &http.Client{Timeout: 60 * time.Second}, + // Chunk PUTs go to Apple's storage, not the API; large chunks on a + // slow uplink can legitimately take minutes. + upload: &http.Client{Timeout: 15 * time.Minute}, + tokens: tokens, + retryDelay: time.Second, + maxRetries: 3, + } + for _, opt := range opts { + opt(c) + } + return c, nil +} + +// Error is an error response from App Store Connect. +type Error struct { + StatusCode int + Method string + Path string + Errors []ErrorDetail + RetryAfter time.Duration +} + +// ErrorDetail is one entry of the JSON:API errors array. +type ErrorDetail struct { + ID string `json:"id,omitempty"` + Status string `json:"status,omitempty"` + Code string `json:"code,omitempty"` + Title string `json:"title,omitempty"` + Detail string `json:"detail,omitempty"` + Source *ErrorSource `json:"source,omitempty"` +} + +// ErrorSource points at the request field or parameter an error refers to. +type ErrorSource struct { + Pointer string `json:"pointer,omitempty"` + Parameter string `json:"parameter,omitempty"` +} + +// Error renders the status and every ASC error on one line. +func (e *Error) Error() string { + var b strings.Builder + fmt.Fprintf(&b, "App Store Connect %s %s: HTTP %d", e.Method, e.Path, e.StatusCode) + for i, d := range e.Errors { + if i == 0 { + b.WriteString(": ") + } else { + b.WriteString("; ") + } + b.WriteString(d.String()) + } + return b.String() +} + +// String renders one error as "CODE: title (detail)". +func (d ErrorDetail) String() string { + var parts []string + if d.Code != "" { + parts = append(parts, d.Code) + } + if d.Title != "" { + parts = append(parts, d.Title) + } + s := strings.Join(parts, ": ") + if d.Detail != "" && d.Detail != d.Title { + if s != "" { + s += " (" + d.Detail + ")" + } else { + s = d.Detail + } + } + if d.Source != nil && d.Source.Pointer != "" { + s += " [" + d.Source.Pointer + "]" + } + return strings.ReplaceAll(s, "\n", " ") +} + +// HasCode reports whether any error carries the code or a code with that prefix. +func (e *Error) HasCode(prefix string) bool { + for _, d := range e.Errors { + if strings.HasPrefix(d.Code, prefix) { + return true + } + } + return false +} + +// IsStatus reports whether err is an App Store Connect error with the given HTTP status. +func IsStatus(err error, status int) bool { + var e *Error + return errors.As(err, &e) && e.StatusCode == status +} + +// Get performs a GET. path is relative to the base URL ("/v1/apps") or an +// absolute URL such as a pagination link; query is appended when non-nil. +func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error { + return c.do(ctx, http.MethodGet, path, query, nil, out) +} + +// Post performs a POST with a JSON body. +func (c *Client) Post(ctx context.Context, path string, body, out any) error { + return c.do(ctx, http.MethodPost, path, nil, body, out) +} + +// Patch performs a PATCH with a JSON body. +func (c *Client) Patch(ctx context.Context, path string, body, out any) error { + return c.do(ctx, http.MethodPatch, path, nil, body, out) +} + +// Delete performs a DELETE, with an optional JSON body (relationship removals). +func (c *Client) Delete(ctx context.Context, path string, body any) error { + return c.do(ctx, http.MethodDelete, path, nil, body, nil) +} + +func (c *Client) do(ctx context.Context, method, path string, query url.Values, body, out any) error { + var payload []byte + if body != nil { + var err error + if payload, err = json.Marshal(body); err != nil { + return fmt.Errorf("encode request: %w", err) + } + } + for attempt := 0; ; attempt++ { + err := c.once(ctx, method, path, query, payload, out) + var apiErr *Error + if err == nil || attempt >= c.maxRetries || !errors.As(err, &apiErr) || !retryable(method, apiErr.StatusCode) { + return err + } + delay := c.retryDelay << attempt + if apiErr.RetryAfter > delay { + delay = apiErr.RetryAfter + } + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} + +// retryable: 429 was not processed, so any method may retry. A 5xx on a POST +// may have created the resource already, so only idempotent methods retry. +func retryable(method string, status int) bool { + if status == http.StatusTooManyRequests { + return true + } + return status >= 500 && status <= 599 && method != http.MethodPost +} + +func (c *Client) once(ctx context.Context, method, path string, query url.Values, payload []byte, out any) error { + target := path + if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") { + target = c.baseURL + path + } + if len(query) > 0 { + sep := "?" + if strings.Contains(target, "?") { + sep = "&" + } + target += sep + query.Encode() + } + var bodyReader io.Reader + if payload != nil { + bodyReader = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, method, target, bodyReader) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + token, err := c.tokens.Token() + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("App Store Connect request failed: %w", err) + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + if resp.StatusCode >= 400 { + return decodeError(method, path, resp, data) + } + if out != nil && len(bytes.TrimSpace(data)) > 0 { + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + return nil +} + +func decodeError(method, path string, resp *http.Response, data []byte) *Error { + e := &Error{StatusCode: resp.StatusCode, Method: method, Path: strings.SplitN(path, "?", 2)[0]} + if ra := resp.Header.Get("Retry-After"); ra != "" { + if secs, err := strconv.Atoi(ra); err == nil && secs > 0 { + e.RetryAfter = time.Duration(secs) * time.Second + } + } + var body struct { + Errors []ErrorDetail `json:"errors"` + } + if json.Unmarshal(data, &body) == nil && len(body.Errors) > 0 { + e.Errors = body.Errors + return e + } + if text := strings.TrimSpace(string(data)); text != "" { + if len(text) > 200 { + text = text[:200] + "..." + } + e.Errors = []ErrorDetail{{Title: http.StatusText(resp.StatusCode), Detail: text}} + } + return e +} diff --git a/internal/asc/client_test.go b/internal/asc/client_test.go new file mode 100644 index 0000000..6653bcf --- /dev/null +++ b/internal/asc/client_test.go @@ -0,0 +1,278 @@ +package asc + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +// newTestClient returns a client pointed at srv with fast retries. +func newTestClient(t *testing.T, srv *httptest.Server) *Client { + t.Helper() + creds, _ := testCredentials(t) + c, err := NewClient(creds, WithBaseURL(srv.URL), WithRetryDelay(time.Millisecond)) + if err != nil { + t.Fatal(err) + } + return c +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func TestGetSendsBearerTokenAndDecodes(t *testing.T) { + var authz string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authz = r.Header.Get("Authorization") + if r.URL.Path != "/v1/apps" || r.URL.Query().Get("filter[bundleId]") != "com.example.app" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{{ + "type": "apps", "id": "app-1", + "attributes": map[string]any{"bundleId": "com.example.app", "name": "Example", "primaryLocale": "en-US"}, + }}}) + })) + defer srv.Close() + c := newTestClient(t, srv) + app, err := c.AppByBundleID(context.Background(), "com.example.app") + if err != nil { + t.Fatal(err) + } + if app.ID != "app-1" || app.Name != "Example" || app.PrimaryLocale != "en-US" { + t.Errorf("app = %+v", app) + } + if !strings.HasPrefix(authz, "Bearer ") || strings.Count(authz, ".") != 2 { + t.Errorf("Authorization = %q", authz) + } +} + +func TestAppByBundleIDNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"data": []any{}}) + })) + defer srv.Close() + _, err := newTestClient(t, srv).AppByBundleID(context.Background(), "com.missing") + if err == nil || !strings.Contains(err.Error(), "com.missing") { + t.Errorf("err = %v", err) + } +} + +func TestErrorDecoding(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 409, map[string]any{"errors": []map[string]any{ + {"id": "x", "status": "409", "code": "STATE_ERROR.ENTITY_STATE_INVALID", "title": "Invalid state", "detail": "Metadata is missing.", "source": map[string]string{"pointer": "/data/relationships/build"}}, + {"status": "409", "code": "ENTITY_ERROR.ATTRIBUTE.REQUIRED", "title": "Attribute required", "detail": "Attribute required"}, + }}) + })) + defer srv.Close() + err := newTestClient(t, srv).Post(context.Background(), "/v1/reviewSubmissions", map[string]any{}, nil) + var apiErr *Error + if !errors.As(err, &apiErr) { + t.Fatalf("err = %T %v", err, err) + } + if apiErr.StatusCode != 409 || len(apiErr.Errors) != 2 || !apiErr.HasCode("STATE_ERROR") || !IsStatus(err, 409) { + t.Errorf("apiErr = %+v", apiErr) + } + msg := err.Error() + for _, want := range []string{"HTTP 409", "STATE_ERROR.ENTITY_STATE_INVALID: Invalid state (Metadata is missing.) [/data/relationships/build]", "; ENTITY_ERROR.ATTRIBUTE.REQUIRED: Attribute required"} { + if !strings.Contains(msg, want) { + t.Errorf("message %q lacks %q", msg, want) + } + } + if strings.Contains(msg, "\n") { + t.Errorf("message is not one line: %q", msg) + } +} + +func TestNonJSONErrorBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(502) + _, _ = w.Write([]byte("Bad Gateway")) + })) + defer srv.Close() + err := newTestClient(t, srv).Post(context.Background(), "/v1/x", nil, nil) + if !IsStatus(err, 502) || !strings.Contains(err.Error(), "Bad Gateway") { + t.Errorf("err = %v", err) + } +} + +func TestPaginationFollowsNextLink(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("limit") != "200" || q.Get("filter[app]") != "app-1" { + t.Errorf("query = %v", q) + } + switch q.Get("cursor") { + case "": + writeJSON(w, 200, map[string]any{ + "data": []map[string]any{{"type": "betaGroups", "id": "g1", "attributes": map[string]any{"name": "Internal", "isInternalGroup": true}}}, + "links": map[string]string{"next": srv.URL + "/v1/betaGroups?filter%5Bapp%5D=app-1&limit=200&cursor=abc"}, + }) + case "abc": + writeJSON(w, 200, map[string]any{ + "data": []map[string]any{{"type": "betaGroups", "id": "g2", "attributes": map[string]any{"name": "External", "isInternalGroup": false, "publicLinkEnabled": true}}}, + }) + default: + t.Errorf("unexpected cursor %q", q.Get("cursor")) + } + })) + defer srv.Close() + groups, err := newTestClient(t, srv).ListBetaGroups(context.Background(), "app-1") + if err != nil { + t.Fatal(err) + } + if len(groups) != 2 || groups[0].Name != "Internal" || !groups[0].Internal || groups[1].Name != "External" || groups[1].Internal || !groups[1].PublicLinkEnabled { + t.Errorf("groups = %+v", groups) + } +} + +func TestRetryOn429HonorsRetryAfter(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) == 1 { + w.Header().Set("Retry-After", "1") + writeJSON(w, 429, map[string]any{"errors": []map[string]any{{"code": "RATE_LIMIT_EXCEEDED", "title": "Rate limit"}}}) + return + } + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "reviewSubmissions", "id": "rs-1", "attributes": map[string]any{"state": "READY_FOR_REVIEW"}}}) + })) + defer srv.Close() + start := time.Now() + sub, err := newTestClient(t, srv).CreateReviewSubmission(context.Background(), "app-1", PlatformIOS) + if err != nil { + t.Fatal(err) + } + if sub.ID != "rs-1" || calls.Load() != 2 { + t.Errorf("sub = %+v, calls = %d", sub, calls.Load()) + } + if time.Since(start) < time.Second { + t.Error("Retry-After was not honored") + } +} + +func TestRetryOn5xxOnlyForIdempotentMethods(t *testing.T) { + var gets, posts atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if gets.Add(1) < 3 { + writeJSON(w, 503, map[string]any{"errors": []map[string]any{{"title": "unavailable"}}}) + return + } + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "builds", "id": "b1", "attributes": map[string]any{"version": "7", "processingState": "VALID"}}}) + case http.MethodPost: + posts.Add(1) + writeJSON(w, 500, map[string]any{"errors": []map[string]any{{"title": "boom"}}}) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + b, err := c.GetBuild(context.Background(), "b1") + if err != nil || b.BuildNumber != "7" || b.ProcessingState != ProcessingStateValid { + t.Errorf("build = %+v, err = %v", b, err) + } + if gets.Load() != 3 { + t.Errorf("GET attempts = %d, want 3", gets.Load()) + } + if err := c.Post(context.Background(), "/v1/things", map[string]any{}, nil); !IsStatus(err, 500) { + t.Errorf("POST err = %v", err) + } + if posts.Load() != 1 { + t.Errorf("POST attempts = %d, want 1 (a 5xx POST may have created the resource)", posts.Load()) + } +} + +func TestRetryGivesUp(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeJSON(w, 503, map[string]any{"errors": []map[string]any{{"title": "unavailable"}}}) + })) + defer srv.Close() + err := newTestClient(t, srv).Get(context.Background(), "/v1/apps", url.Values{"limit": {"1"}}, nil) + if !IsStatus(err, 503) || calls.Load() != 4 { + t.Errorf("err = %v, calls = %d (want 1 + 3 retries)", err, calls.Load()) + } +} + +func TestRequestBodiesAreJSONAPI(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q", r.Header.Get("Content-Type")) + } + _ = json.NewDecoder(r.Body).Decode(&body) + switch r.URL.Path { + case "/v1/builds/b1": + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "builds", "id": "b1", "attributes": map[string]any{"usesNonExemptEncryption": false}}}) + case "/v1/builds/b1/relationships/betaGroups": + w.WriteHeader(204) + case "/v1/betaAppReviewSubmissions": + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "betaAppReviewSubmissions", "id": "bar-1", "attributes": map[string]any{"betaReviewState": "WAITING_FOR_REVIEW"}}}) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + ctx := context.Background() + + b, err := c.SetUsesNonExemptEncryption(ctx, "b1", false) + if err != nil || b.UsesNonExemptEncryption == nil || *b.UsesNonExemptEncryption { + t.Fatalf("build = %+v, err = %v", b, err) + } + data := body["data"].(map[string]any) + if data["type"] != "builds" || data["id"] != "b1" || data["attributes"].(map[string]any)["usesNonExemptEncryption"] != false { + t.Errorf("PATCH body = %v", body) + } + + if err := c.AddBuildToBetaGroups(ctx, "b1", []string{"g1", "g2"}); err != nil { + t.Fatal(err) + } + linkages := body["data"].([]any) + if len(linkages) != 2 || linkages[1].(map[string]any)["id"] != "g2" || linkages[0].(map[string]any)["type"] != "betaGroups" { + t.Errorf("relationship body = %v", body) + } + + sub, err := c.SubmitBuildForBetaReview(ctx, "b1") + if err != nil || sub.ID != "bar-1" || sub.State != BetaReviewWaiting { + t.Fatalf("sub = %+v, err = %v", sub, err) + } + data = body["data"].(map[string]any) + if _, has := data["attributes"]; has { + t.Errorf("empty attributes must be omitted: %v", body) + } + if data["relationships"].(map[string]any)["build"].(map[string]any)["data"].(map[string]any)["id"] != "b1" { + t.Errorf("POST body = %v", body) + } +} + +func TestBetaAppReviewSubmissionAbsent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/builds/none/betaAppReviewSubmission": + writeJSON(w, 200, map[string]any{"data": nil}) + default: + writeJSON(w, 404, map[string]any{"errors": []map[string]any{{"code": "NOT_FOUND", "title": "not found"}}}) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + for _, id := range []string{"none", "missing"} { + sub, err := c.GetBuildBetaAppReviewSubmission(context.Background(), id) + if err != nil || sub != nil { + t.Errorf("%s: sub = %+v, err = %v", id, sub, err) + } + } +} diff --git a/internal/asc/jsonapi.go b/internal/asc/jsonapi.go new file mode 100644 index 0000000..5dfc56a --- /dev/null +++ b/internal/asc/jsonapi.go @@ -0,0 +1,148 @@ +package asc + +import ( + "context" + "encoding/json" + "net/url" + "strconv" +) + +// Document is a JSON:API top-level document. T is a Resource for single +// resources and a []Resource for collections. +type Document[T any] struct { + Data T `json:"data"` + Links Links `json:"links,omitzero"` + Meta *Meta `json:"meta,omitempty"` +} + +// Links carries pagination links. +type Links struct { + Self string `json:"self,omitempty"` + Next string `json:"next,omitempty"` +} + +// Meta carries paging information on collections. +type Meta struct { + Paging struct { + Total int `json:"total"` + Limit int `json:"limit"` + } `json:"paging"` +} + +// Resource is a JSON:API resource object with typed attributes. +type Resource[A any] struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Attributes A `json:"attributes,omitzero"` + Relationships Relationships `json:"relationships,omitempty"` +} + +// Relationships maps relationship names to their linkage. +type Relationships map[string]Relationship + +// Relationship holds a to-one (object) or to-many (array) linkage. +type Relationship struct { + Data json.RawMessage `json:"data,omitempty"` +} + +// Linkage identifies a related resource. +type Linkage struct { + Type string `json:"type"` + ID string `json:"id"` +} + +// ToOne builds a to-one relationship. +func ToOne(resourceType, id string) Relationship { + data, _ := json.Marshal(Linkage{Type: resourceType, ID: id}) + return Relationship{Data: data} +} + +// ToMany builds a to-many relationship. +func ToMany(resourceType string, ids []string) Relationship { + linkages := make([]Linkage, 0, len(ids)) + for _, id := range ids { + linkages = append(linkages, Linkage{Type: resourceType, ID: id}) + } + data, _ := json.Marshal(linkages) + return Relationship{Data: data} +} + +// One decodes a to-one linkage; ok is false when the relationship is null or absent. +func (r Relationship) One() (linkage Linkage, ok bool) { + if len(r.Data) == 0 || json.Unmarshal(r.Data, &linkage) != nil || linkage.ID == "" { + return Linkage{}, false + } + return linkage, true +} + +// One returns the named to-one linkage. +func (r Relationships) One(name string) (Linkage, bool) { + rel, ok := r[name] + if !ok { + return Linkage{}, false + } + return rel.One() +} + +// pageLimit is the largest page App Store Connect serves. +const pageLimit = 200 + +// getOne fetches a single resource. +func getOne[A any](ctx context.Context, c *Client, path string, query url.Values) (*Resource[A], error) { + var doc Document[Resource[A]] + if err := c.Get(ctx, path, query, &doc); err != nil { + return nil, err + } + return &doc.Data, nil +} + +// getAll fetches a collection, following links.next until exhausted. +func getAll[A any](ctx context.Context, c *Client, path string, query url.Values) ([]Resource[A], error) { + if query == nil { + query = url.Values{} + } + if query.Get("limit") == "" { + query.Set("limit", strconv.Itoa(pageLimit)) + } + var all []Resource[A] + next := path + for { + var doc Document[[]Resource[A]] + if err := c.Get(ctx, next, query, &doc); err != nil { + return nil, err + } + all = append(all, doc.Data...) + if doc.Links.Next == "" { + return all, nil + } + // The next link already carries the filters and cursor. + next, query = doc.Links.Next, nil + } +} + +// getPage fetches one page of a collection without following links. +func getPage[A any](ctx context.Context, c *Client, path string, query url.Values) ([]Resource[A], error) { + var doc Document[[]Resource[A]] + if err := c.Get(ctx, path, query, &doc); err != nil { + return nil, err + } + return doc.Data, nil +} + +// post creates a resource and decodes the created one. +func post[Req, Resp any](ctx context.Context, c *Client, path string, req Resource[Req]) (*Resource[Resp], error) { + var doc Document[Resource[Resp]] + if err := c.Post(ctx, path, Document[Resource[Req]]{Data: req}, &doc); err != nil { + return nil, err + } + return &doc.Data, nil +} + +// patch updates a resource and decodes the updated one. +func patch[Req, Resp any](ctx context.Context, c *Client, path string, req Resource[Req]) (*Resource[Resp], error) { + var doc Document[Resource[Resp]] + if err := c.Patch(ctx, path, Document[Resource[Req]]{Data: req}, &doc); err != nil { + return nil, err + } + return &doc.Data, nil +} diff --git a/internal/asc/jwt.go b/internal/asc/jwt.go new file mode 100644 index 0000000..460f60c --- /dev/null +++ b/internal/asc/jwt.go @@ -0,0 +1,153 @@ +// Package asc is a client for the App Store Connect API. +// +// It runs on the developer's machine (or a CI agent) rather than on the macOS +// runner, authenticating with an App Store Connect API key: no Mac, altool or +// Transporter is involved. The client covers the JSON:API plumbing (auth, +// errors, pagination, retries) generically and adds typed helpers for the +// resources Builder needs: apps, builds, build uploads, TestFlight groups and +// App Store review submissions. +package asc + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +// Credentials is an App Store Connect API key: the team's issuer ID, the key +// ID and the .p8 private key as downloaded from App Store Connect (PEM). +type Credentials struct { + IssuerID string + KeyID string + PrivateKey string +} + +// Validate checks that every field is present and that the key is a P-256 key. +func (c Credentials) Validate() error { + if strings.TrimSpace(c.IssuerID) == "" { + return errors.New("issuer ID is empty") + } + if strings.TrimSpace(c.KeyID) == "" { + return errors.New("key ID is empty") + } + _, err := ParsePrivateKey(c.PrivateKey) + return err +} + +// ParsePrivateKey parses the PEM .p8 key App Store Connect issues (PKCS#8 or +// SEC 1 encoded) and checks it is usable for ES256. +func ParsePrivateKey(pemKey string) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(strings.TrimSpace(pemKey))) + if block == nil { + return nil, errors.New("private key is not PEM encoded (expected the .p8 file contents)") + } + var key any + var err error + switch block.Type { + case "PRIVATE KEY": + key, err = x509.ParsePKCS8PrivateKey(block.Bytes) + case "EC PRIVATE KEY": + key, err = x509.ParseECPrivateKey(block.Bytes) + default: + return nil, fmt.Errorf("unsupported PEM block %q", block.Type) + } + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + ecKey, ok := key.(*ecdsa.PrivateKey) + if !ok { + return nil, errors.New("private key is not an EC key; App Store Connect API keys are P-256") + } + if ecKey.Curve != elliptic.P256() { + return nil, errors.New("private key is not on the P-256 curve") + } + return ecKey, nil +} + +const ( + audience = "appstoreconnect-v1" + // Apple caps tokens at 20 minutes; leave a margin for clock skew. + tokenLifetime = 15 * time.Minute + // A token is reissued this long before it expires so an in-flight + // request never carries a token that lapses on the way. + refreshMargin = time.Minute +) + +// tokenSource signs and caches JWTs for one key. +type tokenSource struct { + creds Credentials + key *ecdsa.PrivateKey + now func() time.Time + + mu sync.Mutex + token string + expiry time.Time +} + +func newTokenSource(creds Credentials) (*tokenSource, error) { + if err := creds.Validate(); err != nil { + return nil, err + } + key, err := ParsePrivateKey(creds.PrivateKey) + if err != nil { + return nil, err + } + return &tokenSource{creds: creds, key: key, now: time.Now}, nil +} + +// Token returns a valid bearer token, reusing the cached one until it nears expiry. +func (t *tokenSource) Token() (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + now := t.now() + if t.token != "" && now.Before(t.expiry.Add(-refreshMargin)) { + return t.token, nil + } + exp := now.Add(tokenLifetime) + token, err := signJWT(t.key, t.creds.KeyID, t.creds.IssuerID, now, exp) + if err != nil { + return "", err + } + t.token, t.expiry = token, exp + return token, nil +} + +// signJWT produces an ES256 JWT with the claims App Store Connect requires. +func signJWT(key *ecdsa.PrivateKey, keyID, issuerID string, issuedAt, expiresAt time.Time) (string, error) { + header, err := json.Marshal(map[string]string{"alg": "ES256", "kid": keyID, "typ": "JWT"}) + if err != nil { + return "", err + } + claims, err := json.Marshal(map[string]any{ + "iss": issuerID, + "iat": issuedAt.Unix(), + "exp": expiresAt.Unix(), + "aud": audience, + }) + if err != nil { + return "", err + } + enc := base64.RawURLEncoding + signingInput := enc.EncodeToString(header) + "." + enc.EncodeToString(claims) + digest := sha256.Sum256([]byte(signingInput)) + r, s, err := ecdsa.Sign(rand.Reader, key, digest[:]) + if err != nil { + return "", fmt.Errorf("sign token: %w", err) + } + // JWS wants the raw R||S pair, each left-padded to the curve size, not + // the ASN.1 sequence ecdsa.SignASN1 produces. + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + return signingInput + "." + enc.EncodeToString(sig), nil +} diff --git a/internal/asc/jwt_test.go b/internal/asc/jwt_test.go new file mode 100644 index 0000000..fb98a09 --- /dev/null +++ b/internal/asc/jwt_test.go @@ -0,0 +1,139 @@ +package asc + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" +) + +// testKey returns a fresh P-256 key and its PKCS#8 PEM, as Apple's .p8 files are encoded. +func testKey(t *testing.T) (*ecdsa.PrivateKey, string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + return key, string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) +} + +func testCredentials(t *testing.T) (Credentials, *ecdsa.PrivateKey) { + t.Helper() + key, pemKey := testKey(t) + return Credentials{IssuerID: "issuer-1", KeyID: "KEY123", PrivateKey: pemKey}, key +} + +func decodeSegment(t *testing.T, s string, out any) { + t.Helper() + data, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, out); err != nil { + t.Fatal(err) + } +} + +func TestTokenClaimsAndSignature(t *testing.T) { + creds, key := testCredentials(t) + ts, err := newTokenSource(creds) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC) + ts.now = func() time.Time { return now } + + token, err := ts.Token() + if err != nil { + t.Fatal(err) + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("token has %d segments", len(parts)) + } + var header map[string]string + decodeSegment(t, parts[0], &header) + if header["alg"] != "ES256" || header["kid"] != "KEY123" || header["typ"] != "JWT" { + t.Errorf("header = %v", header) + } + var claims map[string]any + decodeSegment(t, parts[1], &claims) + if claims["iss"] != "issuer-1" || claims["aud"] != audience { + t.Errorf("claims = %v", claims) + } + iat, exp := int64(claims["iat"].(float64)), int64(claims["exp"].(float64)) + if iat != now.Unix() { + t.Errorf("iat = %d, want %d", iat, now.Unix()) + } + if lifetime := exp - iat; lifetime <= 0 || lifetime > 20*60 { + t.Errorf("exp-iat = %ds, must be within Apple's 20 minute cap", lifetime) + } + + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil || len(sig) != 64 { + t.Fatalf("signature: %v, %d bytes (want raw 64-byte R||S)", err, len(sig)) + } + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + r, s := new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:]) + if !ecdsa.Verify(&key.PublicKey, digest[:], r, s) { + t.Error("signature does not verify with the key's public half") + } +} + +func TestTokenCachedAndRefreshedBeforeExpiry(t *testing.T) { + creds, _ := testCredentials(t) + ts, err := newTokenSource(creds) + if err != nil { + t.Fatal(err) + } + now := time.Now() + ts.now = func() time.Time { return now } + first, _ := ts.Token() + now = now.Add(5 * time.Minute) + if again, _ := ts.Token(); again != first { + t.Error("token reissued while still valid") + } + // Inside the refresh margin: a new token must be minted even though the + // old one has not technically expired yet. + now = now.Add(tokenLifetime - 5*time.Minute - refreshMargin/2) + if again, _ := ts.Token(); again == first { + t.Error("token not refreshed before expiry") + } +} + +func TestCredentialsValidate(t *testing.T) { + _, pemKey := testKey(t) + cases := map[string]Credentials{ + "missing issuer": {KeyID: "K", PrivateKey: pemKey}, + "missing key id": {IssuerID: "I", PrivateKey: pemKey}, + "not pem": {IssuerID: "I", KeyID: "K", PrivateKey: "-----BEGIN NOTHING"}, + "wrong block": {IssuerID: "I", KeyID: "K", PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte{1}}))}, + } + for name, c := range cases { + if err := c.Validate(); err == nil { + t.Errorf("%s: want error", name) + } + } + if err := (Credentials{IssuerID: "I", KeyID: "K", PrivateKey: pemKey}).Validate(); err != nil { + t.Errorf("valid credentials rejected: %v", err) + } + // SEC 1 "EC PRIVATE KEY" encoding is accepted too. + key, _ := testKey(t) + der, _ := x509.MarshalECPrivateKey(key) + sec1 := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})) + if _, err := ParsePrivateKey(sec1); err != nil { + t.Errorf("SEC 1 key rejected: %v", err) + } +} diff --git a/internal/asc/review.go b/internal/asc/review.go new file mode 100644 index 0000000..2085abc --- /dev/null +++ b/internal/asc/review.go @@ -0,0 +1,145 @@ +package asc + +import ( + "context" + "net/url" + "strings" + "time" +) + +// Review submission states. +const ( + ReviewStateReadyForReview = "READY_FOR_REVIEW" + ReviewStateWaitingForReview = "WAITING_FOR_REVIEW" + ReviewStateInReview = "IN_REVIEW" + ReviewStateUnresolvedIssues = "UNRESOLVED_ISSUES" + ReviewStateCanceling = "CANCELING" + ReviewStateCompleting = "COMPLETING" + ReviewStateComplete = "COMPLETE" +) + +// ReviewSubmission groups the items submitted to App Review together. +type ReviewSubmission struct { + ID string + Platform string + State string + SubmittedDate time.Time +} + +type reviewSubmissionAttributes struct { + Platform string `json:"platform,omitempty"` + State string `json:"state,omitempty"` + SubmittedDate *time.Time `json:"submittedDate,omitempty"` +} + +type reviewSubmissionUpdate struct { + Submitted *bool `json:"submitted,omitempty"` + Canceled *bool `json:"canceled,omitempty"` +} + +func toReviewSubmission(r Resource[reviewSubmissionAttributes]) ReviewSubmission { + s := ReviewSubmission{ID: r.ID, Platform: r.Attributes.Platform, State: r.Attributes.State} + if r.Attributes.SubmittedDate != nil { + s.SubmittedDate = *r.Attributes.SubmittedDate + } + return s +} + +// ListReviewSubmissions lists the app's submissions on a platform, optionally limited to states. +func (c *Client) ListReviewSubmissions(ctx context.Context, appID, platform string, states []string) ([]ReviewSubmission, error) { + q := url.Values{"filter[app]": {appID}} + if platform != "" { + q.Set("filter[platform]", platform) + } + if len(states) > 0 { + q.Set("filter[state]", strings.Join(states, ",")) + } + rs, err := getAll[reviewSubmissionAttributes](ctx, c, "/v1/reviewSubmissions", q) + if err != nil { + return nil, err + } + subs := make([]ReviewSubmission, 0, len(rs)) + for _, r := range rs { + subs = append(subs, toReviewSubmission(r)) + } + return subs, nil +} + +// CreateReviewSubmission opens a new submission for the app on a platform. +func (c *Client) CreateReviewSubmission(ctx context.Context, appID, platform string) (*ReviewSubmission, error) { + req := Resource[reviewSubmissionAttributes]{ + Type: "reviewSubmissions", + Attributes: reviewSubmissionAttributes{Platform: platform}, + Relationships: Relationships{"app": ToOne("apps", appID)}, + } + r, err := post[reviewSubmissionAttributes, reviewSubmissionAttributes](ctx, c, "/v1/reviewSubmissions", req) + if err != nil { + return nil, err + } + s := toReviewSubmission(*r) + return &s, nil +} + +// ReviewSubmissionItem is one thing under review, here always an App Store version. +type ReviewSubmissionItem struct { + ID string + State string + AppStoreVersionID string +} + +type reviewSubmissionItemAttributes struct { + State string `json:"state,omitempty"` +} + +func toReviewSubmissionItem(r Resource[reviewSubmissionItemAttributes]) ReviewSubmissionItem { + item := ReviewSubmissionItem{ID: r.ID, State: r.Attributes.State} + if l, ok := r.Relationships.One("appStoreVersion"); ok { + item.AppStoreVersionID = l.ID + } + return item +} + +// ListReviewSubmissionItems lists what a submission contains. +func (c *Client) ListReviewSubmissionItems(ctx context.Context, submissionID string) ([]ReviewSubmissionItem, error) { + rs, err := getAll[reviewSubmissionItemAttributes](ctx, c, "/v1/reviewSubmissions/"+submissionID+"/items", url.Values{"include": {"appStoreVersion"}}) + if err != nil { + return nil, err + } + items := make([]ReviewSubmissionItem, 0, len(rs)) + for _, r := range rs { + items = append(items, toReviewSubmissionItem(r)) + } + return items, nil +} + +// AddAppStoreVersionToReviewSubmission puts a version into the submission. +func (c *Client) AddAppStoreVersionToReviewSubmission(ctx context.Context, submissionID, versionID string) (*ReviewSubmissionItem, error) { + req := Resource[struct{}]{ + Type: "reviewSubmissionItems", + Relationships: Relationships{ + "reviewSubmission": ToOne("reviewSubmissions", submissionID), + "appStoreVersion": ToOne("appStoreVersions", versionID), + }, + } + r, err := post[struct{}, reviewSubmissionItemAttributes](ctx, c, "/v1/reviewSubmissionItems", req) + if err != nil { + return nil, err + } + item := toReviewSubmissionItem(*r) + if item.AppStoreVersionID == "" { + item.AppStoreVersionID = versionID + } + return &item, nil +} + +// SubmitReviewSubmission sends the submission to App Review. +func (c *Client) SubmitReviewSubmission(ctx context.Context, id string) (*ReviewSubmission, error) { + submitted := true + req := Resource[reviewSubmissionUpdate]{Type: "reviewSubmissions", ID: id, Attributes: reviewSubmissionUpdate{Submitted: &submitted}} + r, err := patch[reviewSubmissionUpdate, reviewSubmissionAttributes](ctx, c, "/v1/reviewSubmissions/"+id, req) + if err != nil { + return nil, err + } + s := toReviewSubmission(*r) + return &s, nil +} diff --git a/internal/asc/testflight.go b/internal/asc/testflight.go new file mode 100644 index 0000000..43a8e7f --- /dev/null +++ b/internal/asc/testflight.go @@ -0,0 +1,163 @@ +package asc + +import ( + "context" + "net/url" +) + +// BetaGroup is a TestFlight tester group. +type BetaGroup struct { + ID string + Name string + Internal bool + PublicLinkEnabled bool +} + +type betaGroupAttributes struct { + Name string `json:"name,omitempty"` + IsInternalGroup *bool `json:"isInternalGroup,omitempty"` + PublicLinkEnabled *bool `json:"publicLinkEnabled,omitempty"` + HasAccessToAllBuilds *bool `json:"hasAccessToAllBuilds,omitempty"` +} + +// ListBetaGroups lists the app's TestFlight groups. +func (c *Client) ListBetaGroups(ctx context.Context, appID string) ([]BetaGroup, error) { + rs, err := getAll[betaGroupAttributes](ctx, c, "/v1/betaGroups", url.Values{"filter[app]": {appID}}) + if err != nil { + return nil, err + } + groups := make([]BetaGroup, 0, len(rs)) + for _, r := range rs { + g := BetaGroup{ID: r.ID, Name: r.Attributes.Name} + if r.Attributes.IsInternalGroup != nil { + g.Internal = *r.Attributes.IsInternalGroup + } + if r.Attributes.PublicLinkEnabled != nil { + g.PublicLinkEnabled = *r.Attributes.PublicLinkEnabled + } + groups = append(groups, g) + } + return groups, nil +} + +// BetaBuildLocalization is the "What to Test" text of a build in one locale. +type BetaBuildLocalization struct { + ID string + Locale string + WhatsNew string +} + +type betaBuildLocalizationAttributes struct { + WhatsNew string `json:"whatsNew,omitempty"` + Locale string `json:"locale,omitempty"` +} + +func toBetaBuildLocalization(r Resource[betaBuildLocalizationAttributes]) BetaBuildLocalization { + return BetaBuildLocalization{ID: r.ID, Locale: r.Attributes.Locale, WhatsNew: r.Attributes.WhatsNew} +} + +// ListBetaBuildLocalizations lists the build's test notes per locale. +func (c *Client) ListBetaBuildLocalizations(ctx context.Context, buildID string) ([]BetaBuildLocalization, error) { + rs, err := getAll[betaBuildLocalizationAttributes](ctx, c, "/v1/builds/"+buildID+"/betaBuildLocalizations", nil) + if err != nil { + return nil, err + } + out := make([]BetaBuildLocalization, 0, len(rs)) + for _, r := range rs { + out = append(out, toBetaBuildLocalization(r)) + } + return out, nil +} + +// CreateBetaBuildLocalization adds test notes for a locale. +func (c *Client) CreateBetaBuildLocalization(ctx context.Context, buildID, locale, whatsNew string) (*BetaBuildLocalization, error) { + req := Resource[betaBuildLocalizationAttributes]{ + Type: "betaBuildLocalizations", + Attributes: betaBuildLocalizationAttributes{Locale: locale, WhatsNew: whatsNew}, + Relationships: Relationships{"build": ToOne("builds", buildID)}, + } + r, err := post[betaBuildLocalizationAttributes, betaBuildLocalizationAttributes](ctx, c, "/v1/betaBuildLocalizations", req) + if err != nil { + return nil, err + } + l := toBetaBuildLocalization(*r) + return &l, nil +} + +// UpdateBetaBuildLocalization replaces the test notes of an existing locale. +func (c *Client) UpdateBetaBuildLocalization(ctx context.Context, id, whatsNew string) (*BetaBuildLocalization, error) { + req := Resource[betaBuildLocalizationAttributes]{Type: "betaBuildLocalizations", ID: id, Attributes: betaBuildLocalizationAttributes{WhatsNew: whatsNew}} + r, err := patch[betaBuildLocalizationAttributes, betaBuildLocalizationAttributes](ctx, c, "/v1/betaBuildLocalizations/"+id, req) + if err != nil { + return nil, err + } + l := toBetaBuildLocalization(*r) + return &l, nil +} + +// SetWhatsNew creates or updates the build's test notes for the locale. +func (c *Client) SetWhatsNew(ctx context.Context, buildID, locale, whatsNew string) (*BetaBuildLocalization, error) { + existing, err := c.ListBetaBuildLocalizations(ctx, buildID) + if err != nil { + return nil, err + } + for _, l := range existing { + if l.Locale == locale { + return c.UpdateBetaBuildLocalization(ctx, l.ID, whatsNew) + } + } + return c.CreateBetaBuildLocalization(ctx, buildID, locale, whatsNew) +} + +// Beta review states. +const ( + BetaReviewWaiting = "WAITING_FOR_REVIEW" + BetaReviewInReview = "IN_REVIEW" + BetaReviewRejected = "REJECTED" + BetaReviewApproved = "APPROVED" +) + +// BetaAppReviewSubmission is a build's external TestFlight review. +type BetaAppReviewSubmission struct { + ID string + State string +} + +type betaAppReviewSubmissionAttributes struct { + BetaReviewState string `json:"betaReviewState,omitempty"` +} + +// GetBuildBetaAppReviewSubmission returns the build's beta review, or nil when +// the build was never submitted. +func (c *Client) GetBuildBetaAppReviewSubmission(ctx context.Context, buildID string) (*BetaAppReviewSubmission, error) { + r, err := getOne[betaAppReviewSubmissionAttributes](ctx, c, "/v1/builds/"+buildID+"/betaAppReviewSubmission", nil) + if err != nil { + if IsStatus(err, 404) { + return nil, nil + } + return nil, err + } + if r.ID == "" { + return nil, nil + } + return &BetaAppReviewSubmission{ID: r.ID, State: r.Attributes.BetaReviewState}, nil +} + +// GetBetaAppReviewSubmission fetches a beta review by ID. +func (c *Client) GetBetaAppReviewSubmission(ctx context.Context, id string) (*BetaAppReviewSubmission, error) { + r, err := getOne[betaAppReviewSubmissionAttributes](ctx, c, "/v1/betaAppReviewSubmissions/"+id, nil) + if err != nil { + return nil, err + } + return &BetaAppReviewSubmission{ID: r.ID, State: r.Attributes.BetaReviewState}, nil +} + +// SubmitBuildForBetaReview submits the build for external TestFlight review. +func (c *Client) SubmitBuildForBetaReview(ctx context.Context, buildID string) (*BetaAppReviewSubmission, error) { + req := Resource[struct{}]{Type: "betaAppReviewSubmissions", Relationships: Relationships{"build": ToOne("builds", buildID)}} + r, err := post[struct{}, betaAppReviewSubmissionAttributes](ctx, c, "/v1/betaAppReviewSubmissions", req) + if err != nil { + return nil, err + } + return &BetaAppReviewSubmission{ID: r.ID, State: r.Attributes.BetaReviewState}, nil +} diff --git a/internal/asc/uploads.go b/internal/asc/uploads.go new file mode 100644 index 0000000..727329f --- /dev/null +++ b/internal/asc/uploads.go @@ -0,0 +1,392 @@ +package asc + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// Build upload states. +const ( + UploadStateAwaitingUpload = "AWAITING_UPLOAD" + UploadStateProcessing = "PROCESSING" + UploadStateFailed = "FAILED" + UploadStateComplete = "COMPLETE" +) + +// StateDetail is one message App Store Connect attaches to an upload state. +type StateDetail struct { + Code string `json:"code,omitempty"` + Description string `json:"description,omitempty"` +} + +func (d StateDetail) String() string { + if d.Code == "" { + return d.Description + } + return d.Code + ": " + d.Description +} + +// BuildUpload is a build delivery in progress or finished. +type BuildUpload struct { + ID string + Version string + BuildNumber string + Platform string + State string + Errors []StateDetail + Warnings []StateDetail + Infos []StateDetail + CreatedDate time.Time + UploadedDate time.Time +} + +type uploadState struct { + State string `json:"state,omitempty"` + Errors []StateDetail `json:"errors,omitempty"` + Warnings []StateDetail `json:"warnings,omitempty"` + Infos []StateDetail `json:"infos,omitempty"` +} + +type buildUploadAttributes struct { + CFBundleShortVersionString string `json:"cfBundleShortVersionString,omitempty"` + CFBundleVersion string `json:"cfBundleVersion,omitempty"` + Platform string `json:"platform,omitempty"` + State *uploadState `json:"state,omitempty"` + CreatedDate *time.Time `json:"createdDate,omitempty"` + UploadedDate *time.Time `json:"uploadedDate,omitempty"` +} + +func toBuildUpload(r Resource[buildUploadAttributes]) BuildUpload { + u := BuildUpload{ + ID: r.ID, + Version: r.Attributes.CFBundleShortVersionString, + BuildNumber: r.Attributes.CFBundleVersion, + Platform: r.Attributes.Platform, + } + if s := r.Attributes.State; s != nil { + u.State, u.Errors, u.Warnings, u.Infos = s.State, s.Errors, s.Warnings, s.Infos + } + if r.Attributes.CreatedDate != nil { + u.CreatedDate = *r.Attributes.CreatedDate + } + if r.Attributes.UploadedDate != nil { + u.UploadedDate = *r.Attributes.UploadedDate + } + return u +} + +// CreateBuildUpload opens a build delivery for the app. +func (c *Client) CreateBuildUpload(ctx context.Context, appID, version, buildNumber, platform string) (*BuildUpload, error) { + req := Resource[buildUploadAttributes]{ + Type: "buildUploads", + Attributes: buildUploadAttributes{CFBundleShortVersionString: version, CFBundleVersion: buildNumber, Platform: platform}, + Relationships: Relationships{"app": ToOne("apps", appID)}, + } + r, err := post[buildUploadAttributes, buildUploadAttributes](ctx, c, "/v1/buildUploads", req) + if err != nil { + return nil, err + } + u := toBuildUpload(*r) + return &u, nil +} + +// GetBuildUpload fetches the current state of a delivery. +func (c *Client) GetBuildUpload(ctx context.Context, id string) (*BuildUpload, error) { + r, err := getOne[buildUploadAttributes](ctx, c, "/v1/buildUploads/"+id, nil) + if err != nil { + return nil, err + } + u := toBuildUpload(*r) + return &u, nil +} + +// HTTPHeader is a header a presigned upload URL requires. +type HTTPHeader struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// UploadOperation is one chunk PUT to Apple's storage. +type UploadOperation struct { + Method string `json:"method,omitempty"` + URL string `json:"url,omitempty"` + Length int64 `json:"length,omitempty"` + Offset int64 `json:"offset,omitempty"` + RequestHeaders []HTTPHeader `json:"requestHeaders,omitempty"` +} + +// AssetDeliveryState reports whether Apple received the file. +type AssetDeliveryState struct { + State string `json:"state,omitempty"` + Errors []StateDetail `json:"errors,omitempty"` + Warnings []StateDetail `json:"warnings,omitempty"` +} + +// BuildUploadFile is the reserved slot for the IPA within a delivery. +type BuildUploadFile struct { + ID string + FileName string + FileSize int64 + UploadOperations []UploadOperation + AssetDeliveryState *AssetDeliveryState +} + +type buildUploadFileAttributes struct { + AssetType string `json:"assetType,omitempty"` + FileName string `json:"fileName,omitempty"` + FileSize int64 `json:"fileSize,omitempty"` + UTI string `json:"uti,omitempty"` + UploadOperations []UploadOperation `json:"uploadOperations,omitempty"` + AssetDeliveryState *AssetDeliveryState `json:"assetDeliveryState,omitempty"` +} + +// The reference implementation sends no checksum: ASC accepts the upload +// without one and rejects some checksum encodings, so it stays out. +type buildUploadFileCommit struct { + Uploaded bool `json:"uploaded"` +} + +func toBuildUploadFile(r Resource[buildUploadFileAttributes]) BuildUploadFile { + return BuildUploadFile{ + ID: r.ID, + FileName: r.Attributes.FileName, + FileSize: r.Attributes.FileSize, + UploadOperations: r.Attributes.UploadOperations, + AssetDeliveryState: r.Attributes.AssetDeliveryState, + } +} + +// utiFor maps the archive extension to Apple's uniform type identifier. +func utiFor(fileName string) string { + if strings.EqualFold(filepath.Ext(fileName), ".pkg") { + return "com.apple.pkg" + } + return "com.apple.ipa" +} + +// CreateBuildUploadFile reserves the file slot and returns the presigned chunk operations. +func (c *Client) CreateBuildUploadFile(ctx context.Context, uploadID, fileName string, size int64) (*BuildUploadFile, error) { + req := Resource[buildUploadFileAttributes]{ + Type: "buildUploadFiles", + Attributes: buildUploadFileAttributes{AssetType: "ASSET", FileName: fileName, FileSize: size, UTI: utiFor(fileName)}, + Relationships: Relationships{"buildUpload": ToOne("buildUploads", uploadID)}, + } + r, err := post[buildUploadFileAttributes, buildUploadFileAttributes](ctx, c, "/v1/buildUploadFiles", req) + if err != nil { + return nil, err + } + f := toBuildUploadFile(*r) + return &f, nil +} + +// GetBuildUploadFile fetches the file slot, including its delivery state. +func (c *Client) GetBuildUploadFile(ctx context.Context, id string) (*BuildUploadFile, error) { + r, err := getOne[buildUploadFileAttributes](ctx, c, "/v1/buildUploadFiles/"+id, nil) + if err != nil { + return nil, err + } + f := toBuildUploadFile(*r) + return &f, nil +} + +// CommitBuildUploadFile tells App Store Connect every chunk has been sent. +func (c *Client) CommitBuildUploadFile(ctx context.Context, fileID string) error { + req := Resource[buildUploadFileCommit]{Type: "buildUploadFiles", ID: fileID, Attributes: buildUploadFileCommit{Uploaded: true}} + return c.Patch(ctx, "/v1/buildUploadFiles/"+fileID, Document[Resource[buildUploadFileCommit]]{Data: req}, nil) +} + +// UploadChunks PUTs each operation's byte range of file to its presigned URL. +// progress, when set, is called after every chunk with the bytes sent so far. +func (c *Client) UploadChunks(ctx context.Context, file io.ReaderAt, ops []UploadOperation, progress func(sent, total int64)) error { + var total, sent int64 + for _, op := range ops { + total += op.Length + } + for i, op := range ops { + if err := c.uploadChunk(ctx, file, op); err != nil { + return fmt.Errorf("upload chunk %d/%d: %w", i+1, len(ops), err) + } + sent += op.Length + if progress != nil { + progress(sent, total) + } + } + return nil +} + +func (c *Client) uploadChunk(ctx context.Context, file io.ReaderAt, op UploadOperation) error { + if op.URL == "" { + return errors.New("upload operation has no URL") + } + method := op.Method + if method == "" { + method = http.MethodPut + } + var lastErr error + for attempt := 0; attempt <= c.maxRetries; attempt++ { + if attempt > 0 { + timer := time.NewTimer(c.retryDelay << (attempt - 1)) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } + req, err := http.NewRequestWithContext(ctx, method, op.URL, io.NewSectionReader(file, op.Offset, op.Length)) + if err != nil { + return err + } + req.ContentLength = op.Length + for _, h := range op.RequestHeaders { + req.Header.Set(h.Name, h.Value) + } + resp, err := c.upload.Do(req) + if err != nil { + lastErr = err + continue + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + if resp.StatusCode < 300 { + return nil + } + lastErr = fmt.Errorf("storage returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + if resp.StatusCode < 500 && resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode != http.StatusRequestTimeout { + return lastErr + } + } + return lastErr +} + +// UploadBuildOptions describes a build delivery. +type UploadBuildOptions struct { + AppID string + Version string // CFBundleShortVersionString + BuildNumber string // CFBundleVersion + Platform string // defaults to PlatformIOS + Path string // .ipa (or .pkg) on disk + // Progress, when set, receives the bytes sent so far and the total. + Progress func(sent, total int64) +} + +// UploadBuild runs the buildUploads flow end to end: create the delivery, +// reserve the file, PUT the chunks and commit. It returns as soon as App +// Store Connect has the file; use WaitForBuildUpload to follow processing. +func (c *Client) UploadBuild(ctx context.Context, opts UploadBuildOptions) (*BuildUpload, error) { + if opts.Platform == "" { + opts.Platform = PlatformIOS + } + f, err := os.Open(opts.Path) + if err != nil { + return nil, err + } + defer f.Close() + st, err := f.Stat() + if err != nil { + return nil, err + } + upload, err := c.CreateBuildUpload(ctx, opts.AppID, opts.Version, opts.BuildNumber, opts.Platform) + if err != nil { + return nil, fmt.Errorf("create build upload: %w", err) + } + file, err := c.CreateBuildUploadFile(ctx, upload.ID, filepath.Base(opts.Path), st.Size()) + if err != nil { + return nil, fmt.Errorf("reserve upload file: %w", err) + } + if len(file.UploadOperations) == 0 { + return nil, errors.New("App Store Connect returned no upload operations for the file") + } + if err := c.UploadChunks(ctx, f, file.UploadOperations, opts.Progress); err != nil { + return nil, err + } + if err := c.CommitBuildUploadFile(ctx, file.ID); err != nil { + return nil, fmt.Errorf("commit upload: %w", err) + } + return c.GetBuildUpload(ctx, upload.ID) +} + +// UploadFailedError reports a delivery App Store Connect rejected. +type UploadFailedError struct { + Upload *BuildUpload +} + +func (e *UploadFailedError) Error() string { + msgs := make([]string, 0, len(e.Upload.Errors)) + for _, d := range e.Upload.Errors { + msgs = append(msgs, d.String()) + } + if len(msgs) == 0 { + return "App Store Connect rejected the upload without details" + } + return "App Store Connect rejected the upload: " + strings.Join(msgs, "; ") +} + +// WaitForBuildUpload polls the delivery until it is COMPLETE, returning an +// *UploadFailedError when it FAILED. onPoll, when set, sees every poll result. +func (c *Client) WaitForBuildUpload(ctx context.Context, id string, interval time.Duration, onPoll func(*BuildUpload)) (*BuildUpload, error) { + for { + u, err := c.GetBuildUpload(ctx, id) + if err != nil { + return nil, err + } + if onPoll != nil { + onPoll(u) + } + switch u.State { + case UploadStateComplete: + return u, nil + case UploadStateFailed: + return u, &UploadFailedError{Upload: u} + } + if err := sleep(ctx, interval); err != nil { + return u, err + } + } +} + +// WaitForBuild polls until the build for the version pair exists and has +// left PROCESSING. A FAILED or INVALID build is returned with an error. +func (c *Client) WaitForBuild(ctx context.Context, appID, version, buildNumber string, interval time.Duration, onPoll func(*Build)) (*Build, error) { + for { + builds, err := c.ListBuilds(ctx, BuildFilter{AppID: appID, Platform: PlatformIOS, Version: version, BuildNumber: buildNumber, Limit: 1}) + if err != nil { + return nil, err + } + if len(builds) > 0 { + b := &builds[0] + if onPoll != nil { + onPoll(b) + } + switch b.ProcessingState { + case ProcessingStateValid: + return b, nil + case ProcessingStateFailed, ProcessingStateInvalid: + return b, fmt.Errorf("build %s (%s) finished processing as %s; App Store Connect emails the reason to the team", b.BuildNumber, b.ID, b.ProcessingState) + } + } else if onPoll != nil { + onPoll(nil) + } + if err := sleep(ctx, interval); err != nil { + return nil, err + } + } +} + +func sleep(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/asc/uploads_test.go b/internal/asc/uploads_test.go new file mode 100644 index 0000000..082e8a8 --- /dev/null +++ b/internal/asc/uploads_test.go @@ -0,0 +1,250 @@ +package asc + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + "time" +) + +// fakeASC is a minimal buildUploads backend: it hands out two chunk +// operations pointing back at itself, records the PUT bodies, and walks the +// upload state PROCESSING -> COMPLETE, after which the build appears. +type fakeASC struct { + t *testing.T + mu sync.Mutex + srv *httptest.Server + fileSize int64 + chunks map[int64][]byte + headers map[int64]http.Header + created map[string]any + fileReq map[string]any + commit map[string]any + polls int + buildGet int + patched map[string]any + failing bool + chunk500 int +} + +func newFakeASC(t *testing.T, fileSize int64) *fakeASC { + f := &fakeASC{t: t, fileSize: fileSize, chunks: map[int64][]byte{}, headers: map[int64]http.Header{}} + f.srv = httptest.NewServer(http.HandlerFunc(f.handle)) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeASC) handle(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + switch { + case r.Method == "POST" && r.URL.Path == "/v1/buildUploads": + _ = json.NewDecoder(r.Body).Decode(&f.created) + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "buildUploads", "id": "up-1", "attributes": map[string]any{ + "cfBundleShortVersionString": "1.2.3", "cfBundleVersion": "42", "platform": "IOS", "state": map[string]any{"state": "AWAITING_UPLOAD"}, + }}}) + case r.Method == "POST" && r.URL.Path == "/v1/buildUploadFiles": + _ = json.NewDecoder(r.Body).Decode(&f.fileReq) + half := f.fileSize / 2 + ops := []map[string]any{ + {"method": "PUT", "url": f.srv.URL + "/chunk?offset=0", "offset": 0, "length": half, "requestHeaders": []map[string]string{{"name": "Content-Type", "value": "application/octet-stream"}, {"name": "X-Chunk", "value": "first"}}}, + {"method": "PUT", "url": f.srv.URL + "/chunk?offset=" + strconv.FormatInt(half, 10), "offset": half, "length": f.fileSize - half, "requestHeaders": []map[string]string{{"name": "X-Chunk", "value": "second"}}}, + } + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "buildUploadFiles", "id": "file-1", "attributes": map[string]any{"fileName": "App.ipa", "fileSize": f.fileSize, "uploadOperations": ops}}}) + case r.Method == "PUT" && r.URL.Path == "/chunk": + if r.Header.Get("Authorization") != "" { + f.t.Error("bearer token leaked to storage URL") + } + if f.chunk500 > 0 { + f.chunk500-- + w.WriteHeader(503) + return + } + offset, _ := strconv.ParseInt(r.URL.Query().Get("offset"), 10, 64) + data, _ := io.ReadAll(r.Body) + if r.ContentLength != int64(len(data)) { + f.t.Errorf("Content-Length %d for %d bytes", r.ContentLength, len(data)) + } + f.chunks[offset] = data + f.headers[offset] = r.Header.Clone() + w.WriteHeader(200) + case r.Method == "PATCH" && r.URL.Path == "/v1/buildUploadFiles/file-1": + _ = json.NewDecoder(r.Body).Decode(&f.commit) + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "buildUploadFiles", "id": "file-1"}}) + case r.Method == "GET" && r.URL.Path == "/v1/buildUploads/up-1": + f.polls++ + state := map[string]any{"state": "PROCESSING"} + if f.polls >= 3 { + state["state"] = "COMPLETE" + if f.failing { + state = map[string]any{"state": "FAILED", "errors": []map[string]string{{"code": "ITMS-90189", "description": "Redundant Binary Upload."}}} + } + } + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "buildUploads", "id": "up-1", "attributes": map[string]any{"cfBundleShortVersionString": "1.2.3", "cfBundleVersion": "42", "platform": "IOS", "state": state}}}) + case r.Method == "GET" && r.URL.Path == "/v1/builds": + q := r.URL.Query() + if q.Get("filter[preReleaseVersion.version]") != "1.2.3" || q.Get("filter[version]") != "42" || q.Get("filter[app]") != "app-1" || q.Get("limit") != "1" { + f.t.Errorf("builds query = %v", q) + } + f.buildGet++ + if f.buildGet == 1 { + writeJSON(w, 200, map[string]any{"data": []any{}}) + return + } + state := "PROCESSING" + if f.buildGet >= 3 { + state = "VALID" + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{{"type": "builds", "id": "build-9", "attributes": map[string]any{"version": "42", "processingState": state, "uploadedDate": "2026-09-16T10:00:00Z"}}}}) + case r.Method == "PATCH" && r.URL.Path == "/v1/builds/build-9": + _ = json.NewDecoder(r.Body).Decode(&f.patched) + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "builds", "id": "build-9", "attributes": map[string]any{"version": "42", "processingState": "VALID", "usesNonExemptEncryption": false}}}) + default: + f.t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + } +} + +func writeRandomFile(t *testing.T, name string, size int) (string, []byte) { + t.Helper() + data := make([]byte, size) + if _, err := rand.Read(data); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path, data +} + +func TestUploadBuildFlow(t *testing.T) { + path, data := writeRandomFile(t, "App.ipa", 10_001) + fake := newFakeASC(t, int64(len(data))) + c := newTestClient(t, fake.srv) + ctx := context.Background() + + var progress []int64 + upload, err := c.UploadBuild(ctx, UploadBuildOptions{AppID: "app-1", Version: "1.2.3", BuildNumber: "42", Path: path, Progress: func(sent, total int64) { + progress = append(progress, sent) + if total != int64(len(data)) { + t.Errorf("total = %d", total) + } + }}) + if err != nil { + t.Fatal(err) + } + if upload.ID != "up-1" || upload.State != UploadStateProcessing { + t.Errorf("upload = %+v", upload) + } + + fake.mu.Lock() + created := fake.created["data"].(map[string]any) + attrs := created["attributes"].(map[string]any) + if attrs["cfBundleShortVersionString"] != "1.2.3" || attrs["cfBundleVersion"] != "42" || attrs["platform"] != "IOS" { + t.Errorf("buildUploads attributes = %v", attrs) + } + if created["relationships"].(map[string]any)["app"].(map[string]any)["data"].(map[string]any)["id"] != "app-1" { + t.Errorf("buildUploads relationships = %v", created["relationships"]) + } + fileAttrs := fake.fileReq["data"].(map[string]any)["attributes"].(map[string]any) + if fileAttrs["assetType"] != "ASSET" || fileAttrs["fileName"] != "App.ipa" || fileAttrs["fileSize"] != float64(len(data)) || fileAttrs["uti"] != "com.apple.ipa" { + t.Errorf("buildUploadFiles attributes = %v", fileAttrs) + } + if fake.fileReq["data"].(map[string]any)["relationships"].(map[string]any)["buildUpload"].(map[string]any)["data"].(map[string]any)["id"] != "up-1" { + t.Errorf("buildUploadFiles relationships = %v", fake.fileReq) + } + got := append(append([]byte{}, fake.chunks[0]...), fake.chunks[int64(len(data))/2]...) + if !bytes.Equal(got, data) { + t.Errorf("reassembled %d bytes differ from the %d-byte file", len(got), len(data)) + } + if fake.headers[0].Get("X-Chunk") != "first" || fake.headers[0].Get("Content-Type") != "application/octet-stream" || fake.headers[int64(len(data))/2].Get("X-Chunk") != "second" { + t.Errorf("request headers not honored: %v %v", fake.headers[0], fake.headers[int64(len(data))/2]) + } + commit := fake.commit["data"].(map[string]any) + if commit["id"] != "file-1" || commit["attributes"].(map[string]any)["uploaded"] != true { + t.Errorf("commit body = %v", fake.commit) + } + if _, has := commit["attributes"].(map[string]any)["sourceFileChecksums"]; has { + t.Error("checksum must not be sent") + } + fake.mu.Unlock() + if len(progress) != 2 || progress[1] != int64(len(data)) { + t.Errorf("progress = %v", progress) + } + + var states []string + done, err := c.WaitForBuildUpload(ctx, upload.ID, time.Millisecond, func(u *BuildUpload) { states = append(states, u.State) }) + // UploadBuild already fetched the delivery once, so the wait sees the + // remaining PROCESSING poll and then COMPLETE. + if err != nil || done.State != UploadStateComplete || len(states) != 2 { + t.Errorf("wait: %+v %v %v", done, err, states) + } + + var seen int + build, err := c.WaitForBuild(ctx, "app-1", "1.2.3", "42", time.Millisecond, func(*Build) { seen++ }) + if err != nil || build.ID != "build-9" || build.ProcessingState != ProcessingStateValid || seen != 3 { + t.Errorf("build = %+v, err = %v, polls = %d", build, err, seen) + } +} + +func TestUploadBuildRejected(t *testing.T) { + path, data := writeRandomFile(t, "App.ipa", 64) + fake := newFakeASC(t, int64(len(data))) + fake.failing = true + c := newTestClient(t, fake.srv) + ctx := context.Background() + upload, err := c.UploadBuild(ctx, UploadBuildOptions{AppID: "app-1", Version: "1.2.3", BuildNumber: "42", Path: path}) + if err != nil { + t.Fatal(err) + } + _, err = c.WaitForBuildUpload(ctx, upload.ID, time.Millisecond, nil) + var failed *UploadFailedError + if !errors.As(err, &failed) || failed.Upload.Errors[0].Code != "ITMS-90189" { + t.Fatalf("err = %v", err) + } + if want := "ITMS-90189: Redundant Binary Upload."; !bytes.Contains([]byte(err.Error()), []byte(want)) { + t.Errorf("message %q lacks %q", err.Error(), want) + } +} + +func TestUploadChunkRetriesOn5xx(t *testing.T) { + path, data := writeRandomFile(t, "App.pkg", 100) + fake := newFakeASC(t, int64(len(data))) + fake.chunk500 = 2 + c := newTestClient(t, fake.srv) + if _, err := c.UploadBuild(context.Background(), UploadBuildOptions{AppID: "app-1", Version: "1.0", BuildNumber: "1", Path: path}); err != nil { + t.Fatal(err) + } + fake.mu.Lock() + defer fake.mu.Unlock() + if fake.fileReq["data"].(map[string]any)["attributes"].(map[string]any)["uti"] != "com.apple.pkg" { + t.Error("pkg uti not detected") + } + if len(fake.chunks[0]) != 50 || len(fake.chunks[50]) != 50 { + t.Errorf("chunks = %d/%d bytes", len(fake.chunks[0]), len(fake.chunks[50])) + } +} + +func TestWaitForBuildCanceled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"data": []any{}}) + })) + defer srv.Close() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + _, err := newTestClient(t, srv).WaitForBuild(ctx, "app-1", "1.0", "1", time.Millisecond, nil) + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("err = %v", err) + } +} diff --git a/internal/asc/versions.go b/internal/asc/versions.go new file mode 100644 index 0000000..b765ced --- /dev/null +++ b/internal/asc/versions.go @@ -0,0 +1,113 @@ +package asc + +import ( + "context" + "net/url" + "time" +) + +// Release types of an App Store version. +const ( + ReleaseTypeManual = "MANUAL" + ReleaseTypeAfterApproval = "AFTER_APPROVAL" + ReleaseTypeScheduled = "SCHEDULED" +) + +// AppStoreVersion is a version of the app on the App Store. +type AppStoreVersion struct { + ID string + Platform string + VersionString string + // State is appVersionState (e.g. PREPARE_FOR_SUBMISSION, READY_FOR_REVIEW, + // WAITING_FOR_REVIEW, IN_REVIEW, READY_FOR_DISTRIBUTION). + State string + AppStoreState string + ReleaseType string + BuildID string + CreatedDate time.Time +} + +type appStoreVersionAttributes struct { + Platform string `json:"platform,omitempty"` + VersionString string `json:"versionString,omitempty"` + AppStoreState string `json:"appStoreState,omitempty"` + AppVersionState string `json:"appVersionState,omitempty"` + ReleaseType string `json:"releaseType,omitempty"` + CreatedDate *time.Time `json:"createdDate,omitempty"` +} + +func toAppStoreVersion(r Resource[appStoreVersionAttributes]) AppStoreVersion { + v := AppStoreVersion{ + ID: r.ID, + Platform: r.Attributes.Platform, + VersionString: r.Attributes.VersionString, + State: r.Attributes.AppVersionState, + AppStoreState: r.Attributes.AppStoreState, + ReleaseType: r.Attributes.ReleaseType, + } + if r.Attributes.CreatedDate != nil { + v.CreatedDate = *r.Attributes.CreatedDate + } + if l, ok := r.Relationships.One("build"); ok { + v.BuildID = l.ID + } + return v +} + +// ListAppStoreVersions lists the app's versions for a platform, optionally one version string. +func (c *Client) ListAppStoreVersions(ctx context.Context, appID, platform, versionString string) ([]AppStoreVersion, error) { + q := url.Values{"include": {"build"}} + if platform != "" { + q.Set("filter[platform]", platform) + } + if versionString != "" { + q.Set("filter[versionString]", versionString) + } + rs, err := getAll[appStoreVersionAttributes](ctx, c, "/v1/apps/"+appID+"/appStoreVersions", q) + if err != nil { + return nil, err + } + versions := make([]AppStoreVersion, 0, len(rs)) + for _, r := range rs { + versions = append(versions, toAppStoreVersion(r)) + } + return versions, nil +} + +// CreateAppStoreVersion adds a new version to the app. +func (c *Client) CreateAppStoreVersion(ctx context.Context, appID, platform, versionString string) (*AppStoreVersion, error) { + req := Resource[appStoreVersionAttributes]{ + Type: "appStoreVersions", + Attributes: appStoreVersionAttributes{Platform: platform, VersionString: versionString}, + Relationships: Relationships{"app": ToOne("apps", appID)}, + } + r, err := post[appStoreVersionAttributes, appStoreVersionAttributes](ctx, c, "/v1/appStoreVersions", req) + if err != nil { + return nil, err + } + v := toAppStoreVersion(*r) + return &v, nil +} + +// AppStoreVersionUpdate lists the fields UpdateAppStoreVersion changes; empty ones are left alone. +type AppStoreVersionUpdate struct { + ReleaseType string + BuildID string +} + +// UpdateAppStoreVersion attaches a build and/or sets the release type. +func (c *Client) UpdateAppStoreVersion(ctx context.Context, id string, u AppStoreVersionUpdate) (*AppStoreVersion, error) { + req := Resource[appStoreVersionAttributes]{Type: "appStoreVersions", ID: id, Attributes: appStoreVersionAttributes{ReleaseType: u.ReleaseType}} + if u.BuildID != "" { + req.Relationships = Relationships{"build": ToOne("builds", u.BuildID)} + } + r, err := patch[appStoreVersionAttributes, appStoreVersionAttributes](ctx, c, "/v1/appStoreVersions/"+id, req) + if err != nil { + return nil, err + } + v := toAppStoreVersion(*r) + if v.BuildID == "" { + v.BuildID = u.BuildID + } + return &v, nil +} From 6bf8b32f0c5e9d00865b2b147a25a8db2c8c2f84 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:17:09 +0200 Subject: [PATCH 15/75] 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. --- cmd/builder/auth.go | 93 ++++++++++++++++++++++++++++++++++++- internal/auth/apple.go | 93 +++++++++++++++++++++++++++++++++++++ internal/auth/apple_test.go | 92 ++++++++++++++++++++++++++++++++++++ internal/auth/providers.go | 47 +++++++++++++------ 4 files changed, 309 insertions(+), 16 deletions(-) create mode 100644 internal/auth/apple.go create mode 100644 internal/auth/apple_test.go diff --git a/cmd/builder/auth.go b/cmd/builder/auth.go index fcea181..58832cc 100644 --- a/cmd/builder/auth.go +++ b/cmd/builder/auth.go @@ -2,14 +2,17 @@ package main import ( "context" + "errors" "fmt" "io" "os" "strings" + "github.com/MobAI-App/ios-builder/internal/asc" "github.com/MobAI-App/ios-builder/internal/auth" "github.com/MobAI-App/ios-builder/internal/ci" "github.com/spf13/cobra" + "golang.org/x/term" ) var authCmd = &cobra.Command{ @@ -24,8 +27,24 @@ var authGitHubCmd = &cobra.Command{ RunE: runAuthGitHub, } +var authAppleCmd = &cobra.Command{ + Use: "apple", + Short: "Authenticate with App Store Connect (API key)", + Long: `Saves an App Store Connect API key for builder ios upload and builder ios submit. + +Create the key in App Store Connect under Users and Access → Integrations → +App Store Connect API (Team key, role App Manager or Admin). Note the Issuer ID +and Key ID shown there and download the AuthKey_.p8 file; Apple lets you +download it only once. + +Flags left out are prompted for. In CI, set ASC_ISSUER_ID, ASC_KEY_ID and +ASC_PRIVATE_KEY (or ASC_KEY_PATH) instead; they take precedence over the saved login.`, + Args: cobra.NoArgs, + RunE: runAuthApple, +} + var authLogoutCmd = &cobra.Command{ - Use: "logout [github|codemagic|bitrise]", + Use: "logout [github|codemagic|bitrise|apple]", Args: cobra.MaximumNArgs(1), Short: "Remove stored credentials", RunE: runAuthLogout, @@ -39,6 +58,10 @@ func init() { cmd.Flags().Bool("token-stdin", false, "Read API token from stdin instead of a hidden-input prompt") authCmd.AddCommand(cmd) } + authAppleCmd.Flags().String("issuer-id", "", "Issuer ID from App Store Connect → Users and Access → Integrations") + authAppleCmd.Flags().String("key-id", "", "Key ID of the API key") + authAppleCmd.Flags().String("key", "", "Path to the AuthKey_.p8 private key") + authCmd.AddCommand(authAppleCmd) authCmd.AddCommand(&cobra.Command{Use: "status", Short: "Show login availability for all providers", Args: cobra.NoArgs, RunE: runAuthStatus}) } @@ -69,7 +92,10 @@ func runAuthLogout(cmd *cobra.Command, args []string) error { return err } fmt.Printf("Removed saved %s login\n", provider) - if provider != "github" && os.Getenv(strings.ToUpper(provider)+"_API_TOKEN") != "" { + switch { + case provider == "apple" && os.Getenv("ASC_ISSUER_ID") != "": + fmt.Println("ASC_* environment variables are still set; unset them in your shell to stop using them.") + case provider != "github" && provider != "apple" && os.Getenv(strings.ToUpper(provider)+"_API_TOKEN") != "": fmt.Println("An environment token is still set; unset it in your shell to stop using it.") } return nil @@ -110,6 +136,58 @@ func runAuthProvider(cmd *cobra.Command, _ []string) error { return nil } +func runAuthApple(cmd *cobra.Command, _ []string) error { + issuerID, _ := cmd.Flags().GetString("issuer-id") + keyID, _ := cmd.Flags().GetString("key-id") + keyPath, _ := cmd.Flags().GetString("key") + if issuerID == "" || keyID == "" || keyPath == "" { + if stdin, ok := cmd.InOrStdin().(*os.File); !ok || !term.IsTerminal(int(stdin.Fd())) { + return fmt.Errorf("--issuer-id, --key-id and --key are required without a terminal (or set ASC_ISSUER_ID, ASC_KEY_ID and ASC_KEY_PATH)") + } + fmt.Println("App Store Connect → Users and Access → Integrations → App Store Connect API") + var err error + if issuerID == "" { + if issuerID, err = promptString("Issuer ID", ""); err != nil { + return err + } + } + if keyID == "" { + if keyID, err = promptString("Key ID", ""); err != nil { + return err + } + } + if keyPath == "" { + if keyPath, err = promptString("Path to AuthKey_"+keyID+".p8", ""); err != nil { + return err + } + } + } + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return fmt.Errorf("read private key: %w", err) + } + creds := auth.AppleCredentials{IssuerID: strings.TrimSpace(issuerID), KeyID: strings.TrimSpace(keyID), PrivateKey: auth.NormalizePEM(string(keyPEM))} + client, err := asc.NewClient(asc.Credentials{IssuerID: creds.IssuerID, KeyID: creds.KeyID, PrivateKey: creds.PrivateKey}) + if err != nil { + return err + } + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + if err := client.CheckAccess(ctx); err != nil { + return fmt.Errorf("App Store Connect rejected the key: %w", err) + } + if err := auth.StoreAppleCredentials(creds); err != nil { + return err + } + fmt.Printf("Saved Apple login (key %s). Other provider logins are unchanged.\n", creds.KeyID) + if os.Getenv("ASC_ISSUER_ID") != "" { + fmt.Println("ASC_* environment variables are set and take precedence over this saved login.") + } + return nil +} + func runAuthStatus(_ *cobra.Command, _ []string) error { for _, name := range []string{"github", "codemagic", "bitrise"} { _, err := auth.GetProviderToken(name) @@ -119,5 +197,16 @@ func runAuthStatus(_ *cobra.Command, _ []string) error { } fmt.Printf("%s: %s\n", name, state) } + creds, source, err := auth.GetAppleCredentials() + switch { + case errors.Is(err, auth.ErrNotAuthenticated): + fmt.Println("apple: not logged in") + case err != nil: + fmt.Printf("apple: %v\n", err) + case source == auth.AppleSourceEnv: + fmt.Printf("apple: login available from ASC_* environment (key %s, not checked remotely)\n", creds.KeyID) + default: + fmt.Printf("apple: login available (key %s, not checked remotely)\n", creds.KeyID) + } return nil } diff --git a/internal/auth/apple.go b/internal/auth/apple.go new file mode 100644 index 0000000..25e080d --- /dev/null +++ b/internal/auth/apple.go @@ -0,0 +1,93 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" +) + +// appleSecretName is the keyring entry / fallback file holding the ASC API key. +const appleSecretName = "apple-asc-key" + +// AppleCredentials is an App Store Connect API key (Users and Access → Integrations). +type AppleCredentials struct { + IssuerID string `json:"issuer_id"` + KeyID string `json:"key_id"` + PrivateKey string `json:"private_key"` // .p8 contents, PEM +} + +// AppleSource says where GetAppleCredentials found the key. +type AppleSource string + +const ( + // AppleSourceEnv means the ASC_* environment variables were used. + AppleSourceEnv AppleSource = "environment" + // AppleSourceStored means the login saved by `builder auth apple` was used. + AppleSourceStored AppleSource = "stored" +) + +// GetAppleCredentials returns the App Store Connect API key. The environment +// (ASC_ISSUER_ID, ASC_KEY_ID and ASC_PRIVATE_KEY or ASC_KEY_PATH) takes +// precedence over the saved login so CI jobs and agents need no keychain. +func GetAppleCredentials() (*AppleCredentials, AppleSource, error) { + creds, err := appleCredentialsFromEnv() + if err != nil { + return nil, "", err + } + if creds != nil { + return creds, AppleSourceEnv, nil + } + raw, err := readSecret(appleSecretName) + if err != nil { + return nil, "", err + } + var stored AppleCredentials + if err := json.Unmarshal([]byte(raw), &stored); err != nil || stored.IssuerID == "" || stored.KeyID == "" || stored.PrivateKey == "" { + return nil, "", errors.New("saved Apple login is unreadable; run builder auth apple again") + } + return &stored, AppleSourceStored, nil +} + +func appleCredentialsFromEnv() (*AppleCredentials, error) { + issuer := strings.TrimSpace(os.Getenv("ASC_ISSUER_ID")) + keyID := strings.TrimSpace(os.Getenv("ASC_KEY_ID")) + key := os.Getenv("ASC_PRIVATE_KEY") + path := strings.TrimSpace(os.Getenv("ASC_KEY_PATH")) + if issuer == "" && keyID == "" && key == "" && path == "" { + return nil, nil + } + if issuer == "" || keyID == "" || (key == "" && path == "") { + return nil, errors.New("ASC_ISSUER_ID, ASC_KEY_ID and ASC_PRIVATE_KEY (or ASC_KEY_PATH) must all be set to use App Store Connect credentials from the environment") + } + if key == "" { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("ASC_KEY_PATH: %w", err) + } + key = string(data) + } + return &AppleCredentials{IssuerID: issuer, KeyID: keyID, PrivateKey: NormalizePEM(key)}, nil +} + +// NormalizePEM accepts a key pasted with literal "\n" sequences (as CI secret +// stores often flatten it) and returns it with real newlines. +func NormalizePEM(key string) string { + key = strings.ReplaceAll(key, `\n`, "\n") + return strings.TrimSpace(key) + "\n" +} + +// StoreAppleCredentials saves the API key as the Apple login. +func StoreAppleCredentials(c AppleCredentials) error { + c.IssuerID, c.KeyID = strings.TrimSpace(c.IssuerID), strings.TrimSpace(c.KeyID) + c.PrivateKey = NormalizePEM(c.PrivateKey) + if c.IssuerID == "" || c.KeyID == "" || strings.TrimSpace(c.PrivateKey) == "" { + return errors.New("issuer ID, key ID and private key are all required") + } + data, err := json.Marshal(c) + if err != nil { + return err + } + return writeSecret(appleSecretName, string(data)) +} diff --git a/internal/auth/apple_test.go b/internal/auth/apple_test.go new file mode 100644 index 0000000..c7381a3 --- /dev/null +++ b/internal/auth/apple_test.go @@ -0,0 +1,92 @@ +package auth + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/zalando/go-keyring" +) + +func clearAppleEnv(t *testing.T) { + for _, name := range []string{"ASC_ISSUER_ID", "ASC_KEY_ID", "ASC_PRIVATE_KEY", "ASC_KEY_PATH"} { + t.Setenv(name, "") + } +} + +func TestAppleCredentialsStoreGetLogout(t *testing.T) { + keyring.MockInit() + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("APPDATA", dir) + clearAppleEnv(t) + + if _, _, err := GetAppleCredentials(); !errors.Is(err, ErrNotAuthenticated) { + t.Fatalf("before login: %v", err) + } + want := AppleCredentials{IssuerID: " issuer ", KeyID: "KEY1", PrivateKey: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----"} + if err := StoreAppleCredentials(want); err != nil { + t.Fatal(err) + } + // Other logins are untouched by the Apple one. + if err := StoreProviderToken("codemagic", "cm-secret"); err != nil { + t.Fatal(err) + } + got, source, err := GetAppleCredentials() + if err != nil { + t.Fatal(err) + } + if source != AppleSourceStored || got.IssuerID != "issuer" || got.KeyID != "KEY1" || got.PrivateKey != "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n" { + t.Errorf("got %+v from %s", got, source) + } + if err := LogoutProvider("apple"); err != nil { + t.Fatal(err) + } + if _, _, err := GetAppleCredentials(); !errors.Is(err, ErrNotAuthenticated) { + t.Errorf("after logout: %v", err) + } + if token, err := GetProviderToken("codemagic"); err != nil || token != "cm-secret" { + t.Errorf("codemagic login lost: %q %v", token, err) + } + if err := StoreAppleCredentials(AppleCredentials{IssuerID: "i"}); err == nil { + t.Error("incomplete credentials accepted") + } +} + +func TestAppleCredentialsFromEnvironment(t *testing.T) { + keyring.MockInit() + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("APPDATA", dir) + clearAppleEnv(t) + if err := StoreAppleCredentials(AppleCredentials{IssuerID: "stored", KeyID: "S", PrivateKey: "pem"}); err != nil { + t.Fatal(err) + } + + t.Setenv("ASC_ISSUER_ID", "env-issuer") + if _, _, err := GetAppleCredentials(); err == nil { + t.Error("partial environment must be an error, not a silent fallback") + } + t.Setenv("ASC_KEY_ID", "ENVKEY") + t.Setenv("ASC_PRIVATE_KEY", "line1\\nline2") + got, source, err := GetAppleCredentials() + if err != nil || source != AppleSourceEnv || got.IssuerID != "env-issuer" || got.KeyID != "ENVKEY" || got.PrivateKey != "line1\nline2\n" { + t.Errorf("env credentials: %+v %s %v", got, source, err) + } + + t.Setenv("ASC_PRIVATE_KEY", "") + keyPath := filepath.Join(dir, "AuthKey.p8") + if err := os.WriteFile(keyPath, []byte("from-file\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ASC_KEY_PATH", keyPath) + got, _, err = GetAppleCredentials() + if err != nil || got.PrivateKey != "from-file\n" { + t.Errorf("key path: %+v %v", got, err) + } + t.Setenv("ASC_KEY_PATH", filepath.Join(dir, "missing.p8")) + if _, _, err := GetAppleCredentials(); err == nil { + t.Error("missing key file must be an error") + } +} diff --git a/internal/auth/providers.go b/internal/auth/providers.go index 3f3a596..611bfad 100644 --- a/internal/auth/providers.go +++ b/internal/auth/providers.go @@ -31,20 +31,25 @@ func GetProviderToken(provider string) (string, error) { if token := strings.TrimSpace(os.Getenv(strings.ToUpper(provider) + "_API_TOKEN")); token != "" { return token, nil } + return readSecret(provider + "-token") +} + +// readSecret returns a saved secret by name, checking the fallback file before +// the keyring: the file may contain a newer login than an inaccessible old +// keyring entry, and a successful keyring save removes the file. +func readSecret(name string) (string, error) { dir, err := getConfigDir() if err != nil { return "", err } - // A fallback file may contain a newer login than an inaccessible old - // keyring entry. A successful keyring save removes this file. - if token, err := readProviderFile(filepath.Join(dir, provider+"-token")); err == nil { - return token, nil + if value, err := readProviderFile(filepath.Join(dir, name)); err == nil { + return value, nil } else if !errors.Is(err, ErrNotAuthenticated) { return "", err } if runtime.GOOS != "linux" { - if token, err := keyring.Get(keyringService, provider+"-token"); err == nil && token != "" { - return token, nil + if value, err := keyring.Get(keyringService, name); err == nil && value != "" { + return value, nil } } return "", ErrNotAuthenticated @@ -78,20 +83,26 @@ func StoreProviderToken(provider, token string) error { if provider == "github" { return storeToken(token) } + return writeSecret(provider+"-token", token) +} + +// writeSecret saves a secret in the keyring, falling back to a 0600 file in +// the config directory on Linux/WSL or when the keyring is unavailable. +func writeSecret(name, value string) error { dir, err := getConfigDir() if err != nil { return err } - path := filepath.Join(dir, provider+"-token") + path := filepath.Join(dir, name) if runtime.GOOS != "linux" { - if err := keyring.Set(keyringService, provider+"-token", token); err == nil { + if err := keyring.Set(keyringService, name, value); err == nil { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } return nil } } - return writeProviderFile(path, token) + return writeProviderFile(path, value) } func writeProviderFile(path, token string) error { @@ -113,15 +124,23 @@ func writeProviderFile(path, token string) error { // LogoutProvider removes only this provider's saved login, leaving others intact. // It does not unset environment variables in the parent shell. func LogoutProvider(provider string) error { + switch provider { + case "github": + return Logout() + case "apple": + return deleteSecret(appleSecretName) + } if err := validateProvider(provider); err != nil { return err } - if provider == "github" { - return Logout() - } + return deleteSecret(provider + "-token") +} + +// deleteSecret removes a secret from both the keyring and the fallback file. +func deleteSecret(name string) error { var keyringErr error if runtime.GOOS != "linux" { - if err := keyring.Delete(keyringService, provider+"-token"); err != nil && err != keyring.ErrNotFound { + if err := keyring.Delete(keyringService, name); err != nil && err != keyring.ErrNotFound { keyringErr = err } } @@ -129,7 +148,7 @@ func LogoutProvider(provider string) error { if err != nil { return err } - err = os.Remove(filepath.Join(dir, provider+"-token")) + err = os.Remove(filepath.Join(dir, name)) if os.IsNotExist(err) { err = nil } From 364e38f3cc257739d7caf74fff700b7b717393a0 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:17:09 +0200 Subject: [PATCH 16/75] 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. --- cmd/builder/upload.go | 144 ++++++++++ internal/dev/session.go | 37 +-- internal/dev/session_test.go | 40 --- internal/distribute/distribute.go | 144 ++++++++++ internal/distribute/distribute_test.go | 347 +++++++++++++++++++++++++ internal/distribute/upload.go | 157 +++++++++++ internal/ipa/ipa.go | 98 +++++++ internal/ipa/ipa_test.go | 102 ++++++++ 8 files changed, 994 insertions(+), 75 deletions(-) create mode 100644 cmd/builder/upload.go create mode 100644 internal/distribute/distribute.go create mode 100644 internal/distribute/distribute_test.go create mode 100644 internal/distribute/upload.go create mode 100644 internal/ipa/ipa.go create mode 100644 internal/ipa/ipa_test.go diff --git a/cmd/builder/upload.go b/cmd/builder/upload.go new file mode 100644 index 0000000..09b13d1 --- /dev/null +++ b/cmd/builder/upload.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/signal" + "syscall" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" + "github.com/MobAI-App/ios-builder/internal/auth" + "github.com/MobAI-App/ios-builder/internal/distribute" + "github.com/MobAI-App/ios-builder/internal/ipa" + "github.com/spf13/cobra" +) + +var iosUploadCmd = &cobra.Command{ + Use: "upload", + Short: "Upload the IPA to App Store Connect", + Long: `Uploads an IPA to App Store Connect through the API, from any platform: no +Mac, Transporter or altool involved. The IPA must be signed with an Apple +Distribution certificate and an App Store provisioning profile. + +The bundle ID, version and build number are read from the IPA. With --wait the +command follows processing until the build is usable, and answers the export +compliance question when Info.plist declares ITSAppUsesNonExemptEncryption +false (or --no-encryption is given), so the build does not sit in "Missing +Compliance". + +Needs an App Store Connect API key: builder auth apple.`, + Args: cobra.NoArgs, + RunE: runIOSUpload, +} + +func init() { + iosUploadCmd.Flags().String("ipa", "", "IPA to upload (default: newest .ipa in ./dist)") + iosUploadCmd.Flags().Bool("wait", false, "Wait until App Store Connect has processed the build") + iosUploadCmd.Flags().Duration("timeout", 30*time.Minute, "Give up waiting after this long") + iosUploadCmd.Flags().Bool("no-encryption", false, "Declare the app uses no non-exempt encryption (export compliance)") + iosUploadCmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr)") + iosCmd.AddCommand(iosUploadCmd) +} + +// getASCClient builds an App Store Connect client from the saved Apple login +// or the ASC_* environment variables. +func getASCClient() (*asc.Client, error) { + creds, _, err := auth.GetAppleCredentials() + if err != nil { + if errors.Is(err, auth.ErrNotAuthenticated) { + return nil, fmt.Errorf("not authenticated with App Store Connect. Run: builder auth apple") + } + return nil, err + } + return asc.NewClient(asc.Credentials{IssuerID: creds.IssuerID, KeyID: creds.KeyID, PrivateKey: creds.PrivateKey}) +} + +// resolveIPA returns the given path, or the newest IPA in ./dist. +func resolveIPA(path string) (string, error) { + if path != "" { + return path, nil + } + return ipa.Newest("dist") +} + +// commandContext cancels on Ctrl-C and, when waiting, after --timeout. +func commandContext(cmd *cobra.Command, wait bool) (context.Context, context.CancelFunc) { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + if !wait { + return ctx, stop + } + timeout, _ := cmd.Flags().GetDuration("timeout") + ctx, cancel := context.WithTimeout(ctx, timeout) + return ctx, func() { cancel(); stop() } +} + +// output separates human progress from the machine-readable result. +type output struct { + json bool + log io.Writer +} + +func newOutput(cmd *cobra.Command) output { + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + return output{json: true, log: cmd.ErrOrStderr()} + } + return output{log: cmd.OutOrStdout()} +} + +// finish prints the result (JSON, or the human summary on success) and +// returns err with a timeout translated into something actionable. +func (o output) finish(cmd *cobra.Command, result any, err error, human func()) error { + if o.json && result != nil { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + _ = enc.Encode(result) + } + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("timed out waiting for App Store Connect; processing continues server-side, check later with builder ios submit --testflight or raise --timeout") + } + return err + } + if !o.json && human != nil { + human() + } + return nil +} + +func runIOSUpload(cmd *cobra.Command, _ []string) error { + client, err := getASCClient() + if err != nil { + return err + } + ipaPath, _ := cmd.Flags().GetString("ipa") + if ipaPath, err = resolveIPA(ipaPath); err != nil { + return err + } + wait, _ := cmd.Flags().GetBool("wait") + noEncryption, _ := cmd.Flags().GetBool("no-encryption") + ctx, cancel := commandContext(cmd, wait) + defer cancel() + out := newOutput(cmd) + + res, err := distribute.Upload(ctx, client, distribute.UploadOptions{IPAPath: ipaPath, Wait: wait, NoEncryption: noEncryption, Log: out.log}) + return out.finish(cmd, res, err, func() { + fmt.Println() + fmt.Printf("Upload ID: %s (%s)\n", res.Upload.ID, res.Upload.State) + if res.Build != nil { + fmt.Printf("Build ID: %s (build %s, %s)\n", res.Build.ID, res.Build.BuildNumber, res.Build.ProcessingState) + } else { + fmt.Println("Processing continues in App Store Connect; rerun with --wait to follow it.") + } + fmt.Printf("Link: %s\n", res.Link) + }) +} diff --git a/internal/dev/session.go b/internal/dev/session.go index 09beae5..b3bf72d 100644 --- a/internal/dev/session.go +++ b/internal/dev/session.go @@ -2,19 +2,17 @@ package dev import ( - "archive/zip" "context" "fmt" - "io" "os" "os/exec" "path/filepath" "strings" + "github.com/MobAI-App/ios-builder/internal/ipa" "github.com/MobAI-App/ios-builder/internal/mobai" "github.com/gorilla/websocket" "github.com/manifoldco/promptui" - "howett.net/plist" ) // FrameworkHandler handles framework-specific dev workflow. @@ -203,7 +201,7 @@ func (s *Session) installApp(ctx context.Context) error { // Read the IPA from the local path; on WSL absPath becomes a Windows path // that only MobAI can open. - ipaBundleID := extractBundleIDFromIPA(absPath) + ipaBundleID := ipa.BundleID(absPath) absPath = toWindowsPathIfWSL(absPath) req := mobai.InstallAppRequest{Path: absPath} @@ -262,37 +260,6 @@ func guessBundleID(resp *mobai.InstallAppResponse, ipaBundleID string, resigned return ipaBundleID } -func extractBundleIDFromIPA(ipaPath string) string { - r, err := zip.OpenReader(ipaPath) - if err != nil { - return "" - } - defer func() { _ = r.Close() }() - - for _, f := range r.File { - if strings.HasPrefix(f.Name, "Payload/") && strings.HasSuffix(f.Name, ".app/Info.plist") { - rc, err := f.Open() - if err != nil { - return "" - } - data, err := io.ReadAll(rc) - rc.Close() - if err != nil { - return "" - } - - var info struct { - BundleID string `plist:"CFBundleIdentifier"` - } - if _, err := plist.Unmarshal(data, &info); err != nil { - return "" - } - return info.BundleID - } - } - return "" -} - func (s *Session) launchApp(ctx context.Context) (<-chan mobai.DebugOutput, error) { fmt.Println("Launching app with debugger...") diff --git a/internal/dev/session_test.go b/internal/dev/session_test.go index 2faf286..d0dabd2 100644 --- a/internal/dev/session_test.go +++ b/internal/dev/session_test.go @@ -1,51 +1,11 @@ package dev import ( - "archive/zip" - "os" - "path/filepath" "testing" "github.com/MobAI-App/ios-builder/internal/mobai" ) -func writeTestIPA(t *testing.T, bundleID string) string { - t.Helper() - path := filepath.Join(t.TempDir(), "App.ipa") - f, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - zw := zip.NewWriter(f) - w, err := zw.Create("Payload/App.app/Info.plist") - if err != nil { - t.Fatal(err) - } - plist := ` - -CFBundleIdentifier` + bundleID + `` - if _, err := w.Write([]byte(plist)); err != nil { - t.Fatal(err) - } - if err := zw.Close(); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - return path -} - -func TestExtractBundleIDFromIPA(t *testing.T) { - path := writeTestIPA(t, "com.example.app") - if got := extractBundleIDFromIPA(path); got != "com.example.app" { - t.Errorf("extractBundleIDFromIPA = %q, want com.example.app", got) - } - if got := extractBundleIDFromIPA(filepath.Join(t.TempDir(), "missing.ipa")); got != "" { - t.Errorf("missing IPA = %q, want empty", got) - } -} - func TestGuessBundleID(t *testing.T) { response := func(teamID string) *mobai.InstallAppResponse { resp := &mobai.InstallAppResponse{} diff --git a/internal/distribute/distribute.go b/internal/distribute/distribute.go new file mode 100644 index 0000000..8403c14 --- /dev/null +++ b/internal/distribute/distribute.go @@ -0,0 +1,144 @@ +// Package distribute drives the App Store Connect flows behind +// `builder ios upload` and `builder ios submit`: deliver an IPA, wait for +// processing, hand a build to TestFlight groups, and submit an App Store +// version for review. It only orchestrates; every API call lives in asc. +package distribute + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// AppRef identifies the App Store Connect app in results. +type AppRef struct { + ID string `json:"id"` + Name string `json:"name"` + BundleID string `json:"bundle_id"` +} + +// BuildRef describes a build in results. +type BuildRef struct { + ID string `json:"id"` + Version string `json:"version,omitempty"` + BuildNumber string `json:"build_number"` + ProcessingState string `json:"processing_state"` + UsesNonExemptEncryption *bool `json:"uses_non_exempt_encryption"` + Link string `json:"link"` +} + +func appRef(a *asc.App) AppRef { + return AppRef{ID: a.ID, Name: a.Name, BundleID: a.BundleID} +} + +func buildRef(appID, version string, b *asc.Build) BuildRef { + return BuildRef{ + ID: b.ID, + Version: version, + BuildNumber: b.BuildNumber, + ProcessingState: b.ProcessingState, + UsesNonExemptEncryption: b.UsesNonExemptEncryption, + Link: buildLink(appID, b.ID), + } +} + +func testflightLink(appID string) string { + return "https://appstoreconnect.apple.com/apps/" + appID + "/testflight/ios" +} + +func buildLink(appID, buildID string) string { + return testflightLink(appID) + "/" + buildID +} + +func distributionLink(appID string) string { + return "https://appstoreconnect.apple.com/apps/" + appID + "/distribution" +} + +// logf writes progress when w is set. +func logf(w io.Writer, format string, args ...any) { + if w != nil { + fmt.Fprintf(w, format+"\n", args...) + } +} + +// pollInterval applies the default when opts leave it zero. +func pollInterval(d time.Duration) time.Duration { + if d <= 0 { + return 15 * time.Second + } + return d +} + +// pickBuild returns the newest VALID, unexpired build matching the filters. +func pickBuild(ctx context.Context, client *asc.Client, appID, version, buildNumber string) (*asc.Build, error) { + f := asc.BuildFilter{AppID: appID, Platform: asc.PlatformIOS, Version: version, BuildNumber: buildNumber, ProcessingState: asc.ProcessingStateValid, ExcludeExpired: true, Limit: 1} + builds, err := client.ListBuilds(ctx, f) + if err != nil { + return nil, err + } + if len(builds) > 0 { + return &builds[0], nil + } + // Explain why rather than just "not found": the build may still be processing. + f.ProcessingState, f.ExcludeExpired = "", false + any, err := client.ListBuilds(ctx, f) + if err != nil { + return nil, err + } + what := "no build" + if buildNumber != "" { + what = "build " + buildNumber + } + if version != "" { + what += " of version " + version + } + if len(any) == 0 { + return nil, fmt.Errorf("%s is available in App Store Connect; upload one with builder ios upload --wait", what) + } + b := any[0] + if b.Expired { + return nil, fmt.Errorf("%s (%s) has expired; upload a new build", what, b.ID) + } + return nil, fmt.Errorf("%s (%s) is %s; wait for processing to finish (builder ios upload --wait) and retry", what, b.ID, b.ProcessingState) +} + +// setCompliance answers the export compliance question with "no non-exempt +// encryption" when the caller asked for it and the build is still unanswered. +// It returns what happened for the result. +func setCompliance(ctx context.Context, client *asc.Client, log io.Writer, build *asc.Build, exempt bool) (string, error) { + switch { + case build.UsesNonExemptEncryption != nil: + return "already_set", nil + case !exempt: + return "pending", nil + } + logf(log, "Setting export compliance: no non-exempt encryption") + updated, err := client.SetUsesNonExemptEncryption(ctx, build.ID, false) + if err != nil { + return "", fmt.Errorf("set export compliance: %w", err) + } + build.UsesNonExemptEncryption = updated.UsesNonExemptEncryption + return "set_exempt", nil +} + +// stateErrorHint rephrases App Store Connect's 409 state conflicts, which are +// nearly always incomplete metadata or a version in the wrong state. +func stateErrorHint(err error, what string) error { + var e *asc.Error + if errors.As(err, &e) && (e.StatusCode == 409 || e.StatusCode == 422) { + return fmt.Errorf("%s: %w. App Store Connect needs the version's metadata complete before review (description, screenshots, age rating, pricing, privacy); finish it in App Store Connect or with asc-cli (https://github.com/tddworks/asc-cli), then rerun", what, err) + } + return fmt.Errorf("%s: %w", what, err) +} + +func joinDetails(details []asc.StateDetail) []string { + out := make([]string, 0, len(details)) + for _, d := range details { + out = append(out, d.String()) + } + return out +} diff --git a/internal/distribute/distribute_test.go b/internal/distribute/distribute_test.go new file mode 100644 index 0000000..b7f42fb --- /dev/null +++ b/internal/distribute/distribute_test.go @@ -0,0 +1,347 @@ +package distribute + +import ( + "archive/zip" + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +func writeIPA(t *testing.T, plistBody string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "App.ipa") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + w, err := zw.Create("Payload/App.app/Info.plist") + if err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`` + plistBody + ``)) + bin, _ := zw.Create("Payload/App.app/App") + payload := make([]byte, 3000) + _, _ = rand.Read(payload) + _, _ = bin.Write(payload) + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return path +} + +const plistExempt = `CFBundleIdentifiercom.example.appCFBundleShortVersionString2.0.0CFBundleVersion7ITSAppUsesNonExemptEncryption` +const plistUndeclared = `CFBundleIdentifiercom.example.appCFBundleShortVersionString2.0.0CFBundleVersion7` + +// fake is an in-memory App Store Connect covering the routes the flows use. +type fake struct { + t *testing.T + srv *httptest.Server + mu sync.Mutex + // calls lists "METHOD /path" in order; bodies keeps the last body per call. + calls []string + bodies map[string]map[string]any + // state knobs + buildState string + buildEncryption *bool + versionExists bool + versionState string + openSubmission bool + submitStatus int + betaReviewExists bool +} + +func newFake(t *testing.T) *fake { + f := &fake{t: t, bodies: map[string]map[string]any{}, buildState: "VALID", versionState: "PREPARE_FOR_SUBMISSION", submitStatus: 200} + mux := http.NewServeMux() + res := func(typ, id string, attrs map[string]any, rels map[string]any) map[string]any { + r := map[string]any{"type": typ, "id": id, "attributes": attrs} + if rels != nil { + r["relationships"] = rels + } + return r + } + one := func(w http.ResponseWriter, status int, r any) { writeJSON(w, status, map[string]any{"data": r}) } + many := func(w http.ResponseWriter, rs ...any) { + if rs == nil { + rs = []any{} + } + writeJSON(w, 200, map[string]any{"data": rs}) + } + record := func(r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + key := r.Method + " " + r.URL.Path + f.calls = append(f.calls, key) + if r.Body != nil { + var body map[string]any + data, _ := io.ReadAll(r.Body) + if json.Unmarshal(data, &body) == nil { + f.bodies[key] = body + } + } + } + wrap := func(h func(w http.ResponseWriter, r *http.Request)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chunk" && !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + f.t.Errorf("%s %s without bearer token", r.Method, r.URL.Path) + } + record(r) + f.mu.Lock() + defer f.mu.Unlock() + h(w, r) + } + } + build := func() map[string]any { + attrs := map[string]any{"version": "7", "processingState": f.buildState, "uploadedDate": "2026-09-16T10:00:00Z", "expired": false} + if f.buildEncryption != nil { + attrs["usesNonExemptEncryption"] = *f.buildEncryption + } + return res("builds", "build-9", attrs, nil) + } + mux.HandleFunc("GET /v1/apps", wrap(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("filter[bundleId]") != "com.example.app" { + many(w) + return + } + many(w, res("apps", "app-1", map[string]any{"bundleId": "com.example.app", "name": "Example", "primaryLocale": "de-DE"}, nil)) + })) + mux.HandleFunc("POST /v1/buildUploads", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 201, res("buildUploads", "up-1", map[string]any{"state": map[string]any{"state": "AWAITING_UPLOAD"}}, nil)) + })) + mux.HandleFunc("POST /v1/buildUploadFiles", wrap(func(w http.ResponseWriter, r *http.Request) { + size := f.bodies["POST /v1/buildUploadFiles"]["data"].(map[string]any)["attributes"].(map[string]any)["fileSize"].(float64) + one(w, 201, res("buildUploadFiles", "file-1", map[string]any{"uploadOperations": []map[string]any{{"method": "PUT", "url": f.srv.URL + "/chunk", "offset": 0, "length": int64(size), "requestHeaders": []map[string]string{{"name": "X-Test", "value": "1"}}}}}, nil)) + })) + mux.HandleFunc("PUT /chunk", wrap(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Test") != "1" { + f.t.Error("chunk request header missing") + } + w.WriteHeader(200) + })) + mux.HandleFunc("PATCH /v1/buildUploadFiles/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { one(w, 200, res("buildUploadFiles", "file-1", nil, nil)) })) + mux.HandleFunc("GET /v1/buildUploads/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 200, res("buildUploads", "up-1", map[string]any{"cfBundleShortVersionString": "2.0.0", "cfBundleVersion": "7", "state": map[string]any{"state": "COMPLETE", "warnings": []map[string]string{{"code": "ITMS-90000", "description": "Some warning"}}}}, nil)) + })) + mux.HandleFunc("GET /v1/builds", wrap(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("filter[app]") != "app-1" || q.Get("filter[preReleaseVersion.platform]") != "IOS" || q.Get("sort") != "-uploadedDate" { + f.t.Errorf("builds query = %v", q) + } + if q.Get("filter[processingState]") == "VALID" && f.buildState != "VALID" { + many(w) + return + } + many(w, build()) + })) + mux.HandleFunc("PATCH /v1/builds/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + v := f.bodies["PATCH /v1/builds/build-9"]["data"].(map[string]any)["attributes"].(map[string]any)["usesNonExemptEncryption"].(bool) + f.buildEncryption = &v + one(w, 200, build()) + })) + mux.HandleFunc("GET /v1/betaGroups", wrap(func(w http.ResponseWriter, r *http.Request) { + many(w, + res("betaGroups", "g-int", map[string]any{"name": "Team", "isInternalGroup": true}, nil), + res("betaGroups", "g-ext", map[string]any{"name": "Beta Testers", "isInternalGroup": false, "publicLinkEnabled": true}, nil)) + })) + mux.HandleFunc("GET /v1/builds/{id}/betaBuildLocalizations", wrap(func(w http.ResponseWriter, r *http.Request) { + many(w, res("betaBuildLocalizations", "loc-en", map[string]any{"locale": "en-US", "whatsNew": "old"}, nil)) + })) + mux.HandleFunc("POST /v1/betaBuildLocalizations", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 201, res("betaBuildLocalizations", "loc-de", map[string]any{"locale": "de-DE"}, nil)) + })) + mux.HandleFunc("PATCH /v1/betaBuildLocalizations/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 200, res("betaBuildLocalizations", "loc-en", map[string]any{"locale": "en-US"}, nil)) + })) + mux.HandleFunc("GET /v1/builds/{id}/betaAppReviewSubmission", wrap(func(w http.ResponseWriter, r *http.Request) { + if f.betaReviewExists { + one(w, 200, res("betaAppReviewSubmissions", "bar-0", map[string]any{"betaReviewState": "APPROVED"}, nil)) + return + } + one(w, 200, nil) + })) + mux.HandleFunc("POST /v1/betaAppReviewSubmissions", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 201, res("betaAppReviewSubmissions", "bar-1", map[string]any{"betaReviewState": "WAITING_FOR_REVIEW"}, nil)) + })) + mux.HandleFunc("GET /v1/betaAppReviewSubmissions/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 200, res("betaAppReviewSubmissions", "bar-1", map[string]any{"betaReviewState": "APPROVED"}, nil)) + })) + mux.HandleFunc("POST /v1/builds/{id}/relationships/betaGroups", wrap(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })) + version := func() map[string]any { + return res("appStoreVersions", "ver-1", map[string]any{"platform": "IOS", "versionString": "2.0.0", "appVersionState": f.versionState, "releaseType": "MANUAL"}, map[string]any{"build": map[string]any{"data": nil}}) + } + mux.HandleFunc("GET /v1/apps/{id}/appStoreVersions", wrap(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("filter[versionString]") != "2.0.0" || r.URL.Query().Get("filter[platform]") != "IOS" { + f.t.Errorf("versions query = %v", r.URL.Query()) + } + if f.versionExists { + many(w, version()) + return + } + many(w) + })) + mux.HandleFunc("POST /v1/appStoreVersions", wrap(func(w http.ResponseWriter, r *http.Request) { f.versionExists = true; one(w, 201, version()) })) + mux.HandleFunc("PATCH /v1/appStoreVersions/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + v := version() + v["attributes"].(map[string]any)["releaseType"] = "AFTER_APPROVAL" + v["relationships"] = map[string]any{"build": map[string]any{"data": map[string]string{"type": "builds", "id": "build-9"}}} + one(w, 200, v) + })) + mux.HandleFunc("GET /v1/reviewSubmissions", wrap(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("filter[state]") != "READY_FOR_REVIEW,UNRESOLVED_ISSUES" { + f.t.Errorf("submissions query = %v", r.URL.Query()) + } + if f.openSubmission { + many(w, res("reviewSubmissions", "rs-0", map[string]any{"platform": "IOS", "state": "READY_FOR_REVIEW"}, nil)) + return + } + many(w) + })) + mux.HandleFunc("POST /v1/reviewSubmissions", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 201, res("reviewSubmissions", "rs-1", map[string]any{"platform": "IOS", "state": "READY_FOR_REVIEW"}, nil)) + })) + mux.HandleFunc("GET /v1/reviewSubmissions/{id}/items", wrap(func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("id") == "rs-0" { + many(w, res("reviewSubmissionItems", "item-0", map[string]any{"state": "READY_FOR_REVIEW"}, map[string]any{"appStoreVersion": map[string]any{"data": map[string]string{"type": "appStoreVersions", "id": "ver-1"}}})) + return + } + many(w) + })) + mux.HandleFunc("POST /v1/reviewSubmissionItems", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 201, res("reviewSubmissionItems", "item-1", map[string]any{"state": "READY_FOR_REVIEW"}, nil)) + })) + mux.HandleFunc("PATCH /v1/reviewSubmissions/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + if f.submitStatus != 200 { + writeJSON(w, f.submitStatus, map[string]any{"errors": []map[string]any{{"status": "409", "code": "STATE_ERROR.ENTITY_STATE_INVALID", "title": "The request cannot be fulfilled because of the state of another resource.", "detail": "You must provide a screenshot for iPhone 6.5\" displays."}}}) + return + } + one(w, 200, res("reviewSubmissions", r.PathValue("id"), map[string]any{"platform": "IOS", "state": "WAITING_FOR_REVIEW", "submittedDate": "2026-09-16T11:00:00Z"}, nil)) + })) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + f.t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + }) + f.srv = httptest.NewServer(mux) + t.Cleanup(f.srv.Close) + return f +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func (f *fake) client(t *testing.T) *asc.Client { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, _ := x509.MarshalPKCS8PrivateKey(key) + creds := asc.Credentials{IssuerID: "iss", KeyID: "kid", PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))} + c, err := asc.NewClient(creds, asc.WithBaseURL(f.srv.URL), asc.WithRetryDelay(time.Millisecond)) + if err != nil { + t.Fatal(err) + } + return c +} + +func (f *fake) called(key string) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, c := range f.calls { + if c == key { + return true + } + } + return false +} + +func (f *fake) body(key string) map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + return f.bodies[key] +} + +func TestUploadWithWaitSetsCompliance(t *testing.T) { + f := newFake(t) + var log bytes.Buffer + res, err := Upload(context.Background(), f.client(t), UploadOptions{IPAPath: writeIPA(t, plistExempt), Wait: true, PollInterval: time.Millisecond, Log: &log}) + if err != nil { + t.Fatalf("%v\n%s", err, log.String()) + } + if res.App.ID != "app-1" || res.IPA.Version != "2.0.0" || res.IPA.BuildNumber != "7" || res.Upload.ID != "up-1" || res.Upload.State != "COMPLETE" { + t.Errorf("result = %+v", res) + } + if res.Build == nil || res.Build.ID != "build-9" || res.Build.ProcessingState != "VALID" || res.Compliance != "set_exempt" || res.Build.UsesNonExemptEncryption == nil || *res.Build.UsesNonExemptEncryption { + t.Errorf("build = %+v, compliance = %s", res.Build, res.Compliance) + } + if res.Link != "https://appstoreconnect.apple.com/apps/app-1/testflight/ios/build-9" { + t.Errorf("link = %s", res.Link) + } + if len(res.Upload.Warnings) != 1 || !strings.Contains(log.String(), "ITMS-90000") { + t.Errorf("warnings not surfaced: %+v\n%s", res.Upload.Warnings, log.String()) + } + for _, key := range []string{"POST /v1/buildUploads", "POST /v1/buildUploadFiles", "PUT /chunk", "PATCH /v1/buildUploadFiles/file-1", "GET /v1/buildUploads/up-1", "GET /v1/builds", "PATCH /v1/builds/build-9"} { + if !f.called(key) { + t.Errorf("%s not called; calls = %v", key, f.calls) + } + } + attrs := f.body("POST /v1/buildUploads")["data"].(map[string]any)["attributes"].(map[string]any) + if attrs["cfBundleShortVersionString"] != "2.0.0" || attrs["cfBundleVersion"] != "7" || attrs["platform"] != "IOS" { + t.Errorf("upload attributes = %v", attrs) + } +} + +func TestUploadWithoutWaitLeavesComplianceForLater(t *testing.T) { + f := newFake(t) + res, err := Upload(context.Background(), f.client(t), UploadOptions{IPAPath: writeIPA(t, plistUndeclared), NoEncryption: true}) + if err != nil { + t.Fatal(err) + } + if res.Build != nil || res.Compliance != "pending" || res.Link != "https://appstoreconnect.apple.com/apps/app-1/testflight/ios" { + t.Errorf("result = %+v", res) + } + if f.called("PATCH /v1/builds/build-9") || f.called("GET /v1/builds") { + t.Errorf("must not touch builds without --wait: %v", f.calls) + } +} + +func TestUploadUndeclaredEncryptionStaysPending(t *testing.T) { + f := newFake(t) + res, err := Upload(context.Background(), f.client(t), UploadOptions{IPAPath: writeIPA(t, plistUndeclared), Wait: true, PollInterval: time.Millisecond}) + if err != nil { + t.Fatal(err) + } + if res.Compliance != "pending" || f.called("PATCH /v1/builds/build-9") { + t.Errorf("compliance = %s, calls = %v", res.Compliance, f.calls) + } +} + +func TestUploadUnknownApp(t *testing.T) { + f := newFake(t) + _, err := Upload(context.Background(), f.client(t), UploadOptions{IPAPath: writeIPA(t, strings.ReplaceAll(plistExempt, "com.example.app", "com.other"))}) + if err == nil || !strings.Contains(err.Error(), "com.other") { + t.Errorf("err = %v", err) + } +} diff --git a/internal/distribute/upload.go b/internal/distribute/upload.go new file mode 100644 index 0000000..9a3929e --- /dev/null +++ b/internal/distribute/upload.go @@ -0,0 +1,157 @@ +package distribute + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" + "github.com/MobAI-App/ios-builder/internal/ipa" +) + +// UploadOptions configures Upload. +type UploadOptions struct { + IPAPath string + // Wait polls until the delivery completes and the build is VALID. + Wait bool + // NoEncryption answers the export compliance question with "no", even + // when the IPA's Info.plist does not declare ITSAppUsesNonExemptEncryption. + NoEncryption bool + PollInterval time.Duration + // Log receives progress lines; nil discards them. + Log io.Writer +} + +// IPARef describes the uploaded archive. +type IPARef struct { + Path string `json:"path"` + Version string `json:"version"` + BuildNumber string `json:"build_number"` + UsesNonExemptEncryption *bool `json:"uses_non_exempt_encryption"` +} + +// UploadRef describes the delivery. +type UploadRef struct { + ID string `json:"id"` + State string `json:"state"` + Errors []string `json:"errors,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +// UploadResult is what Upload reports. +type UploadResult struct { + App AppRef `json:"app"` + IPA IPARef `json:"ipa"` + Upload UploadRef `json:"upload"` + // Build is set once processing finished (Wait). + Build *BuildRef `json:"build,omitempty"` + // Compliance is set_exempt, already_set, pending or skipped. + Compliance string `json:"encryption_compliance"` + Link string `json:"link"` +} + +// Upload delivers the IPA to App Store Connect and, with Wait, follows it +// until the build is VALID and its export compliance is answered. +func Upload(ctx context.Context, client *asc.Client, opts UploadOptions) (*UploadResult, error) { + info, err := ipa.ReadInfo(opts.IPAPath) + if err != nil { + return nil, err + } + if info.Version == "" || info.BuildNumber == "" { + return nil, fmt.Errorf("%s: Info.plist lacks CFBundleShortVersionString or CFBundleVersion", opts.IPAPath) + } + app, err := client.AppByBundleID(ctx, info.BundleID) + if err != nil { + return nil, err + } + res := &UploadResult{ + App: appRef(app), + IPA: IPARef{Path: opts.IPAPath, Version: info.Version, BuildNumber: info.BuildNumber, UsesNonExemptEncryption: info.UsesNonExemptEncryption}, + Compliance: "skipped", + Link: testflightLink(app.ID), + } + exempt := opts.NoEncryption || (info.UsesNonExemptEncryption != nil && !*info.UsesNonExemptEncryption) + + logf(opts.Log, "Uploading %s (%s build %s) to %s...", opts.IPAPath, info.Version, info.BuildNumber, app.Name) + var lastPercent int64 = -1 + upload, err := client.UploadBuild(ctx, asc.UploadBuildOptions{ + AppID: app.ID, Version: info.Version, BuildNumber: info.BuildNumber, Platform: asc.PlatformIOS, Path: opts.IPAPath, + Progress: func(sent, total int64) { + if total == 0 { + return + } + if pct := sent * 100 / total; pct/10 > lastPercent/10 || pct == 100 { + lastPercent = pct + logf(opts.Log, " %d%% (%d/%d MB)", pct, sent>>20, total>>20) + } + }, + }) + if err != nil { + return nil, err + } + res.Upload = UploadRef{ID: upload.ID, State: upload.State, Errors: joinDetails(upload.Errors), Warnings: joinDetails(upload.Warnings)} + logf(opts.Log, "Upload %s accepted; App Store Connect is processing it.", upload.ID) + + if !opts.Wait { + if exempt { + res.Compliance = "pending" + logf(opts.Log, "Export compliance will be set once the build exists: rerun with --wait, or pass --no-encryption to builder ios submit.") + } + return res, nil + } + + interval := pollInterval(opts.PollInterval) + lastState := "" + upload, err = client.WaitForBuildUpload(ctx, upload.ID, interval, func(u *asc.BuildUpload) { + if u.State != lastState { + lastState = u.State + logf(opts.Log, " delivery: %s", u.State) + } + }) + if upload != nil { + res.Upload = UploadRef{ID: upload.ID, State: upload.State, Errors: joinDetails(upload.Errors), Warnings: joinDetails(upload.Warnings)} + } + if err != nil { + var failed *asc.UploadFailedError + if errors.As(err, &failed) { + return res, err + } + return res, fmt.Errorf("wait for delivery: %w", err) + } + for _, w := range res.Upload.Warnings { + logf(opts.Log, " warning: %s", w) + } + + logf(opts.Log, "Waiting for build %s to finish processing...", info.BuildNumber) + lastState = "" + build, err := client.WaitForBuild(ctx, app.ID, info.Version, info.BuildNumber, interval, func(b *asc.Build) { + state := "not visible yet" + if b != nil { + state = b.ProcessingState + } + if state != lastState { + lastState = state + logf(opts.Log, " build: %s", state) + } + }) + if build != nil { + ref := buildRef(app.ID, info.Version, build) + res.Build = &ref + res.Link = ref.Link + } + if err != nil { + return res, err + } + res.Compliance, err = setCompliance(ctx, client, opts.Log, build, exempt) + if err != nil { + return res, err + } + res.Build.UsesNonExemptEncryption = build.UsesNonExemptEncryption + if res.Compliance == "pending" { + logf(opts.Log, "Export compliance is unanswered; TestFlight shows the build as Missing Compliance until it is. Declare ITSAppUsesNonExemptEncryption in Info.plist, or pass --no-encryption.") + } + logf(opts.Log, "Build %s (%s) is VALID: %s", build.BuildNumber, build.ID, res.Link) + return res, nil +} diff --git a/internal/ipa/ipa.go b/internal/ipa/ipa.go new file mode 100644 index 0000000..63e13ea --- /dev/null +++ b/internal/ipa/ipa.go @@ -0,0 +1,98 @@ +// Package ipa reads the metadata of an .ipa archive without extracting it. +package ipa + +import ( + "archive/zip" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "howett.net/plist" +) + +// Info is the subset of the app's Info.plist that Builder needs. +type Info struct { + BundleID string `plist:"CFBundleIdentifier"` + Version string `plist:"CFBundleShortVersionString"` + BuildNumber string `plist:"CFBundleVersion"` + // UsesNonExemptEncryption is nil when the plist does not declare + // ITSAppUsesNonExemptEncryption, in which case App Store Connect asks for + // the export compliance answer before a build can be distributed. + UsesNonExemptEncryption *bool `plist:"ITSAppUsesNonExemptEncryption"` +} + +// ReadInfo returns the Info.plist of the app bundle inside the IPA. +func ReadInfo(path string) (*Info, error) { + r, err := zip.OpenReader(path) + if err != nil { + return nil, fmt.Errorf("open IPA: %w", err) + } + defer func() { _ = r.Close() }() + + for _, f := range r.File { + if !isAppInfoPlist(f.Name) { + continue + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("read %s: %w", f.Name, err) + } + data, err := io.ReadAll(rc) + _ = rc.Close() + if err != nil { + return nil, fmt.Errorf("read %s: %w", f.Name, err) + } + var info Info + if _, err := plist.Unmarshal(data, &info); err != nil { + return nil, fmt.Errorf("parse %s: %w", f.Name, err) + } + if info.BundleID == "" { + return nil, fmt.Errorf("%s has no CFBundleIdentifier", f.Name) + } + return &info, nil + } + return nil, errors.New("no Payload/*.app/Info.plist in IPA") +} + +// isAppInfoPlist matches the top-level app's plist only, not the ones of +// embedded frameworks, extensions or watch apps. +func isAppInfoPlist(name string) bool { + return strings.HasPrefix(name, "Payload/") && + strings.HasSuffix(name, ".app/Info.plist") && + strings.Count(name, "/") == 2 +} + +// BundleID returns the bundle identifier of the IPA, or "" when it cannot be read. +func BundleID(path string) string { + info, err := ReadInfo(path) + if err != nil { + return "" + } + return info.BundleID +} + +// Newest returns the most recently modified .ipa in dir. +func Newest(dir string) (string, error) { + matches, err := filepath.Glob(filepath.Join(dir, "*.ipa")) + if err != nil { + return "", err + } + var newest string + var newestTime int64 + for _, m := range matches { + st, err := os.Stat(m) + if err != nil || st.IsDir() { + continue + } + if newest == "" || st.ModTime().UnixNano() > newestTime { + newest, newestTime = m, st.ModTime().UnixNano() + } + } + if newest == "" { + return "", fmt.Errorf("no .ipa found in %s; run builder ios build or pass --ipa", dir) + } + return newest, nil +} diff --git a/internal/ipa/ipa_test.go b/internal/ipa/ipa_test.go new file mode 100644 index 0000000..e46212f --- /dev/null +++ b/internal/ipa/ipa_test.go @@ -0,0 +1,102 @@ +package ipa + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" + "time" +) + +func writeIPA(t *testing.T, path string, entries map[string]string) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + for name, body := range entries { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } +} + +const appPlist = ` + +CFBundleIdentifiercom.example.app +CFBundleShortVersionString1.2.3 +CFBundleVersion42 +ITSAppUsesNonExemptEncryption +` + +const frameworkPlist = ` +CFBundleIdentifiercom.example.framework` + +func TestReadInfo(t *testing.T) { + path := filepath.Join(t.TempDir(), "App.ipa") + writeIPA(t, path, map[string]string{ + // Listed first so a naive suffix match would pick the framework. + "Payload/App.app/Frameworks/Lib.framework/Info.plist": frameworkPlist, + "Payload/App.app/Info.plist": appPlist, + }) + info, err := ReadInfo(path) + if err != nil { + t.Fatal(err) + } + if info.BundleID != "com.example.app" || info.Version != "1.2.3" || info.BuildNumber != "42" { + t.Errorf("unexpected info: %+v", info) + } + if info.UsesNonExemptEncryption == nil || *info.UsesNonExemptEncryption { + t.Errorf("UsesNonExemptEncryption = %v, want false", info.UsesNonExemptEncryption) + } + if got := BundleID(path); got != "com.example.app" { + t.Errorf("BundleID = %q", got) + } +} + +func TestReadInfoErrors(t *testing.T) { + if _, err := ReadInfo(filepath.Join(t.TempDir(), "missing.ipa")); err == nil { + t.Error("missing IPA: want error") + } + path := filepath.Join(t.TempDir(), "NoPlist.ipa") + writeIPA(t, path, map[string]string{"Payload/App.app/app": "bin"}) + if _, err := ReadInfo(path); err == nil { + t.Error("IPA without plist: want error") + } + if got := BundleID(path); got != "" { + t.Errorf("BundleID = %q, want empty", got) + } +} + +func TestNewest(t *testing.T) { + dir := t.TempDir() + if _, err := Newest(dir); err == nil { + t.Error("empty dir: want error") + } + old := filepath.Join(dir, "old.ipa") + recent := filepath.Join(dir, "recent.ipa") + for _, p := range []string{old, recent} { + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + past := time.Now().Add(-time.Hour) + if err := os.Chtimes(old, past, past); err != nil { + t.Fatal(err) + } + got, err := Newest(dir) + if err != nil || got != recent { + t.Errorf("Newest = %q, %v; want %q", got, err, recent) + } +} From 146e2876fc6cf94fd24904707d902bf73fd9582d Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:17:09 +0200 Subject: [PATCH 17/75] 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. --- cmd/builder/submit.go | 129 +++++++++++++++++++ cmd/builder/submit_test.go | 14 +++ internal/distribute/appstore.go | 142 +++++++++++++++++++++ internal/distribute/submit_test.go | 175 ++++++++++++++++++++++++++ internal/distribute/testflight.go | 191 +++++++++++++++++++++++++++++ 5 files changed, 651 insertions(+) create mode 100644 cmd/builder/submit.go create mode 100644 cmd/builder/submit_test.go create mode 100644 internal/distribute/appstore.go create mode 100644 internal/distribute/submit_test.go create mode 100644 internal/distribute/testflight.go diff --git a/cmd/builder/submit.go b/cmd/builder/submit.go new file mode 100644 index 0000000..95b0c82 --- /dev/null +++ b/cmd/builder/submit.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" + "github.com/MobAI-App/ios-builder/internal/distribute" + "github.com/MobAI-App/ios-builder/internal/ipa" + "github.com/spf13/cobra" +) + +var iosSubmitCmd = &cobra.Command{ + Use: "submit", + Short: "Hand a processed build to TestFlight groups or App Review", + Long: `Distributes a build that App Store Connect has already processed. + + --testflight adds the build to the named TestFlight groups (--group, repeatable), + sets the "What to Test" notes (--notes) and, for external groups, + submits the build for beta review. Without --group it reports the + build and lists the available groups. + --app-store finds or creates the App Store version for the marketing version, + attaches the build, sets the release type and submits it for review. + The version's metadata (description, screenshots, pricing, privacy) + must already be complete in App Store Connect. + +The app is identified by the IPA in ./dist (or --ipa), or by --bundle-id. The +newest VALID build is used unless --build-number is given.`, + Args: cobra.NoArgs, + RunE: runIOSSubmit, +} + +func init() { + iosSubmitCmd.Flags().Bool("testflight", false, "Distribute to TestFlight") + iosSubmitCmd.Flags().Bool("app-store", false, "Submit an App Store version for review") + iosSubmitCmd.Flags().String("ipa", "", "IPA whose bundle ID and version identify the app (default: newest .ipa in ./dist)") + iosSubmitCmd.Flags().String("bundle-id", "", "App bundle ID, instead of reading an IPA") + iosSubmitCmd.Flags().String("build-number", "", "Build number (CFBundleVersion) to use (default: newest VALID build)") + iosSubmitCmd.Flags().String("version", "", "Marketing version (default: from the IPA; required with --app-store and --bundle-id)") + iosSubmitCmd.Flags().StringArray("group", nil, "TestFlight group name to add the build to (repeatable)") + iosSubmitCmd.Flags().String("notes", "", "What to Test notes for the build") + iosSubmitCmd.Flags().String("locale", "", "Locale for --notes (default: the app's primary locale)") + iosSubmitCmd.Flags().String("release", "", "App Store release: manual or after-approval") + iosSubmitCmd.Flags().Bool("no-encryption", false, "Declare the app uses no non-exempt encryption (export compliance)") + iosSubmitCmd.Flags().Bool("wait", false, "Wait for the external beta review decision (--testflight)") + iosSubmitCmd.Flags().Duration("timeout", 30*time.Minute, "Give up waiting after this long") + iosSubmitCmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr)") + iosCmd.AddCommand(iosSubmitCmd) +} + +func runIOSSubmit(cmd *cobra.Command, _ []string) error { + testflight, _ := cmd.Flags().GetBool("testflight") + appStore, _ := cmd.Flags().GetBool("app-store") + if testflight == appStore { + return fmt.Errorf("pass exactly one of --testflight or --app-store") + } + client, err := getASCClient() + if err != nil { + return err + } + bundleID, _ := cmd.Flags().GetString("bundle-id") + version, _ := cmd.Flags().GetString("version") + if bundleID == "" { + ipaPath, _ := cmd.Flags().GetString("ipa") + if ipaPath, err = resolveIPA(ipaPath); err != nil { + return fmt.Errorf("%w (or pass --bundle-id)", err) + } + info, err := ipa.ReadInfo(ipaPath) + if err != nil { + return err + } + bundleID = info.BundleID + if version == "" && appStore { + version = info.Version + } + } + buildNumber, _ := cmd.Flags().GetString("build-number") + noEncryption, _ := cmd.Flags().GetBool("no-encryption") + wait, _ := cmd.Flags().GetBool("wait") + ctx, cancel := commandContext(cmd, wait) + defer cancel() + out := newOutput(cmd) + + if testflight { + groups, _ := cmd.Flags().GetStringArray("group") + notes, _ := cmd.Flags().GetString("notes") + locale, _ := cmd.Flags().GetString("locale") + res, err := distribute.SubmitTestFlight(ctx, client, distribute.TestFlightOptions{ + BundleID: bundleID, Version: version, BuildNumber: buildNumber, Groups: groups, Notes: notes, Locale: locale, + NoEncryption: noEncryption, Wait: wait, Log: out.log, + }) + return out.finish(cmd, res, err, func() { + fmt.Println() + fmt.Printf("Build ID: %s (build %s)\n", res.Build.ID, res.Build.BuildNumber) + if res.BetaReview != nil { + fmt.Printf("Beta review: %s\n", res.BetaReview.State) + } + fmt.Printf("Link: %s\n", res.Link) + }) + } + + releaseFlag, _ := cmd.Flags().GetString("release") + releaseType, err := parseReleaseType(releaseFlag) + if err != nil { + return err + } + res, err := distribute.SubmitAppStore(ctx, client, distribute.AppStoreOptions{ + BundleID: bundleID, Version: version, BuildNumber: buildNumber, ReleaseType: releaseType, NoEncryption: noEncryption, Log: out.log, + }) + return out.finish(cmd, res, err, func() { + fmt.Println() + fmt.Printf("Version: %s (%s)\n", res.Version.VersionString, res.Version.State) + fmt.Printf("Build ID: %s (build %s)\n", res.Build.ID, res.Build.BuildNumber) + fmt.Printf("Submission: %s (%s)\n", res.Submission.ID, res.Submission.State) + fmt.Printf("Link: %s\n", res.Link) + }) +} + +func parseReleaseType(flag string) (string, error) { + switch flag { + case "": + return "", nil + case "manual": + return asc.ReleaseTypeManual, nil + case "after-approval": + return asc.ReleaseTypeAfterApproval, nil + } + return "", fmt.Errorf("--release must be manual or after-approval, got %q", flag) +} diff --git a/cmd/builder/submit_test.go b/cmd/builder/submit_test.go new file mode 100644 index 0000000..dedd396 --- /dev/null +++ b/cmd/builder/submit_test.go @@ -0,0 +1,14 @@ +package main + +import "testing" + +func TestParseReleaseType(t *testing.T) { + for flag, want := range map[string]string{"": "", "manual": "MANUAL", "after-approval": "AFTER_APPROVAL"} { + if got, err := parseReleaseType(flag); err != nil || got != want { + t.Errorf("%q: %q %v", flag, got, err) + } + } + if _, err := parseReleaseType("scheduled"); err == nil { + t.Error("unknown release type accepted") + } +} diff --git a/internal/distribute/appstore.go b/internal/distribute/appstore.go new file mode 100644 index 0000000..24c130e --- /dev/null +++ b/internal/distribute/appstore.go @@ -0,0 +1,142 @@ +package distribute + +import ( + "context" + "fmt" + "io" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// AppStoreOptions configures SubmitAppStore. +type AppStoreOptions struct { + BundleID string + // Version is the marketing version to submit (CFBundleShortVersionString). + Version string + // BuildNumber narrows the build; empty picks the newest VALID build of Version. + BuildNumber string + // ReleaseType is asc.ReleaseTypeManual or asc.ReleaseTypeAfterApproval; empty leaves it as is. + ReleaseType string + // NoEncryption answers export compliance with "no" when still unanswered. + NoEncryption bool + Log io.Writer +} + +// VersionRef describes the App Store version. +type VersionRef struct { + ID string `json:"id"` + VersionString string `json:"version_string"` + State string `json:"state"` + ReleaseType string `json:"release_type,omitempty"` + Created bool `json:"created"` +} + +// AppStoreResult is what SubmitAppStore reports. +type AppStoreResult struct { + App AppRef `json:"app"` + Build BuildRef `json:"build"` + Compliance string `json:"encryption_compliance"` + Version VersionRef `json:"version"` + Submission ReviewRef `json:"submission"` + Link string `json:"link"` +} + +// SubmitAppStore attaches a build to the App Store version and submits it for review. +func SubmitAppStore(ctx context.Context, client *asc.Client, opts AppStoreOptions) (*AppStoreResult, error) { + if opts.Version == "" { + return nil, fmt.Errorf("a marketing version is required (--version, or --ipa to read it from the archive)") + } + app, err := client.AppByBundleID(ctx, opts.BundleID) + if err != nil { + return nil, err + } + build, err := pickBuild(ctx, client, app.ID, opts.Version, opts.BuildNumber) + if err != nil { + return nil, err + } + res := &AppStoreResult{App: appRef(app), Build: buildRef(app.ID, opts.Version, build), Link: distributionLink(app.ID)} + logf(opts.Log, "Using build %s (%s) for version %s", build.BuildNumber, build.ID, opts.Version) + + res.Compliance, err = setCompliance(ctx, client, opts.Log, build, opts.NoEncryption) + if err != nil { + return res, err + } + res.Build.UsesNonExemptEncryption = build.UsesNonExemptEncryption + + versions, err := client.ListAppStoreVersions(ctx, app.ID, asc.PlatformIOS, opts.Version) + if err != nil { + return res, err + } + var version *asc.AppStoreVersion + if len(versions) > 0 { + version = &versions[0] + logf(opts.Log, "App Store version %s exists (%s)", version.VersionString, version.State) + } else { + logf(opts.Log, "Creating App Store version %s...", opts.Version) + version, err = client.CreateAppStoreVersion(ctx, app.ID, asc.PlatformIOS, opts.Version) + if err != nil { + return res, stateErrorHint(err, "create App Store version") + } + res.Version.Created = true + } + res.Version.ID, res.Version.VersionString, res.Version.State, res.Version.ReleaseType = version.ID, version.VersionString, version.State, version.ReleaseType + switch version.State { + case asc.ReviewStateWaitingForReview, asc.ReviewStateInReview: + return res, fmt.Errorf("version %s is already %s; cancel that submission in App Store Connect before submitting another build", version.VersionString, version.State) + } + + update := asc.AppStoreVersionUpdate{ReleaseType: opts.ReleaseType} + if version.BuildID != build.ID { + update.BuildID = build.ID + } + if update.BuildID != "" || update.ReleaseType != "" { + version, err = client.UpdateAppStoreVersion(ctx, version.ID, update) + if err != nil { + return res, stateErrorHint(err, "attach build to version") + } + res.Version.State, res.Version.ReleaseType = version.State, version.ReleaseType + logf(opts.Log, "Attached build %s to version %s (release: %s)", build.BuildNumber, version.VersionString, version.ReleaseType) + } + + // Reuse an open submission: App Store Connect allows one per platform. + subs, err := client.ListReviewSubmissions(ctx, app.ID, asc.PlatformIOS, []string{asc.ReviewStateReadyForReview, asc.ReviewStateUnresolvedIssues}) + if err != nil { + return res, err + } + var sub *asc.ReviewSubmission + if len(subs) > 0 { + sub = &subs[0] + logf(opts.Log, "Using open review submission %s (%s)", sub.ID, sub.State) + } else { + sub, err = client.CreateReviewSubmission(ctx, app.ID, asc.PlatformIOS) + if err != nil { + return res, stateErrorHint(err, "create review submission") + } + } + res.Submission = ReviewRef{ID: sub.ID, State: sub.State} + + items, err := client.ListReviewSubmissionItems(ctx, sub.ID) + if err != nil { + return res, err + } + hasVersion := false + for _, item := range items { + if item.AppStoreVersionID == version.ID { + hasVersion = true + } + } + if !hasVersion { + if _, err := client.AddAppStoreVersionToReviewSubmission(ctx, sub.ID, version.ID); err != nil { + return res, stateErrorHint(err, "add version to review submission") + } + } + + logf(opts.Log, "Submitting version %s for App Review...", version.VersionString) + submitted, err := client.SubmitReviewSubmission(ctx, sub.ID) + if err != nil { + return res, stateErrorHint(err, "submit for review") + } + res.Submission.State = submitted.State + logf(opts.Log, "Submitted: %s (%s)", res.Link, submitted.State) + return res, nil +} diff --git a/internal/distribute/submit_test.go b/internal/distribute/submit_test.go new file mode 100644 index 0000000..c554181 --- /dev/null +++ b/internal/distribute/submit_test.go @@ -0,0 +1,175 @@ +package distribute + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +func TestSubmitTestFlightExternalGroup(t *testing.T) { + f := newFake(t) + var log bytes.Buffer + res, err := SubmitTestFlight(context.Background(), f.client(t), TestFlightOptions{ + BundleID: "com.example.app", Groups: []string{"team", "Beta Testers"}, Notes: "Try the new login", NoEncryption: true, Wait: true, PollInterval: time.Millisecond, Log: &log, + }) + if err != nil { + t.Fatalf("%v\n%s", err, log.String()) + } + if res.Build.ID != "build-9" || res.Compliance != "set_exempt" || len(res.Groups) != 2 || res.Groups[0].Name != "Team" || !res.Groups[0].Internal || res.Groups[1].Internal { + t.Errorf("result = %+v", res) + } + if res.BetaReview == nil || res.BetaReview.ID != "bar-1" || res.BetaReview.State != "APPROVED" { + t.Errorf("beta review = %+v", res.BetaReview) + } + // Notes go to the app's primary locale, which has no localization yet, so it is created. + notes := f.body("POST /v1/betaBuildLocalizations")["data"].(map[string]any) + if notes["attributes"].(map[string]any)["locale"] != "de-DE" || notes["attributes"].(map[string]any)["whatsNew"] != "Try the new login" { + t.Errorf("localization body = %v", notes) + } + links := f.body("POST /v1/builds/build-9/relationships/betaGroups")["data"].([]any) + if len(links) != 2 || links[1].(map[string]any)["id"] != "g-ext" { + t.Errorf("group linkage = %v", links) + } + // Review must be requested before the build lands in the external group. + var reviewAt, groupAt int + for i, c := range f.calls { + switch c { + case "POST /v1/betaAppReviewSubmissions": + reviewAt = i + case "POST /v1/builds/build-9/relationships/betaGroups": + groupAt = i + } + } + if reviewAt == 0 || groupAt < reviewAt { + t.Errorf("order: %v", f.calls) + } +} + +func TestSubmitTestFlightUpdatesExistingNotesAndSkipsReviewForInternal(t *testing.T) { + f := newFake(t) + yes := false + f.buildEncryption = &yes + res, err := SubmitTestFlight(context.Background(), f.client(t), TestFlightOptions{BundleID: "com.example.app", BuildNumber: "7", Groups: []string{"Team"}, Notes: "n", Locale: "en-US"}) + if err != nil { + t.Fatal(err) + } + if res.Compliance != "already_set" || res.BetaReview != nil || f.called("POST /v1/betaAppReviewSubmissions") || f.called("PATCH /v1/builds/build-9") { + t.Errorf("result = %+v, calls = %v", res, f.calls) + } + if !f.called("PATCH /v1/betaBuildLocalizations/loc-en") || f.called("POST /v1/betaBuildLocalizations") { + t.Errorf("existing locale must be updated: %v", f.calls) + } +} + +func TestSubmitTestFlightListsGroupsWithoutGroupFlag(t *testing.T) { + f := newFake(t) + res, err := SubmitTestFlight(context.Background(), f.client(t), TestFlightOptions{BundleID: "com.example.app"}) + if err != nil { + t.Fatal(err) + } + if len(res.AvailableGroups) != 2 || len(res.Groups) != 0 || f.called("POST /v1/builds/build-9/relationships/betaGroups") { + t.Errorf("result = %+v", res) + } +} + +func TestSubmitTestFlightErrors(t *testing.T) { + f := newFake(t) + c := f.client(t) + _, err := SubmitTestFlight(context.Background(), c, TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Nobody"}, NoEncryption: true}) + if err == nil || !strings.Contains(err.Error(), "Nobody") || !strings.Contains(err.Error(), "Beta Testers") { + t.Errorf("unknown group: %v", err) + } + f.mu.Lock() + f.buildEncryption = nil // the call above answered it + f.mu.Unlock() + _, err = SubmitTestFlight(context.Background(), c, TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Team"}}) + if err == nil || !strings.Contains(err.Error(), "export compliance") { + t.Errorf("missing compliance: %v", err) + } + f.buildState = "PROCESSING" + _, err = SubmitTestFlight(context.Background(), c, TestFlightOptions{BundleID: "com.example.app", BuildNumber: "7"}) + if err == nil || !strings.Contains(err.Error(), "PROCESSING") { + t.Errorf("processing build: %v", err) + } +} + +func TestSubmitAppStoreCreatesVersionAndSubmission(t *testing.T) { + f := newFake(t) + var log bytes.Buffer + res, err := SubmitAppStore(context.Background(), f.client(t), AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0", ReleaseType: asc.ReleaseTypeAfterApproval, NoEncryption: true, Log: &log}) + if err != nil { + t.Fatalf("%v\n%s", err, log.String()) + } + if !res.Version.Created || res.Version.ID != "ver-1" || res.Version.ReleaseType != "AFTER_APPROVAL" || res.Submission.ID != "rs-1" || res.Submission.State != "WAITING_FOR_REVIEW" { + t.Errorf("result = %+v", res) + } + create := f.body("POST /v1/appStoreVersions")["data"].(map[string]any) + if create["attributes"].(map[string]any)["versionString"] != "2.0.0" || create["attributes"].(map[string]any)["platform"] != "IOS" || create["relationships"].(map[string]any)["app"].(map[string]any)["data"].(map[string]any)["id"] != "app-1" { + t.Errorf("version create = %v", create) + } + upd := f.body("PATCH /v1/appStoreVersions/ver-1")["data"].(map[string]any) + if upd["attributes"].(map[string]any)["releaseType"] != "AFTER_APPROVAL" || upd["relationships"].(map[string]any)["build"].(map[string]any)["data"].(map[string]any)["id"] != "build-9" { + t.Errorf("version update = %v", upd) + } + item := f.body("POST /v1/reviewSubmissionItems")["data"].(map[string]any)["relationships"].(map[string]any) + if item["reviewSubmission"].(map[string]any)["data"].(map[string]any)["id"] != "rs-1" || item["appStoreVersion"].(map[string]any)["data"].(map[string]any)["id"] != "ver-1" { + t.Errorf("item = %v", item) + } + submit := f.body("PATCH /v1/reviewSubmissions/rs-1")["data"].(map[string]any) + if submit["attributes"].(map[string]any)["submitted"] != true { + t.Errorf("submit = %v", submit) + } +} + +func TestSubmitAppStoreReusesOpenSubmission(t *testing.T) { + f := newFake(t) + f.versionExists, f.openSubmission = true, true + yes := true + f.buildEncryption = &yes + res, err := SubmitAppStore(context.Background(), f.client(t), AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0"}) + if err != nil { + t.Fatal(err) + } + if res.Version.Created || res.Submission.ID != "rs-0" || f.called("POST /v1/appStoreVersions") || f.called("POST /v1/reviewSubmissions") || f.called("POST /v1/reviewSubmissionItems") { + t.Errorf("result = %+v, calls = %v", res, f.calls) + } + if !f.called("PATCH /v1/reviewSubmissions/rs-0") { + t.Errorf("not submitted: %v", f.calls) + } +} + +func TestSubmitAppStoreMetadataConflict(t *testing.T) { + f := newFake(t) + f.submitStatus = 409 + _, err := SubmitAppStore(context.Background(), f.client(t), AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0", NoEncryption: true}) + var apiErr *asc.Error + if !errors.As(err, &apiErr) || apiErr.StatusCode != 409 { + t.Fatalf("err = %v", err) + } + msg := err.Error() + for _, want := range []string{"screenshot for iPhone", "metadata", "asc-cli"} { + if !strings.Contains(msg, want) { + t.Errorf("%q lacks %q", msg, want) + } + } + if strings.Contains(msg, "\n") { + t.Error("error spans lines") + } +} + +func TestSubmitAppStoreRefusesVersionInReview(t *testing.T) { + f := newFake(t) + f.versionExists, f.versionState = true, "IN_REVIEW" + _, err := SubmitAppStore(context.Background(), f.client(t), AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0", NoEncryption: true}) + if err == nil || !strings.Contains(err.Error(), "IN_REVIEW") { + t.Errorf("err = %v", err) + } + if _, err := SubmitAppStore(context.Background(), f.client(t), AppStoreOptions{BundleID: "com.example.app"}); err == nil { + t.Error("missing version accepted") + } +} diff --git a/internal/distribute/testflight.go b/internal/distribute/testflight.go new file mode 100644 index 0000000..bff9c87 --- /dev/null +++ b/internal/distribute/testflight.go @@ -0,0 +1,191 @@ +package distribute + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// TestFlightOptions configures SubmitTestFlight. +type TestFlightOptions struct { + BundleID string + // Version and BuildNumber narrow the build; empty picks the newest VALID build. + Version string + BuildNumber string + // Groups are TestFlight group names (case-insensitive). Empty adds the + // build nowhere and reports the available groups instead. + Groups []string + // Notes is the "What to Test" text; Locale defaults to the app's primary locale. + Notes string + Locale string + // NoEncryption answers export compliance with "no" when still unanswered. + NoEncryption bool + // Wait follows the external beta review until it is decided. + Wait bool + PollInterval time.Duration + Log io.Writer +} + +// GroupRef describes a TestFlight group. +type GroupRef struct { + ID string `json:"id"` + Name string `json:"name"` + Internal bool `json:"internal"` +} + +// ReviewRef describes a review's state. +type ReviewRef struct { + ID string `json:"id"` + State string `json:"state"` +} + +// TestFlightResult is what SubmitTestFlight reports. +type TestFlightResult struct { + App AppRef `json:"app"` + Build BuildRef `json:"build"` + Compliance string `json:"encryption_compliance"` + Notes string `json:"notes,omitempty"` + Groups []GroupRef `json:"groups"` + AvailableGroups []GroupRef `json:"available_groups,omitempty"` + // BetaReview is set when an external group required App Review. + BetaReview *ReviewRef `json:"beta_review,omitempty"` + Link string `json:"link"` +} + +// SubmitTestFlight hands a processed build to TestFlight groups. +func SubmitTestFlight(ctx context.Context, client *asc.Client, opts TestFlightOptions) (*TestFlightResult, error) { + app, err := client.AppByBundleID(ctx, opts.BundleID) + if err != nil { + return nil, err + } + build, err := pickBuild(ctx, client, app.ID, opts.Version, opts.BuildNumber) + if err != nil { + return nil, err + } + res := &TestFlightResult{App: appRef(app), Build: buildRef(app.ID, opts.Version, build), Groups: []GroupRef{}, Link: buildLink(app.ID, build.ID)} + logf(opts.Log, "Using build %s (%s, uploaded %s)", build.BuildNumber, build.ID, build.UploadedDate.Local().Format("2006-01-02 15:04")) + + res.Compliance, err = setCompliance(ctx, client, opts.Log, build, opts.NoEncryption) + if err != nil { + return res, err + } + res.Build.UsesNonExemptEncryption = build.UsesNonExemptEncryption + + if opts.Notes != "" { + locale := opts.Locale + if locale == "" { + locale = app.PrimaryLocale + } + if locale == "" { + locale = "en-US" + } + if _, err := client.SetWhatsNew(ctx, build.ID, locale, opts.Notes); err != nil { + return res, fmt.Errorf("set test notes: %w", err) + } + res.Notes = opts.Notes + logf(opts.Log, "Set What to Test (%s)", locale) + } + + groups, err := client.ListBetaGroups(ctx, app.ID) + if err != nil { + return res, err + } + if len(opts.Groups) == 0 { + for _, g := range groups { + res.AvailableGroups = append(res.AvailableGroups, GroupRef{ID: g.ID, Name: g.Name, Internal: g.Internal}) + } + logf(opts.Log, "No --group given; the build was added to no TestFlight group. Available groups:") + for _, g := range groups { + kind := "external" + if g.Internal { + kind = "internal" + } + logf(opts.Log, " %s (%s)", g.Name, kind) + } + if res.Compliance == "pending" { + logf(opts.Log, "Export compliance is unanswered (Missing Compliance); pass --no-encryption if the app uses no non-exempt encryption.") + } + return res, nil + } + + var ids []string + var external bool + var unknown []string + for _, name := range opts.Groups { + found := false + for _, g := range groups { + if strings.EqualFold(g.Name, name) { + ids = append(ids, g.ID) + res.Groups = append(res.Groups, GroupRef{ID: g.ID, Name: g.Name, Internal: g.Internal}) + external = external || !g.Internal + found = true + break + } + } + if !found { + unknown = append(unknown, name) + } + } + if len(unknown) > 0 { + names := make([]string, 0, len(groups)) + for _, g := range groups { + names = append(names, g.Name) + } + return res, fmt.Errorf("no TestFlight group named %s; %s has: %s", strings.Join(unknown, ", "), app.Name, strings.Join(names, ", ")) + } + if res.Compliance == "pending" { + return res, fmt.Errorf("build %s has no export compliance answer, so TestFlight cannot distribute it; pass --no-encryption if the app uses no non-exempt encryption, or answer in App Store Connect", build.BuildNumber) + } + + if external { + review, err := client.GetBuildBetaAppReviewSubmission(ctx, build.ID) + if err != nil { + return res, err + } + if review == nil { + logf(opts.Log, "Submitting build for external TestFlight review...") + review, err = client.SubmitBuildForBetaReview(ctx, build.ID) + if err != nil { + return res, stateErrorHint(err, "submit for beta review") + } + } else { + logf(opts.Log, "Beta review already %s", review.State) + } + res.BetaReview = &ReviewRef{ID: review.ID, State: review.State} + } + + if err := client.AddBuildToBetaGroups(ctx, build.ID, ids); err != nil { + return res, fmt.Errorf("add build to groups: %w", err) + } + logf(opts.Log, "Added build %s to %s", build.BuildNumber, strings.Join(opts.Groups, ", ")) + + if opts.Wait && res.BetaReview != nil { + interval := pollInterval(opts.PollInterval) + for res.BetaReview.State == asc.BetaReviewWaiting || res.BetaReview.State == asc.BetaReviewInReview || res.BetaReview.State == "" { + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return res, ctx.Err() + case <-timer.C: + } + review, err := client.GetBetaAppReviewSubmission(ctx, res.BetaReview.ID) + if err != nil { + return res, err + } + if review.State != res.BetaReview.State { + logf(opts.Log, " beta review: %s", review.State) + } + res.BetaReview.State = review.State + } + if res.BetaReview.State == asc.BetaReviewRejected { + return res, fmt.Errorf("beta review rejected build %s; see the resolution center in App Store Connect", build.BuildNumber) + } + } + logf(opts.Log, "TestFlight: %s", res.Link) + return res, nil +} From ef25c19a3f33713304ee8c586f353e8572653e4d Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:17:09 +0200 Subject: [PATCH 18/75] 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. --- CLAUDE.md | 60 ++++++++++++++++++++++++++++++-- README.md | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b15fd87..83f70a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**Builder** is a Go CLI tool for iOS development without a Mac. It has two main capabilities: +**Builder** is a Go CLI tool for iOS development without a Mac. It has three main capabilities: 1. **Remote builds**: Build iOS apps via GitHub Actions from any platform 2. **Dev tools**: Hot reload on real iOS devices using MobAI (Flutter and React Native) +3. **Distribution**: Upload builds to App Store Connect and submit them to TestFlight or App Review ## Build Commands @@ -29,6 +30,10 @@ go install ./cmd/builder ./builder dev kmp # Kotlin Multiplatform install + launch (no hot reload) ./builder dev flutter --skip-install --bundle-id # Use already installed app ./builder dev rn --skip-install --bundle-id # Use already installed app +./builder auth apple # Save an App Store Connect API key +./builder ios upload --wait # Upload dist/*.ipa to App Store Connect, wait for processing +./builder ios submit --testflight --group --notes # TestFlight +./builder ios submit --app-store --release after-approval # App Review ``` ## Architecture @@ -100,6 +105,27 @@ builder dev kmp ─────────► Connects to MobAI │ ▼ Launches app and streams output (no hot reload) + +builder ios upload ──────► Reads bundle ID / version / build number from dist/*.ipa + │ + ▼ + App Store Connect API (ES256 JWT from the .p8 key) + ├─ apps?filter[bundleId] + ├─ POST buildUploads → POST buildUploadFiles + ├─ PUT chunks to presigned URLs + ├─ PATCH buildUploadFiles uploaded=true + └─ --wait: poll buildUploads state, then builds → VALID + │ + ▼ + PATCH builds usesNonExemptEncryption (plist / --no-encryption) + +builder ios submit ──────► Picks the newest VALID build (or --build-number) + ├─ --testflight: betaBuildLocalizations (notes), + │ betaAppReviewSubmissions (external groups), + │ builds/{id}/relationships/betaGroups + └─ --app-store: appStoreVersions (find/create, attach + build, releaseType), reviewSubmissions + + reviewSubmissionItems, PATCH submitted=true ``` ### Module Layout @@ -107,8 +133,11 @@ builder dev kmp ─────────► Connects to MobAI ``` cmd/builder/ # CLI entrypoint (Cobra) internal/ - auth/ # GitHub OAuth device flow + keyring storage + auth/ # GitHub OAuth device flow + keyring storage (also CI tokens, ASC API key) github/ # GitHub REST API (workflow dispatch, artifacts) + asc/ # App Store Connect API client (JWT, JSON:API, builds, uploads, TestFlight, review) + distribute/ # Upload / TestFlight / App Store flows on top of asc + ipa/ # Info.plist reading from .ipa archives build/ # Build coordination (snapshot + trigger + poll + download) signing/ # CSR generation and .p12 assembly (signing without a Mac) snapshot/ # Working-tree snapshot as a throwaway commit on a remote ref @@ -169,6 +198,33 @@ internal/ CLI calls KMP but the runner does not gets no JDK, and vice versa. - **KMP Has No Hot Reload**: shared Kotlin compiles to a native framework at build time, so `dev kmp` only installs, launches and streams output; code changes need `ios build` +- **ASC Client** (`internal/asc`): runs locally, never on the runner. Auth is an ES256 JWT + (15 min, cached, refreshed a minute early) signed with the `.p8` key. JSON:API plumbing is + generic (`Document`/`Resource[A]`, `getOne`/`getAll`/`post`/`patch`); typed helpers exist only + for what the commands use, so item 2 (bundle IDs, certificates, profiles, devices) adds files in + the same package without restructuring. `getAll` follows `links.next`; 429 retries on every + method, 5xx only on idempotent ones (a failed POST may have created the resource). `*asc.Error` + carries the ASC `errors[]` and renders on one line. +- **ASC Credentials**: one JSON secret (`apple-asc-key`) in the keyring/file store, via the + shared `readSecret`/`writeSecret`/`deleteSecret` helpers the CI tokens use. `ASC_ISSUER_ID`, + `ASC_KEY_ID` + `ASC_PRIVATE_KEY`|`ASC_KEY_PATH` take precedence; a partially set environment is + an error, not a fallback. Only `auth apple` prompts; `upload`/`submit` never do. +- **Build Upload**: `buildUploads` → `buildUploadFiles` (returns `uploadOperations`) → PUT each + byte range with its `requestHeaders`, no bearer token → PATCH `uploaded=true` → poll the upload + `state` (COMPLETE/FAILED with `errors[]`) → poll `builds` filtered by app, marketing version and + build number until VALID. No checksum is sent (asc-cli found ASC rejects some encodings). The IPA + must be App Store signed and each upload needs a higher `CFBundleVersion`. +- **Export Compliance**: a build sits in "Missing Compliance" until `usesNonExemptEncryption` is + answered. `upload --wait` PATCHes it to false when Info.plist says `ITSAppUsesNonExemptEncryption` + false or `--no-encryption` is given; the build must exist first, so without `--wait` it is left + for `submit --no-encryption`. `submit --testflight` refuses to add an unanswered build to groups. +- **Submit Order**: TestFlight is compliance → notes → `betaAppReviewSubmissions` (only when a + chosen group is external and none exists) → add groups. App Store reuses an open + `reviewSubmission` (READY_FOR_REVIEW/UNRESOLVED_ISSUES), skips the item when the version is + already in it, and rewrites ASC 409/422 with a "complete the metadata" hint. +- **Extension Points**: item 5 (`ios release`, auto build numbers) composes `distribute.Upload` + and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` for the latest build number; + the `pkg/` wrappers do not expose `asc` yet. ## Configuration diff --git a/README.md b/README.md index d99627b..8c36b0f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Builder is a CLI tool for iOS development without a Mac. It uses GitHub Actions - **Flutter & React Native dev tools**: Hot reload on real iOS devices from Windows/Linux - **Simple setup**: One command to add the workflow to your repo - **Code signing**: Optional signing with your certificate and provisioning profile +- **TestFlight and App Store**: Upload builds and submit them for review through the App Store Connect API, from any platform - **Device integration**: Install and run apps via MobAI ## How It Works @@ -165,8 +166,9 @@ go build -o builder ./cmd/builder # Setup builder auth github # Authenticate with GitHub builder auth codemagic # Authenticate with Codemagic (also: bitrise) +builder auth apple # Save an App Store Connect API key builder auth status # Show which providers you are signed in to -builder auth logout [name] # Remove stored credentials +builder auth logout [name] # Remove stored credentials (github, codemagic, bitrise, apple) builder init # Set up workflows in current repo builder update # Update builder to the latest release @@ -199,8 +201,16 @@ builder mobai forward # Forward a device port builder signing csr # Create a private key + certificate signing request builder signing p12 # Assemble a .p12 from the key and Apple's certificate builder signing setup # Upload code signing secrets to GitHub + +# TestFlight and App Store (needs builder auth apple) +builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing +builder ios submit --testflight --group "Beta Testers" --notes "What to test" +builder ios submit --app-store --release after-approval # Submit the version for App Review ``` +Every `upload`/`submit` command takes `--json` for machine-readable output and +never prompts, so agents and CI jobs can drive them. + ## Configuration `builder.json`: @@ -342,6 +352,94 @@ secrets by hand as described in the [signing and MobAI secrets guide](docs/provider-secrets.md), then set `ios.signing` to `true` yourself. +## TestFlight and App Store + +Builder uploads builds to App Store Connect and submits them to TestFlight or +App Review through the App Store Connect API, from Windows, Linux or macOS. No +Transporter, `altool` or Xcode is involved, and the API key never leaves your +machine: the CI runner only builds and signs, the upload happens locally from +the IPA in `./dist/`. + +You need: + +- A paid [Apple Developer Program](https://developer.apple.com/programs/) + membership and an app record in App Store Connect (My Apps → +) with your + bundle ID +- An IPA signed with an **Apple Distribution** certificate and an **App Store** + provisioning profile. `builder signing setup` accepts both, exactly as in the + steps above; pick those types on the portal instead of the development ones. + An IPA signed for development is rejected at upload. +- An App Store Connect API key: App Store Connect → Users and Access → + Integrations → App Store Connect API → Team Keys. Give it the **App Manager** + role, note the **Issuer ID** and **Key ID**, and download the + `AuthKey_.p8` file (Apple offers the download once). + +### 1. Save the API key + +```bash +builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key AuthKey_ABC123DEFG.p8 +``` + +Flags you leave out are prompted for. Builder verifies the key against App +Store Connect and stores it like the other logins (keychain, or a `0600` file on +Linux/WSL); `builder auth status` shows it and `builder auth logout apple` +removes it. In CI or for a coding agent, set `ASC_ISSUER_ID`, `ASC_KEY_ID` and +either `ASC_PRIVATE_KEY` (the .p8 contents; literal `\n` is fine) or +`ASC_KEY_PATH` instead — they take precedence over the saved login. + +### 2. Upload the build + +```bash +builder ios build # produces a signed dist/*.ipa +builder ios upload --wait +``` + +`upload` reads the bundle ID, version and build number from the newest IPA in +`./dist/` (or `--ipa `), finds the app, uploads the archive in chunks +and, with `--wait`, follows App Store Connect until the build has finished +processing and prints its build ID and TestFlight link. Without `--wait` it +returns as soon as Apple has the file. + +Two things Apple checks on every upload: + +- **Build numbers must increase.** A second upload with the same + `CFBundleVersion` for the same version is rejected (`ITMS-90189`), so bump + it before rebuilding. +- **Export compliance.** A build shows as *Missing Compliance* in TestFlight + until you say whether it uses non-exempt encryption. If your Info.plist sets + `ITSAppUsesNonExemptEncryption` to `false`, `upload --wait` answers that + automatically; otherwise pass `--no-encryption` (here or to `submit`) when + your app only uses standard iOS encryption. + +### 3. Distribute to TestFlight + +```bash +builder ios submit --testflight --group "Beta Testers" --notes "New login flow" +``` + +This takes the newest processed build (or `--build-number N`), sets the *What +to Test* notes and adds it to the named groups (`--group` repeats). Internal +groups get the build immediately; the first external group triggers Apple's +beta review, which Builder submits for you (`--wait` follows the decision). Run +it without `--group` to see the build and the groups the app has. + +### 4. Submit to the App Store + +```bash +builder ios submit --app-store --release after-approval +``` + +Builder finds or creates the App Store version matching the IPA's marketing +version (or `--version X.Y.Z`), attaches the build, sets the release type +(`manual` or `after-approval`) and submits it for review. The version's +metadata — description, screenshots, age rating, pricing, privacy — must +already be complete: App Store Connect refuses the submission otherwise and +Builder prints Apple's reasons verbatim. Builder does not manage metadata, +screenshots or in-app purchases; fill them in App Store Connect, or on a Mac +with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of +the `buildUploads` API also proved that the Mac-free upload path works and +served as the reference for Builder's implementation. + ## Installing the IPA Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account). From d6173324e4f9562e5b137f20e08863f8cfe364a6 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:22:57 +0200 Subject: [PATCH 19/75] 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. --- cmd/builder/root.go | 6 +++--- internal/build/coordinator.go | 17 ++++++++++------- internal/build/inputs_test.go | 2 +- internal/build/progress.go | 2 +- internal/build/remote.go | 11 +++++++---- internal/build/remote_test.go | 2 +- internal/config/profile.go | 4 ++-- internal/config/profile_test.go | 3 ++- 8 files changed, 27 insertions(+), 20 deletions(-) diff --git a/cmd/builder/root.go b/cmd/builder/root.go index 10f7b5a..7a8ce53 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -471,7 +471,7 @@ func runInit(cmd *cobra.Command, args []string) error { if buildErr == nil { fmt.Println() - return runBuild(context.Background(), cfg, build.BuildOptions{ + return runBuild(context.Background(), cfg, &build.BuildOptions{ OutputDir: "dist", Timeout: 30 * time.Minute, Remote: remoteName, @@ -611,7 +611,7 @@ func runIOSBuild(cmd *cobra.Command, args []string) error { ctx, stop = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() } - return runBuild(ctx, cfg, build.BuildOptions{ + return runBuild(ctx, cfg, &build.BuildOptions{ Provider: provider, Profile: profile, OutputDir: outputDir, @@ -680,7 +680,7 @@ func runIOSShare(cmd *cobra.Command, args []string) error { return nil } -func runBuild(ctx context.Context, cfg *config.Config, opts build.BuildOptions) error { +func runBuild(ctx context.Context, cfg *config.Config, opts *build.BuildOptions) error { ghClient, err := clientForProvider(cfg, opts.Provider) if err != nil { return err diff --git a/internal/build/coordinator.go b/internal/build/coordinator.go index 6dcde6c..24b8040 100644 --- a/internal/build/coordinator.go +++ b/internal/build/coordinator.go @@ -67,29 +67,29 @@ type BuildOptions struct { // settings applies the selected profile, then the command flags, over // builder.json. The returned name is the provider that will run the job. -func (c *Coordinator) settings(profile, provider string, unsigned bool) (config.BuildSettings, string, error) { +func (c *Coordinator) settings(profile, provider string, unsigned bool) (*config.BuildSettings, string, error) { s, err := c.config.ResolveProfile(profile) if err != nil { - return s, "", err + return nil, "", err } if provider != "" { s.Provider = provider } name, err := c.config.ProviderName(s.Provider) if err != nil { - return s, "", err + return nil, "", err } if unsigned { s.Signing = false } - return s, name, nil + return &s, name, nil } // workflowInputs maps the settings onto the workflow_dispatch inputs both // GitHub workflows share. Empty values are left out so the declared defaults // apply, and `profile` is only sent when one is selected: a workflow file from // before profiles rejects a dispatch carrying an input it does not declare. -func (c *Coordinator) workflowInputs(buildID, ref string, s config.BuildSettings) map[string]string { +func (c *Coordinator) workflowInputs(buildID, ref string, s *config.BuildSettings) map[string]string { inputs := map[string]string{ "build_id": buildID, "snapshot_ref": ref, @@ -116,7 +116,7 @@ func (c *Coordinator) workflowInputs(buildID, ref string, s config.BuildSettings // buildInputs are the ios-build.yml inputs: the shared ones plus signing and // configuration, which the simulator workflow has no use for. -func (c *Coordinator) buildInputs(buildID, ref string, s config.BuildSettings) map[string]string { +func (c *Coordinator) buildInputs(buildID, ref string, s *config.BuildSettings) map[string]string { inputs := c.workflowInputs(buildID, ref, s) if s.Signing { inputs["use_signing"] = "true" @@ -148,7 +148,10 @@ type BuildResult struct { } // Build triggers a remote build and downloads the IPA artifact -func (c *Coordinator) Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) { +func (c *Coordinator) Build(ctx context.Context, opts *BuildOptions) (*BuildResult, error) { + // Defaults below are filled in on a copy: opts belongs to the caller. + o := *opts + opts = &o settings, name, err := c.settings(opts.Profile, opts.Provider, opts.Unsigned) if err != nil { return nil, err diff --git a/internal/build/inputs_test.go b/internal/build/inputs_test.go index 49744a5..5ec62c8 100644 --- a/internal/build/inputs_test.go +++ b/internal/build/inputs_test.go @@ -148,7 +148,7 @@ func TestSettingsPrinted(t *testing.T) { var out bytes.Buffer p := NewProgress(&out) p.Start("abcdef12") - p.Settings(config.BuildSettings{Profile: "preview", Configuration: "Release", Signing: true, Env: map[string]string{"B": "2", "A": "1"}, Distribution: "ad-hoc"}, "github") + p.Settings(&config.BuildSettings{Profile: "preview", Configuration: "Release", Signing: true, Env: map[string]string{"B": "2", "A": "1"}, Distribution: "ad-hoc"}, "github") for _, want := range []string{"Profile: preview", "Configuration: Release", "Scheme: (auto-detected)", "Signing: signed", "Provider: github", "Env: A, B", "Distribution: ad-hoc"} { if !strings.Contains(out.String(), want) { t.Errorf("missing %q in:\n%s", want, out.String()) diff --git a/internal/build/progress.go b/internal/build/progress.go index 5d1cf53..e1c2083 100644 --- a/internal/build/progress.go +++ b/internal/build/progress.go @@ -73,7 +73,7 @@ func (p *Progress) Start(buildID string) { // Settings prints what the job will run with, before anything is dispatched, // so a wrong profile or flag is visible without opening the provider's logs. // It completes the header that Start begins. -func (p *Progress) Settings(s config.BuildSettings, provider string) { +func (p *Progress) Settings(s *config.BuildSettings, provider string) { p.mu.Lock() defer p.mu.Unlock() diff --git a/internal/build/remote.go b/internal/build/remote.go index f1b6cdc..f41b530 100644 --- a/internal/build/remote.go +++ b/internal/build/remote.go @@ -65,7 +65,7 @@ func (c *Coordinator) remote(override string) (ci.Provider, config.CIConfig, err // profile's env travels as one JSON object in BUILD_ENV, which the runner // exports before installing dependencies; DISTRIBUTION is passed through for // the export step. Both are only set when the profile provides them. -func (c *Coordinator) inputs(buildID, ref, sha string, s config.BuildSettings) map[string]string { +func (c *Coordinator) inputs(buildID, ref, sha string, s *config.BuildSettings) map[string]string { v := map[string]string{"BUILD_ID": buildID, "SNAPSHOT_REF": ref, "SNAPSHOT_SHA": sha, "IOS_PATH": c.config.IOS.Path, "SCHEME": s.Scheme, "CONFIGURATION": s.Configuration, "FLUTTER_VERSION": c.config.Flutter.Version, @@ -92,7 +92,7 @@ func (c *Coordinator) inputs(buildID, ref, sha string, s config.BuildSettings) m return v } -func (c *Coordinator) pushSnapshot(ctx context.Context, remote, buildID string, s config.BuildSettings, provider string) (string, string, error) { +func (c *Coordinator) pushSnapshot(ctx context.Context, remote, buildID string, s *config.BuildSettings, provider string) (string, string, error) { c.progress.Start(buildID) c.progress.Settings(s, provider) c.progress.Update(PhaseSnapshot, "Snapshotting working tree...") @@ -108,11 +108,14 @@ func (c *Coordinator) pushSnapshot(ctx context.Context, remote, buildID string, return ref, sha, nil } -func (c *Coordinator) buildRemote(ctx context.Context, opts BuildOptions, s config.BuildSettings) (*BuildResult, error) { +func (c *Coordinator) buildRemote(ctx context.Context, opts *BuildOptions, s *config.BuildSettings) (*BuildResult, error) { p, cfgCI, err := c.remote(s.Provider) if err != nil { return nil, err } + // Defaults below are filled in on a copy: opts belongs to the caller. + o := *opts + opts = &o if opts.Timeout < 0 { return nil, fmt.Errorf("timeout must be positive") } @@ -274,7 +277,7 @@ func saveRemoteIPA(ctx context.Context, p ci.Provider, run ci.Run, a ci.Artifact return dest, n, nil } -func (c *Coordinator) shareRemote(ctx context.Context, opts ShareOptions, s config.BuildSettings) (*ShareResult, error) { +func (c *Coordinator) shareRemote(ctx context.Context, opts ShareOptions, s *config.BuildSettings) (*ShareResult, error) { p, cfgCI, err := c.remote(s.Provider) if err != nil { return nil, err diff --git a/internal/build/remote_test.go b/internal/build/remote_test.go index e2de200..7cc359b 100644 --- a/internal/build/remote_test.go +++ b/internal/build/remote_test.go @@ -163,7 +163,7 @@ func TestRemoteSnapshotLifecycle(t *testing.T) { if strings.HasSuffix(tt.name, "share") { _, err = c.Share(context.Background(), ShareOptions{}) } else { - result, err = c.Build(context.Background(), BuildOptions{OutputDir: filepath.Join(dir, "dist")}) + result, err = c.Build(context.Background(), &BuildOptions{OutputDir: filepath.Join(dir, "dist")}) } if tt.name == "success" || tt.name == "transient poll recovers" { if err != nil || result == nil { diff --git a/internal/config/profile.go b/internal/config/profile.go index 682c9a7..c700c7c 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -125,7 +125,7 @@ func (c *Config) ResolveProfile(name string) (BuildSettings, error) { // EnvJSON encodes the profile's environment as a JSON object, which is how it // travels to the runner: workflow inputs and CI variables are strings, and JSON // survives values with spaces, quotes and newlines. Empty when there is none. -func (s BuildSettings) EnvJSON() string { +func (s *BuildSettings) EnvJSON() string { if len(s.Env) == 0 { return "" } @@ -138,7 +138,7 @@ func (s BuildSettings) EnvJSON() string { // keeping the workflow under GitHub's limit of ten inputs. Empty when no // profile is selected, so older workflow files keep receiving the inputs they // declare. -func (s BuildSettings) ProfileInput() string { +func (s *BuildSettings) ProfileInput() string { if s.Profile == "" { return "" } diff --git a/internal/config/profile_test.go b/internal/config/profile_test.go index 112ad4c..4217437 100644 --- a/internal/config/profile_test.go +++ b/internal/config/profile_test.go @@ -128,7 +128,8 @@ func TestProfileEncodings(t *testing.T) { if strings.Contains(s.ProfileInput(), "\n") { t.Fatal("profile input must be a single line") } - if got := (BuildSettings{Profile: "development"}).ProfileInput(); !strings.Contains(got, `"env":{}`) { + noEnv := BuildSettings{Profile: "development"} + if got := noEnv.ProfileInput(); !strings.Contains(got, `"env":{}`) { t.Fatalf("env should be an object even when empty: %s", got) } } From 78452481c831a772e234426c4464a95a515c64c2 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:30:36 +0200 Subject: [PATCH 20/75] 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. --- internal/asc/apps.go | 13 --- internal/asc/builds.go | 2 +- internal/asc/client.go | 33 ++++---- internal/asc/client_test.go | 83 ++++++++++++++++--- internal/asc/jwt.go | 15 ++-- internal/asc/jwt_test.go | 12 ++- internal/asc/testflight.go | 23 ++++++ internal/asc/uploads.go | 107 +++++++++++-------------- internal/asc/uploads_test.go | 25 +++--- internal/asc/versions.go | 10 ++- internal/distribute/appstore.go | 4 +- internal/distribute/distribute.go | 8 +- internal/distribute/distribute_test.go | 43 ++++++++-- internal/distribute/submit_test.go | 46 +++++------ internal/distribute/testflight.go | 27 +++---- internal/distribute/upload.go | 4 +- 16 files changed, 271 insertions(+), 184 deletions(-) diff --git a/internal/asc/apps.go b/internal/asc/apps.go index 87c2c69..bb7f264 100644 --- a/internal/asc/apps.go +++ b/internal/asc/apps.go @@ -42,19 +42,6 @@ func (c *Client) AppByBundleID(ctx context.Context, bundleID string) (*App, erro return nil, fmt.Errorf("no App Store Connect app has bundle ID %s; create the app record in App Store Connect (My Apps → +) with that bundle ID first, and check the API key can see it", bundleID) } -// ListApps lists the apps the key can see. -func (c *Client) ListApps(ctx context.Context) ([]App, error) { - rs, err := getAll[appAttributes](ctx, c, "/v1/apps", nil) - if err != nil { - return nil, err - } - apps := make([]App, 0, len(rs)) - for _, r := range rs { - apps = append(apps, toApp(r)) - } - return apps, nil -} - // CheckAccess makes the cheapest authenticated call to verify the key works. func (c *Client) CheckAccess(ctx context.Context) error { _, err := getPage[appAttributes](ctx, c, "/v1/apps", url.Values{"limit": {"1"}}) diff --git a/internal/asc/builds.go b/internal/asc/builds.go index 46582cd..6c1451e 100644 --- a/internal/asc/builds.go +++ b/internal/asc/builds.go @@ -76,7 +76,7 @@ type BuildFilter struct { } // ListBuilds lists builds, newest first. -func (c *Client) ListBuilds(ctx context.Context, f BuildFilter) ([]Build, error) { +func (c *Client) ListBuilds(ctx context.Context, f *BuildFilter) ([]Build, error) { q := url.Values{"sort": {"-uploadedDate"}} if f.AppID != "" { q.Set("filter[app]", f.AppID) diff --git a/internal/asc/client.go b/internal/asc/client.go index b86b903..24c5cdb 100644 --- a/internal/asc/client.go +++ b/internal/asc/client.go @@ -25,6 +25,8 @@ type Client struct { tokens *tokenSource retryDelay time.Duration maxRetries int + // sleep waits between retries and polls; tests replace it. + sleep func(context.Context, time.Duration) error } // Option configures a Client. @@ -60,6 +62,7 @@ func NewClient(creds Credentials, opts ...Option) (*Client, error) { tokens: tokens, retryDelay: time.Second, maxRetries: 3, + sleep: sleep, } for _, opt := range opts { opt(c) @@ -130,16 +133,6 @@ func (d ErrorDetail) String() string { return strings.ReplaceAll(s, "\n", " ") } -// HasCode reports whether any error carries the code or a code with that prefix. -func (e *Error) HasCode(prefix string) bool { - for _, d := range e.Errors { - if strings.HasPrefix(d.Code, prefix) { - return true - } - } - return false -} - // IsStatus reports whether err is an App Store Connect error with the given HTTP status. func IsStatus(err error, status int) bool { var e *Error @@ -185,16 +178,24 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, if apiErr.RetryAfter > delay { delay = apiErr.RetryAfter } - timer := time.NewTimer(delay) - select { - case <-ctx.Done(): - timer.Stop() - return ctx.Err() - case <-timer.C: + if err := c.sleep(ctx, delay); err != nil { + return err } } } +// sleep waits for d or until ctx is done. +func sleep(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + // retryable: 429 was not processed, so any method may retry. A 5xx on a POST // may have created the resource already, so only idempotent methods retry. func retryable(method string, status int) bool { diff --git a/internal/asc/client_test.go b/internal/asc/client_test.go index 6653bcf..e893021 100644 --- a/internal/asc/client_test.go +++ b/internal/asc/client_test.go @@ -30,6 +30,46 @@ func writeJSON(w http.ResponseWriter, status int, v any) { _ = json.NewEncoder(w).Encode(v) } +// obj walks decoded JSON down the given object keys; a missing or non-object +// step fails the test and yields nil, which later lookups tolerate. +func obj(t *testing.T, v any, keys ...string) map[string]any { + t.Helper() + for i := 0; ; i++ { + m, ok := v.(map[string]any) + if !ok { + t.Errorf("JSON path %v: %T is not an object", keys[:i], v) + return nil + } + if i == len(keys) { + return m + } + v = m[keys[i]] + } +} + +// arr is obj for a final array value. +func arr(t *testing.T, v any, keys ...string) []any { + t.Helper() + if len(keys) > 0 { + v = obj(t, v, keys[:len(keys)-1]...)[keys[len(keys)-1]] + } + a, ok := v.([]any) + if !ok { + t.Errorf("JSON path %v: %T is not an array", keys, v) + } + return a +} + +// recordSleeps makes the client's waits instant and returns the requested durations. +func recordSleeps(c *Client) *[]time.Duration { + var slept []time.Duration + c.sleep = func(_ context.Context, d time.Duration) error { + slept = append(slept, d) + return nil + } + return &slept +} + func TestGetSendsBearerTokenAndDecodes(t *testing.T) { var authz string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -80,7 +120,7 @@ func TestErrorDecoding(t *testing.T) { if !errors.As(err, &apiErr) { t.Fatalf("err = %T %v", err, err) } - if apiErr.StatusCode != 409 || len(apiErr.Errors) != 2 || !apiErr.HasCode("STATE_ERROR") || !IsStatus(err, 409) { + if apiErr.StatusCode != 409 || len(apiErr.Errors) != 2 || apiErr.Errors[0].Code != "STATE_ERROR.ENTITY_STATE_INVALID" || !IsStatus(err, 409) { t.Errorf("apiErr = %+v", apiErr) } msg := err.Error() @@ -148,16 +188,37 @@ func TestRetryOn429HonorsRetryAfter(t *testing.T) { writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "reviewSubmissions", "id": "rs-1", "attributes": map[string]any{"state": "READY_FOR_REVIEW"}}}) })) defer srv.Close() - start := time.Now() - sub, err := newTestClient(t, srv).CreateReviewSubmission(context.Background(), "app-1", PlatformIOS) + c := newTestClient(t, srv) + slept := recordSleeps(c) + sub, err := c.CreateReviewSubmission(context.Background(), "app-1", PlatformIOS) if err != nil { t.Fatal(err) } if sub.ID != "rs-1" || calls.Load() != 2 { t.Errorf("sub = %+v, calls = %d", sub, calls.Load()) } - if time.Since(start) < time.Second { - t.Error("Retry-After was not honored") + if len(*slept) != 1 || (*slept)[0] != time.Second { + t.Errorf("slept %v, want the 1s Retry-After over the 1ms base delay", *slept) + } +} + +func TestPollerBacksOffToCap(t *testing.T) { + c := &Client{} + slept := recordSleeps(c) + p := c.newPoller(10 * time.Second) + for range 6 { + if err := p.wait(context.Background()); err != nil { + t.Fatal(err) + } + } + want := []time.Duration{10 * time.Second, 15 * time.Second, 22500 * time.Millisecond, 33750 * time.Millisecond, 40 * time.Second, 40 * time.Second} + if len(*slept) != len(want) { + t.Fatalf("slept %v, want %v", *slept, want) + } + for i := range want { + if (*slept)[i] != want[i] { + t.Errorf("wait %d = %v, want %v", i, (*slept)[i], want[i]) + } } } @@ -232,16 +293,16 @@ func TestRequestBodiesAreJSONAPI(t *testing.T) { if err != nil || b.UsesNonExemptEncryption == nil || *b.UsesNonExemptEncryption { t.Fatalf("build = %+v, err = %v", b, err) } - data := body["data"].(map[string]any) - if data["type"] != "builds" || data["id"] != "b1" || data["attributes"].(map[string]any)["usesNonExemptEncryption"] != false { + data := obj(t, body, "data") + if data["type"] != "builds" || data["id"] != "b1" || obj(t, data, "attributes")["usesNonExemptEncryption"] != false { t.Errorf("PATCH body = %v", body) } if err := c.AddBuildToBetaGroups(ctx, "b1", []string{"g1", "g2"}); err != nil { t.Fatal(err) } - linkages := body["data"].([]any) - if len(linkages) != 2 || linkages[1].(map[string]any)["id"] != "g2" || linkages[0].(map[string]any)["type"] != "betaGroups" { + linkages := arr(t, body, "data") + if len(linkages) != 2 || obj(t, linkages[1])["id"] != "g2" || obj(t, linkages[0])["type"] != "betaGroups" { t.Errorf("relationship body = %v", body) } @@ -249,11 +310,11 @@ func TestRequestBodiesAreJSONAPI(t *testing.T) { if err != nil || sub.ID != "bar-1" || sub.State != BetaReviewWaiting { t.Fatalf("sub = %+v, err = %v", sub, err) } - data = body["data"].(map[string]any) + data = obj(t, body, "data") if _, has := data["attributes"]; has { t.Errorf("empty attributes must be omitted: %v", body) } - if data["relationships"].(map[string]any)["build"].(map[string]any)["data"].(map[string]any)["id"] != "b1" { + if obj(t, data, "relationships", "build", "data")["id"] != "b1" { t.Errorf("POST body = %v", body) } } diff --git a/internal/asc/jwt.go b/internal/asc/jwt.go index 460f60c..19138a2 100644 --- a/internal/asc/jwt.go +++ b/internal/asc/jwt.go @@ -34,13 +34,7 @@ type Credentials struct { // Validate checks that every field is present and that the key is a P-256 key. func (c Credentials) Validate() error { - if strings.TrimSpace(c.IssuerID) == "" { - return errors.New("issuer ID is empty") - } - if strings.TrimSpace(c.KeyID) == "" { - return errors.New("key ID is empty") - } - _, err := ParsePrivateKey(c.PrivateKey) + _, err := newTokenSource(c) return err } @@ -95,8 +89,11 @@ type tokenSource struct { } func newTokenSource(creds Credentials) (*tokenSource, error) { - if err := creds.Validate(); err != nil { - return nil, err + if strings.TrimSpace(creds.IssuerID) == "" { + return nil, errors.New("issuer ID is empty") + } + if strings.TrimSpace(creds.KeyID) == "" { + return nil, errors.New("key ID is empty") } key, err := ParsePrivateKey(creds.PrivateKey) if err != nil { diff --git a/internal/asc/jwt_test.go b/internal/asc/jwt_test.go index fb98a09..e26a7ab 100644 --- a/internal/asc/jwt_test.go +++ b/internal/asc/jwt_test.go @@ -73,12 +73,16 @@ func TestTokenClaimsAndSignature(t *testing.T) { if claims["iss"] != "issuer-1" || claims["aud"] != audience { t.Errorf("claims = %v", claims) } - iat, exp := int64(claims["iat"].(float64)), int64(claims["exp"].(float64)) - if iat != now.Unix() { - t.Errorf("iat = %d, want %d", iat, now.Unix()) + iat, iatOK := claims["iat"].(float64) + exp, expOK := claims["exp"].(float64) + if !iatOK || !expOK { + t.Fatalf("iat/exp are not numbers: %v", claims) + } + if int64(iat) != now.Unix() { + t.Errorf("iat = %v, want %d", iat, now.Unix()) } if lifetime := exp - iat; lifetime <= 0 || lifetime > 20*60 { - t.Errorf("exp-iat = %ds, must be within Apple's 20 minute cap", lifetime) + t.Errorf("exp-iat = %vs, must be within Apple's 20 minute cap", lifetime) } sig, err := base64.RawURLEncoding.DecodeString(parts[2]) diff --git a/internal/asc/testflight.go b/internal/asc/testflight.go index 43a8e7f..aaa2165 100644 --- a/internal/asc/testflight.go +++ b/internal/asc/testflight.go @@ -3,6 +3,7 @@ package asc import ( "context" "net/url" + "time" ) // BetaGroup is a TestFlight tester group. @@ -152,6 +153,28 @@ func (c *Client) GetBetaAppReviewSubmission(ctx context.Context, id string) (*Be return &BetaAppReviewSubmission{ID: r.ID, State: r.Attributes.BetaReviewState}, nil } +// WaitForBetaAppReview polls the beta review until Apple has decided it +// (APPROVED or REJECTED). onPoll, when set, sees every state change. +func (c *Client) WaitForBetaAppReview(ctx context.Context, id string, interval time.Duration, onPoll func(*BetaAppReviewSubmission)) (*BetaAppReviewSubmission, error) { + p := c.newPoller(interval) + for { + review, err := c.GetBetaAppReviewSubmission(ctx, id) + if err != nil { + return nil, err + } + if onPoll != nil { + onPoll(review) + } + switch review.State { + case BetaReviewApproved, BetaReviewRejected: + return review, nil + } + if err := p.wait(ctx); err != nil { + return review, err + } + } +} + // SubmitBuildForBetaReview submits the build for external TestFlight review. func (c *Client) SubmitBuildForBetaReview(ctx context.Context, buildID string) (*BetaAppReviewSubmission, error) { req := Resource[struct{}]{Type: "betaAppReviewSubmissions", Relationships: Relationships{"build": ToOne("builds", buildID)}} diff --git a/internal/asc/uploads.go b/internal/asc/uploads.go index 727329f..8c8bc14 100644 --- a/internal/asc/uploads.go +++ b/internal/asc/uploads.go @@ -122,29 +122,20 @@ type UploadOperation struct { RequestHeaders []HTTPHeader `json:"requestHeaders,omitempty"` } -// AssetDeliveryState reports whether Apple received the file. -type AssetDeliveryState struct { - State string `json:"state,omitempty"` - Errors []StateDetail `json:"errors,omitempty"` - Warnings []StateDetail `json:"warnings,omitempty"` -} - // BuildUploadFile is the reserved slot for the IPA within a delivery. type BuildUploadFile struct { - ID string - FileName string - FileSize int64 - UploadOperations []UploadOperation - AssetDeliveryState *AssetDeliveryState + ID string + FileName string + FileSize int64 + UploadOperations []UploadOperation } type buildUploadFileAttributes struct { - AssetType string `json:"assetType,omitempty"` - FileName string `json:"fileName,omitempty"` - FileSize int64 `json:"fileSize,omitempty"` - UTI string `json:"uti,omitempty"` - UploadOperations []UploadOperation `json:"uploadOperations,omitempty"` - AssetDeliveryState *AssetDeliveryState `json:"assetDeliveryState,omitempty"` + AssetType string `json:"assetType,omitempty"` + FileName string `json:"fileName,omitempty"` + FileSize int64 `json:"fileSize,omitempty"` + UTI string `json:"uti,omitempty"` + UploadOperations []UploadOperation `json:"uploadOperations,omitempty"` } // The reference implementation sends no checksum: ASC accepts the upload @@ -155,11 +146,10 @@ type buildUploadFileCommit struct { func toBuildUploadFile(r Resource[buildUploadFileAttributes]) BuildUploadFile { return BuildUploadFile{ - ID: r.ID, - FileName: r.Attributes.FileName, - FileSize: r.Attributes.FileSize, - UploadOperations: r.Attributes.UploadOperations, - AssetDeliveryState: r.Attributes.AssetDeliveryState, + ID: r.ID, + FileName: r.Attributes.FileName, + FileSize: r.Attributes.FileSize, + UploadOperations: r.Attributes.UploadOperations, } } @@ -186,16 +176,6 @@ func (c *Client) CreateBuildUploadFile(ctx context.Context, uploadID, fileName s return &f, nil } -// GetBuildUploadFile fetches the file slot, including its delivery state. -func (c *Client) GetBuildUploadFile(ctx context.Context, id string) (*BuildUploadFile, error) { - r, err := getOne[buildUploadFileAttributes](ctx, c, "/v1/buildUploadFiles/"+id, nil) - if err != nil { - return nil, err - } - f := toBuildUploadFile(*r) - return &f, nil -} - // CommitBuildUploadFile tells App Store Connect every chunk has been sent. func (c *Client) CommitBuildUploadFile(ctx context.Context, fileID string) error { req := Resource[buildUploadFileCommit]{Type: "buildUploadFiles", ID: fileID, Attributes: buildUploadFileCommit{Uploaded: true}} @@ -232,12 +212,8 @@ func (c *Client) uploadChunk(ctx context.Context, file io.ReaderAt, op UploadOpe var lastErr error for attempt := 0; attempt <= c.maxRetries; attempt++ { if attempt > 0 { - timer := time.NewTimer(c.retryDelay << (attempt - 1)) - select { - case <-ctx.Done(): - timer.Stop() - return ctx.Err() - case <-timer.C: + if err := c.sleep(ctx, c.retryDelay<<(attempt-1)); err != nil { + return err } } req, err := http.NewRequestWithContext(ctx, method, op.URL, io.NewSectionReader(file, op.Offset, op.Length)) @@ -253,7 +229,9 @@ func (c *Client) uploadChunk(ctx context.Context, file io.ReaderAt, op UploadOpe lastErr = err continue } - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + // Storage error bodies are short XML; 1 KB keeps the reason without + // echoing a whole presigned request back into the error. + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) resp.Body.Close() if resp.StatusCode < 300 { return nil @@ -280,9 +258,10 @@ type UploadBuildOptions struct { // UploadBuild runs the buildUploads flow end to end: create the delivery, // reserve the file, PUT the chunks and commit. It returns as soon as App // Store Connect has the file; use WaitForBuildUpload to follow processing. -func (c *Client) UploadBuild(ctx context.Context, opts UploadBuildOptions) (*BuildUpload, error) { - if opts.Platform == "" { - opts.Platform = PlatformIOS +func (c *Client) UploadBuild(ctx context.Context, opts *UploadBuildOptions) (*BuildUpload, error) { + platform := opts.Platform + if platform == "" { + platform = PlatformIOS } f, err := os.Open(opts.Path) if err != nil { @@ -293,7 +272,7 @@ func (c *Client) UploadBuild(ctx context.Context, opts UploadBuildOptions) (*Bui if err != nil { return nil, err } - upload, err := c.CreateBuildUpload(ctx, opts.AppID, opts.Version, opts.BuildNumber, opts.Platform) + upload, err := c.CreateBuildUpload(ctx, opts.AppID, opts.Version, opts.BuildNumber, platform) if err != nil { return nil, fmt.Errorf("create build upload: %w", err) } @@ -329,9 +308,31 @@ func (e *UploadFailedError) Error() string { return "App Store Connect rejected the upload: " + strings.Join(msgs, "; ") } +// poller spaces out status polls: the wait starts at the base interval and +// grows by half each time, capped at four times the base, so a long +// processing run costs fewer requests without making short ones sluggish. +type poller struct { + c *Client + next time.Duration + maximum time.Duration +} + +func (c *Client) newPoller(interval time.Duration) *poller { + return &poller{c: c, next: interval, maximum: 4 * interval} +} + +func (p *poller) wait(ctx context.Context) error { + d := p.next + if p.next = p.next * 3 / 2; p.next > p.maximum { + p.next = p.maximum + } + return p.c.sleep(ctx, d) +} + // WaitForBuildUpload polls the delivery until it is COMPLETE, returning an // *UploadFailedError when it FAILED. onPoll, when set, sees every poll result. func (c *Client) WaitForBuildUpload(ctx context.Context, id string, interval time.Duration, onPoll func(*BuildUpload)) (*BuildUpload, error) { + p := c.newPoller(interval) for { u, err := c.GetBuildUpload(ctx, id) if err != nil { @@ -346,7 +347,7 @@ func (c *Client) WaitForBuildUpload(ctx context.Context, id string, interval tim case UploadStateFailed: return u, &UploadFailedError{Upload: u} } - if err := sleep(ctx, interval); err != nil { + if err := p.wait(ctx); err != nil { return u, err } } @@ -355,8 +356,9 @@ func (c *Client) WaitForBuildUpload(ctx context.Context, id string, interval tim // WaitForBuild polls until the build for the version pair exists and has // left PROCESSING. A FAILED or INVALID build is returned with an error. func (c *Client) WaitForBuild(ctx context.Context, appID, version, buildNumber string, interval time.Duration, onPoll func(*Build)) (*Build, error) { + p := c.newPoller(interval) for { - builds, err := c.ListBuilds(ctx, BuildFilter{AppID: appID, Platform: PlatformIOS, Version: version, BuildNumber: buildNumber, Limit: 1}) + builds, err := c.ListBuilds(ctx, &BuildFilter{AppID: appID, Platform: PlatformIOS, Version: version, BuildNumber: buildNumber, Limit: 1}) if err != nil { return nil, err } @@ -374,19 +376,8 @@ func (c *Client) WaitForBuild(ctx context.Context, appID, version, buildNumber s } else if onPoll != nil { onPoll(nil) } - if err := sleep(ctx, interval); err != nil { + if err := p.wait(ctx); err != nil { return nil, err } } } - -func sleep(ctx context.Context, d time.Duration) error { - timer := time.NewTimer(d) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} diff --git a/internal/asc/uploads_test.go b/internal/asc/uploads_test.go index 082e8a8..14fbdec 100644 --- a/internal/asc/uploads_test.go +++ b/internal/asc/uploads_test.go @@ -135,7 +135,7 @@ func TestUploadBuildFlow(t *testing.T) { ctx := context.Background() var progress []int64 - upload, err := c.UploadBuild(ctx, UploadBuildOptions{AppID: "app-1", Version: "1.2.3", BuildNumber: "42", Path: path, Progress: func(sent, total int64) { + upload, err := c.UploadBuild(ctx, &UploadBuildOptions{AppID: "app-1", Version: "1.2.3", BuildNumber: "42", Path: path, Progress: func(sent, total int64) { progress = append(progress, sent) if total != int64(len(data)) { t.Errorf("total = %d", total) @@ -149,19 +149,18 @@ func TestUploadBuildFlow(t *testing.T) { } fake.mu.Lock() - created := fake.created["data"].(map[string]any) - attrs := created["attributes"].(map[string]any) + attrs := obj(t, fake.created, "data", "attributes") if attrs["cfBundleShortVersionString"] != "1.2.3" || attrs["cfBundleVersion"] != "42" || attrs["platform"] != "IOS" { t.Errorf("buildUploads attributes = %v", attrs) } - if created["relationships"].(map[string]any)["app"].(map[string]any)["data"].(map[string]any)["id"] != "app-1" { - t.Errorf("buildUploads relationships = %v", created["relationships"]) + if obj(t, fake.created, "data", "relationships", "app", "data")["id"] != "app-1" { + t.Errorf("buildUploads relationships = %v", fake.created) } - fileAttrs := fake.fileReq["data"].(map[string]any)["attributes"].(map[string]any) + fileAttrs := obj(t, fake.fileReq, "data", "attributes") if fileAttrs["assetType"] != "ASSET" || fileAttrs["fileName"] != "App.ipa" || fileAttrs["fileSize"] != float64(len(data)) || fileAttrs["uti"] != "com.apple.ipa" { t.Errorf("buildUploadFiles attributes = %v", fileAttrs) } - if fake.fileReq["data"].(map[string]any)["relationships"].(map[string]any)["buildUpload"].(map[string]any)["data"].(map[string]any)["id"] != "up-1" { + if obj(t, fake.fileReq, "data", "relationships", "buildUpload", "data")["id"] != "up-1" { t.Errorf("buildUploadFiles relationships = %v", fake.fileReq) } got := append(append([]byte{}, fake.chunks[0]...), fake.chunks[int64(len(data))/2]...) @@ -171,11 +170,11 @@ func TestUploadBuildFlow(t *testing.T) { if fake.headers[0].Get("X-Chunk") != "first" || fake.headers[0].Get("Content-Type") != "application/octet-stream" || fake.headers[int64(len(data))/2].Get("X-Chunk") != "second" { t.Errorf("request headers not honored: %v %v", fake.headers[0], fake.headers[int64(len(data))/2]) } - commit := fake.commit["data"].(map[string]any) - if commit["id"] != "file-1" || commit["attributes"].(map[string]any)["uploaded"] != true { + commit := obj(t, fake.commit, "data") + if commit["id"] != "file-1" || obj(t, commit, "attributes")["uploaded"] != true { t.Errorf("commit body = %v", fake.commit) } - if _, has := commit["attributes"].(map[string]any)["sourceFileChecksums"]; has { + if _, has := obj(t, commit, "attributes")["sourceFileChecksums"]; has { t.Error("checksum must not be sent") } fake.mu.Unlock() @@ -204,7 +203,7 @@ func TestUploadBuildRejected(t *testing.T) { fake.failing = true c := newTestClient(t, fake.srv) ctx := context.Background() - upload, err := c.UploadBuild(ctx, UploadBuildOptions{AppID: "app-1", Version: "1.2.3", BuildNumber: "42", Path: path}) + upload, err := c.UploadBuild(ctx, &UploadBuildOptions{AppID: "app-1", Version: "1.2.3", BuildNumber: "42", Path: path}) if err != nil { t.Fatal(err) } @@ -223,12 +222,12 @@ func TestUploadChunkRetriesOn5xx(t *testing.T) { fake := newFakeASC(t, int64(len(data))) fake.chunk500 = 2 c := newTestClient(t, fake.srv) - if _, err := c.UploadBuild(context.Background(), UploadBuildOptions{AppID: "app-1", Version: "1.0", BuildNumber: "1", Path: path}); err != nil { + if _, err := c.UploadBuild(context.Background(), &UploadBuildOptions{AppID: "app-1", Version: "1.0", BuildNumber: "1", Path: path}); err != nil { t.Fatal(err) } fake.mu.Lock() defer fake.mu.Unlock() - if fake.fileReq["data"].(map[string]any)["attributes"].(map[string]any)["uti"] != "com.apple.pkg" { + if obj(t, fake.fileReq, "data", "attributes")["uti"] != "com.apple.pkg" { t.Error("pkg uti not detected") } if len(fake.chunks[0]) != 50 || len(fake.chunks[50]) != 50 { diff --git a/internal/asc/versions.go b/internal/asc/versions.go index b765ced..63918a8 100644 --- a/internal/asc/versions.go +++ b/internal/asc/versions.go @@ -13,13 +13,19 @@ const ( ReleaseTypeScheduled = "SCHEDULED" ) +// App Store version states (appVersionState) the flows act on; others include +// PREPARE_FOR_SUBMISSION, READY_FOR_REVIEW, REJECTED and READY_FOR_DISTRIBUTION. +const ( + VersionStateWaitingForReview = "WAITING_FOR_REVIEW" + VersionStateInReview = "IN_REVIEW" +) + // AppStoreVersion is a version of the app on the App Store. type AppStoreVersion struct { ID string Platform string VersionString string - // State is appVersionState (e.g. PREPARE_FOR_SUBMISSION, READY_FOR_REVIEW, - // WAITING_FOR_REVIEW, IN_REVIEW, READY_FOR_DISTRIBUTION). + // State is appVersionState. State string AppStoreState string ReleaseType string diff --git a/internal/distribute/appstore.go b/internal/distribute/appstore.go index 24c130e..1fdd274 100644 --- a/internal/distribute/appstore.go +++ b/internal/distribute/appstore.go @@ -42,7 +42,7 @@ type AppStoreResult struct { } // SubmitAppStore attaches a build to the App Store version and submits it for review. -func SubmitAppStore(ctx context.Context, client *asc.Client, opts AppStoreOptions) (*AppStoreResult, error) { +func SubmitAppStore(ctx context.Context, client *asc.Client, opts *AppStoreOptions) (*AppStoreResult, error) { if opts.Version == "" { return nil, fmt.Errorf("a marketing version is required (--version, or --ipa to read it from the archive)") } @@ -81,7 +81,7 @@ func SubmitAppStore(ctx context.Context, client *asc.Client, opts AppStoreOption } res.Version.ID, res.Version.VersionString, res.Version.State, res.Version.ReleaseType = version.ID, version.VersionString, version.State, version.ReleaseType switch version.State { - case asc.ReviewStateWaitingForReview, asc.ReviewStateInReview: + case asc.VersionStateWaitingForReview, asc.VersionStateInReview: return res, fmt.Errorf("version %s is already %s; cancel that submission in App Store Connect before submitting another build", version.VersionString, version.State) } diff --git a/internal/distribute/distribute.go b/internal/distribute/distribute.go index 8403c14..7da0911 100644 --- a/internal/distribute/distribute.go +++ b/internal/distribute/distribute.go @@ -75,7 +75,7 @@ func pollInterval(d time.Duration) time.Duration { // pickBuild returns the newest VALID, unexpired build matching the filters. func pickBuild(ctx context.Context, client *asc.Client, appID, version, buildNumber string) (*asc.Build, error) { - f := asc.BuildFilter{AppID: appID, Platform: asc.PlatformIOS, Version: version, BuildNumber: buildNumber, ProcessingState: asc.ProcessingStateValid, ExcludeExpired: true, Limit: 1} + f := &asc.BuildFilter{AppID: appID, Platform: asc.PlatformIOS, Version: version, BuildNumber: buildNumber, ProcessingState: asc.ProcessingStateValid, ExcludeExpired: true, Limit: 1} builds, err := client.ListBuilds(ctx, f) if err != nil { return nil, err @@ -85,7 +85,7 @@ func pickBuild(ctx context.Context, client *asc.Client, appID, version, buildNum } // Explain why rather than just "not found": the build may still be processing. f.ProcessingState, f.ExcludeExpired = "", false - any, err := client.ListBuilds(ctx, f) + matches, err := client.ListBuilds(ctx, f) if err != nil { return nil, err } @@ -96,10 +96,10 @@ func pickBuild(ctx context.Context, client *asc.Client, appID, version, buildNum if version != "" { what += " of version " + version } - if len(any) == 0 { + if len(matches) == 0 { return nil, fmt.Errorf("%s is available in App Store Connect; upload one with builder ios upload --wait", what) } - b := any[0] + b := matches[0] if b.Expired { return nil, fmt.Errorf("%s (%s) has expired; upload a new build", what, b.ID) } diff --git a/internal/distribute/distribute_test.go b/internal/distribute/distribute_test.go index b7f42fb..8b1597e 100644 --- a/internal/distribute/distribute_test.go +++ b/internal/distribute/distribute_test.go @@ -129,7 +129,7 @@ func newFake(t *testing.T) *fake { one(w, 201, res("buildUploads", "up-1", map[string]any{"state": map[string]any{"state": "AWAITING_UPLOAD"}}, nil)) })) mux.HandleFunc("POST /v1/buildUploadFiles", wrap(func(w http.ResponseWriter, r *http.Request) { - size := f.bodies["POST /v1/buildUploadFiles"]["data"].(map[string]any)["attributes"].(map[string]any)["fileSize"].(float64) + size, _ := obj(f.t, f.bodies["POST /v1/buildUploadFiles"], "data", "attributes")["fileSize"].(float64) one(w, 201, res("buildUploadFiles", "file-1", map[string]any{"uploadOperations": []map[string]any{{"method": "PUT", "url": f.srv.URL + "/chunk", "offset": 0, "length": int64(size), "requestHeaders": []map[string]string{{"name": "X-Test", "value": "1"}}}}}, nil)) })) mux.HandleFunc("PUT /chunk", wrap(func(w http.ResponseWriter, r *http.Request) { @@ -154,7 +154,7 @@ func newFake(t *testing.T) *fake { many(w, build()) })) mux.HandleFunc("PATCH /v1/builds/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { - v := f.bodies["PATCH /v1/builds/build-9"]["data"].(map[string]any)["attributes"].(map[string]any)["usesNonExemptEncryption"].(bool) + v, _ := obj(f.t, f.bodies["PATCH /v1/builds/build-9"], "data", "attributes")["usesNonExemptEncryption"].(bool) f.buildEncryption = &v one(w, 200, build()) })) @@ -202,7 +202,7 @@ func newFake(t *testing.T) *fake { mux.HandleFunc("POST /v1/appStoreVersions", wrap(func(w http.ResponseWriter, r *http.Request) { f.versionExists = true; one(w, 201, version()) })) mux.HandleFunc("PATCH /v1/appStoreVersions/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { v := version() - v["attributes"].(map[string]any)["releaseType"] = "AFTER_APPROVAL" + obj(f.t, v, "attributes")["releaseType"] = "AFTER_APPROVAL" v["relationships"] = map[string]any{"build": map[string]any{"data": map[string]string{"type": "builds", "id": "build-9"}}} one(w, 200, v) })) @@ -251,6 +251,33 @@ func writeJSON(w http.ResponseWriter, status int, v any) { _ = json.NewEncoder(w).Encode(v) } +// obj walks decoded JSON down the given object keys; a missing or non-object +// step fails the test and yields nil, which later lookups tolerate. +func obj(t *testing.T, v any, keys ...string) map[string]any { + t.Helper() + for i := 0; ; i++ { + m, ok := v.(map[string]any) + if !ok { + t.Errorf("JSON path %v: %T is not an object", keys[:i], v) + return nil + } + if i == len(keys) { + return m + } + v = m[keys[i]] + } +} + +// arr is obj for a final array value. +func arr(t *testing.T, v any, keys ...string) []any { + t.Helper() + a, ok := obj(t, v, keys[:len(keys)-1]...)[keys[len(keys)-1]].([]any) + if !ok { + t.Errorf("JSON path %v is not an array", keys) + } + return a +} + func (f *fake) client(t *testing.T) *asc.Client { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -286,7 +313,7 @@ func (f *fake) body(key string) map[string]any { func TestUploadWithWaitSetsCompliance(t *testing.T) { f := newFake(t) var log bytes.Buffer - res, err := Upload(context.Background(), f.client(t), UploadOptions{IPAPath: writeIPA(t, plistExempt), Wait: true, PollInterval: time.Millisecond, Log: &log}) + res, err := Upload(context.Background(), f.client(t), &UploadOptions{IPAPath: writeIPA(t, plistExempt), Wait: true, PollInterval: time.Millisecond, Log: &log}) if err != nil { t.Fatalf("%v\n%s", err, log.String()) } @@ -307,7 +334,7 @@ func TestUploadWithWaitSetsCompliance(t *testing.T) { t.Errorf("%s not called; calls = %v", key, f.calls) } } - attrs := f.body("POST /v1/buildUploads")["data"].(map[string]any)["attributes"].(map[string]any) + attrs := obj(t, f.body("POST /v1/buildUploads"), "data", "attributes") if attrs["cfBundleShortVersionString"] != "2.0.0" || attrs["cfBundleVersion"] != "7" || attrs["platform"] != "IOS" { t.Errorf("upload attributes = %v", attrs) } @@ -315,7 +342,7 @@ func TestUploadWithWaitSetsCompliance(t *testing.T) { func TestUploadWithoutWaitLeavesComplianceForLater(t *testing.T) { f := newFake(t) - res, err := Upload(context.Background(), f.client(t), UploadOptions{IPAPath: writeIPA(t, plistUndeclared), NoEncryption: true}) + res, err := Upload(context.Background(), f.client(t), &UploadOptions{IPAPath: writeIPA(t, plistUndeclared), NoEncryption: true}) if err != nil { t.Fatal(err) } @@ -329,7 +356,7 @@ func TestUploadWithoutWaitLeavesComplianceForLater(t *testing.T) { func TestUploadUndeclaredEncryptionStaysPending(t *testing.T) { f := newFake(t) - res, err := Upload(context.Background(), f.client(t), UploadOptions{IPAPath: writeIPA(t, plistUndeclared), Wait: true, PollInterval: time.Millisecond}) + res, err := Upload(context.Background(), f.client(t), &UploadOptions{IPAPath: writeIPA(t, plistUndeclared), Wait: true, PollInterval: time.Millisecond}) if err != nil { t.Fatal(err) } @@ -340,7 +367,7 @@ func TestUploadUndeclaredEncryptionStaysPending(t *testing.T) { func TestUploadUnknownApp(t *testing.T) { f := newFake(t) - _, err := Upload(context.Background(), f.client(t), UploadOptions{IPAPath: writeIPA(t, strings.ReplaceAll(plistExempt, "com.example.app", "com.other"))}) + _, err := Upload(context.Background(), f.client(t), &UploadOptions{IPAPath: writeIPA(t, strings.ReplaceAll(plistExempt, "com.example.app", "com.other"))}) if err == nil || !strings.Contains(err.Error(), "com.other") { t.Errorf("err = %v", err) } diff --git a/internal/distribute/submit_test.go b/internal/distribute/submit_test.go index c554181..2a4f740 100644 --- a/internal/distribute/submit_test.go +++ b/internal/distribute/submit_test.go @@ -14,7 +14,7 @@ import ( func TestSubmitTestFlightExternalGroup(t *testing.T) { f := newFake(t) var log bytes.Buffer - res, err := SubmitTestFlight(context.Background(), f.client(t), TestFlightOptions{ + res, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{ BundleID: "com.example.app", Groups: []string{"team", "Beta Testers"}, Notes: "Try the new login", NoEncryption: true, Wait: true, PollInterval: time.Millisecond, Log: &log, }) if err != nil { @@ -27,12 +27,12 @@ func TestSubmitTestFlightExternalGroup(t *testing.T) { t.Errorf("beta review = %+v", res.BetaReview) } // Notes go to the app's primary locale, which has no localization yet, so it is created. - notes := f.body("POST /v1/betaBuildLocalizations")["data"].(map[string]any) - if notes["attributes"].(map[string]any)["locale"] != "de-DE" || notes["attributes"].(map[string]any)["whatsNew"] != "Try the new login" { + notes := obj(t, f.body("POST /v1/betaBuildLocalizations"), "data", "attributes") + if notes["locale"] != "de-DE" || notes["whatsNew"] != "Try the new login" { t.Errorf("localization body = %v", notes) } - links := f.body("POST /v1/builds/build-9/relationships/betaGroups")["data"].([]any) - if len(links) != 2 || links[1].(map[string]any)["id"] != "g-ext" { + links := arr(t, f.body("POST /v1/builds/build-9/relationships/betaGroups"), "data") + if len(links) != 2 || obj(t, links[1])["id"] != "g-ext" { t.Errorf("group linkage = %v", links) } // Review must be requested before the build lands in the external group. @@ -54,7 +54,7 @@ func TestSubmitTestFlightUpdatesExistingNotesAndSkipsReviewForInternal(t *testin f := newFake(t) yes := false f.buildEncryption = &yes - res, err := SubmitTestFlight(context.Background(), f.client(t), TestFlightOptions{BundleID: "com.example.app", BuildNumber: "7", Groups: []string{"Team"}, Notes: "n", Locale: "en-US"}) + res, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app", BuildNumber: "7", Groups: []string{"Team"}, Notes: "n", Locale: "en-US"}) if err != nil { t.Fatal(err) } @@ -68,7 +68,7 @@ func TestSubmitTestFlightUpdatesExistingNotesAndSkipsReviewForInternal(t *testin func TestSubmitTestFlightListsGroupsWithoutGroupFlag(t *testing.T) { f := newFake(t) - res, err := SubmitTestFlight(context.Background(), f.client(t), TestFlightOptions{BundleID: "com.example.app"}) + res, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app"}) if err != nil { t.Fatal(err) } @@ -80,19 +80,19 @@ func TestSubmitTestFlightListsGroupsWithoutGroupFlag(t *testing.T) { func TestSubmitTestFlightErrors(t *testing.T) { f := newFake(t) c := f.client(t) - _, err := SubmitTestFlight(context.Background(), c, TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Nobody"}, NoEncryption: true}) + _, err := SubmitTestFlight(context.Background(), c, &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Nobody"}, NoEncryption: true}) if err == nil || !strings.Contains(err.Error(), "Nobody") || !strings.Contains(err.Error(), "Beta Testers") { t.Errorf("unknown group: %v", err) } f.mu.Lock() f.buildEncryption = nil // the call above answered it f.mu.Unlock() - _, err = SubmitTestFlight(context.Background(), c, TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Team"}}) + _, err = SubmitTestFlight(context.Background(), c, &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Team"}}) if err == nil || !strings.Contains(err.Error(), "export compliance") { t.Errorf("missing compliance: %v", err) } f.buildState = "PROCESSING" - _, err = SubmitTestFlight(context.Background(), c, TestFlightOptions{BundleID: "com.example.app", BuildNumber: "7"}) + _, err = SubmitTestFlight(context.Background(), c, &TestFlightOptions{BundleID: "com.example.app", BuildNumber: "7"}) if err == nil || !strings.Contains(err.Error(), "PROCESSING") { t.Errorf("processing build: %v", err) } @@ -101,27 +101,27 @@ func TestSubmitTestFlightErrors(t *testing.T) { func TestSubmitAppStoreCreatesVersionAndSubmission(t *testing.T) { f := newFake(t) var log bytes.Buffer - res, err := SubmitAppStore(context.Background(), f.client(t), AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0", ReleaseType: asc.ReleaseTypeAfterApproval, NoEncryption: true, Log: &log}) + res, err := SubmitAppStore(context.Background(), f.client(t), &AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0", ReleaseType: asc.ReleaseTypeAfterApproval, NoEncryption: true, Log: &log}) if err != nil { t.Fatalf("%v\n%s", err, log.String()) } if !res.Version.Created || res.Version.ID != "ver-1" || res.Version.ReleaseType != "AFTER_APPROVAL" || res.Submission.ID != "rs-1" || res.Submission.State != "WAITING_FOR_REVIEW" { t.Errorf("result = %+v", res) } - create := f.body("POST /v1/appStoreVersions")["data"].(map[string]any) - if create["attributes"].(map[string]any)["versionString"] != "2.0.0" || create["attributes"].(map[string]any)["platform"] != "IOS" || create["relationships"].(map[string]any)["app"].(map[string]any)["data"].(map[string]any)["id"] != "app-1" { + create := obj(t, f.body("POST /v1/appStoreVersions"), "data") + if attrs := obj(t, create, "attributes"); attrs["versionString"] != "2.0.0" || attrs["platform"] != "IOS" || obj(t, create, "relationships", "app", "data")["id"] != "app-1" { t.Errorf("version create = %v", create) } - upd := f.body("PATCH /v1/appStoreVersions/ver-1")["data"].(map[string]any) - if upd["attributes"].(map[string]any)["releaseType"] != "AFTER_APPROVAL" || upd["relationships"].(map[string]any)["build"].(map[string]any)["data"].(map[string]any)["id"] != "build-9" { + upd := obj(t, f.body("PATCH /v1/appStoreVersions/ver-1"), "data") + if obj(t, upd, "attributes")["releaseType"] != "AFTER_APPROVAL" || obj(t, upd, "relationships", "build", "data")["id"] != "build-9" { t.Errorf("version update = %v", upd) } - item := f.body("POST /v1/reviewSubmissionItems")["data"].(map[string]any)["relationships"].(map[string]any) - if item["reviewSubmission"].(map[string]any)["data"].(map[string]any)["id"] != "rs-1" || item["appStoreVersion"].(map[string]any)["data"].(map[string]any)["id"] != "ver-1" { + item := obj(t, f.body("POST /v1/reviewSubmissionItems"), "data", "relationships") + if obj(t, item, "reviewSubmission", "data")["id"] != "rs-1" || obj(t, item, "appStoreVersion", "data")["id"] != "ver-1" { t.Errorf("item = %v", item) } - submit := f.body("PATCH /v1/reviewSubmissions/rs-1")["data"].(map[string]any) - if submit["attributes"].(map[string]any)["submitted"] != true { + submit := f.body("PATCH /v1/reviewSubmissions/rs-1") + if obj(t, submit, "data", "attributes")["submitted"] != true { t.Errorf("submit = %v", submit) } } @@ -131,7 +131,7 @@ func TestSubmitAppStoreReusesOpenSubmission(t *testing.T) { f.versionExists, f.openSubmission = true, true yes := true f.buildEncryption = &yes - res, err := SubmitAppStore(context.Background(), f.client(t), AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0"}) + res, err := SubmitAppStore(context.Background(), f.client(t), &AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0"}) if err != nil { t.Fatal(err) } @@ -146,7 +146,7 @@ func TestSubmitAppStoreReusesOpenSubmission(t *testing.T) { func TestSubmitAppStoreMetadataConflict(t *testing.T) { f := newFake(t) f.submitStatus = 409 - _, err := SubmitAppStore(context.Background(), f.client(t), AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0", NoEncryption: true}) + _, err := SubmitAppStore(context.Background(), f.client(t), &AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0", NoEncryption: true}) var apiErr *asc.Error if !errors.As(err, &apiErr) || apiErr.StatusCode != 409 { t.Fatalf("err = %v", err) @@ -165,11 +165,11 @@ func TestSubmitAppStoreMetadataConflict(t *testing.T) { func TestSubmitAppStoreRefusesVersionInReview(t *testing.T) { f := newFake(t) f.versionExists, f.versionState = true, "IN_REVIEW" - _, err := SubmitAppStore(context.Background(), f.client(t), AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0", NoEncryption: true}) + _, err := SubmitAppStore(context.Background(), f.client(t), &AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0", NoEncryption: true}) if err == nil || !strings.Contains(err.Error(), "IN_REVIEW") { t.Errorf("err = %v", err) } - if _, err := SubmitAppStore(context.Background(), f.client(t), AppStoreOptions{BundleID: "com.example.app"}); err == nil { + if _, err := SubmitAppStore(context.Background(), f.client(t), &AppStoreOptions{BundleID: "com.example.app"}); err == nil { t.Error("missing version accepted") } } diff --git a/internal/distribute/testflight.go b/internal/distribute/testflight.go index bff9c87..a43cfe8 100644 --- a/internal/distribute/testflight.go +++ b/internal/distribute/testflight.go @@ -57,7 +57,7 @@ type TestFlightResult struct { } // SubmitTestFlight hands a processed build to TestFlight groups. -func SubmitTestFlight(ctx context.Context, client *asc.Client, opts TestFlightOptions) (*TestFlightResult, error) { +func SubmitTestFlight(ctx context.Context, client *asc.Client, opts *TestFlightOptions) (*TestFlightResult, error) { app, err := client.AppByBundleID(ctx, opts.BundleID) if err != nil { return nil, err @@ -164,25 +164,16 @@ func SubmitTestFlight(ctx context.Context, client *asc.Client, opts TestFlightOp logf(opts.Log, "Added build %s to %s", build.BuildNumber, strings.Join(opts.Groups, ", ")) if opts.Wait && res.BetaReview != nil { - interval := pollInterval(opts.PollInterval) - for res.BetaReview.State == asc.BetaReviewWaiting || res.BetaReview.State == asc.BetaReviewInReview || res.BetaReview.State == "" { - timer := time.NewTimer(interval) - select { - case <-ctx.Done(): - timer.Stop() - return res, ctx.Err() - case <-timer.C: + review, err := client.WaitForBetaAppReview(ctx, res.BetaReview.ID, pollInterval(opts.PollInterval), func(r *asc.BetaAppReviewSubmission) { + if r.State != res.BetaReview.State { + logf(opts.Log, " beta review: %s", r.State) } - review, err := client.GetBetaAppReviewSubmission(ctx, res.BetaReview.ID) - if err != nil { - return res, err - } - if review.State != res.BetaReview.State { - logf(opts.Log, " beta review: %s", review.State) - } - res.BetaReview.State = review.State + res.BetaReview.State = r.State + }) + if err != nil { + return res, fmt.Errorf("wait for beta review: %w", err) } - if res.BetaReview.State == asc.BetaReviewRejected { + if review.State == asc.BetaReviewRejected { return res, fmt.Errorf("beta review rejected build %s; see the resolution center in App Store Connect", build.BuildNumber) } } diff --git a/internal/distribute/upload.go b/internal/distribute/upload.go index 9a3929e..a608497 100644 --- a/internal/distribute/upload.go +++ b/internal/distribute/upload.go @@ -54,7 +54,7 @@ type UploadResult struct { // Upload delivers the IPA to App Store Connect and, with Wait, follows it // until the build is VALID and its export compliance is answered. -func Upload(ctx context.Context, client *asc.Client, opts UploadOptions) (*UploadResult, error) { +func Upload(ctx context.Context, client *asc.Client, opts *UploadOptions) (*UploadResult, error) { info, err := ipa.ReadInfo(opts.IPAPath) if err != nil { return nil, err @@ -76,7 +76,7 @@ func Upload(ctx context.Context, client *asc.Client, opts UploadOptions) (*Uploa logf(opts.Log, "Uploading %s (%s build %s) to %s...", opts.IPAPath, info.Version, info.BuildNumber, app.Name) var lastPercent int64 = -1 - upload, err := client.UploadBuild(ctx, asc.UploadBuildOptions{ + upload, err := client.UploadBuild(ctx, &asc.UploadBuildOptions{ AppID: app.ID, Version: info.Version, BuildNumber: info.BuildNumber, Platform: asc.PlatformIOS, Path: opts.IPAPath, Progress: func(sent, total int64) { if total == 0 { From 23b9fd74f3a9ce952870436b70c8a1ebfb14b2a1 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:30:36 +0200 Subject: [PATCH 21/75] 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. --- cmd/builder/auth.go | 2 +- cmd/builder/submit.go | 8 ++++---- cmd/builder/upload.go | 11 ++++++----- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/cmd/builder/auth.go b/cmd/builder/auth.go index 58832cc..011c04e 100644 --- a/cmd/builder/auth.go +++ b/cmd/builder/auth.go @@ -176,7 +176,7 @@ func runAuthApple(cmd *cobra.Command, _ []string) error { ctx = context.Background() } if err := client.CheckAccess(ctx); err != nil { - return fmt.Errorf("App Store Connect rejected the key: %w", err) + return fmt.Errorf("the key was rejected by App Store Connect: %w", err) } if err := auth.StoreAppleCredentials(creds); err != nil { return err diff --git a/cmd/builder/submit.go b/cmd/builder/submit.go index 95b0c82..c3e664a 100644 --- a/cmd/builder/submit.go +++ b/cmd/builder/submit.go @@ -85,11 +85,11 @@ func runIOSSubmit(cmd *cobra.Command, _ []string) error { groups, _ := cmd.Flags().GetStringArray("group") notes, _ := cmd.Flags().GetString("notes") locale, _ := cmd.Flags().GetString("locale") - res, err := distribute.SubmitTestFlight(ctx, client, distribute.TestFlightOptions{ + res, err := distribute.SubmitTestFlight(ctx, client, &distribute.TestFlightOptions{ BundleID: bundleID, Version: version, BuildNumber: buildNumber, Groups: groups, Notes: notes, Locale: locale, NoEncryption: noEncryption, Wait: wait, Log: out.log, }) - return out.finish(cmd, res, err, func() { + return finish(out, cmd, res, err, func() { fmt.Println() fmt.Printf("Build ID: %s (build %s)\n", res.Build.ID, res.Build.BuildNumber) if res.BetaReview != nil { @@ -104,10 +104,10 @@ func runIOSSubmit(cmd *cobra.Command, _ []string) error { if err != nil { return err } - res, err := distribute.SubmitAppStore(ctx, client, distribute.AppStoreOptions{ + res, err := distribute.SubmitAppStore(ctx, client, &distribute.AppStoreOptions{ BundleID: bundleID, Version: version, BuildNumber: buildNumber, ReleaseType: releaseType, NoEncryption: noEncryption, Log: out.log, }) - return out.finish(cmd, res, err, func() { + return finish(out, cmd, res, err, func() { fmt.Println() fmt.Printf("Version: %s (%s)\n", res.Version.VersionString, res.Version.State) fmt.Printf("Build ID: %s (build %s)\n", res.Build.ID, res.Build.BuildNumber) diff --git a/cmd/builder/upload.go b/cmd/builder/upload.go index 09b13d1..88220f9 100644 --- a/cmd/builder/upload.go +++ b/cmd/builder/upload.go @@ -51,7 +51,7 @@ func getASCClient() (*asc.Client, error) { creds, _, err := auth.GetAppleCredentials() if err != nil { if errors.Is(err, auth.ErrNotAuthenticated) { - return nil, fmt.Errorf("not authenticated with App Store Connect. Run: builder auth apple") + return nil, fmt.Errorf("no App Store Connect API key configured. Run: builder auth apple (or set ASC_ISSUER_ID, ASC_KEY_ID and ASC_PRIVATE_KEY or ASC_KEY_PATH)") } return nil, err } @@ -96,8 +96,9 @@ func newOutput(cmd *cobra.Command) output { } // finish prints the result (JSON, or the human summary on success) and -// returns err with a timeout translated into something actionable. -func (o output) finish(cmd *cobra.Command, result any, err error, human func()) error { +// returns err with a timeout translated into something actionable. A partial +// result on failure is still printed as JSON so agents see how far it got. +func finish[T any](o output, cmd *cobra.Command, result *T, err error, human func()) error { if o.json && result != nil { enc := json.NewEncoder(cmd.OutOrStdout()) enc.SetIndent("", " ") @@ -130,8 +131,8 @@ func runIOSUpload(cmd *cobra.Command, _ []string) error { defer cancel() out := newOutput(cmd) - res, err := distribute.Upload(ctx, client, distribute.UploadOptions{IPAPath: ipaPath, Wait: wait, NoEncryption: noEncryption, Log: out.log}) - return out.finish(cmd, res, err, func() { + res, err := distribute.Upload(ctx, client, &distribute.UploadOptions{IPAPath: ipaPath, Wait: wait, NoEncryption: noEncryption, Log: out.log}) + return finish(out, cmd, res, err, func() { fmt.Println() fmt.Printf("Upload ID: %s (%s)\n", res.Upload.ID, res.Upload.State) if res.Build != nil { From 7aa74d65d80d51d7f7e2089c12c162a78b8e61af Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:30:36 +0200 Subject: [PATCH 22/75] docs: Release configuration prerequisite, drop roadmap item numbers --- CLAUDE.md | 16 +++++++++------- README.md | 3 +++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 83f70a9..d36f8d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,10 +201,12 @@ internal/ - **ASC Client** (`internal/asc`): runs locally, never on the runner. Auth is an ES256 JWT (15 min, cached, refreshed a minute early) signed with the `.p8` key. JSON:API plumbing is generic (`Document`/`Resource[A]`, `getOne`/`getAll`/`post`/`patch`); typed helpers exist only - for what the commands use, so item 2 (bundle IDs, certificates, profiles, devices) adds files in - the same package without restructuring. `getAll` follows `links.next`; 429 retries on every - method, 5xx only on idempotent ones (a failed POST may have created the resource). `*asc.Error` - carries the ASC `errors[]` and renders on one line. + for what the commands use, so the signing resources (bundle IDs, certificates, profiles, + devices) add files in the same package without restructuring. `getAll` follows `links.next`; + 429 retries on every method, 5xx only on idempotent ones (a failed POST may have created the + resource). All waits go through `Client.sleep`, which tests replace, so retry and poll tests + run instantly; status polls (`poller`) grow 1.5× per round up to 4× the base interval. + `*asc.Error` carries the ASC `errors[]` and renders on one line. - **ASC Credentials**: one JSON secret (`apple-asc-key`) in the keyring/file store, via the shared `readSecret`/`writeSecret`/`deleteSecret` helpers the CI tokens use. `ASC_ISSUER_ID`, `ASC_KEY_ID` + `ASC_PRIVATE_KEY`|`ASC_KEY_PATH` take precedence; a partially set environment is @@ -222,9 +224,9 @@ internal/ chosen group is external and none exists) → add groups. App Store reuses an open `reviewSubmission` (READY_FOR_REVIEW/UNRESOLVED_ISSUES), skips the item when the version is already in it, and rewrites ASC 409/422 with a "complete the metadata" hint. -- **Extension Points**: item 5 (`ios release`, auto build numbers) composes `distribute.Upload` - and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` for the latest build number; - the `pkg/` wrappers do not expose `asc` yet. +- **Extension Points**: a future `ios release` (upload + TestFlight, automatic build numbers) + composes `distribute.Upload` and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` + for the latest build number; the `pkg/` wrappers do not expose `asc` yet. ## Configuration diff --git a/README.md b/README.md index 8c36b0f..b11157a 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,9 @@ You need: provisioning profile. `builder signing setup` accepts both, exactly as in the steps above; pick those types on the portal instead of the development ones. An IPA signed for development is rejected at upload. +- `"configuration": "Release"` under `ios` in `builder.json`: `ios build` + defaults to `Debug`, which is what the dev commands expect, not what you want + to ship. - An App Store Connect API key: App Store Connect → Users and Access → Integrations → App Store Connect API → Team Keys. Give it the **App Manager** role, note the **Issuer ID** and **Key ID**, and download the From 5c4308549c7d85ac68c027a40ed405d6d41ba648 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:40:12 +0200 Subject: [PATCH 23/75] 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. --- internal/asc/bundleids.go | 54 +++++++++++ internal/asc/bundleids_test.go | 63 +++++++++++++ internal/asc/certificates.go | 96 ++++++++++++++++++++ internal/asc/certificates_test.go | 85 ++++++++++++++++++ internal/asc/devices.go | 63 +++++++++++++ internal/asc/devices_test.go | 52 +++++++++++ internal/asc/profiles.go | 144 ++++++++++++++++++++++++++++++ internal/asc/profiles_test.go | 127 ++++++++++++++++++++++++++ 8 files changed, 684 insertions(+) create mode 100644 internal/asc/bundleids.go create mode 100644 internal/asc/bundleids_test.go create mode 100644 internal/asc/certificates.go create mode 100644 internal/asc/certificates_test.go create mode 100644 internal/asc/devices.go create mode 100644 internal/asc/devices_test.go create mode 100644 internal/asc/profiles.go create mode 100644 internal/asc/profiles_test.go diff --git a/internal/asc/bundleids.go b/internal/asc/bundleids.go new file mode 100644 index 0000000..e681924 --- /dev/null +++ b/internal/asc/bundleids.go @@ -0,0 +1,54 @@ +package asc + +import ( + "context" + "net/url" +) + +// BundleID is a registered App ID (Certificates, Identifiers & Profiles → Identifiers). +type BundleID struct { + ID string + Identifier string + Name string + Platform string + SeedID string +} + +type bundleIDAttributes struct { + Identifier string `json:"identifier,omitempty"` + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + SeedID string `json:"seedId,omitempty"` +} + +func toBundleID(r Resource[bundleIDAttributes]) BundleID { + return BundleID{ID: r.ID, Identifier: r.Attributes.Identifier, Name: r.Attributes.Name, Platform: r.Attributes.Platform, SeedID: r.Attributes.SeedID} +} + +// BundleIDByIdentifier finds the App ID registered for an exact bundle +// identifier, or returns nil when none is. +func (c *Client) BundleIDByIdentifier(ctx context.Context, identifier string) (*BundleID, error) { + rs, err := getAll[bundleIDAttributes](ctx, c, "/v1/bundleIds", url.Values{"filter[identifier]": {identifier}}) + if err != nil { + return nil, err + } + for _, r := range rs { + // The filter also matches wildcard and prefixed identifiers. + if r.Attributes.Identifier == identifier { + b := toBundleID(r) + return &b, nil + } + } + return nil, nil +} + +// CreateBundleID registers an App ID. platform is PlatformIOS for iOS apps. +func (c *Client) CreateBundleID(ctx context.Context, identifier, name, platform string) (*BundleID, error) { + req := Resource[bundleIDAttributes]{Type: "bundleIds", Attributes: bundleIDAttributes{Identifier: identifier, Name: name, Platform: platform}} + r, err := post[bundleIDAttributes, bundleIDAttributes](ctx, c, "/v1/bundleIds", req) + if err != nil { + return nil, err + } + b := toBundleID(*r) + return &b, nil +} diff --git a/internal/asc/bundleids_test.go b/internal/asc/bundleids_test.go new file mode 100644 index 0000000..e9b7f51 --- /dev/null +++ b/internal/asc/bundleids_test.go @@ -0,0 +1,63 @@ +package asc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestBundleIDByIdentifierSkipsPrefixMatches(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/bundleIds" || r.URL.Query().Get("filter[identifier]") != "com.example.app" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{ + {"type": "bundleIds", "id": "bid-2", "attributes": map[string]any{"identifier": "com.example.app.watch", "name": "Watch", "platform": "IOS"}}, + {"type": "bundleIds", "id": "bid-1", "attributes": map[string]any{"identifier": "com.example.app", "name": "Example", "platform": "IOS", "seedId": "TEAM1"}}, + }}) + })) + defer srv.Close() + b, err := newTestClient(t, srv).BundleIDByIdentifier(context.Background(), "com.example.app") + if err != nil { + t.Fatal(err) + } + if b == nil || b.ID != "bid-1" || b.Name != "Example" || b.SeedID != "TEAM1" || b.Platform != PlatformIOS { + t.Errorf("bundle ID = %+v", b) + } +} + +func TestBundleIDByIdentifierAbsent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"data": []any{}}) + })) + defer srv.Close() + b, err := newTestClient(t, srv).BundleIDByIdentifier(context.Background(), "com.missing") + if err != nil || b != nil { + t.Errorf("bundle ID = %+v, err = %v", b, err) + } +} + +func TestCreateBundleID(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/bundleIds" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + _ = json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "bundleIds", "id": "bid-9", "attributes": map[string]any{"identifier": "com.example.app", "name": "com example app", "platform": "IOS"}}}) + })) + defer srv.Close() + b, err := newTestClient(t, srv).CreateBundleID(context.Background(), "com.example.app", "com example app", PlatformIOS) + if err != nil { + t.Fatal(err) + } + if b.ID != "bid-9" || b.Identifier != "com.example.app" { + t.Errorf("bundle ID = %+v", b) + } + attrs := obj(t, body, "data", "attributes") + if obj(t, body, "data")["type"] != "bundleIds" || attrs["identifier"] != "com.example.app" || attrs["name"] != "com example app" || attrs["platform"] != "IOS" { + t.Errorf("POST body = %v", body) + } +} diff --git a/internal/asc/certificates.go b/internal/asc/certificates.go new file mode 100644 index 0000000..e4acc56 --- /dev/null +++ b/internal/asc/certificates.go @@ -0,0 +1,96 @@ +package asc + +import ( + "context" + "encoding/base64" + "fmt" + "net/url" + "time" +) + +// Certificate types Builder issues. DEVELOPMENT is "Apple Development", +// DISTRIBUTION is "Apple Distribution"; both sign iOS apps (the older +// IOS_DEVELOPMENT / IOS_DISTRIBUTION types are iOS-only variants). +const ( + CertificateTypeDevelopment = "DEVELOPMENT" + CertificateTypeDistribution = "DISTRIBUTION" +) + +// Certificate is a signing certificate issued to the team. +type Certificate struct { + ID string + Name string + DisplayName string + SerialNumber string + Type string + Platform string + ExpirationDate time.Time + // Content is the certificate in DER form, as the portal's .cer download. + Content []byte +} + +type certificateAttributes struct { + CertificateContent string `json:"certificateContent,omitempty"` + DisplayName string `json:"displayName,omitempty"` + ExpirationDate *time.Time `json:"expirationDate,omitempty"` + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + SerialNumber string `json:"serialNumber,omitempty"` + CertificateType string `json:"certificateType,omitempty"` + CSRContent string `json:"csrContent,omitempty"` +} + +func toCertificate(r Resource[certificateAttributes]) (Certificate, error) { + c := Certificate{ + ID: r.ID, + Name: r.Attributes.Name, + DisplayName: r.Attributes.DisplayName, + SerialNumber: r.Attributes.SerialNumber, + Type: r.Attributes.CertificateType, + Platform: r.Attributes.Platform, + } + if r.Attributes.ExpirationDate != nil { + c.ExpirationDate = *r.Attributes.ExpirationDate + } + if r.Attributes.CertificateContent != "" { + der, err := base64.StdEncoding.DecodeString(r.Attributes.CertificateContent) + if err != nil { + return c, fmt.Errorf("certificate %s: decode certificateContent: %w", r.ID, err) + } + c.Content = der + } + return c, nil +} + +// ListCertificates lists the team's certificates of one type +// (CertificateTypeDevelopment or CertificateTypeDistribution). +func (c *Client) ListCertificates(ctx context.Context, certificateType string) ([]Certificate, error) { + rs, err := getAll[certificateAttributes](ctx, c, "/v1/certificates", url.Values{"filter[certificateType]": {certificateType}}) + if err != nil { + return nil, err + } + certs := make([]Certificate, 0, len(rs)) + for _, r := range rs { + cert, err := toCertificate(r) + if err != nil { + return nil, err + } + certs = append(certs, cert) + } + return certs, nil +} + +// CreateCertificate has Apple issue a certificate for a PEM-encoded signing +// request (as written by signing.GenerateKeyAndCSR). +func (c *Client) CreateCertificate(ctx context.Context, certificateType string, csrPEM []byte) (*Certificate, error) { + req := Resource[certificateAttributes]{Type: "certificates", Attributes: certificateAttributes{CertificateType: certificateType, CSRContent: string(csrPEM)}} + r, err := post[certificateAttributes, certificateAttributes](ctx, c, "/v1/certificates", req) + if err != nil { + return nil, err + } + cert, err := toCertificate(*r) + if err != nil { + return nil, err + } + return &cert, nil +} diff --git a/internal/asc/certificates_test.go b/internal/asc/certificates_test.go new file mode 100644 index 0000000..3fbb9c0 --- /dev/null +++ b/internal/asc/certificates_test.go @@ -0,0 +1,85 @@ +package asc + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestListCertificatesDecodesContent(t *testing.T) { + der := []byte{0x30, 0x03, 0x02, 0x01, 0x01} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/certificates" || r.URL.Query().Get("filter[certificateType]") != "DEVELOPMENT" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{{ + "type": "certificates", "id": "cert-1", + "attributes": map[string]any{ + "certificateContent": base64.StdEncoding.EncodeToString(der), + "displayName": "Jane Doe", + "name": "Apple Development: Jane Doe (ABC123)", + "serialNumber": "1A2B3C", + "certificateType": "DEVELOPMENT", + "platform": "IOS", + "expirationDate": "2027-09-16T10:00:00.000+00:00", + }, + }}}) + })) + defer srv.Close() + certs, err := newTestClient(t, srv).ListCertificates(context.Background(), CertificateTypeDevelopment) + if err != nil { + t.Fatal(err) + } + if len(certs) != 1 { + t.Fatalf("certs = %+v", certs) + } + c := certs[0] + if c.ID != "cert-1" || c.SerialNumber != "1A2B3C" || c.Type != CertificateTypeDevelopment || c.DisplayName != "Jane Doe" || c.ExpirationDate.Year() != 2027 || !bytes.Equal(c.Content, der) { + t.Errorf("cert = %+v", c) + } +} + +func TestListCertificatesRejectsBadContent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"data": []map[string]any{{"type": "certificates", "id": "cert-1", "attributes": map[string]any{"certificateContent": "not base64!"}}}}) + })) + defer srv.Close() + _, err := newTestClient(t, srv).ListCertificates(context.Background(), CertificateTypeDistribution) + if err == nil || !strings.Contains(err.Error(), "cert-1") { + t.Errorf("err = %v", err) + } +} + +func TestCreateCertificateSendsCSR(t *testing.T) { + var body map[string]any + csr := []byte("-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----\n") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/certificates" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + _ = json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "certificates", "id": "cert-2", "attributes": map[string]any{ + "certificateContent": base64.StdEncoding.EncodeToString([]byte("DER")), "certificateType": "DISTRIBUTION", "name": "Apple Distribution: Team (ABC123)", + }}}) + })) + defer srv.Close() + cert, err := newTestClient(t, srv).CreateCertificate(context.Background(), CertificateTypeDistribution, csr) + if err != nil { + t.Fatal(err) + } + if cert.ID != "cert-2" || cert.Type != CertificateTypeDistribution || string(cert.Content) != "DER" { + t.Errorf("cert = %+v", cert) + } + attrs := obj(t, body, "data", "attributes") + if obj(t, body, "data")["type"] != "certificates" || attrs["certificateType"] != "DISTRIBUTION" || attrs["csrContent"] != string(csr) { + t.Errorf("POST body = %v", body) + } + if _, has := attrs["certificateContent"]; has { + t.Errorf("request must not carry certificateContent: %v", attrs) + } +} diff --git a/internal/asc/devices.go b/internal/asc/devices.go new file mode 100644 index 0000000..a3fde66 --- /dev/null +++ b/internal/asc/devices.go @@ -0,0 +1,63 @@ +package asc + +import ( + "context" + "net/url" +) + +// Device statuses. Disabled devices stay registered and count against the +// yearly limit, but cannot be put in a profile. +const ( + DeviceStatusEnabled = "ENABLED" + DeviceStatusDisabled = "DISABLED" +) + +// Device is a device registered with the team. +type Device struct { + ID string + Name string + UDID string + Platform string + Status string + DeviceClass string + Model string +} + +type deviceAttributes struct { + DeviceClass string `json:"deviceClass,omitempty"` + Model string `json:"model,omitempty"` + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + Status string `json:"status,omitempty"` + UDID string `json:"udid,omitempty"` +} + +func toDevice(r Resource[deviceAttributes]) Device { + return Device{ID: r.ID, Name: r.Attributes.Name, UDID: r.Attributes.UDID, Platform: r.Attributes.Platform, Status: r.Attributes.Status, DeviceClass: r.Attributes.DeviceClass, Model: r.Attributes.Model} +} + +// ListDevices lists the registered devices of a platform (PlatformIOS), +// enabled and disabled. +func (c *Client) ListDevices(ctx context.Context, platform string) ([]Device, error) { + rs, err := getAll[deviceAttributes](ctx, c, "/v1/devices", url.Values{"filter[platform]": {platform}}) + if err != nil { + return nil, err + } + devices := make([]Device, 0, len(rs)) + for _, r := range rs { + devices = append(devices, toDevice(r)) + } + return devices, nil +} + +// RegisterDevice registers a device by UDID. Apple allows 100 devices per +// product family per membership year and never frees a slot on removal. +func (c *Client) RegisterDevice(ctx context.Context, name, udid, platform string) (*Device, error) { + req := Resource[deviceAttributes]{Type: "devices", Attributes: deviceAttributes{Name: name, UDID: udid, Platform: platform}} + r, err := post[deviceAttributes, deviceAttributes](ctx, c, "/v1/devices", req) + if err != nil { + return nil, err + } + d := toDevice(*r) + return &d, nil +} diff --git a/internal/asc/devices_test.go b/internal/asc/devices_test.go new file mode 100644 index 0000000..649c793 --- /dev/null +++ b/internal/asc/devices_test.go @@ -0,0 +1,52 @@ +package asc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListDevices(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/devices" || r.URL.Query().Get("filter[platform]") != "IOS" || r.URL.Query().Get("limit") != "200" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{ + {"type": "devices", "id": "dev-1", "attributes": map[string]any{"name": "Jane's iPhone", "udid": "00008030-000000000000001E", "platform": "IOS", "status": "ENABLED", "deviceClass": "IPHONE", "model": "iPhone 15"}}, + {"type": "devices", "id": "dev-2", "attributes": map[string]any{"name": "Old iPad", "udid": "00008020-000000000000002E", "platform": "IOS", "status": "DISABLED", "deviceClass": "IPAD"}}, + }}) + })) + defer srv.Close() + devices, err := newTestClient(t, srv).ListDevices(context.Background(), PlatformIOS) + if err != nil { + t.Fatal(err) + } + if len(devices) != 2 || devices[0].ID != "dev-1" || devices[0].UDID != "00008030-000000000000001E" || devices[0].Status != DeviceStatusEnabled || devices[0].Model != "iPhone 15" || devices[1].Status != DeviceStatusDisabled || devices[1].DeviceClass != "IPAD" { + t.Errorf("devices = %+v", devices) + } +} + +func TestRegisterDevice(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/devices" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + _ = json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "devices", "id": "dev-3", "attributes": map[string]any{"name": "iPhone 00001E", "udid": "00008030-000000000000001E", "platform": "IOS", "status": "ENABLED"}}}) + })) + defer srv.Close() + d, err := newTestClient(t, srv).RegisterDevice(context.Background(), "iPhone 00001E", "00008030-000000000000001E", PlatformIOS) + if err != nil { + t.Fatal(err) + } + if d.ID != "dev-3" || d.Status != DeviceStatusEnabled { + t.Errorf("device = %+v", d) + } + attrs := obj(t, body, "data", "attributes") + if obj(t, body, "data")["type"] != "devices" || attrs["name"] != "iPhone 00001E" || attrs["udid"] != "00008030-000000000000001E" || attrs["platform"] != "IOS" { + t.Errorf("POST body = %v", body) + } +} diff --git a/internal/asc/profiles.go b/internal/asc/profiles.go new file mode 100644 index 0000000..f5d5561 --- /dev/null +++ b/internal/asc/profiles.go @@ -0,0 +1,144 @@ +package asc + +import ( + "context" + "encoding/base64" + "fmt" + "net/url" + "time" +) + +// iOS profile types. +const ( + ProfileTypeIOSAppDevelopment = "IOS_APP_DEVELOPMENT" + ProfileTypeIOSAppAdHoc = "IOS_APP_ADHOC" + ProfileTypeIOSAppStore = "IOS_APP_STORE" +) + +// Profile states. A profile turns INVALID when a certificate or device in it +// is revoked, removed or expired; Apple does not repair it, it must be recreated. +const ( + ProfileStateActive = "ACTIVE" + ProfileStateInvalid = "INVALID" +) + +// Profile is a provisioning profile. +type Profile struct { + ID string + Name string + UUID string + Type string + State string + Platform string + CreatedDate time.Time + ExpirationDate time.Time + // Content is the .mobileprovision file. + Content []byte +} + +type profileAttributes struct { + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + ProfileContent string `json:"profileContent,omitempty"` + UUID string `json:"uuid,omitempty"` + CreatedDate *time.Time `json:"createdDate,omitempty"` + ProfileState string `json:"profileState,omitempty"` + ProfileType string `json:"profileType,omitempty"` + ExpirationDate *time.Time `json:"expirationDate,omitempty"` +} + +func toProfile(r Resource[profileAttributes]) (Profile, error) { + p := Profile{ + ID: r.ID, + Name: r.Attributes.Name, + UUID: r.Attributes.UUID, + Type: r.Attributes.ProfileType, + State: r.Attributes.ProfileState, + Platform: r.Attributes.Platform, + } + if r.Attributes.CreatedDate != nil { + p.CreatedDate = *r.Attributes.CreatedDate + } + if r.Attributes.ExpirationDate != nil { + p.ExpirationDate = *r.Attributes.ExpirationDate + } + if r.Attributes.ProfileContent != "" { + data, err := base64.StdEncoding.DecodeString(r.Attributes.ProfileContent) + if err != nil { + return p, fmt.Errorf("profile %s: decode profileContent: %w", r.ID, err) + } + p.Content = data + } + return p, nil +} + +// ListProfilesByName lists the profiles with exactly the given name; the +// portal allows duplicates. +func (c *Client) ListProfilesByName(ctx context.Context, name string) ([]Profile, error) { + rs, err := getAll[profileAttributes](ctx, c, "/v1/profiles", url.Values{"filter[name]": {name}}) + if err != nil { + return nil, err + } + profiles := make([]Profile, 0, len(rs)) + for _, r := range rs { + if r.Attributes.Name != name { + continue + } + p, err := toProfile(r) + if err != nil { + return nil, err + } + profiles = append(profiles, p) + } + return profiles, nil +} + +// ProfileCertificateIDs returns the IDs of the certificates in a profile. +func (c *Client) ProfileCertificateIDs(ctx context.Context, profileID string) ([]string, error) { + return c.relatedIDs(ctx, "/v1/profiles/"+profileID+"/relationships/certificates") +} + +// ProfileDeviceIDs returns the IDs of the devices in a profile. +func (c *Client) ProfileDeviceIDs(ctx context.Context, profileID string) ([]string, error) { + return c.relatedIDs(ctx, "/v1/profiles/"+profileID+"/relationships/devices") +} + +// relatedIDs reads a paginated to-many relationship endpoint. +func (c *Client) relatedIDs(ctx context.Context, path string) ([]string, error) { + rs, err := getAll[struct{}](ctx, c, path, nil) + if err != nil { + return nil, err + } + ids := make([]string, 0, len(rs)) + for _, r := range rs { + ids = append(ids, r.ID) + } + return ids, nil +} + +// CreateProfile creates a profile for the App ID with the given certificates +// and devices. deviceIDs must be nil for ProfileTypeIOSAppStore. +func (c *Client) CreateProfile(ctx context.Context, name, profileType, bundleIDResourceID string, certificateIDs, deviceIDs []string) (*Profile, error) { + rels := Relationships{ + "bundleId": ToOne("bundleIds", bundleIDResourceID), + "certificates": ToMany("certificates", certificateIDs), + } + if deviceIDs != nil { + rels["devices"] = ToMany("devices", deviceIDs) + } + req := Resource[profileAttributes]{Type: "profiles", Attributes: profileAttributes{Name: name, ProfileType: profileType}, Relationships: rels} + r, err := post[profileAttributes, profileAttributes](ctx, c, "/v1/profiles", req) + if err != nil { + return nil, err + } + p, err := toProfile(*r) + if err != nil { + return nil, err + } + return &p, nil +} + +// DeleteProfile removes a profile. Certificates and devices are untouched. +func (c *Client) DeleteProfile(ctx context.Context, profileID string) error { + return c.Delete(ctx, "/v1/profiles/"+profileID, nil) +} diff --git a/internal/asc/profiles_test.go b/internal/asc/profiles_test.go new file mode 100644 index 0000000..ad45f77 --- /dev/null +++ b/internal/asc/profiles_test.go @@ -0,0 +1,127 @@ +package asc + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListProfilesByNameDropsOtherNames(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/profiles" || r.URL.Query().Get("filter[name]") != "Builder development com.example.app" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{ + {"type": "profiles", "id": "prof-1", "attributes": map[string]any{ + "name": "Builder development com.example.app", "platform": "IOS", "uuid": "1111-2222", "profileState": "ACTIVE", "profileType": "IOS_APP_DEVELOPMENT", + "profileContent": base64.StdEncoding.EncodeToString([]byte("plist")), "createdDate": "2026-09-16T10:00:00.000+00:00", "expirationDate": "2027-09-16T10:00:00.000+00:00", + }}, + {"type": "profiles", "id": "prof-2", "attributes": map[string]any{"name": "Builder development com.example.app 2", "profileState": "INVALID"}}, + }}) + })) + defer srv.Close() + profiles, err := newTestClient(t, srv).ListProfilesByName(context.Background(), "Builder development com.example.app") + if err != nil { + t.Fatal(err) + } + if len(profiles) != 1 { + t.Fatalf("profiles = %+v", profiles) + } + p := profiles[0] + if p.ID != "prof-1" || p.UUID != "1111-2222" || p.State != ProfileStateActive || p.Type != ProfileTypeIOSAppDevelopment || string(p.Content) != "plist" || p.ExpirationDate.Year() != 2027 || p.CreatedDate.Year() != 2026 { + t.Errorf("profile = %+v", p) + } +} + +func TestProfileRelationshipIDsFollowPagination(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/profiles/prof-1/relationships/certificates": + writeJSON(w, 200, map[string]any{"data": []map[string]any{{"type": "certificates", "id": "cert-1"}}}) + case r.URL.Path == "/v1/profiles/prof-1/relationships/devices" && r.URL.Query().Get("cursor") == "": + writeJSON(w, 200, map[string]any{ + "data": []map[string]any{{"type": "devices", "id": "dev-1"}}, + "links": map[string]string{"next": srv.URL + "/v1/profiles/prof-1/relationships/devices?limit=200&cursor=n"}, + }) + case r.URL.Path == "/v1/profiles/prof-1/relationships/devices": + writeJSON(w, 200, map[string]any{"data": []map[string]any{{"type": "devices", "id": "dev-2"}}}) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + certs, err := c.ProfileCertificateIDs(context.Background(), "prof-1") + if err != nil || len(certs) != 1 || certs[0] != "cert-1" { + t.Errorf("certs = %v, err = %v", certs, err) + } + devices, err := c.ProfileDeviceIDs(context.Background(), "prof-1") + if err != nil || len(devices) != 2 || devices[0] != "dev-1" || devices[1] != "dev-2" { + t.Errorf("devices = %v, err = %v", devices, err) + } +} + +func TestCreateAndDeleteProfile(t *testing.T) { + var body map[string]any + var deleted string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/profiles": + _ = json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "profiles", "id": "prof-9", "attributes": map[string]any{ + "name": "Builder ad-hoc com.example.app", "profileType": "IOS_APP_ADHOC", "profileState": "ACTIVE", "uuid": "u-9", "profileContent": base64.StdEncoding.EncodeToString([]byte("plist")), + }}}) + case r.Method == http.MethodDelete && r.URL.Path == "/v1/profiles/prof-1": + deleted = "prof-1" + w.WriteHeader(204) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + ctx := context.Background() + + p, err := c.CreateProfile(ctx, "Builder ad-hoc com.example.app", ProfileTypeIOSAppAdHoc, "bid-1", []string{"cert-1"}, []string{"dev-1", "dev-2"}) + if err != nil { + t.Fatal(err) + } + if p.ID != "prof-9" || p.State != ProfileStateActive || p.UUID != "u-9" || string(p.Content) != "plist" { + t.Errorf("profile = %+v", p) + } + data := obj(t, body, "data") + attrs := obj(t, data, "attributes") + if data["type"] != "profiles" || attrs["name"] != "Builder ad-hoc com.example.app" || attrs["profileType"] != "IOS_APP_ADHOC" { + t.Errorf("POST body = %v", body) + } + if _, has := attrs["profileContent"]; has { + t.Errorf("request must not carry profileContent: %v", attrs) + } + if obj(t, data, "relationships", "bundleId", "data")["id"] != "bid-1" { + t.Errorf("bundleId relationship = %v", obj(t, data, "relationships")) + } + certs := arr(t, data, "relationships", "certificates", "data") + if len(certs) != 1 || obj(t, certs[0])["type"] != "certificates" || obj(t, certs[0])["id"] != "cert-1" { + t.Errorf("certificates relationship = %v", certs) + } + devices := arr(t, data, "relationships", "devices", "data") + if len(devices) != 2 || obj(t, devices[1])["id"] != "dev-2" { + t.Errorf("devices relationship = %v", devices) + } + + // App Store profiles take no devices; the relationship must be absent, not empty. + if _, err := c.CreateProfile(ctx, "Builder app-store com.example.app", ProfileTypeIOSAppStore, "bid-1", []string{"cert-1"}, nil); err != nil { + t.Fatal(err) + } + if _, has := obj(t, body, "data", "relationships")["devices"]; has { + t.Errorf("App Store profile request carries devices: %v", body) + } + + if err := c.DeleteProfile(ctx, "prof-1"); err != nil || deleted != "prof-1" { + t.Errorf("delete: err = %v, deleted = %q", err, deleted) + } +} From e15399f00cb73b9415769b3746e3c4e4bd6fdce7 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:45:28 +0200 Subject: [PATCH 24/75] 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. --- internal/signing/auto.go | 484 +++++++++++++++++++++++++ internal/signing/auto_test.go | 640 ++++++++++++++++++++++++++++++++++ internal/signing/signing.go | 94 +++-- 3 files changed, 1191 insertions(+), 27 deletions(-) create mode 100644 internal/signing/auto.go create mode 100644 internal/signing/auto_test.go diff --git a/internal/signing/auto.go b/internal/signing/auto.go new file mode 100644 index 0000000..785b2bb --- /dev/null +++ b/internal/signing/auto.go @@ -0,0 +1,484 @@ +package signing + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + "time" + "unicode" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// Type is what the signing material is for: which certificate is issued and +// which profile type wraps it. +type Type string + +// Signing types, as accepted by --type. +const ( + TypeDevelopment Type = "development" + TypeAdHoc Type = "ad-hoc" + TypeAppStore Type = "app-store" +) + +// ParseType validates a --type value. +func ParseType(s string) (Type, error) { + switch t := Type(strings.ToLower(strings.TrimSpace(s))); t { + case TypeDevelopment, TypeAdHoc, TypeAppStore: + return t, nil + case "adhoc": + return TypeAdHoc, nil + case "appstore": + return TypeAppStore, nil + default: + return "", fmt.Errorf("--type must be development, ad-hoc or app-store, got %q", s) + } +} + +// NeedsDevices reports whether profiles of this type list the devices the +// app may run on; App Store profiles do not. +func (t Type) NeedsDevices() bool { return t != TypeAppStore } + +func (t Type) certificateType() string { + if t == TypeDevelopment { + return asc.CertificateTypeDevelopment + } + return asc.CertificateTypeDistribution +} + +func (t Type) profileType() string { + switch t { + case TypeAdHoc: + return asc.ProfileTypeIOSAppAdHoc + case TypeAppStore: + return asc.ProfileTypeIOSAppStore + default: + return asc.ProfileTypeIOSAppDevelopment + } +} + +// Device is a device to register, by UDID. +type Device struct { + Name string `json:"name"` + UDID string `json:"udid"` +} + +// File names written to the output directory. +const ( + KeyFileName = "ios-signing.key" + P12FileName = "ios-signing.p12" +) + +// AutoOptions configures Auto. +type AutoOptions struct { + BundleID string + Type Type + // Devices are registered when missing; development and ad-hoc profiles + // then cover every enabled iOS device on the account. + Devices []Device + // KeyPEM is an existing private key. When nil a key is generated and + // written to OutDir/ios-signing.key. + KeyPEM []byte + // CommonName goes into the CSR subject of a new certificate. + CommonName string + // Password protects the .p12. + Password string + // Force issues a new certificate and profile even when valid ones exist. + Force bool + // OutDir receives the key, .p12 and .mobileprovision (default "."). + OutDir string + // Log receives progress; nil is silent. + Log io.Writer + // now is replaced by tests. + now func() time.Time +} + +// BundleIDResult reports the App ID used. +type BundleIDResult struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + Created bool `json:"created"` +} + +// CertificateResult reports the certificate the .p12 holds. +type CertificateResult struct { + ID string `json:"id"` + Name string `json:"name"` + SerialNumber string `json:"serial_number"` + Type string `json:"type"` + ExpirationDate time.Time `json:"expiration_date"` + Created bool `json:"created"` + // ValidOnAccount counts unexpired certificates of this type before the + // run, so a user hitting Apple's limit can see why. + ValidOnAccount int `json:"valid_on_account"` +} + +// DevicesResult reports device registration; empty for App Store. +type DevicesResult struct { + Registered []Device `json:"registered"` + // InProfile counts the enabled devices the profile covers. + InProfile int `json:"in_profile"` +} + +// ProfileResult reports the profile written. +type ProfileResult struct { + ID string `json:"id"` + Name string `json:"name"` + UUID string `json:"uuid"` + Type string `json:"type"` + State string `json:"state"` + ExpirationDate time.Time `json:"expiration_date"` + Created bool `json:"created"` + // Reason says why a profile was created ("missing", "invalid", "expired", + // "certificate changed", "devices changed", "forced"); empty when reused. + Reason string `json:"reason,omitempty"` +} + +// Files lists what Auto wrote. +type Files struct { + // Key is set only when a key was generated. + Key string `json:"key,omitempty"` + P12 string `json:"p12"` + Profile string `json:"profile"` +} + +// AutoResult is what Auto found, created and wrote. +type AutoResult struct { + Type Type `json:"type"` + BundleID BundleIDResult `json:"bundle_id"` + Certificate CertificateResult `json:"certificate"` + Devices DevicesResult `json:"devices"` + Profile ProfileResult `json:"profile"` + Files Files `json:"files"` + // P12 and ProfileContent are the bytes written, for uploading. + P12 []byte `json:"-"` + ProfileContent []byte `json:"-"` +} + +// Auto provisions everything an iOS build needs to sign through the App Store +// Connect API: the App ID, a certificate whose private key is on this +// machine, the devices, and a profile tying them together. It is idempotent: +// a second run reuses what is valid and recreates only what is missing, +// expired, invalid or no longer matches. Nothing is ever revoked. +func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResult, error) { + if opts.BundleID == "" { + return nil, errors.New("bundle ID is required") + } + if _, err := ParseType(string(opts.Type)); err != nil { + return nil, err + } + if opts.Password == "" { + return nil, errors.New("a .p12 password is required") + } + if opts.OutDir == "" { + opts.OutDir = "." + } + now := opts.now + if now == nil { + now = time.Now + } + res := &AutoResult{Type: opts.Type} + + // 1. Bundle ID + bundle, err := client.BundleIDByIdentifier(ctx, opts.BundleID) + if err != nil { + return res, err + } + if bundle == nil { + logf(opts.Log, "Registering App ID %s...", opts.BundleID) + if bundle, err = client.CreateBundleID(ctx, opts.BundleID, bundleIDName(opts.BundleID), asc.PlatformIOS); err != nil { + return res, fmt.Errorf("register App ID %s: %w", opts.BundleID, err) + } + res.BundleID.Created = true + } else { + logf(opts.Log, "App ID %s is registered (%s)", bundle.Identifier, bundle.Name) + } + res.BundleID.ID, res.BundleID.Identifier = bundle.ID, bundle.Identifier + + // 2. Certificate + keyPEM := opts.KeyPEM + if keyPEM == nil { + if keyPEM, err = generateKey(); err != nil { + return res, err + } + res.Files.Key = filepath.Join(opts.OutDir, KeyFileName) + } + cert, err := ensureCertificate(ctx, client, opts, keyPEM, now(), &res.Certificate) + if err != nil { + return res, err + } + res.P12, err = BuildP12(keyPEM, cert.Content, opts.Password) + if err != nil { + return res, err + } + + // 3. Devices + var deviceIDs []string + if opts.Type.NeedsDevices() { + if deviceIDs, err = ensureDevices(ctx, client, opts, &res.Devices); err != nil { + return res, err + } + } + + // 4. Profile + profile, err := ensureProfile(ctx, client, opts, bundle.ID, cert.ID, deviceIDs, now(), &res.Profile) + if err != nil { + return res, err + } + res.ProfileContent = profile.Content + + // 5. Files + if err := os.MkdirAll(opts.OutDir, 0755); err != nil { + return res, fmt.Errorf("create %s: %w", opts.OutDir, err) + } + if res.Files.Key != "" { + if err := os.WriteFile(res.Files.Key, keyPEM, 0600); err != nil { + return res, fmt.Errorf("write private key: %w", err) + } + } + res.Files.P12 = filepath.Join(opts.OutDir, P12FileName) + if err := os.WriteFile(res.Files.P12, res.P12, 0600); err != nil { + return res, fmt.Errorf("write .p12: %w", err) + } + res.Files.Profile = filepath.Join(opts.OutDir, ProfileFileName(profile.Name)) + if err := os.WriteFile(res.Files.Profile, profile.Content, 0600); err != nil { + return res, fmt.Errorf("write provisioning profile: %w", err) + } + return res, nil +} + +// ProfileName is the portal name of the profile Auto manages for a bundle ID. +func ProfileName(t Type, bundleID string) string { + return fmt.Sprintf("Builder %s %s", t, bundleID) +} + +// ProfileFileName is the .mobileprovision file name for a profile name. +func ProfileFileName(profileName string) string { + return strings.ReplaceAll(profileName, " ", "-") + ".mobileprovision" +} + +// bundleIDName derives the App ID's display name, which the portal restricts +// to letters, digits and spaces. +func bundleIDName(identifier string) string { + mapped := strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return r + } + return ' ' + }, identifier) + return strings.Join(strings.Fields(mapped), " ") +} + +func generateKey() ([]byte, error) { + keyPEM, _, err := GenerateKeyAndCSR("Builder", "") + return keyPEM, err +} + +// ensureCertificate reuses a valid certificate issued for the key, else has +// Apple issue one. Certificates without their key on this machine cannot go +// into a .p12, so they are ignored rather than revoked. +func ensureCertificate(ctx context.Context, client *asc.Client, opts *AutoOptions, keyPEM []byte, now time.Time, out *CertificateResult) (*asc.Certificate, error) { + certType := opts.Type.certificateType() + certs, err := client.ListCertificates(ctx, certType) + if err != nil { + return nil, err + } + var valid []asc.Certificate + for i := range certs { + if certs[i].ExpirationDate.After(now) { + valid = append(valid, certs[i]) + } + } + out.ValidOnAccount = len(valid) + if !opts.Force && opts.KeyPEM != nil { + for i := range valid { + if KeyMatchesCertificate(keyPEM, valid[i].Content) { + logf(opts.Log, "Reusing %s certificate %s (expires %s)", certType, valid[i].Name, valid[i].ExpirationDate.Format("2006-01-02")) + fillCertificate(out, &valid[i], false) + return &valid[i], nil + } + } + logf(opts.Log, "%d valid %s certificate(s) on the account, none issued for the private key", len(valid), certType) + } else if len(valid) > 0 && !opts.Force { + logf(opts.Log, "%d valid %s certificate(s) on the account, but their private keys are not on this machine", len(valid), certType) + } + + commonName := opts.CommonName + if commonName == "" { + commonName = "Builder" + } + csr, err := CreateCSR(keyPEM, commonName, "") + if err != nil { + return nil, err + } + logf(opts.Log, "Requesting a new %s certificate...", certType) + cert, err := client.CreateCertificate(ctx, certType, csr) + if err != nil { + return nil, withLimitHint(err, "Apple limits a team to 2 Apple Development and 3 Apple Distribution certificates. Revoke one you no longer use at https://developer.apple.com/account/resources/certificates/list (Builder never revokes anything), or pass --key with the private key of an existing certificate to reuse it.") + } + if !KeyMatchesCertificate(keyPEM, cert.Content) { + return nil, fmt.Errorf("certificate %s from App Store Connect was not issued for the private key", cert.ID) + } + fillCertificate(out, cert, true) + return cert, nil +} + +func fillCertificate(out *CertificateResult, c *asc.Certificate, created bool) { + out.ID, out.Name, out.SerialNumber, out.Type, out.ExpirationDate, out.Created = c.ID, c.Name, c.SerialNumber, c.Type, c.ExpirationDate, created +} + +// ensureDevices registers the missing devices and returns the IDs of every +// enabled iOS device on the account, sorted, which is what the profile covers. +func ensureDevices(ctx context.Context, client *asc.Client, opts *AutoOptions, out *DevicesResult) ([]string, error) { + devices, err := client.ListDevices(ctx, asc.PlatformIOS) + if err != nil { + return nil, err + } + byUDID := make(map[string]asc.Device, len(devices)) + for _, d := range devices { + byUDID[strings.ToUpper(d.UDID)] = d + } + out.Registered = []Device{} + for _, want := range opts.Devices { + udid := strings.TrimSpace(want.UDID) + if udid == "" { + continue + } + if existing, ok := byUDID[strings.ToUpper(udid)]; ok { + logf(opts.Log, "Device %s is registered as %q (%s)", udid, existing.Name, strings.ToLower(existing.Status)) + continue + } + name := strings.TrimSpace(want.Name) + if name == "" { + name = "iPhone " + udid[max(0, len(udid)-6):] + } + logf(opts.Log, "Registering device %q (%s)...", name, udid) + d, err := client.RegisterDevice(ctx, name, udid, asc.PlatformIOS) + if err != nil { + return nil, withLimitHint(fmt.Errorf("register device %s: %w", udid, err), "Apple allows 100 iOS devices per membership year and frees no slot when one is removed; the count resets when the membership renews. Disable unused devices at https://developer.apple.com/account/resources/devices/list to keep them out of profiles.") + } + byUDID[strings.ToUpper(d.UDID)] = *d + out.Registered = append(out.Registered, Device{Name: d.Name, UDID: d.UDID}) + } + var ids []string + for _, d := range byUDID { + if d.Status == asc.DeviceStatusEnabled { + ids = append(ids, d.ID) + } + } + if len(ids) == 0 { + return nil, fmt.Errorf("no iOS devices are registered on the account and a %s profile needs at least one: pass --device (repeatable) or --devices-from-mobai", opts.Type) + } + slices.Sort(ids) + out.InProfile = len(ids) + logf(opts.Log, "%d enabled device(s) will be in the profile", len(ids)) + return ids, nil +} + +// ensureProfile reuses the Builder-managed profile when it is ACTIVE, +// unexpired and still lists exactly this certificate and these devices; +// otherwise it deletes and recreates it. Same-named duplicates go too. +func ensureProfile(ctx context.Context, client *asc.Client, opts *AutoOptions, bundleResourceID, certID string, deviceIDs []string, now time.Time, out *ProfileResult) (*asc.Profile, error) { + name := ProfileName(opts.Type, opts.BundleID) + profileType := opts.Type.profileType() + existing, err := client.ListProfilesByName(ctx, name) + if err != nil { + return nil, err + } + reason := "missing" + if len(existing) > 0 { + p := &existing[0] + if reason, err = recreateReason(ctx, client, opts, p, certID, deviceIDs, now); err != nil { + return nil, err + } + if reason == "" { + logf(opts.Log, "Reusing profile %q (%s, expires %s)", p.Name, strings.ToLower(p.State), p.ExpirationDate.Format("2006-01-02")) + fillProfile(out, p, false, "") + return p, nil + } + logf(opts.Log, "Recreating profile %q: %s", name, reason) + for i := range existing { + if err := client.DeleteProfile(ctx, existing[i].ID); err != nil { + return nil, fmt.Errorf("delete profile %s: %w", existing[i].ID, err) + } + } + } else { + logf(opts.Log, "Creating profile %q...", name) + } + if !opts.Type.NeedsDevices() { + deviceIDs = nil + } + p, err := client.CreateProfile(ctx, name, profileType, bundleResourceID, []string{certID}, deviceIDs) + if err != nil { + return nil, fmt.Errorf("create profile %q: %w", name, err) + } + if len(p.Content) == 0 { + return nil, fmt.Errorf("profile %s from App Store Connect has no content", p.ID) + } + fillProfile(out, p, true, reason) + return p, nil +} + +// recreateReason says why the profile cannot be reused, or "" when it can. +func recreateReason(ctx context.Context, client *asc.Client, opts *AutoOptions, p *asc.Profile, certID string, deviceIDs []string, now time.Time) (string, error) { + switch { + case opts.Force: + return "forced", nil + case p.State != asc.ProfileStateActive: + return strings.ToLower(p.State), nil + case !p.ExpirationDate.IsZero() && !p.ExpirationDate.After(now): + return "expired", nil + case p.Type != opts.Type.profileType(): + return "type changed", nil + case len(p.Content) == 0: + return "no content", nil + } + certIDs, err := client.ProfileCertificateIDs(ctx, p.ID) + if err != nil { + return "", err + } + if len(certIDs) != 1 || certIDs[0] != certID { + return "certificate changed", nil + } + if opts.Type.NeedsDevices() { + have, err := client.ProfileDeviceIDs(ctx, p.ID) + if err != nil { + return "", err + } + slices.Sort(have) + if !slices.Equal(have, deviceIDs) { + return "devices changed", nil + } + } + return "", nil +} + +func fillProfile(out *ProfileResult, p *asc.Profile, created bool, reason string) { + out.ID, out.Name, out.UUID, out.Type, out.State, out.ExpirationDate, out.Created, out.Reason = p.ID, p.Name, p.UUID, p.Type, p.State, p.ExpirationDate, created, reason +} + +// withLimitHint appends hint when App Store Connect refused for a quota. +func withLimitHint(err error, hint string) error { + var apiErr *asc.Error + if !errors.As(err, &apiErr) { + return err + } + for _, d := range apiErr.Errors { + text := strings.ToLower(d.Title + " " + d.Detail) + if strings.Contains(text, "maximum") || strings.Contains(text, "limit") || strings.Contains(text, "already have") { + return fmt.Errorf("%w\n%s", err, hint) + } + } + return err +} + +func logf(w io.Writer, format string, args ...any) { + if w != nil { + fmt.Fprintf(w, format+"\n", args...) + } +} diff --git a/internal/signing/auto_test.go b/internal/signing/auto_test.go new file mode 100644 index 0000000..3683895 --- /dev/null +++ b/internal/signing/auto_test.go @@ -0,0 +1,640 @@ +package signing + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" + pkcs12 "software.sslmate.com/src/go-pkcs12" +) + +var testNow = time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC) + +type certRec struct { + id, typ string + der []byte + exp time.Time +} + +type deviceRec struct{ id, name, udid, status string } + +type profileRec struct { + id, name, typ, state string + certIDs, deviceIDs []string + exp time.Time +} + +// portal is an in-memory Apple Developer portal behind the ASC endpoints Auto uses. +type portal struct { + t *testing.T + srv *httptest.Server + signer *rsa.PrivateKey + mu sync.Mutex + calls []string + seq int + + bundleIDs []string // registered identifiers + certs []certRec + devices []deviceRec + profiles []profileRec + // refuseCertificates / refuseDevices make the POST fail with Apple's quota wording. + refuseCertificates, refuseDevices bool +} + +func newPortal(t *testing.T) *portal { + t.Helper() + signer, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + p := &portal{t: t, signer: signer} + mux := http.NewServeMux() + res := func(typ, id string, attrs map[string]any) map[string]any { + return map[string]any{"type": typ, "id": id, "attributes": attrs} + } + many := func(w http.ResponseWriter, rs ...any) { + if rs == nil { + rs = []any{} + } + writeJSON(w, 200, map[string]any{"data": rs}) + } + refuse := func(w http.ResponseWriter, detail string) { + writeJSON(w, 409, map[string]any{"errors": []map[string]any{{"status": "409", "code": "ENTITY_ERROR.ATTRIBUTE.INVALID", "title": "There is a problem with the request entity", "detail": detail}}}) + } + body := func(r *http.Request) map[string]any { + var b map[string]any + _ = json.NewDecoder(r.Body).Decode(&b) + return b + } + attrs := func(b map[string]any) map[string]any { return obj(p.t, b, "data", "attributes") } + str := func(m map[string]any, key string) string { + s, _ := m[key].(string) + return s + } + linkIDs := func(b map[string]any, rel string) []string { + rels := obj(p.t, b, "data", "relationships") + raw, ok := rels[rel] + if !ok { + return nil + } + var ids []string + for _, l := range arr(p.t, raw, "data") { + ids = append(ids, str(obj(p.t, l), "id")) + } + return ids + } + wrap := func(h func(w http.ResponseWriter, r *http.Request)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + p.t.Errorf("%s %s without bearer token", r.Method, r.URL.Path) + } + p.mu.Lock() + defer p.mu.Unlock() + p.calls = append(p.calls, r.Method+" "+r.URL.Path) + h(w, r) + } + } + certRes := func(c certRec) map[string]any { + return res("certificates", c.id, map[string]any{"certificateType": c.typ, "name": "Apple " + c.typ + ": Builder", "serialNumber": c.id, "certificateContent": base64.StdEncoding.EncodeToString(c.der), "expirationDate": c.exp.Format(time.RFC3339)}) + } + deviceRes := func(d deviceRec) map[string]any { + return res("devices", d.id, map[string]any{"name": d.name, "udid": d.udid, "platform": "IOS", "status": d.status, "deviceClass": "IPHONE"}) + } + profileRes := func(pr profileRec) map[string]any { + return res("profiles", pr.id, map[string]any{"name": pr.name, "profileType": pr.typ, "profileState": pr.state, "uuid": "uuid-" + pr.id, "platform": "IOS", "profileContent": base64.StdEncoding.EncodeToString([]byte("profile:" + pr.id)), "expirationDate": pr.exp.Format(time.RFC3339)}) + } + + mux.HandleFunc("GET /v1/bundleIds", wrap(func(w http.ResponseWriter, r *http.Request) { + want := r.URL.Query().Get("filter[identifier]") + var rs []any + for _, id := range p.bundleIDs { + if strings.HasPrefix(id, want) { + rs = append(rs, res("bundleIds", "bid-"+id, map[string]any{"identifier": id, "name": bundleIDName(id), "platform": "IOS"})) + } + } + many(w, rs...) + })) + mux.HandleFunc("POST /v1/bundleIds", wrap(func(w http.ResponseWriter, r *http.Request) { + a := attrs(body(r)) + if a["platform"] != "IOS" || a["name"] == "" { + p.t.Errorf("bundleIds POST attributes = %v", a) + } + id := str(a, "identifier") + p.bundleIDs = append(p.bundleIDs, id) + writeJSON(w, 201, map[string]any{"data": res("bundleIds", "bid-"+id, a)}) + })) + mux.HandleFunc("GET /v1/certificates", wrap(func(w http.ResponseWriter, r *http.Request) { + var rs []any + for _, c := range p.certs { + if c.typ == r.URL.Query().Get("filter[certificateType]") { + rs = append(rs, certRes(c)) + } + } + many(w, rs...) + })) + mux.HandleFunc("POST /v1/certificates", wrap(func(w http.ResponseWriter, r *http.Request) { + if p.refuseCertificates { + refuse(w, "You already have a current Development certificate or a pending certificate request; the maximum number of certificates has been reached.") + return + } + a := attrs(body(r)) + block, _ := pem.Decode([]byte(str(a, "csrContent"))) + if block == nil { + p.t.Fatalf("csrContent is not PEM: %v", a["csrContent"]) + } + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + p.t.Fatalf("parse CSR: %v", err) + } + if err := csr.CheckSignature(); err != nil { + p.t.Fatalf("CSR signature: %v", err) + } + pub, ok := csr.PublicKey.(*rsa.PublicKey) + if !ok { + p.t.Fatalf("CSR public key is %T, want RSA", csr.PublicKey) + } + c := p.issue(str(a, "certificateType"), pub, testNow.AddDate(1, 0, 0)) + writeJSON(w, 201, map[string]any{"data": certRes(c)}) + })) + mux.HandleFunc("GET /v1/devices", wrap(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("filter[platform]") != "IOS" { + p.t.Errorf("devices query = %v", r.URL.Query()) + } + var rs []any + for _, d := range p.devices { + rs = append(rs, deviceRes(d)) + } + many(w, rs...) + })) + mux.HandleFunc("POST /v1/devices", wrap(func(w http.ResponseWriter, r *http.Request) { + if p.refuseDevices { + refuse(w, "You have reached the maximum number of devices for this membership year.") + return + } + a := attrs(body(r)) + d := deviceRec{id: p.nextID("dev"), name: str(a, "name"), udid: str(a, "udid"), status: "ENABLED"} + p.devices = append(p.devices, d) + writeJSON(w, 201, map[string]any{"data": deviceRes(d)}) + })) + mux.HandleFunc("GET /v1/profiles", wrap(func(w http.ResponseWriter, r *http.Request) { + var rs []any + for i := range p.profiles { + if p.profiles[i].name == r.URL.Query().Get("filter[name]") { + rs = append(rs, profileRes(p.profiles[i])) + } + } + many(w, rs...) + })) + mux.HandleFunc("GET /v1/profiles/{id}/relationships/{rel}", wrap(func(w http.ResponseWriter, r *http.Request) { + for i := range p.profiles { + pr := &p.profiles[i] + if pr.id != r.PathValue("id") { + continue + } + ids, typ := pr.certIDs, "certificates" + if r.PathValue("rel") == "devices" { + ids, typ = pr.deviceIDs, "devices" + } + var rs []any + for _, id := range ids { + rs = append(rs, map[string]any{"type": typ, "id": id}) + } + many(w, rs...) + return + } + writeJSON(w, 404, map[string]any{"errors": []map[string]any{{"code": "NOT_FOUND", "title": "not found"}}}) + })) + mux.HandleFunc("POST /v1/profiles", wrap(func(w http.ResponseWriter, r *http.Request) { + b := body(r) + a := attrs(b) + bundle, ok := obj(p.t, b, "data", "relationships", "bundleId", "data")["id"].(string) + if !ok || !slices.Contains(p.bundleIDs, strings.TrimPrefix(bundle, "bid-")) { + p.t.Errorf("profile POST for unknown bundle ID %q", bundle) + } + pr := profileRec{id: p.nextID("prof"), name: str(a, "name"), typ: str(a, "profileType"), state: "ACTIVE", certIDs: linkIDs(b, "certificates"), deviceIDs: linkIDs(b, "devices"), exp: testNow.AddDate(1, 0, 0)} + if pr.typ == asc.ProfileTypeIOSAppStore && pr.deviceIDs != nil { + p.t.Errorf("App Store profile POST carries devices: %v", pr.deviceIDs) + } + p.profiles = append(p.profiles, pr) + writeJSON(w, 201, map[string]any{"data": profileRes(pr)}) + })) + mux.HandleFunc("DELETE /v1/profiles/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + p.profiles = slices.DeleteFunc(p.profiles, func(pr profileRec) bool { return pr.id == r.PathValue("id") }) + w.WriteHeader(204) + })) + mux.HandleFunc("/", wrap(func(w http.ResponseWriter, r *http.Request) { + p.t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + })) + p.srv = httptest.NewServer(mux) + t.Cleanup(p.srv.Close) + return p +} + +func (p *portal) nextID(prefix string) string { + p.seq++ + return fmt.Sprintf("%s-%d", prefix, p.seq) +} + +// issue signs a certificate for pub and records it; callers hold p.mu or run before the server. +func (p *portal) issue(typ string, pub *rsa.PublicKey, exp time.Time) certRec { + c := certRec{id: p.nextID("cert"), typ: typ, der: issueCert(p.t, pub, p.signer), exp: exp} + p.certs = append(p.certs, c) + return c +} + +func (p *portal) client(t *testing.T) *asc.Client { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, _ := x509.MarshalPKCS8PrivateKey(key) + creds := asc.Credentials{IssuerID: "iss", KeyID: "kid", PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))} + c, err := asc.NewClient(creds, asc.WithBaseURL(p.srv.URL), asc.WithRetryDelay(time.Millisecond)) + if err != nil { + t.Fatal(err) + } + return c +} + +// count returns how many recorded calls match "METHOD /path". +func (p *portal) count(key string) int { + p.mu.Lock() + defer p.mu.Unlock() + n := 0 + for _, c := range p.calls { + if c == key { + n++ + } + } + return n +} + +func (p *portal) reset() { + p.mu.Lock() + defer p.mu.Unlock() + p.calls = nil +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func obj(t *testing.T, v any, keys ...string) map[string]any { + t.Helper() + for i := 0; ; i++ { + m, ok := v.(map[string]any) + if !ok { + t.Errorf("JSON path %v: %T is not an object", keys[:i], v) + return nil + } + if i == len(keys) { + return m + } + v = m[keys[i]] + } +} + +func arr(t *testing.T, v any, keys ...string) []any { + t.Helper() + if len(keys) > 0 { + v = obj(t, v, keys[:len(keys)-1]...)[keys[len(keys)-1]] + } + a, ok := v.([]any) + if !ok { + t.Errorf("JSON path %v: %T is not an array", keys, v) + } + return a +} + +func devOpts(dir string) *AutoOptions { + return &AutoOptions{ + BundleID: "com.example.app", + Type: TypeDevelopment, + Devices: []Device{{Name: "Jane's iPhone", UDID: "00008030-000000000000001E"}}, + Password: "secret", + OutDir: dir, + now: func() time.Time { return testNow }, + } +} + +func run(t *testing.T, p *portal, opts *AutoOptions) *AutoResult { + t.Helper() + p.reset() + res, err := Auto(context.Background(), p.client(t), opts) + if err != nil { + t.Fatalf("Auto: %v", err) + } + return res +} + +func TestAutoFirstRunCreatesEverything(t *testing.T) { + p := newPortal(t) + dir := t.TempDir() + res := run(t, p, devOpts(dir)) + + if !res.BundleID.Created || res.BundleID.ID != "bid-com.example.app" { + t.Errorf("bundle ID = %+v", res.BundleID) + } + if !res.Certificate.Created || res.Certificate.Type != asc.CertificateTypeDevelopment || res.Certificate.ValidOnAccount != 0 { + t.Errorf("certificate = %+v", res.Certificate) + } + if len(res.Devices.Registered) != 1 || res.Devices.Registered[0].Name != "Jane's iPhone" || res.Devices.InProfile != 1 { + t.Errorf("devices = %+v", res.Devices) + } + if !res.Profile.Created || res.Profile.Reason != "missing" || res.Profile.Name != "Builder development com.example.app" || res.Profile.Type != asc.ProfileTypeIOSAppDevelopment || res.Profile.State != asc.ProfileStateActive { + t.Errorf("profile = %+v", res.Profile) + } + if p.count("DELETE /v1/profiles/prof-3") != 0 || p.count("POST /v1/profiles") != 1 || p.count("POST /v1/certificates") != 1 || p.count("POST /v1/devices") != 1 || p.count("POST /v1/bundleIds") != 1 { + t.Errorf("calls = %v", p.calls) + } + + // Files: key, .p12 and profile in the output directory; the .p12 opens with the password and holds the issued certificate. + if res.Files.Key != filepath.Join(dir, "ios-signing.key") || res.Files.P12 != filepath.Join(dir, "ios-signing.p12") || res.Files.Profile != filepath.Join(dir, "Builder-development-com.example.app.mobileprovision") { + t.Errorf("files = %+v", res.Files) + } + keyPEM, err := os.ReadFile(res.Files.Key) + if err != nil { + t.Fatal(err) + } + p12, err := os.ReadFile(res.Files.P12) + if err != nil { + t.Fatal(err) + } + gotKey, gotCert, err := pkcs12.Decode(p12, "secret") + if err != nil { + t.Fatalf("pkcs12.Decode: %v", err) + } + rsaKey, ok := gotKey.(*rsa.PrivateKey) + if !ok || !KeyMatchesCertificate(keyPEM, gotCert.Raw) || !rsaKey.PublicKey.Equal(gotCert.PublicKey) { + t.Error(".p12 key and certificate do not match the written key") + } + if !slices.Equal(gotCert.Raw, p.certs[0].der) { + t.Error(".p12 holds a different certificate than the portal issued") + } + profile, err := os.ReadFile(res.Files.Profile) + if err != nil || string(profile) != "profile:prof-3" || string(res.ProfileContent) != "profile:prof-3" { + t.Errorf("profile file = %q, err = %v", profile, err) + } +} + +func TestAutoSecondRunReusesEverything(t *testing.T) { + p := newPortal(t) + dir := t.TempDir() + first := run(t, p, devOpts(dir)) + keyPEM, err := os.ReadFile(first.Files.Key) + if err != nil { + t.Fatal(err) + } + + opts := devOpts(dir) + opts.KeyPEM = keyPEM + res := run(t, p, opts) + if res.BundleID.Created || res.Certificate.Created || res.Profile.Created || res.Profile.Reason != "" || len(res.Devices.Registered) != 0 { + t.Errorf("second run created something: %+v", res) + } + if res.Certificate.ID != first.Certificate.ID || res.Profile.ID != first.Profile.ID || res.Certificate.ValidOnAccount != 1 { + t.Errorf("second run = %+v, first = %+v", res, first) + } + if res.Files.Key != "" { + t.Errorf("a supplied key must not be rewritten: %+v", res.Files) + } + for _, call := range p.calls { + if strings.HasPrefix(call, "POST") || strings.HasPrefix(call, "DELETE") { + t.Errorf("second run made %s", call) + } + } + if _, err := os.Stat(res.Files.P12); err != nil { + t.Errorf(".p12 not rewritten: %v", err) + } +} + +func TestAutoRecreatesProfileWhenDevicesChange(t *testing.T) { + p := newPortal(t) + dir := t.TempDir() + first := run(t, p, devOpts(dir)) + keyPEM, _ := os.ReadFile(first.Files.Key) + + opts := devOpts(dir) + opts.KeyPEM = keyPEM + opts.Devices = append(opts.Devices, Device{UDID: "00008110-00000000000000AB"}) + res := run(t, p, opts) + if res.Certificate.Created || !res.Profile.Created || res.Profile.Reason != "devices changed" || res.Devices.InProfile != 2 { + t.Errorf("result = %+v", res) + } + if len(res.Devices.Registered) != 1 || res.Devices.Registered[0].Name != "iPhone 0000AB" { + t.Errorf("registered = %+v (want the default name from the UDID)", res.Devices.Registered) + } + if p.count("DELETE /v1/profiles/"+first.Profile.ID) != 1 || p.count("POST /v1/profiles") != 1 || len(p.profiles) != 1 { + t.Errorf("calls = %v, profiles = %+v", p.calls, p.profiles) + } + if len(p.profiles[0].deviceIDs) != 2 { + t.Errorf("new profile devices = %v", p.profiles[0].deviceIDs) + } +} + +func TestAutoRecreatesInvalidProfile(t *testing.T) { + p := newPortal(t) + dir := t.TempDir() + first := run(t, p, devOpts(dir)) + keyPEM, _ := os.ReadFile(first.Files.Key) + p.profiles[0].state = asc.ProfileStateInvalid + + opts := devOpts(dir) + opts.KeyPEM = keyPEM + res := run(t, p, opts) + if res.Certificate.Created || !res.Profile.Created || res.Profile.Reason != "invalid" || res.Profile.ID == first.Profile.ID { + t.Errorf("result = %+v", res) + } + // An INVALID profile needs no relationship lookups to be condemned. + if p.count("GET /v1/profiles/"+first.Profile.ID+"/relationships/certificates") != 0 { + t.Errorf("calls = %v", p.calls) + } +} + +func TestAutoRecreatesProfileWhenCertificateChanges(t *testing.T) { + p := newPortal(t) + dir := t.TempDir() + first := run(t, p, devOpts(dir)) + + // No key on disk any more: a new certificate is issued and the profile + // follows it; the old certificate stays on the account. + res := run(t, p, devOpts(t.TempDir())) + if !res.Certificate.Created || res.Certificate.ID == first.Certificate.ID || res.Certificate.ValidOnAccount != 1 { + t.Errorf("certificate = %+v", res.Certificate) + } + if !res.Profile.Created || res.Profile.Reason != "certificate changed" { + t.Errorf("profile = %+v", res.Profile) + } + if len(p.certs) != 2 || p.profiles[0].certIDs[0] != res.Certificate.ID { + t.Errorf("certs = %d, profile certs = %v", len(p.certs), p.profiles[0].certIDs) + } +} + +func TestAutoForceIssuesNewCertificateAndProfile(t *testing.T) { + p := newPortal(t) + dir := t.TempDir() + first := run(t, p, devOpts(dir)) + keyPEM, _ := os.ReadFile(first.Files.Key) + + opts := devOpts(dir) + opts.KeyPEM = keyPEM + opts.Force = true + res := run(t, p, opts) + if !res.Certificate.Created || res.Certificate.ID == first.Certificate.ID || !res.Profile.Created || res.Profile.Reason != "forced" { + t.Errorf("result = %+v", res) + } + if len(p.certs) != 2 { + t.Errorf("force must not revoke the old certificate: %d certificates left", len(p.certs)) + } + if !KeyMatchesCertificate(keyPEM, p.certs[1].der) { + t.Error("the new certificate must be issued for the supplied key") + } +} + +func TestAutoAppStoreNeedsNoDevices(t *testing.T) { + p := newPortal(t) + p.bundleIDs = []string{"com.example.app.widget", "com.example.app"} + opts := devOpts(t.TempDir()) + opts.Type = TypeAppStore + opts.Devices = nil + res := run(t, p, opts) + if res.BundleID.Created || res.BundleID.ID != "bid-com.example.app" { + t.Errorf("bundle ID = %+v (must match the exact identifier)", res.BundleID) + } + if res.Certificate.Type != asc.CertificateTypeDistribution || res.Profile.Type != asc.ProfileTypeIOSAppStore || res.Profile.Name != "Builder app-store com.example.app" { + t.Errorf("result = %+v", res) + } + if res.Devices.InProfile != 0 || p.count("GET /v1/devices") != 0 || p.profiles[0].deviceIDs != nil { + t.Errorf("App Store setup touched devices: %+v, calls %v", res.Devices, p.calls) + } +} + +func TestAutoDevelopmentWithoutDevicesFails(t *testing.T) { + p := newPortal(t) + opts := devOpts(t.TempDir()) + opts.Devices = nil + _, err := Auto(context.Background(), p.client(t), opts) + if err == nil || !strings.Contains(err.Error(), "--device") || !strings.Contains(err.Error(), "--devices-from-mobai") { + t.Errorf("err = %v", err) + } + if p.count("POST /v1/profiles") != 0 { + t.Errorf("profile created without devices: %v", p.calls) + } +} + +func TestAutoDisabledDevicesStayOutOfProfile(t *testing.T) { + p := newPortal(t) + p.devices = []deviceRec{ + {id: "dev-old", name: "Old", udid: "00008020-0000000000000001", status: "DISABLED"}, + {id: "dev-ok", name: "Jane's iPhone", udid: "00008030-000000000000001e", status: "ENABLED"}, + } + res := run(t, p, devOpts(t.TempDir())) + if len(res.Devices.Registered) != 0 { + t.Errorf("UDID matching must be case-insensitive: registered %+v", res.Devices.Registered) + } + if res.Devices.InProfile != 1 || !slices.Equal(p.profiles[0].deviceIDs, []string{"dev-ok"}) { + t.Errorf("profile devices = %v", p.profiles[0].deviceIDs) + } +} + +func TestAutoWithSuppliedKeyReusesMatchingCertificate(t *testing.T) { + p := newPortal(t) + keyPEM, _, err := GenerateKeyAndCSR("Jane", "jane@example.com") + if err != nil { + t.Fatal(err) + } + key, _ := parseKey(keyPEM) + p.issue(asc.CertificateTypeDevelopment, &key.PublicKey, testNow.AddDate(0, 6, 0)) + // An expired one for the same key must not be picked. + expired := p.issue(asc.CertificateTypeDevelopment, &key.PublicKey, testNow.AddDate(0, -1, 0)) + + opts := devOpts(t.TempDir()) + opts.KeyPEM = keyPEM + res := run(t, p, opts) + if res.Certificate.Created || res.Certificate.ID != "cert-1" || res.Certificate.ID == expired.id || res.Certificate.ValidOnAccount != 1 { + t.Errorf("certificate = %+v", res.Certificate) + } + if p.count("POST /v1/certificates") != 0 { + t.Errorf("calls = %v", p.calls) + } +} + +func TestAutoCertificateLimitHint(t *testing.T) { + p := newPortal(t) + p.refuseCertificates = true + _, err := Auto(context.Background(), p.client(t), devOpts(t.TempDir())) + if err == nil || !strings.Contains(err.Error(), "maximum number of certificates") || !strings.Contains(err.Error(), "Revoke one") || !strings.Contains(err.Error(), "--key") { + t.Errorf("err = %v", err) + } +} + +func TestAutoDeviceLimitHint(t *testing.T) { + p := newPortal(t) + p.refuseDevices = true + _, err := Auto(context.Background(), p.client(t), devOpts(t.TempDir())) + if err == nil || !strings.Contains(err.Error(), "00008030-000000000000001E") || !strings.Contains(err.Error(), "100 iOS devices") { + t.Errorf("err = %v", err) + } +} + +func TestAutoRejectsBadOptions(t *testing.T) { + p := newPortal(t) + for name, mutate := range map[string]func(*AutoOptions){ + "no bundle ID": func(o *AutoOptions) { o.BundleID = "" }, + "bad type": func(o *AutoOptions) { o.Type = "enterprise" }, + "no password": func(o *AutoOptions) { o.Password = "" }, + } { + opts := devOpts(t.TempDir()) + mutate(opts) + if _, err := Auto(context.Background(), p.client(t), opts); err == nil { + t.Errorf("%s: no error", name) + } + } + if len(p.calls) != 0 { + t.Errorf("invalid options reached the API: %v", p.calls) + } +} + +func TestParseType(t *testing.T) { + for in, want := range map[string]Type{"development": TypeDevelopment, "Ad-Hoc": TypeAdHoc, "adhoc": TypeAdHoc, "app-store": TypeAppStore, "appstore": TypeAppStore} { + got, err := ParseType(in) + if err != nil || got != want { + t.Errorf("ParseType(%q) = %q, %v; want %q", in, got, err, want) + } + } + if _, err := ParseType("enterprise"); err == nil || !strings.Contains(err.Error(), "enterprise") { + t.Errorf("err = %v", err) + } + if TypeAppStore.NeedsDevices() || !TypeAdHoc.NeedsDevices() || !TypeDevelopment.NeedsDevices() { + t.Error("NeedsDevices: only App Store profiles list no devices") + } +} + +func TestBundleIDName(t *testing.T) { + for in, want := range map[string]string{"com.example.app": "com example app", "com.example.my-app_2": "com example my app 2", "App": "App"} { + if got := bundleIDName(in); got != want { + t.Errorf("bundleIDName(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/signing/signing.go b/internal/signing/signing.go index 5e71360..14fccbd 100644 --- a/internal/signing/signing.go +++ b/internal/signing/signing.go @@ -24,45 +24,50 @@ func GenerateKeyAndCSR(commonName, email string) (keyPEM, csrPEM []byte, err err if err != nil { return nil, nil, fmt.Errorf("failed to generate private key: %w", err) } + keyPEM = pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + csrPEM, err = CreateCSR(keyPEM, commonName, email) + if err != nil { + return nil, nil, err + } + return keyPEM, csrPEM, nil +} +// CreateCSR makes a PEM certificate signing request for an existing private +// key (as written by GenerateKeyAndCSR). The email is omitted from the +// subject when empty. +func CreateCSR(keyPEM []byte, commonName, email string) ([]byte, error) { + key, err := parseKey(keyPEM) + if err != nil { + return nil, err + } template := x509.CertificateRequest{ - Subject: pkix.Name{ - CommonName: commonName, - ExtraNames: []pkix.AttributeTypeAndValue{ - // emailAddress (OID 1.2.840.113549.1.9.1), as in Keychain CSRs. - // Forced to IA5String: Go would otherwise encode the '@' as - // UTF8String, which is not the standard encoding for this field. - { - Type: []int{1, 2, 840, 113549, 1, 9, 1}, - Value: asn1.RawValue{Tag: asn1.TagIA5String, Bytes: []byte(email)}, - }, - }, - }, + Subject: pkix.Name{CommonName: commonName}, SignatureAlgorithm: x509.SHA256WithRSA, } + if email != "" { + // emailAddress (OID 1.2.840.113549.1.9.1), as in Keychain CSRs. + // Forced to IA5String: Go would otherwise encode the '@' as + // UTF8String, which is not the standard encoding for this field. + template.Subject.ExtraNames = []pkix.AttributeTypeAndValue{{ + Type: []int{1, 2, 840, 113549, 1, 9, 1}, + Value: asn1.RawValue{Tag: asn1.TagIA5String, Bytes: []byte(email)}, + }} + } csrDER, err := x509.CreateCertificateRequest(rand.Reader, &template, key) if err != nil { - return nil, nil, fmt.Errorf("failed to create CSR: %w", err) + return nil, fmt.Errorf("failed to create CSR: %w", err) } - - keyPEM = pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(key), - }) - csrPEM = pem.EncodeToMemory(&pem.Block{ + return pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE REQUEST", Bytes: csrDER, - }) - return keyPEM, csrPEM, nil + }), nil } -// BuildP12 combines a PEM private key (from GenerateKeyAndCSR) with the -// certificate Apple issued for its CSR (DER .cer as downloaded from the -// portal, or PEM) into a password-protected PKCS#12 bundle, the same format -// Keychain Access exports. The legacy encoding is used because that is what -// macOS `security import` expects. -func BuildP12(keyPEM, certData []byte, password string) ([]byte, error) { +func parseKey(keyPEM []byte) (*rsa.PrivateKey, error) { block, _ := pem.Decode(keyPEM) if block == nil { return nil, fmt.Errorf("invalid private key: not PEM encoded") @@ -71,7 +76,10 @@ func BuildP12(keyPEM, certData []byte, password string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to parse private key: %w", err) } + return key, nil +} +func parseCertificate(certData []byte) (*x509.Certificate, error) { certDER := certData if certBlock, _ := pem.Decode(certData); certBlock != nil { certDER = certBlock.Bytes @@ -80,6 +88,38 @@ func BuildP12(keyPEM, certData []byte, password string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to parse certificate (expected the .cer file downloaded from the Apple Developer portal): %w", err) } + return cert, nil +} + +// KeyMatchesCertificate reports whether the certificate (DER or PEM) was +// issued for the private key's public key. +func KeyMatchesCertificate(keyPEM, certData []byte) bool { + key, err := parseKey(keyPEM) + if err != nil { + return false + } + cert, err := parseCertificate(certData) + if err != nil { + return false + } + certKey, ok := cert.PublicKey.(*rsa.PublicKey) + return ok && certKey.Equal(key.Public()) +} + +// BuildP12 combines a PEM private key (from GenerateKeyAndCSR) with the +// certificate Apple issued for its CSR (DER .cer as downloaded from the +// portal, or PEM) into a password-protected PKCS#12 bundle, the same format +// Keychain Access exports. The legacy encoding is used because that is what +// macOS `security import` expects. +func BuildP12(keyPEM, certData []byte, password string) ([]byte, error) { + key, err := parseKey(keyPEM) + if err != nil { + return nil, err + } + cert, err := parseCertificate(certData) + if err != nil { + return nil, err + } certKey, ok := cert.PublicKey.(*rsa.PublicKey) if !ok || !certKey.Equal(key.Public()) { From 1abbeb5a591306bd6c808f2ea98e9965046e3bf3 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:49:45 +0200 Subject: [PATCH 25/75] 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. --- cmd/builder/root.go | 42 +++++ cmd/builder/root_test.go | 66 ++++++++ cmd/builder/signing.go | 49 ++++-- cmd/builder/signing_auto.go | 330 ++++++++++++++++++++++++++++++++++++ internal/config/types.go | 1 + 5 files changed, 475 insertions(+), 13 deletions(-) create mode 100644 cmd/builder/root_test.go create mode 100644 cmd/builder/signing_auto.go diff --git a/cmd/builder/root.go b/cmd/builder/root.go index cfda5b9..b70af33 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -219,6 +219,45 @@ func detectIOSPath() (string, string) { return "", "" } +// bundleIDRe matches PRODUCT_BUNDLE_IDENTIFIER assignments in a project.pbxproj. +var bundleIDRe = regexp.MustCompile(`PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^";\s]+)"?\s*;`) + +// detectBundleID reads the app's bundle identifier from the Xcode project +// under iosPath. Test targets (…Tests) and values built from build settings +// ($(…)) are ignored; anything still ambiguous yields "" so init leaves the +// field for `signing setup` to resolve. +func detectBundleID(iosPath string) string { + if iosPath == "" { + iosPath = "." + } + projects, _ := filepath.Glob(filepath.Join(iosPath, "*.xcodeproj", "project.pbxproj")) + var found []string + for _, path := range projects { + data, err := os.ReadFile(path) + if err != nil { + continue + } + found = append(found, bundleIDsFromPbxproj(string(data))...) + } + if len(found) == 1 { + return found[0] + } + return "" +} + +// bundleIDsFromPbxproj returns the distinct app bundle identifiers in pbxproj text. +func bundleIDsFromPbxproj(text string) []string { + var ids []string + for _, m := range bundleIDRe.FindAllStringSubmatch(text, -1) { + id := m[1] + if strings.Contains(id, "$") || strings.HasSuffix(id, "Tests") || slices.Contains(ids, id) { + continue + } + ids = append(ids, id) + } + return ids +} + func detectGitHubRepo(remoteName string) (owner, repo string, err error) { // Try to get GitHub remote URL from git cmd := exec.Command("git", "remote", "get-url", remoteName) @@ -397,6 +436,9 @@ func runInit(cmd *cobra.Command, args []string) error { cfg.Project, cfg.Platform = projectName, "ios" cfg.GitHub = config.GitHubConfig{Owner: githubOwner, Repo: repoName} cfg.IOS.Path, cfg.IOS.Scheme = iosPath, scheme + if cfg.IOS.BundleID == "" { + cfg.IOS.BundleID = detectBundleID(iosPath) + } if flutterVersion != "" { cfg.Flutter.Version = flutterVersion } diff --git a/cmd/builder/root_test.go b/cmd/builder/root_test.go new file mode 100644 index 0000000..9432815 --- /dev/null +++ b/cmd/builder/root_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +const flutterPbxproj = ` + 97C147061CF9000F007C117D /* Debug */ = { + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = com.example.myApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + }; + 97C147071CF9000F007C117D /* Release */ = { + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = com.example.myApp; + }; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = com.example.myApp.RunnerTests; + }; + }; +` + +func TestBundleIDsFromPbxproj(t *testing.T) { + if got := bundleIDsFromPbxproj(flutterPbxproj); len(got) != 1 || got[0] != "com.example.myApp" { + t.Errorf("bundleIDsFromPbxproj = %v, want [com.example.myApp]", got) + } + quoted := `PRODUCT_BUNDLE_IDENTIFIER = "com.example.my-app"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).app";` + if got := bundleIDsFromPbxproj(quoted); len(got) != 1 || got[0] != "com.example.my-app" { + t.Errorf("bundleIDsFromPbxproj(quoted) = %v", got) + } + two := `PRODUCT_BUNDLE_IDENTIFIER = com.example.free; PRODUCT_BUNDLE_IDENTIFIER = com.example.pro;` + if got := bundleIDsFromPbxproj(two); len(got) != 2 { + t.Errorf("bundleIDsFromPbxproj(two apps) = %v", got) + } +} + +func TestDetectBundleID(t *testing.T) { + dir := t.TempDir() + proj := filepath.Join(dir, "ios", "Runner.xcodeproj") + if err := os.MkdirAll(proj, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(proj, "project.pbxproj"), []byte(flutterPbxproj), 0644); err != nil { + t.Fatal(err) + } + if got := detectBundleID(filepath.Join(dir, "ios")); got != "com.example.myApp" { + t.Errorf("detectBundleID = %q", got) + } + if got := detectBundleID(filepath.Join(dir, "missing")); got != "" { + t.Errorf("detectBundleID(missing) = %q, want empty", got) + } + // Two app targets: ambiguous, leave it to signing setup. + two := filepath.Join(dir, "two", "App.xcodeproj") + if err := os.MkdirAll(two, 0755); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(two, "project.pbxproj"), []byte(`PRODUCT_BUNDLE_IDENTIFIER = com.example.free; PRODUCT_BUNDLE_IDENTIFIER = com.example.pro;`), 0644) + if got := detectBundleID(filepath.Join(dir, "two")); got != "" { + t.Errorf("detectBundleID(two apps) = %q, want empty", got) + } +} diff --git a/cmd/builder/signing.go b/cmd/builder/signing.go index 1c64163..9de5b4b 100644 --- a/cmd/builder/signing.go +++ b/cmd/builder/signing.go @@ -23,23 +23,31 @@ var signingCmd = &cobra.Command{ var signingSetupCmd = &cobra.Command{ Use: "setup", Short: "Set up code signing for iOS builds", - Long: `Uploads your iOS signing certificate and provisioning profile to GitHub Secrets. - -The certificate can be either: + Long: `Sets up code signing for iOS builds and uploads the material to GitHub Secrets. + +Without --certificate/--profile the whole thing is automatic, using the App +Store Connect API key from 'builder auth apple': the App ID is registered if +missing, a certificate is issued for a private key generated here (or --key), +devices are registered (--device, --devices-from-mobai) and a provisioning +profile named "Builder " is created. Running it again is +safe: valid material is reused and only what is missing, expired, invalid or +changed is recreated. Nothing is ever revoked. + + --type development Apple Development certificate, devices required (default) + --type ad-hoc Apple Distribution certificate, devices required + --type app-store Apple Distribution certificate, no devices; TestFlight/App + Store uploads need this and ios.configuration Release + +With --certificate and --profile the files are taken as they are: - A .p12 file (exported from Keychain Access on a Mac) - A .cer file downloaded from the Apple Developer portal, together with the private key from 'builder signing csr' (--key) — the .p12 is then assembled locally, so no Mac is needed at any point -This command will: -- Read your certificate and .mobileprovision provisioning profile -- Base64 encode and encrypt them -- Upload them as GitHub repository secrets: - - IOS_CERTIFICATE - - IOS_CERTIFICATE_PASSWORD - - IOS_PROVISIONING_PROFILE - -After setup, builds will be signed automatically.`, +Either way the command uploads three GitHub repository secrets — +IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD, IOS_PROVISIONING_PROFILE — and sets +ios.signing in builder.json. For Codemagic and Bitrise it writes the files and +points at docs/provider-secrets.md instead.`, RunE: runSigningSetup, } @@ -75,7 +83,16 @@ func init() { signingSetupCmd.Flags().StringP("certificate", "c", "", "Path to certificate file (.p12, or .cer from the Apple Developer portal)") signingSetupCmd.Flags().StringP("profile", "p", "", "Path to .mobileprovision file") - signingSetupCmd.Flags().StringP("key", "k", "", "Path to the private key from 'builder signing csr' (required with a .cer)") + signingSetupCmd.Flags().StringP("key", "k", "", "Path to the private key from 'builder signing csr' (required with a .cer; automatic mode reuses it and its certificate)") + signingSetupCmd.Flags().String("bundle-id", "", "App bundle ID (default: ios.bundleId in builder.json, else the newest IPA in ./dist)") + signingSetupCmd.Flags().String("type", string(signing.TypeDevelopment), "Signing type: development, ad-hoc or app-store") + signingSetupCmd.Flags().StringArray("device", nil, "Device UDID to register (repeatable)") + signingSetupCmd.Flags().Bool("devices-from-mobai", false, "Register the physical iOS devices connected to MobAI") + signingSetupCmd.Flags().String("out-dir", ".", "Directory for the private key, .p12 and .mobileprovision") + signingSetupCmd.Flags().String("password", "", "Password to protect the .p12 (prompted; generated with --yes)") + signingSetupCmd.Flags().Bool("force", false, "Issue a new certificate and profile even when valid ones exist") + signingSetupCmd.Flags().BoolP("yes", "y", false, "Skip confirmations") + signingSetupCmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr)") signingCSRCmd.Flags().String("name", "", "Your name (certificate common name)") signingCSRCmd.Flags().String("email", "", "Email address of your Apple Developer account") @@ -235,6 +252,12 @@ func expandPath(path string) string { } func runSigningSetup(cmd *cobra.Command, args []string) error { + if certFlag, _ := cmd.Flags().GetString("certificate"); certFlag == "" { + if profileFlag, _ := cmd.Flags().GetString("profile"); profileFlag == "" { + return runSigningAuto(cmd) + } + } + cfg, err := loadConfig() if err != nil { return err diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go new file mode 100644 index 0000000..51cbfe2 --- /dev/null +++ b/cmd/builder/signing_auto.go @@ -0,0 +1,330 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/github" + "github.com/MobAI-App/ios-builder/internal/ipa" + "github.com/MobAI-App/ios-builder/internal/mobai" + "github.com/MobAI-App/ios-builder/internal/signing" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// providerSecretsDoc explains the dashboard steps for Codemagic and Bitrise. +const providerSecretsDoc = "https://github.com/MobAI-App/ios-builder/blob/main/docs/provider-secrets.md" + +// signingAutoResult is the JSON output of the automatic `signing setup`. +type signingAutoResult struct { + *signing.AutoResult + Provider string `json:"provider"` + SecretsUploaded bool `json:"secrets_uploaded"` + // GeneratedPassword is set when no password was given: it is printed + // exactly once, here. + GeneratedPassword string `json:"generated_password,omitempty"` +} + +func stdinIsTerminal() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} + +// runSigningAuto is `signing setup` without --certificate/--profile: it +// provisions everything through the App Store Connect API. +func runSigningAuto(cmd *cobra.Command) error { + cfg, err := loadConfig() + if err != nil { + return err + } + typeFlag, _ := cmd.Flags().GetString("type") + typ, err := signing.ParseType(typeFlag) + if err != nil { + return err + } + client, err := getASCClient() + if err != nil { + return err + } + provider, err := cfg.ProviderName("") + if err != nil { + return err + } + var ghClient *github.Client + if provider == "github" { + if ghClient, err = getGitHubClient(); err != nil { + return err + } + } + out := newOutput(cmd) + yes, _ := cmd.Flags().GetBool("yes") + force, _ := cmd.Flags().GetBool("force") + outDir, _ := cmd.Flags().GetString("out-dir") + outDir = expandPath(outDir) + ctx, cancel := commandContext(cmd, false) + defer cancel() + + bundleID, err := resolveSigningBundleID(cmd, cfg, out) + if err != nil { + return err + } + devices, err := signingDevices(ctx, cmd, cfg, typ) + if err != nil { + return err + } + keyPEM, keyPath, err := signingKey(cmd, outDir) + if err != nil { + return err + } + + // The plan, then one confirmation before anything is created. + fmt.Fprintf(out.log, "Bundle ID: %s\n", bundleID) + fmt.Fprintf(out.log, "Type: %s\n", typ) + if typ.NeedsDevices() { + fmt.Fprintf(out.log, "Devices: %s\n", describeDevices(devices)) + } + if keyPath != "" { + fmt.Fprintf(out.log, "Key: %s (reusing its certificate if one is valid)\n", keyPath) + } else { + fmt.Fprintf(out.log, "Key: new, written to %s\n", filepath.Join(outDir, signing.KeyFileName)) + } + fmt.Fprintf(out.log, "Provider: %s\n", provider) + if force { + fmt.Fprintln(out.log, "Force: a new certificate and profile will be issued") + } + fmt.Fprintln(out.log) + if !yes { + if !stdinIsTerminal() { + return errors.New("this creates resources in your Apple Developer account; confirm with --yes when not running in a terminal") + } + if _, err := (&promptui.Prompt{Label: "Continue", IsConfirm: true}).Run(); err != nil { + return errors.New("canceled") + } + } + + password, _ := cmd.Flags().GetString("password") + var generated string + switch { + case password != "": + case yes || !stdinIsTerminal(): + if generated, err = randomPassword(); err != nil { + return err + } + password = generated + default: + if password, err = promptPassword("Password to protect the .p12"); err != nil { + return err + } + } + + res := &signingAutoResult{Provider: provider, GeneratedPassword: generated} + res.AutoResult, err = signing.Auto(ctx, client, &signing.AutoOptions{ + BundleID: bundleID, Type: typ, Devices: devices, KeyPEM: keyPEM, CommonName: cfg.Project, + Password: password, Force: force, OutDir: outDir, Log: out.log, + }) + if err != nil { + return finish(out, cmd, res, err, nil) + } + if ghClient != nil { + fmt.Fprintf(out.log, "\nUploading secrets to %s/%s...\n", cfg.GitHub.Owner, cfg.GitHub.Repo) + if err := uploadSigningSecrets(ctx, ghClient, cfg, out.log, res.P12, password, res.ProfileContent); err != nil { + return finish(out, cmd, res, err, nil) + } + res.SecretsUploaded = true + cfg.IOS.Signing = true + } + if cfg.IOS.BundleID == "" { + cfg.IOS.BundleID = bundleID + } + if err := config.NewManager().Save(cfg); err != nil { + return finish(out, cmd, res, fmt.Errorf("failed to update config: %w", err), nil) + } + fmt.Fprintln(out.log, " Updated: builder.json") + + return finish(out, cmd, res, nil, func() { printSigningSummary(cfg, res) }) +} + +// resolveSigningBundleID takes the flag, then builder.json, then the newest +// IPA in ./dist, then asks (only in a terminal). +func resolveSigningBundleID(cmd *cobra.Command, cfg *config.Config, out output) (string, error) { + if id, _ := cmd.Flags().GetString("bundle-id"); id != "" { + return strings.TrimSpace(id), nil + } + if cfg.IOS.BundleID != "" { + return cfg.IOS.BundleID, nil + } + if path, err := ipa.Newest("dist"); err == nil { + if id := ipa.BundleID(path); id != "" { + fmt.Fprintf(out.log, "Bundle ID %s read from %s\n", id, path) + return id, nil + } + } + if !stdinIsTerminal() || out.json { + return "", errors.New("bundle ID unknown: pass --bundle-id, set ios.bundleId in builder.json, or build once so ./dist has an IPA to read it from") + } + id, err := promptString("App bundle ID (e.g. com.example.app)", "") + if err != nil { + return "", err + } + if id = strings.TrimSpace(id); id == "" { + return "", errors.New("a bundle ID is required") + } + return id, nil +} + +// signingDevices collects --device UDIDs and, with --devices-from-mobai, the +// physical iOS devices MobAI has connected. +func signingDevices(ctx context.Context, cmd *cobra.Command, cfg *config.Config, typ signing.Type) ([]signing.Device, error) { + udids, _ := cmd.Flags().GetStringArray("device") + fromMobAI, _ := cmd.Flags().GetBool("devices-from-mobai") + if !typ.NeedsDevices() && (len(udids) > 0 || fromMobAI) { + return nil, fmt.Errorf("--type %s profiles list no devices; drop --device/--devices-from-mobai", typ) + } + var devices []signing.Device + for _, u := range udids { + devices = append(devices, signing.Device{UDID: strings.TrimSpace(u)}) + } + if !fromMobAI { + return devices, nil + } + url := cfg.MobAI.URL + if url == "" { + url = mobai.DefaultBaseURL + } + connected, err := mobai.NewClient(url).ListDevices(ctx) + if err != nil { + return nil, fmt.Errorf("list MobAI devices: %w (is MobAI running? try builder mobai ping)", err) + } + found := 0 + for _, d := range connected { + if d.Virtual || (d.Platform != "" && !strings.EqualFold(d.Platform, "ios")) { + continue + } + devices = append(devices, signing.Device{Name: d.Name, UDID: d.ID}) + found++ + } + if found == 0 { + return nil, errors.New("MobAI has no physical iOS device connected; plug one in or pass --device ") + } + return devices, nil +} + +// signingKey returns --key, else the key a previous run left in outDir, else +// nil so a key is generated. keyPath is "" when generating. +func signingKey(cmd *cobra.Command, outDir string) (keyPEM []byte, keyPath string, err error) { + keyPath, _ = cmd.Flags().GetString("key") + if keyPath == "" { + candidate := filepath.Join(outDir, signing.KeyFileName) + if _, err := os.Stat(candidate); err != nil { + return nil, "", nil + } + keyPath = candidate + } + keyPath = expandPath(keyPath) + keyPEM, err = os.ReadFile(keyPath) + if err != nil { + return nil, "", fmt.Errorf("failed to read private key %s: %w", keyPath, err) + } + return keyPEM, keyPath, nil +} + +func describeDevices(devices []signing.Device) string { + if len(devices) == 0 { + return "none given; the profile covers the devices already on the account" + } + parts := make([]string, 0, len(devices)) + for _, d := range devices { + if d.Name != "" { + parts = append(parts, fmt.Sprintf("%s (%s)", d.Name, d.UDID)) + } else { + parts = append(parts, d.UDID) + } + } + return strings.Join(parts, ", ") +} + +// randomPassword is 128 bits of randomness as URL-safe base64. +func randomPassword() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate password: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// uploadSigningSecrets encrypts and stores the three signing secrets. +func uploadSigningSecrets(ctx context.Context, gh *github.Client, cfg *config.Config, log io.Writer, p12 []byte, password string, profile []byte) error { + publicKey, err := gh.GetPublicKey(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo) + if err != nil { + return fmt.Errorf("failed to get repository public key: %w", err) + } + secrets := []struct{ name, value string }{ + {"IOS_CERTIFICATE", base64.StdEncoding.EncodeToString(p12)}, + {"IOS_CERTIFICATE_PASSWORD", password}, + {"IOS_PROVISIONING_PROFILE", base64.StdEncoding.EncodeToString(profile)}, + } + for _, s := range secrets { + encrypted, err := github.EncryptSecret(publicKey.Key, s.value) + if err != nil { + return fmt.Errorf("failed to encrypt %s: %w", s.name, err) + } + if err := gh.CreateOrUpdateSecret(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo, s.name, encrypted, publicKey.KeyID); err != nil { + return fmt.Errorf("failed to upload %s: %w", s.name, err) + } + fmt.Fprintf(log, " Uploaded: %s\n", s.name) + } + return nil +} + +func printSigningSummary(cfg *config.Config, res *signingAutoResult) { + state := func(created bool, reason string) string { + if !created { + return "reused" + } + if reason != "" && reason != "missing" { + return "new (" + reason + ")" + } + return "new" + } + fmt.Println() + fmt.Printf("Bundle ID: %s (%s)\n", res.BundleID.Identifier, state(res.BundleID.Created, "")) + fmt.Printf("Certificate: %s (%s, expires %s)\n", res.Certificate.Name, state(res.Certificate.Created, ""), res.Certificate.ExpirationDate.Format("2006-01-02")) + if res.Type.NeedsDevices() { + fmt.Printf("Devices: %d in the profile, %d registered now\n", res.Devices.InProfile, len(res.Devices.Registered)) + } + fmt.Printf("Profile: %s (%s, %s, expires %s)\n", res.Profile.Name, state(res.Profile.Created, res.Profile.Reason), strings.ToLower(res.Profile.State), res.Profile.ExpirationDate.Format("2006-01-02")) + fmt.Println() + if res.Files.Key != "" { + fmt.Printf("Private key: %s\n", res.Files.Key) + } + fmt.Printf("Certificate: %s\n", res.Files.P12) + fmt.Printf("Profile: %s\n", res.Files.Profile) + if res.GeneratedPassword != "" { + fmt.Printf("Password: %s (generated; shown only now)\n", res.GeneratedPassword) + } + fmt.Println("Keep these out of git (add them to .gitignore); gitignored files are also left out of build snapshots.") + fmt.Println() + if res.SecretsUploaded { + fmt.Printf("Secrets uploaded to %s/%s and ios.signing enabled in builder.json.\n", cfg.GitHub.Owner, cfg.GitHub.Repo) + } else { + fmt.Printf("%s secrets are set in its dashboard, not by Builder. Add:\n", res.Provider) + fmt.Printf(" IOS_CERTIFICATE base64 of %s\n", res.Files.P12) + fmt.Println(" IOS_CERTIFICATE_PASSWORD the .p12 password") + fmt.Printf(" IOS_PROVISIONING_PROFILE base64 of %s\n", res.Files.Profile) + fmt.Printf("then set ios.signing to true in builder.json. Steps: %s\n", providerSecretsDoc) + } + fmt.Println() + fmt.Println("Next: builder ios build") + if res.Type == signing.TypeAppStore { + fmt.Println(`App Store builds need "configuration": "Release" under ios in builder.json; then builder ios upload --wait.`) + } + fmt.Println("Run builder signing setup again any time: it reuses what is valid and renews only what expired or changed.") +} diff --git a/internal/config/types.go b/internal/config/types.go index d67914d..758348d 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -110,6 +110,7 @@ type IOSConfig struct { // Empty means root directory contains the Xcode project Path string `json:"path,omitempty"` Scheme string `json:"scheme,omitempty"` // Xcode scheme to build (auto-detected if empty) + BundleID string `json:"bundleId,omitempty"` // App bundle identifier, for signing setup (detected by init when unambiguous) Signing bool `json:"signing,omitempty"` // Whether code signing is configured Configuration string `json:"configuration,omitempty"` // Build configuration: Debug (faster) or Release (production) } From ab633309f8f04eb95958513da1f410de1b9f912a Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:51:46 +0200 Subject: [PATCH 26/75] 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. --- CLAUDE.md | 44 +++++++++++++++-- README.md | 101 ++++++++++++++++++++++++++++++--------- docs/provider-secrets.md | 18 +++++-- 3 files changed, 135 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d36f8d5..3a7c581 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,8 @@ go install ./cmd/builder ./builder dev flutter --skip-install --bundle-id # Use already installed app ./builder dev rn --skip-install --bundle-id # Use already installed app ./builder auth apple # Save an App Store Connect API key +./builder signing setup --devices-from-mobai # Certificate + devices + profile via the ASC API, secrets to GitHub +./builder signing setup --type app-store --yes --json # Distribution certificate + App Store profile, no prompts ./builder ios upload --wait # Upload dist/*.ipa to App Store Connect, wait for processing ./builder ios submit --testflight --group --notes # TestFlight ./builder ios submit --app-store --release after-approval # App Review @@ -106,6 +108,20 @@ builder dev kmp ─────────► Connects to MobAI ▼ Launches app and streams output (no hot reload) +builder signing setup ───► Bundle ID: --bundle-id → ios.bundleId → dist/*.ipa → prompt + │ + ▼ + App Store Connect API (signing.Auto) + ├─ bundleIds?filter[identifier] → POST bundleIds + ├─ certificates?filter[certificateType] → reuse if the + │ key matches, else CSR → POST certificates → .p12 + ├─ devices?filter[platform]=IOS → POST devices (dev/ad-hoc) + └─ profiles?filter[name] → reuse / DELETE + POST profiles + │ + ▼ + Writes key/.p12/.mobileprovision, uploads the three IOS_* + secrets (GitHub) or prints them (Codemagic/Bitrise) + builder ios upload ──────► Reads bundle ID / version / build number from dist/*.ipa │ ▼ @@ -135,11 +151,12 @@ cmd/builder/ # CLI entrypoint (Cobra) internal/ auth/ # GitHub OAuth device flow + keyring storage (also CI tokens, ASC API key) github/ # GitHub REST API (workflow dispatch, artifacts) - asc/ # App Store Connect API client (JWT, JSON:API, builds, uploads, TestFlight, review) + asc/ # App Store Connect API client (JWT, JSON:API, builds, uploads, TestFlight, review, + # bundle IDs, certificates, devices, profiles) distribute/ # Upload / TestFlight / App Store flows on top of asc ipa/ # Info.plist reading from .ipa archives build/ # Build coordination (snapshot + trigger + poll + download) - signing/ # CSR generation and .p12 assembly (signing without a Mac) + signing/ # CSR generation, .p12 assembly, and Auto (portal-free provisioning on top of asc) snapshot/ # Working-tree snapshot as a throwaway commit on a remote ref workflow/ # Workflow template (embedded) config/ # builder.json management @@ -224,6 +241,23 @@ internal/ chosen group is external and none exists) → add groups. App Store reuses an open `reviewSubmission` (READY_FOR_REVIEW/UNRESOLVED_ISSUES), skips the item when the version is already in it, and rewrites ASC 409/422 with a "complete the metadata" hint. +- **Automatic Signing** (`signing.Auto`, behind `signing setup` without `--certificate`/ + `--profile`): idempotent and never revokes. A certificate is reused only when its private key + is local (`--key`, or the `ios-signing.key` a previous run left in `--out-dir`), since a .p12 + needs the key; otherwise a new one is issued and Apple's quota error (2 Development / + 3 Distribution) gets a hint. Dev/ad-hoc profiles cover every ENABLED iOS device on the + account, not just the ones passed; App Store profiles send no `devices` relationship at + all (an empty one is rejected). Profile membership is read from + `/v1/profiles/{id}/relationships/{certificates,devices}` (paginated), not `include=`, which + caps linkage arrays. The profile `Builder ` is recreated when INVALID, + expired, `--force`, or when the certificate/device set differs; same-named duplicates are + deleted with it. `filter[identifier]` on bundleIds is a prefix match, so the exact identifier + is checked client-side. The manual `--certificate`/`--profile` path in `runSigningSetup` is + untouched; the automatic one lives in `cmd/builder/signing_auto.go`. +- **Export Method Is Still `development`**: `ios-build.yml` and `runner.sh` hardcode + `method = development` in ExportOptions.plist, so an ad-hoc or App Store profile from + `signing setup --type ad-hoc|app-store` signs the archive but the export step needs the + matching method before those IPAs work (roadmap prerequisite under item 1). - **Extension Points**: a future `ios release` (upload + TestFlight, automatic build numbers) composes `distribute.Upload` and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` for the latest build number; the `pkg/` wrappers do not expose `asc` yet. @@ -236,10 +270,14 @@ internal/ "project": "MyApp", "platform": "ios", "github": { "owner": "username", "repo": "my-ios-app" }, - "ios": { "path": "ios", "scheme": "" } + "ios": { "path": "ios", "scheme": "", "bundleId": "com.example.app" } } ``` +`ios.bundleId` is optional: `init` fills it from `PRODUCT_BUNDLE_IDENTIFIER` when the Xcode +project has exactly one app target (test targets and `$(…)` values are skipped), and +`signing setup` saves whatever it resolved. + ## Workflow Features The embedded workflow template (`internal/workflow/templates/ios-build.yml`): diff --git a/README.md b/README.md index b11157a..1b90fc4 100644 --- a/README.md +++ b/README.md @@ -197,10 +197,12 @@ builder mobai install # Install an IPA on the device builder mobai run-debug # Launch an app with the debugger attached builder mobai forward # Forward a device port -# Code signing -builder signing csr # Create a private key + certificate signing request -builder signing p12 # Assemble a .p12 from the key and Apple's certificate -builder signing setup # Upload code signing secrets to GitHub +# Code signing (automatic mode needs builder auth apple) +builder signing setup --devices-from-mobai # Certificate, devices, profile and GitHub secrets, no portal +builder signing setup --type app-store # Apple Distribution certificate + App Store profile +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision # Upload your own files +builder signing csr # Manual path: create a private key + certificate signing request +builder signing p12 # Manual path: assemble a .p12 from the key and Apple's certificate # TestFlight and App Store (needs builder auth apple) builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing @@ -250,6 +252,7 @@ never prompts, so agents and CI jobs can drive them. |-------|-------------|---------| | `ios.path` | Path to the Xcode project relative to the repo root | detected by `init` | | `ios.scheme` | Xcode scheme to build | auto-detected | +| `ios.bundleId` | App bundle identifier, used by `signing setup` | detected by `init` when the project has one app target; else saved by `signing setup` | | `ios.signing` | Sign the IPA with the uploaded certificate and profile | `false` | | `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` | @@ -278,23 +281,75 @@ mirrored networking. ## Code Signing -For Codemagic and Bitrise, follow the [signing and MobAI secrets guide](docs/provider-secrets.md) -for dashboard instructions, file encoding, and verification. The `signing setup` -command below uploads to GitHub Actions only. - By default, builds are unsigned. Signed builds need a signing certificate and a provisioning profile — and despite what many guides claim, **you do not need a -Mac to create either one**. The `.p12` certificate is normally created through -Keychain Access, but Builder does the same thing itself: it generates the -private key and certificate signing request, and assembles the `.p12` from the -certificate Apple issues. +Mac to create either one**, nor a tour of the Apple Developer portal. With an +App Store Connect API key, `builder signing setup` does the whole thing through +the API; the [manual path](#manual-path-through-the-apple-developer-portal) +below is the fallback when you would rather click, or already have the files. You need a paid [Apple Developer Program](https://developer.apple.com/programs/) -membership — the portal only issues certificates to paid accounts. (Without one, +membership — Apple only issues certificates to paid accounts. (Without one, build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free Apple ID.) -### 1. Create a certificate signing request +### Automatic setup + +```bash +builder auth apple # once: save the App Store Connect API key +builder signing setup --devices-from-mobai # development signing for the devices MobAI sees +``` + +The key needs the **Admin** role (or App Manager plus *Access to Certificates, +Identifiers & Profiles*): Developer-role keys cannot create certificates. +`setup` then: + +1. Registers the **App ID** if the bundle identifier is not on the account yet. + The bundle ID comes from `--bundle-id`, `ios.bundleId` in `builder.json` + (which `init` fills when the Xcode project has a single app target), or the + newest IPA in `./dist/`; in a terminal it asks as a last resort. +2. Issues a **certificate** — Apple Development for `--type development`, Apple + Distribution for `ad-hoc` and `app-store` — for a private key generated on + your machine (`ios-signing.key`, or `--key` to reuse one from `signing csr`). + A valid certificate on the account is reused only when its private key is + here, because that is the only way to build the `.p12`; otherwise a new one + is issued. Nothing is ever revoked: when Apple's limit (2 Development, 3 + Distribution) is hit, the error names it and points at the portal. +3. Registers **devices** from `--device ` (repeatable) and + `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has + connected). Development and ad-hoc profiles cover every enabled iOS device on + the account, so with none given and none registered the command stops and + says so. App Store profiles take no devices. Apple allows 100 devices per + membership year and never frees a slot; that error is passed through too. +4. Creates the **profile** `Builder ` (iOS App Development, + Ad Hoc or App Store). An existing one is reused while it is `ACTIVE`, + unexpired and still lists exactly this certificate and these devices; + otherwise it is deleted and recreated, and the summary says why (`invalid`, + `expired`, `certificate changed`, `devices changed`, `forced`). +5. Writes `ios-signing.key` (when generated), `ios-signing.p12` and + `Builder--.mobileprovision` to `--out-dir` (default `.`), + uploads `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and + `IOS_PROVISIONING_PROFILE` to GitHub Secrets and sets `ios.signing` to + `true`. For Codemagic and Bitrise it prints the three values to paste + instead, following the [signing and MobAI secrets guide](docs/provider-secrets.md). + +The command shows its plan and asks once before creating anything; `--yes` +skips that (required without a terminal), and then the `.p12` password is +generated and printed once unless `--password` is given. `--json` prints the +result as JSON with progress on stderr. Keep the written files out of git. + +Run it again whenever you like: it reports what it found and recreates only what +is missing, expired, invalid or changed — add a device, re-run, rebuild. +`--force` issues a fresh certificate and profile regardless. For TestFlight use +`--type app-store` and set `ios.configuration` to `Release`. + +### Manual path through the Apple Developer portal + +The `.p12` certificate is normally created through Keychain Access, but Builder +does the same thing itself: it generates the private key and certificate +signing request, and assembles the `.p12` from the certificate Apple issues. + +#### 1. Create a certificate signing request ```bash builder signing csr @@ -305,13 +360,13 @@ directory: `ios-signing.key` (your private key) and `ios-signing.csr`. Keep the key wherever suits you — just don't commit it (add it to `.gitignore`; gitignored files are also excluded from build snapshots). -### 2. Create the certificate +#### 2. Create the certificate 1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal 2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc) 3. Upload `ios-signing.csr` and download the resulting `.cer` file -### 3. Assemble the .p12 +#### 3. Assemble the .p12 ```bash builder signing p12 --certificate development.cer --key ios-signing.key @@ -322,7 +377,7 @@ password you choose — byte-for-byte the same kind of file Keychain Access exports, and usable anywhere one is: `builder signing setup`, Sideloadly, AltStore, or importing it on a Mac. Keep it, and don't commit it. -### 4. Create a provisioning profile +#### 4. Create a provisioning profile On the portal: @@ -330,13 +385,15 @@ On the portal: 2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page) 3. **Profiles** → create an **iOS App Development** (or Ad Hoc) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file -### 5. Upload the signing secrets +#### 5. Upload the signing secrets ```bash builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision ``` -This uploads the signing material to GitHub Secrets: +With `--certificate` and `--profile` given, `setup` takes the files as they are +(no App Store Connect key involved) and uploads the signing material to GitHub +Secrets: - `IOS_CERTIFICATE` - Base64-encoded .p12 file - `IOS_CERTIFICATE_PASSWORD` - Certificate password - `IOS_PROVISIONING_PROFILE` - Base64-encoded .mobileprovision file @@ -366,9 +423,9 @@ You need: membership and an app record in App Store Connect (My Apps → +) with your bundle ID - An IPA signed with an **Apple Distribution** certificate and an **App Store** - provisioning profile. `builder signing setup` accepts both, exactly as in the - steps above; pick those types on the portal instead of the development ones. - An IPA signed for development is rejected at upload. + provisioning profile: `builder signing setup --type app-store` creates both, + or pick those types on the portal in the manual path. An IPA signed for + development is rejected at upload. - `"configuration": "Release"` under `ios` in `builder.json`: `ios build` defaults to `Debug`, which is what the dev commands expect, not what you want to ship. diff --git a/docs/provider-secrets.md b/docs/provider-secrets.md index e47a643..00a651f 100644 --- a/docs/provider-secrets.md +++ b/docs/provider-secrets.md @@ -31,8 +31,19 @@ explains selecting the App ID, certificate, and devices. App Store/Ad Hoc export requires a corresponding change to the generated runner's export settings. If you already have the P12 and profile, reuse them. If you have no certificate, -follow Builder's [certificate creation instructions](../README.md#1-create-a-certificate-signing-request). -Run the CSR/P12 commands in a private directory outside your source checkout: +the quickest way is the [automatic setup](../README.md#automatic-setup) with an +App Store Connect API key (`builder auth apple`), pointed at a private directory +outside your source checkout: + +```sh +builder signing setup --devices-from-mobai --out-dir ~/signing +``` + +With `provider` set to Codemagic or Bitrise in `builder.json`, this creates the +certificate, devices and profile through the API, writes `ios-signing.p12` and +the `.mobileprovision` to `~/signing`, and prints the three values to paste +below instead of uploading them. Alternatively follow the +[manual certificate steps](../README.md#1-create-a-certificate-signing-request): ```sh builder signing csr @@ -40,7 +51,8 @@ builder signing csr builder signing p12 --certificate development.cer --key ios-signing.key ``` -The second command prompts for the P12 password. Use that exact password below. +The `p12` command prompts for the P12 password (automatic setup prompts too, or +generates one with `--yes` and prints it once). Use that exact password below. A `.cer` alone is not the value for `IOS_CERTIFICATE`; assemble the P12 first. Keep private keys, P12 files, and encoded copies out of Git and build snapshots. From 620099e2710a5b963b18cad94bd2f353003e6da1 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:58:50 +0200 Subject: [PATCH 27/75] 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. --- internal/signing/auto.go | 36 ++++++++++++++++++----------------- internal/signing/auto_test.go | 25 +++++++++++++++++++++--- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/internal/signing/auto.go b/internal/signing/auto.go index 785b2bb..fde6599 100644 --- a/internal/signing/auto.go +++ b/internal/signing/auto.go @@ -200,13 +200,31 @@ func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResu } res.BundleID.ID, res.BundleID.Identifier = bundle.ID, bundle.Identifier - // 2. Certificate + // 2. Devices, before anything that counts against a quota: a development + // profile with no device to cover is an error, and it must not cost a + // certificate. + var deviceIDs []string + if opts.Type.NeedsDevices() { + if deviceIDs, err = ensureDevices(ctx, client, opts, &res.Devices); err != nil { + return res, err + } + } + + // 3. Certificate. A generated key is on disk before the CSR goes to + // Apple: a certificate whose key is lost cannot be revoked by Builder and + // occupies one of the team's slots for a year. + if err := os.MkdirAll(opts.OutDir, 0755); err != nil { + return res, fmt.Errorf("create %s: %w", opts.OutDir, err) + } keyPEM := opts.KeyPEM if keyPEM == nil { if keyPEM, err = generateKey(); err != nil { return res, err } res.Files.Key = filepath.Join(opts.OutDir, KeyFileName) + if err := os.WriteFile(res.Files.Key, keyPEM, 0600); err != nil { + return res, fmt.Errorf("write private key: %w", err) + } } cert, err := ensureCertificate(ctx, client, opts, keyPEM, now(), &res.Certificate) if err != nil { @@ -217,14 +235,6 @@ func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResu return res, err } - // 3. Devices - var deviceIDs []string - if opts.Type.NeedsDevices() { - if deviceIDs, err = ensureDevices(ctx, client, opts, &res.Devices); err != nil { - return res, err - } - } - // 4. Profile profile, err := ensureProfile(ctx, client, opts, bundle.ID, cert.ID, deviceIDs, now(), &res.Profile) if err != nil { @@ -233,14 +243,6 @@ func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResu res.ProfileContent = profile.Content // 5. Files - if err := os.MkdirAll(opts.OutDir, 0755); err != nil { - return res, fmt.Errorf("create %s: %w", opts.OutDir, err) - } - if res.Files.Key != "" { - if err := os.WriteFile(res.Files.Key, keyPEM, 0600); err != nil { - return res, fmt.Errorf("write private key: %w", err) - } - } res.Files.P12 = filepath.Join(opts.OutDir, P12FileName) if err := os.WriteFile(res.Files.P12, res.P12, 0600); err != nil { return res, fmt.Errorf("write .p12: %w", err) diff --git a/internal/signing/auto_test.go b/internal/signing/auto_test.go index 3683895..159cde8 100644 --- a/internal/signing/auto_test.go +++ b/internal/signing/auto_test.go @@ -538,8 +538,13 @@ func TestAutoDevelopmentWithoutDevicesFails(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "--device") || !strings.Contains(err.Error(), "--devices-from-mobai") { t.Errorf("err = %v", err) } - if p.count("POST /v1/profiles") != 0 { - t.Errorf("profile created without devices: %v", p.calls) + // Devices are checked before the certificate: no device means no key + // written and no certificate slot spent. + if p.count("POST /v1/certificates") != 0 || p.count("POST /v1/profiles") != 0 { + t.Errorf("certificate or profile created without devices: %v", p.calls) + } + if _, err := os.Stat(filepath.Join(opts.OutDir, KeyFileName)); err == nil { + t.Error("a key was written although no certificate was requested") } } @@ -583,10 +588,24 @@ func TestAutoWithSuppliedKeyReusesMatchingCertificate(t *testing.T) { func TestAutoCertificateLimitHint(t *testing.T) { p := newPortal(t) p.refuseCertificates = true - _, err := Auto(context.Background(), p.client(t), devOpts(t.TempDir())) + dir := t.TempDir() + res, err := Auto(context.Background(), p.client(t), devOpts(dir)) if err == nil || !strings.Contains(err.Error(), "maximum number of certificates") || !strings.Contains(err.Error(), "Revoke one") || !strings.Contains(err.Error(), "--key") { t.Errorf("err = %v", err) } + // The key is on disk before the request goes out, so whatever Apple did + // with it, the next run can carry on with the same key. + keyPEM, readErr := os.ReadFile(res.Files.Key) + if readErr != nil || res.Files.Key != filepath.Join(dir, KeyFileName) { + t.Fatalf("key after a refused certificate: %+v, %v", res.Files, readErr) + } + p.refuseCertificates = false + opts := devOpts(dir) + opts.KeyPEM = keyPEM + res = run(t, p, opts) + if !res.Certificate.Created || !KeyMatchesCertificate(keyPEM, p.certs[0].der) || res.Files.Key != "" { + t.Errorf("retry = %+v", res) + } } func TestAutoDeviceLimitHint(t *testing.T) { From 21ea5f8c79867c81eacb647cd5c134f6676f6c63 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:58:50 +0200 Subject: [PATCH 28/75] 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. --- README.md | 5 +++-- cmd/builder/signing_auto.go | 32 ++++++++++++++++++++------- cmd/builder/signing_auto_test.go | 37 ++++++++++++++++++++++++++++++++ internal/mobai/types.go | 1 + 4 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 cmd/builder/signing_auto_test.go diff --git a/README.md b/README.md index 1b90fc4..d8388ce 100644 --- a/README.md +++ b/README.md @@ -317,8 +317,9 @@ Identifiers & Profiles*): Developer-role keys cannot create certificates. Distribution) is hit, the error names it and points at the portal. 3. Registers **devices** from `--device ` (repeatable) and `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has - connected). Development and ad-hoc profiles cover every enabled iOS device on - the account, so with none given and none registered the command stops and + connected; simulators and cloud farm devices are skipped). Development and + ad-hoc profiles cover every enabled iOS device on the account, so with none + given and none registered the command stops and says so. App Store profiles take no devices. Apple allows 100 devices per membership year and never frees a slot; that error is passed through too. 4. Creates the **profile** `Builder ` (iOS App Development, diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index 51cbfe2..3c7aa6e 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "github.com/MobAI-App/ios-builder/internal/config" @@ -190,7 +191,11 @@ func signingDevices(ctx context.Context, cmd *cobra.Command, cfg *config.Config, } var devices []signing.Device for _, u := range udids { - devices = append(devices, signing.Device{UDID: strings.TrimSpace(u)}) + u = strings.TrimSpace(u) + if !udidRe.MatchString(u) { + return nil, fmt.Errorf("--device %q is not a UDID (40 hex digits, or 8-16 hex digits like 00008030-000A1B2C3D4E5F60)", u) + } + devices = append(devices, signing.Device{UDID: u}) } if !fromMobAI { return devices, nil @@ -203,18 +208,29 @@ func signingDevices(ctx context.Context, cmd *cobra.Command, cfg *config.Config, if err != nil { return nil, fmt.Errorf("list MobAI devices: %w (is MobAI running? try builder mobai ping)", err) } - found := 0 + physical := mobaiSigningDevices(connected) + if len(physical) == 0 { + return nil, errors.New("MobAI has no physical iOS device connected; plug one in or pass --device ") + } + return append(devices, physical...), nil +} + +// udidRe matches an iOS device UDID: 40 hex digits on devices before the +// iPhone XS, 8-16 hex digits since. +var udidRe = regexp.MustCompile(`^(?i)([0-9a-f]{40}|[0-9a-f]{8}-[0-9a-f]{16})$`) + +// mobaiSigningDevices keeps the devices whose MobAI ID is a UDID Apple can +// register: physical iOS devices attached to this or a peer machine. +// Simulators and cloud farm devices (their IDs are farm handles) are skipped. +func mobaiSigningDevices(connected []mobai.Device) []signing.Device { + var devices []signing.Device for _, d := range connected { - if d.Virtual || (d.Platform != "" && !strings.EqualFold(d.Platform, "ios")) { + if d.Virtual || d.Cloud || (d.Platform != "" && !strings.EqualFold(d.Platform, "ios")) || !udidRe.MatchString(d.ID) { continue } devices = append(devices, signing.Device{Name: d.Name, UDID: d.ID}) - found++ - } - if found == 0 { - return nil, errors.New("MobAI has no physical iOS device connected; plug one in or pass --device ") } - return devices, nil + return devices } // signingKey returns --key, else the key a previous run left in outDir, else diff --git a/cmd/builder/signing_auto_test.go b/cmd/builder/signing_auto_test.go new file mode 100644 index 0000000..19e037c --- /dev/null +++ b/cmd/builder/signing_auto_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "testing" + + "github.com/MobAI-App/ios-builder/internal/mobai" +) + +func TestMobaiSigningDevicesKeepsPhysicalIOSOnly(t *testing.T) { + connected := []mobai.Device{ + {ID: "00008030-000A1B2C3D4E5F60", Name: "Jane's iPhone", Platform: "ios"}, + {ID: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", Name: "Old iPad", Platform: "iOS"}, + {ID: "86906E11-6B70-499D-8257-16C95EE2BAF5", Name: "Simulator", Platform: "ios", Virtual: true}, + {ID: "awsdevicefarm:Apple_iPhone_16:26.0", Name: "Farm iPhone", Platform: "ios", Cloud: true}, + {ID: "R58M12345AB", Name: "Pixel", Platform: "android"}, + {ID: "not-a-udid", Name: "Unknown", Platform: "ios"}, + } + got := mobaiSigningDevices(connected) + if len(got) != 2 || got[0].UDID != "00008030-000A1B2C3D4E5F60" || got[0].Name != "Jane's iPhone" || got[1].UDID != connected[1].ID { + t.Errorf("mobaiSigningDevices = %+v", got) + } +} + +func TestUDIDRe(t *testing.T) { + for udid, want := range map[string]bool{ + "00008030-000A1B2C3D4E5F60": true, + "00008030-000a1b2c3d4e5f60": true, + "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678": true, + "00008030-000A1B2C3D4E5F6": false, + "browserstack:iPhone_14:26": false, + "": false, + } { + if got := udidRe.MatchString(udid); got != want { + t.Errorf("udidRe(%q) = %v, want %v", udid, got, want) + } + } +} diff --git a/internal/mobai/types.go b/internal/mobai/types.go index f26c20a..d910fe5 100644 --- a/internal/mobai/types.go +++ b/internal/mobai/types.go @@ -11,6 +11,7 @@ type Device struct { OSVersion string `json:"osVersion"` BridgeRunning bool `json:"bridgeRunning"` Virtual bool `json:"virtual"` + Cloud bool `json:"cloud"` // lives in a device farm; the ID is a farm handle, not a UDID } // InstallAppRequest is the request body for installing an app From d4293b7fb90bcc883a3b1196ea4094e0344f5cfe Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:58:51 +0200 Subject: [PATCH 29/75] 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. --- CLAUDE.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3a7c581..a9f2348 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,10 +254,11 @@ internal/ deleted with it. `filter[identifier]` on bundleIds is a prefix match, so the exact identifier is checked client-side. The manual `--certificate`/`--profile` path in `runSigningSetup` is untouched; the automatic one lives in `cmd/builder/signing_auto.go`. -- **Export Method Is Still `development`**: `ios-build.yml` and `runner.sh` hardcode - `method = development` in ExportOptions.plist, so an ad-hoc or App Store profile from - `signing setup --type ad-hoc|app-store` signs the archive but the export step needs the - matching method before those IPAs work (roadmap prerequisite under item 1). +- **Export Method Follows The Profile**: the `method` in ExportOptions.plist must match the + uploaded profile's type (`development`, `ad-hoc`, `app-store`), or xcodebuild refuses the + export. `signing setup --type ad-hoc|app-store` only produces the material; deriving the + method from the profile in `ios-build.yml` and `runner.sh` is PR #17, so those IPAs work + once both are merged. - **Extension Points**: a future `ios release` (upload + TestFlight, automatic build numbers) composes `distribute.Upload` and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` for the latest build number; the `pkg/` wrappers do not expose `asc` yet. From f86a7ab11462724b256de44c99214eb404b7476f Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 15:43:18 +0200 Subject: [PATCH 30/75] 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_, IOS_CERTIFICATE_PASSWORD_ and IOS_PROVISIONING_PROFILE_, 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. --- internal/build/inputs_test.go | 15 ++++++++- internal/build/progress.go | 3 ++ internal/config/profile.go | 13 +++++--- internal/config/profile_test.go | 2 ++ internal/config/signing.go | 56 +++++++++++++++++++++++++++++++++ internal/config/signing_test.go | 44 ++++++++++++++++++++++++++ internal/config/types.go | 5 +-- 7 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 internal/config/signing.go create mode 100644 internal/config/signing_test.go diff --git a/internal/build/inputs_test.go b/internal/build/inputs_test.go index 5ec62c8..f045cfc 100644 --- a/internal/build/inputs_test.go +++ b/internal/build/inputs_test.go @@ -149,7 +149,7 @@ func TestSettingsPrinted(t *testing.T) { p := NewProgress(&out) p.Start("abcdef12") p.Settings(&config.BuildSettings{Profile: "preview", Configuration: "Release", Signing: true, Env: map[string]string{"B": "2", "A": "1"}, Distribution: "ad-hoc"}, "github") - for _, want := range []string{"Profile: preview", "Configuration: Release", "Scheme: (auto-detected)", "Signing: signed", "Provider: github", "Env: A, B", "Distribution: ad-hoc"} { + for _, want := range []string{"Profile: preview", "Configuration: Release", "Scheme: (auto-detected)", "Signing: signed", "Signing set: AD_HOC", "Provider: github", "Env: A, B", "Distribution: ad-hoc"} { if !strings.Contains(out.String(), want) { t.Errorf("missing %q in:\n%s", want, out.String()) } @@ -157,4 +157,17 @@ func TestSettingsPrinted(t *testing.T) { if strings.Contains(out.String(), "staging") || strings.Contains(out.String(), "=1") { t.Fatal("env values should not be printed, only names") } + + // Signed without a distribution reads the development set; unsigned + // builds read none. + out.Reset() + p.Settings(&config.BuildSettings{Signing: true}, "github") + if !strings.Contains(out.String(), "Signing set: DEVELOPMENT") { + t.Errorf("default set not printed:\n%s", out.String()) + } + out.Reset() + p.Settings(&config.BuildSettings{Distribution: "app-store"}, "github") + if strings.Contains(out.String(), "Signing set") { + t.Errorf("unsigned build printed a signing set:\n%s", out.String()) + } } diff --git a/internal/build/progress.go b/internal/build/progress.go index e1c2083..30adeca 100644 --- a/internal/build/progress.go +++ b/internal/build/progress.go @@ -91,6 +91,9 @@ func (p *Progress) Settings(s *config.BuildSettings, provider string) { fmt.Fprintf(p.writer, " Configuration: %s\n", orDefault(s.Configuration, "Debug")) fmt.Fprintf(p.writer, " Scheme: %s\n", orDefault(s.Scheme, "(auto-detected)")) fmt.Fprintf(p.writer, " Signing: %s\n", signing) + if s.Signing { + fmt.Fprintf(p.writer, " Signing set: %s\n", s.SigningSet()) + } fmt.Fprintf(p.writer, " Provider: %s\n", provider) if len(s.Env) > 0 { keys := slices.Sorted(maps.Keys(s.Env)) diff --git a/internal/config/profile.go b/internal/config/profile.go index c700c7c..81e6ca1 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -32,15 +32,18 @@ var Distributions = []string{"development", "ad-hoc", "app-store", "enterprise"} var reservedEnv = []string{ "BUILD_ID", "SNAPSHOT_REF", "SNAPSHOT_SHA", "IOS_PATH", "SCHEME", "CONFIGURATION", "USE_SIGNING", "FLUTTER_VERSION", "JDK_VERSION", "BUILD_ENV", "DISTRIBUTION", - "DURATION", "PROJECT_TYPE", "EXPORT_METHOD", - "IOS_CERTIFICATE", "IOS_CERTIFICATE_PASSWORD", "IOS_PROVISIONING_PROFILE", "MOBAI_API_KEY", + "SIGNING_SET", "SIGNING_SET_USED", "DURATION", "PROJECT_TYPE", "EXPORT_METHOD", + "MOBAI_API_KEY", "PATH", "HOME", "USER", "SHELL", "TMPDIR", "DEVELOPER_DIR", "NODE_OPTIONS", } // reservedEnvPrefixes cover the runners' own namespaces: Builder's, GitHub -// Actions' (GITHUB_*, RUNNER_*, ACTIONS_*), Codemagic's (CM_*, FCI_*) and -// Bitrise's. -var reservedEnvPrefixes = []string{"BUILDER_", "GITHUB_", "RUNNER_", "ACTIONS_", "CM_", "FCI_", "BITRISE_"} +// Actions' (GITHUB_*, RUNNER_*, ACTIONS_*), Codemagic's (CM_*, FCI_*), +// Bitrise's, and the signing secrets with every set suffix. +var reservedEnvPrefixes = []string{ + "BUILDER_", "GITHUB_", "RUNNER_", "ACTIONS_", "CM_", "FCI_", "BITRISE_", + "IOS_CERTIFICATE", "IOS_PROVISIONING_PROFILE", +} var envNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) diff --git a/internal/config/profile_test.go b/internal/config/profile_test.go index 4217437..50a7179 100644 --- a/internal/config/profile_test.go +++ b/internal/config/profile_test.go @@ -78,6 +78,8 @@ func TestResolveProfileErrors(t *testing.T) { "env with equals": {Env: map[string]string{"A=B": "x"}}, "reserved env": {Env: map[string]string{"SCHEME": "Other"}}, "reserved secret": {Env: map[string]string{"IOS_CERTIFICATE": "x"}}, + "reserved set": {Env: map[string]string{"IOS_PROVISIONING_PROFILE_APP_STORE": "x"}}, + "reserved SIGNING": {Env: map[string]string{"SIGNING_SET": "AD_HOC"}}, "reserved PATH": {Env: map[string]string{"PATH": "/tmp"}}, "GitHub namespace": {Env: map[string]string{"GITHUB_TOKEN": "x"}}, "Codemagic space": {Env: map[string]string{"CM_BUILD_ID": "x"}}, diff --git a/internal/config/signing.go b/internal/config/signing.go new file mode 100644 index 0000000..0639c67 --- /dev/null +++ b/internal/config/signing.go @@ -0,0 +1,56 @@ +package config + +import ( + "fmt" + "strings" +) + +// signingSets maps a profile's distribution to the suffix of the IOS_* secrets +// the runner reads for it. No distribution means development, so a repository +// set up before signing sets keeps building with the secrets it has. The shell +// function signing_set in ios-build.yml and runner.sh is the same table. +var signingSets = map[string]string{ + "": "DEVELOPMENT", + "development": "DEVELOPMENT", + "ad-hoc": "AD_HOC", + "app-store": "APP_STORE", + "enterprise": "ENTERPRISE", +} + +// SigningSet returns the suffix of the secrets a distribution is signed with: +// DEVELOPMENT, AD_HOC, APP_STORE or ENTERPRISE. +func SigningSet(distribution string) (string, error) { + set, ok := signingSets[distribution] + if !ok { + return "", fmt.Errorf("distribution %q must be one of %s", distribution, strings.Join(Distributions, ", ")) + } + return set, nil +} + +// SigningSecrets names the three secrets of a signing set. +type SigningSecrets struct { + Certificate string // base64 .p12 + Password string // the .p12 password + Profile string // base64 .mobileprovision +} + +// SigningSecretNames returns the secret names of a set: IOS_CERTIFICATE_, +// IOS_CERTIFICATE_PASSWORD_ and IOS_PROVISIONING_PROFILE_. The empty +// set names the unsuffixed secrets, which every set falls back to. +func SigningSecretNames(set string) SigningSecrets { + suffix := "" + if set != "" { + suffix = "_" + set + } + return SigningSecrets{ + Certificate: "IOS_CERTIFICATE" + suffix, + Password: "IOS_CERTIFICATE_PASSWORD" + suffix, + Profile: "IOS_PROVISIONING_PROFILE" + suffix, + } +} + +// SigningSet is the secret set the build signs with, from its distribution. +func (s *BuildSettings) SigningSet() string { + set, _ := SigningSet(s.Distribution) // validated by ResolveProfile + return set +} diff --git a/internal/config/signing_test.go b/internal/config/signing_test.go new file mode 100644 index 0000000..fd34125 --- /dev/null +++ b/internal/config/signing_test.go @@ -0,0 +1,44 @@ +package config + +import "testing" + +func TestSigningSet(t *testing.T) { + for distribution, want := range map[string]string{ + "": "DEVELOPMENT", "development": "DEVELOPMENT", "ad-hoc": "AD_HOC", "app-store": "APP_STORE", "enterprise": "ENTERPRISE", + } { + got, err := SigningSet(distribution) + if err != nil || got != want { + t.Errorf("SigningSet(%q) = %q, %v; want %q", distribution, got, err, want) + } + } + for _, bad := range []string{"adhoc", "AD_HOC", "Development"} { + if _, err := SigningSet(bad); err == nil { + t.Errorf("SigningSet(%q) accepted", bad) + } + } + // Every accepted distribution has a set, and the settings expose it. + for _, d := range Distributions { + s := BuildSettings{Distribution: d} + if s.SigningSet() == "" { + t.Errorf("no set for %s", d) + } + } +} + +func TestSigningSecretNames(t *testing.T) { + got := SigningSecretNames("APP_STORE") + want := SigningSecrets{"IOS_CERTIFICATE_APP_STORE", "IOS_CERTIFICATE_PASSWORD_APP_STORE", "IOS_PROVISIONING_PROFILE_APP_STORE"} + if got != want { + t.Errorf("suffixed = %+v, want %+v", got, want) + } + legacy := SigningSecretNames("") + if legacy != (SigningSecrets{"IOS_CERTIFICATE", "IOS_CERTIFICATE_PASSWORD", "IOS_PROVISIONING_PROFILE"}) { + t.Errorf("legacy = %+v", legacy) + } + // Every name is one a profile's env may not set. + for _, name := range []string{got.Certificate, got.Password, got.Profile, legacy.Certificate, legacy.Password, legacy.Profile} { + if !reservedEnvName(name) { + t.Errorf("%s is not reserved", name) + } + } +} diff --git a/internal/config/types.go b/internal/config/types.go index aeb432c..cd75be5 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -33,8 +33,9 @@ type Profile struct { Signing *bool `json:"signing,omitempty"` // overrides ios.signing; a pointer so false can override true Provider string `json:"provider,omitempty"` // overrides provider Env map[string]string `json:"env,omitempty"` // exported on the runner before dependencies and the build - // Distribution is reserved for the export step (development, ad-hoc, app-store, - // enterprise). It is validated and passed to the runner but not applied yet. + // Distribution (development, ad-hoc, app-store, enterprise) selects the signing + // set the runner reads (IOS_*_ secrets, see SigningSet) and the type the + // provisioning profile in it must have. Empty means development. Distribution string `json:"distribution,omitempty"` } From 022e1a903a79b3b940ec91afd7932361beab1102 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 15:47:48 +0200 Subject: [PATCH 31/75] 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-.key/.p12, so setting up a second type in the same directory keeps the first type's material. --- internal/signing/auto.go | 41 +++++++++++------ internal/signing/auto_test.go | 40 ++++++++++++----- internal/signing/profile.go | 37 ++++++++++++++++ internal/signing/profile_test.go | 76 ++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 25 deletions(-) create mode 100644 internal/signing/profile.go create mode 100644 internal/signing/profile_test.go diff --git a/internal/signing/auto.go b/internal/signing/auto.go index fde6599..3e5c591 100644 --- a/internal/signing/auto.go +++ b/internal/signing/auto.go @@ -19,30 +19,36 @@ import ( // which profile type wraps it. type Type string -// Signing types, as accepted by --type. +// Signing types, as accepted by --type. They are the values of a build +// profile's distribution, and each one has a signing set of secrets. const ( TypeDevelopment Type = "development" TypeAdHoc Type = "ad-hoc" TypeAppStore Type = "app-store" + // TypeEnterprise is an in-house profile. Auto cannot issue one; it is + // only reached with --certificate/--profile. + TypeEnterprise Type = "enterprise" ) // ParseType validates a --type value. func ParseType(s string) (Type, error) { switch t := Type(strings.ToLower(strings.TrimSpace(s))); t { - case TypeDevelopment, TypeAdHoc, TypeAppStore: + case TypeDevelopment, TypeAdHoc, TypeAppStore, TypeEnterprise: return t, nil case "adhoc": return TypeAdHoc, nil case "appstore": return TypeAppStore, nil + case "in-house", "inhouse": + return TypeEnterprise, nil default: - return "", fmt.Errorf("--type must be development, ad-hoc or app-store, got %q", s) + return "", fmt.Errorf("--type must be development, ad-hoc, app-store or enterprise, got %q", s) } } // NeedsDevices reports whether profiles of this type list the devices the -// app may run on; App Store profiles do not. -func (t Type) NeedsDevices() bool { return t != TypeAppStore } +// app may run on; App Store and enterprise profiles do not. +func (t Type) NeedsDevices() bool { return t == TypeDevelopment || t == TypeAdHoc } func (t Type) certificateType() string { if t == TypeDevelopment { @@ -68,11 +74,17 @@ type Device struct { UDID string `json:"udid"` } -// File names written to the output directory. -const ( - KeyFileName = "ios-signing.key" - P12FileName = "ios-signing.p12" -) +// LegacyKeyFileName is where runs before signing sets wrote the private key. +// A key found under it is still reused, so no certificate slot is spent on +// the upgrade. +const LegacyKeyFileName = "ios-signing.key" + +// KeyFileName is the private key file of a signing type, ios-signing-.key, +// so setting up a second type does not overwrite the first type's key. +func KeyFileName(t Type) string { return fmt.Sprintf("ios-signing-%s.key", t) } + +// P12FileName is the .p12 file of a signing type, ios-signing-.p12. +func P12FileName(t Type) string { return fmt.Sprintf("ios-signing-%s.p12", t) } // AutoOptions configures Auto. type AutoOptions struct { @@ -82,7 +94,7 @@ type AutoOptions struct { // then cover every enabled iOS device on the account. Devices []Device // KeyPEM is an existing private key. When nil a key is generated and - // written to OutDir/ios-signing.key. + // written to OutDir/ios-signing-.key. KeyPEM []byte // CommonName goes into the CSR subject of a new certificate. CommonName string @@ -172,6 +184,9 @@ func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResu if _, err := ParseType(string(opts.Type)); err != nil { return nil, err } + if opts.Type == TypeEnterprise { + return nil, errors.New("enterprise (in-house) profiles are not issued through the App Store Connect API; pass --certificate and --profile with the files from the portal") + } if opts.Password == "" { return nil, errors.New("a .p12 password is required") } @@ -221,7 +236,7 @@ func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResu if keyPEM, err = generateKey(); err != nil { return res, err } - res.Files.Key = filepath.Join(opts.OutDir, KeyFileName) + res.Files.Key = filepath.Join(opts.OutDir, KeyFileName(opts.Type)) if err := os.WriteFile(res.Files.Key, keyPEM, 0600); err != nil { return res, fmt.Errorf("write private key: %w", err) } @@ -243,7 +258,7 @@ func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResu res.ProfileContent = profile.Content // 5. Files - res.Files.P12 = filepath.Join(opts.OutDir, P12FileName) + res.Files.P12 = filepath.Join(opts.OutDir, P12FileName(opts.Type)) if err := os.WriteFile(res.Files.P12, res.P12, 0600); err != nil { return res, fmt.Errorf("write .p12: %w", err) } diff --git a/internal/signing/auto_test.go b/internal/signing/auto_test.go index 159cde8..0e5685b 100644 --- a/internal/signing/auto_test.go +++ b/internal/signing/auto_test.go @@ -370,7 +370,7 @@ func TestAutoFirstRunCreatesEverything(t *testing.T) { } // Files: key, .p12 and profile in the output directory; the .p12 opens with the password and holds the issued certificate. - if res.Files.Key != filepath.Join(dir, "ios-signing.key") || res.Files.P12 != filepath.Join(dir, "ios-signing.p12") || res.Files.Profile != filepath.Join(dir, "Builder-development-com.example.app.mobileprovision") { + if res.Files.Key != filepath.Join(dir, "ios-signing-development.key") || res.Files.P12 != filepath.Join(dir, "ios-signing-development.p12") || res.Files.Profile != filepath.Join(dir, "Builder-development-com.example.app.mobileprovision") { t.Errorf("files = %+v", res.Files) } keyPEM, err := os.ReadFile(res.Files.Key) @@ -543,7 +543,7 @@ func TestAutoDevelopmentWithoutDevicesFails(t *testing.T) { if p.count("POST /v1/certificates") != 0 || p.count("POST /v1/profiles") != 0 { t.Errorf("certificate or profile created without devices: %v", p.calls) } - if _, err := os.Stat(filepath.Join(opts.OutDir, KeyFileName)); err == nil { + if _, err := os.Stat(filepath.Join(opts.OutDir, KeyFileName(TypeDevelopment))); err == nil { t.Error("a key was written although no certificate was requested") } } @@ -596,7 +596,7 @@ func TestAutoCertificateLimitHint(t *testing.T) { // The key is on disk before the request goes out, so whatever Apple did // with it, the next run can carry on with the same key. keyPEM, readErr := os.ReadFile(res.Files.Key) - if readErr != nil || res.Files.Key != filepath.Join(dir, KeyFileName) { + if readErr != nil || res.Files.Key != filepath.Join(dir, KeyFileName(TypeDevelopment)) { t.Fatalf("key after a refused certificate: %+v, %v", res.Files, readErr) } p.refuseCertificates = false @@ -635,18 +635,34 @@ func TestAutoRejectsBadOptions(t *testing.T) { } } -func TestParseType(t *testing.T) { - for in, want := range map[string]Type{"development": TypeDevelopment, "Ad-Hoc": TypeAdHoc, "adhoc": TypeAdHoc, "app-store": TypeAppStore, "appstore": TypeAppStore} { - got, err := ParseType(in) - if err != nil || got != want { - t.Errorf("ParseType(%q) = %q, %v; want %q", in, got, err, want) +// A second type in the same directory leaves the first type's key, .p12 and +// profile in place: each set is a separate file trio. +func TestAutoTypesKeepSeparateFiles(t *testing.T) { + p := newPortal(t) + dir := t.TempDir() + dev := run(t, p, devOpts(dir)) + opts := devOpts(dir) + opts.Type, opts.Devices = TypeAppStore, nil + store := run(t, p, opts) + if dev.Files.Key == store.Files.Key || dev.Files.P12 == store.Files.P12 || dev.Files.Profile == store.Files.Profile { + t.Fatalf("files collide: %+v vs %+v", dev.Files, store.Files) + } + for _, f := range []string{dev.Files.Key, dev.Files.P12, dev.Files.Profile, store.Files.Key, store.Files.P12, store.Files.Profile} { + if _, err := os.Stat(f); err != nil { + t.Errorf("%s: %v", f, err) } } - if _, err := ParseType("enterprise"); err == nil || !strings.Contains(err.Error(), "enterprise") { - t.Errorf("err = %v", err) + if len(p.profiles) != 2 || len(p.certs) != 2 { + t.Errorf("one certificate and profile per type expected: %d profiles, %d certificates", len(p.profiles), len(p.certs)) } - if TypeAppStore.NeedsDevices() || !TypeAdHoc.NeedsDevices() || !TypeDevelopment.NeedsDevices() { - t.Error("NeedsDevices: only App Store profiles list no devices") +} + +func TestAutoRefusesEnterprise(t *testing.T) { + p := newPortal(t) + opts := devOpts(t.TempDir()) + opts.Type = TypeEnterprise + if _, err := Auto(context.Background(), p.client(t), opts); err == nil || !strings.Contains(err.Error(), "--certificate") || len(p.calls) != 0 { + t.Errorf("err = %v, calls %v", err, p.calls) } } diff --git a/internal/signing/profile.go b/internal/signing/profile.go new file mode 100644 index 0000000..d51da66 --- /dev/null +++ b/internal/signing/profile.go @@ -0,0 +1,37 @@ +package signing + +import ( + "bytes" + "errors" + "fmt" + + "howett.net/plist" +) + +// ProfileType reads the type of a .mobileprovision. The file is a CMS +// signature wrapping an XML plist; the plist alone decides the type, by the +// rules detect_export_method applies on the runner: ProvisionsAllDevices is +// enterprise, ProvisionedDevices with get-task-allow is development and +// without it ad-hoc, and a profile with neither is App Store. +func ProfileType(data []byte) (Type, error) { + start := bytes.Index(data, []byte("")) + if start < 0 || end < start { + return "", errors.New("not a provisioning profile: no plist inside") + } + var dict map[string]any + if _, err := plist.Unmarshal(data[start:end+len("")], &dict); err != nil { + return "", fmt.Errorf("parse provisioning profile: %w", err) + } + if all, _ := dict["ProvisionsAllDevices"].(bool); all { + return TypeEnterprise, nil + } + if _, ok := dict["ProvisionedDevices"]; ok { + entitlements, _ := dict["Entitlements"].(map[string]any) + if allow, _ := entitlements["get-task-allow"].(bool); allow { + return TypeDevelopment, nil + } + return TypeAdHoc, nil + } + return TypeAppStore, nil +} diff --git a/internal/signing/profile_test.go b/internal/signing/profile_test.go new file mode 100644 index 0000000..7023136 --- /dev/null +++ b/internal/signing/profile_test.go @@ -0,0 +1,76 @@ +package signing + +import ( + "strings" + "testing" +) + +// mobileprovision wraps a plist body in bytes that stand in for the CMS +// signature around the real thing: binary before, binary after, and a +// " + +` + body + `` + head := "\x30\x82\x1a\x00\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x07\x02\xa0\x82" + tail := "\x00\x00\x30\x82\x05\xff" + strings.Repeat("\xa1", 64) + return []byte(head + plist + tail) +} + +func TestProfileType(t *testing.T) { + devices := "ProvisionedDevices00008030-001" + allow := func(v bool) string { + if v { + return "Entitlementsget-task-allow" + } + return "Entitlementsget-task-allow" + } + for _, tc := range []struct { + name string + body string + want Type + }{ + {"development", devices + allow(true), TypeDevelopment}, + {"ad-hoc", devices + allow(false), TypeAdHoc}, + {"app-store", allow(false), TypeAppStore}, + {"enterprise", "ProvisionsAllDevices" + allow(false), TypeEnterprise}, + {"enterprise with devices", "ProvisionsAllDevices" + devices + allow(false), TypeEnterprise}, + {"empty device list is still ad-hoc", "ProvisionedDevices" + allow(false), TypeAdHoc}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ProfileType(mobileprovision(tc.body)) + if err != nil || got != tc.want { + t.Errorf("ProfileType = %q, %v; want %q", got, err, tc.want) + } + }) + } + for name, data := range map[string][]byte{ + "no plist": []byte("\x30\x82\x1a\x00 just bytes"), + "bad plist": []byte("x"), + "empty": nil, + } { + if _, err := ProfileType(data); err == nil { + t.Errorf("%s accepted", name) + } + } +} + +func TestParseType(t *testing.T) { + for in, want := range map[string]Type{ + "development": TypeDevelopment, "ad-hoc": TypeAdHoc, "adhoc": TypeAdHoc, " App-Store ": TypeAppStore, + "appstore": TypeAppStore, "enterprise": TypeEnterprise, "in-house": TypeEnterprise, + } { + if got, err := ParseType(in); err != nil || got != want { + t.Errorf("ParseType(%q) = %q, %v; want %q", in, got, err, want) + } + } + if _, err := ParseType("distribution"); err == nil { + t.Error("unknown type accepted") + } + if TypeAppStore.NeedsDevices() || TypeEnterprise.NeedsDevices() || !TypeDevelopment.NeedsDevices() || !TypeAdHoc.NeedsDevices() { + t.Error("NeedsDevices: only development and ad-hoc profiles list devices") + } + if KeyFileName(TypeAppStore) != "ios-signing-app-store.key" || P12FileName(TypeAdHoc) != "ios-signing-ad-hoc.p12" { + t.Errorf("file names: %s %s", KeyFileName(TypeAppStore), P12FileName(TypeAdHoc)) + } +} From f385fdd11c222b2fca32936f3f0bce9a052d1a91 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 15:47:48 +0200 Subject: [PATCH 32/75] signing: upload setup material to the set of its type signing setup writes IOS_CERTIFICATE_, IOS_CERTIFICATE_PASSWORD_ and IOS_PROVISIONING_PROFILE_ 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-.key, then the legacy ios-signing.key. --- cmd/builder/signing.go | 99 +++++++++++------- cmd/builder/signing_auto.go | 82 ++++++++++----- cmd/builder/signing_sets_test.go | 170 +++++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+), 62 deletions(-) create mode 100644 cmd/builder/signing_sets_test.go diff --git a/cmd/builder/signing.go b/cmd/builder/signing.go index 9de5b4b..244b461 100644 --- a/cmd/builder/signing.go +++ b/cmd/builder/signing.go @@ -2,14 +2,12 @@ package main import ( "context" - "encoding/base64" "fmt" "os" "path/filepath" "strings" "github.com/MobAI-App/ios-builder/internal/config" - "github.com/MobAI-App/ios-builder/internal/github" "github.com/MobAI-App/ios-builder/internal/signing" "github.com/manifoldco/promptui" "github.com/spf13/cobra" @@ -36,18 +34,25 @@ changed is recreated. Nothing is ever revoked. --type development Apple Development certificate, devices required (default) --type ad-hoc Apple Distribution certificate, devices required --type app-store Apple Distribution certificate, no devices; TestFlight/App - Store uploads need this and ios.configuration Release + Store uploads need this and a Release configuration With --certificate and --profile the files are taken as they are: - A .p12 file (exported from Keychain Access on a Mac) - A .cer file downloaded from the Apple Developer portal, together with the private key from 'builder signing csr' (--key) — the .p12 is then assembled locally, so no Mac is needed at any point - -Either way the command uploads three GitHub repository secrets — -IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD, IOS_PROVISIONING_PROFILE — and sets -ios.signing in builder.json. For Codemagic and Bitrise it writes the files and -points at docs/provider-secrets.md instead.`, +The type is read from the .mobileprovision (development, ad-hoc, app-store or +enterprise); --type overrides it. + +Either way the command uploads the three GitHub repository secrets of the +type's signing set — IOS_CERTIFICATE_, IOS_CERTIFICATE_PASSWORD_, +IOS_PROVISIONING_PROFILE_, with SET one of DEVELOPMENT, AD_HOC, +APP_STORE, ENTERPRISE — and sets ios.signing in builder.json. A build reads +the set named by its profile's distribution (development when there is +none), so one repository can hold a development set for devices and an +App Store set for releases; existing unsuffixed secrets stay in place and +remain the fallback. For Codemagic and Bitrise it writes the files and points +at docs/provider-secrets.md instead.`, RunE: runSigningSetup, } @@ -85,7 +90,7 @@ func init() { signingSetupCmd.Flags().StringP("profile", "p", "", "Path to .mobileprovision file") signingSetupCmd.Flags().StringP("key", "k", "", "Path to the private key from 'builder signing csr' (required with a .cer; automatic mode reuses it and its certificate)") signingSetupCmd.Flags().String("bundle-id", "", "App bundle ID (default: ios.bundleId in builder.json, else the newest IPA in ./dist)") - signingSetupCmd.Flags().String("type", string(signing.TypeDevelopment), "Signing type: development, ad-hoc or app-store") + signingSetupCmd.Flags().String("type", string(signing.TypeDevelopment), "Signing type: development, ad-hoc, app-store or enterprise (with --profile: read from the profile unless given)") signingSetupCmd.Flags().StringArray("device", nil, "Device UDID to register (repeatable)") signingSetupCmd.Flags().Bool("devices-from-mobai", false, "Register the physical iOS devices connected to MobAI") signingSetupCmd.Flags().String("out-dir", ".", "Directory for the private key, .p12 and .mobileprovision") @@ -302,6 +307,17 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { } fmt.Printf("Profile: %s (%.1f KB)\n", profilePath, float64(len(profileData))/1024) + typeFlag, _ := cmd.Flags().GetString("type") + typ, source, err := manualSigningType(profileData, typeFlag, cmd.Flags().Changed("type")) + if err != nil { + return err + } + set, err := config.SigningSet(string(typ)) + if err != nil { + return err + } + fmt.Printf("Type: %s (%s), signing set %s\n", typ, source, set) + var password string if isPortalCertificate(certPath) { // A .cer from the Apple Developer portal: assemble the .p12 locally @@ -327,7 +343,7 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { } // Save the .p12: it is the reusable signing identity (Sideloadly, // another machine, re-running setup), not a throwaway. - p12Path := "ios-signing.p12" + p12Path := signing.P12FileName(typ) if err := os.WriteFile(p12Path, certData, 0600); err != nil { return fmt.Errorf("failed to write .p12: %w", err) } @@ -346,34 +362,8 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { if ctx == nil { ctx = context.Background() } - - // Get repository public key for encryption - publicKey, err := ghClient.GetPublicKey(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo) - if err != nil { - return fmt.Errorf("failed to get repository public key: %w", err) - } - - // Base64 encode the files - certBase64 := base64.StdEncoding.EncodeToString(certData) - profileBase64 := base64.StdEncoding.EncodeToString(profileData) - - // Encrypt and upload secrets - secrets := map[string]string{ - "IOS_CERTIFICATE": certBase64, - "IOS_CERTIFICATE_PASSWORD": password, - "IOS_PROVISIONING_PROFILE": profileBase64, - } - - for name, value := range secrets { - encrypted, err := github.EncryptSecret(publicKey.Key, value) - if err != nil { - return fmt.Errorf("failed to encrypt %s: %w", name, err) - } - - if err := ghClient.CreateOrUpdateSecret(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo, name, encrypted, publicKey.KeyID); err != nil { - return fmt.Errorf("failed to upload %s: %w", name, err) - } - fmt.Printf(" Uploaded: %s\n", name) + if err := uploadSigningSecrets(ctx, ghClient, cfg, os.Stdout, set, certData, password, profileData); err != nil { + return err } // Update config to indicate signing is enabled @@ -387,8 +377,39 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { fmt.Println() fmt.Println("Code signing configured successfully!") fmt.Println() - fmt.Println("Your next build will be signed. To build unsigned, use:") + printSigningSetUsage(typ, set) + fmt.Println("To build unsigned, use:") fmt.Println(" builder ios build --unsigned") return nil } + +// manualSigningType is the type of the profile being uploaded: --type when +// given, else what the .mobileprovision says. A --type that disagrees with the +// profile is taken, with a warning, since the runner will refuse the pair. +func manualSigningType(profileData []byte, typeFlag string, typeGiven bool) (typ signing.Type, source string, err error) { + detected, detectErr := signing.ProfileType(profileData) + if !typeGiven { + if detectErr != nil { + return "", "", fmt.Errorf("%w; pass --type development|ad-hoc|app-store|enterprise", detectErr) + } + return detected, "read from the profile", nil + } + if typ, err = signing.ParseType(typeFlag); err != nil { + return "", "", err + } + if detectErr == nil && detected != typ { + fmt.Printf("Warning: the profile is a %s profile but --type %s was given; builds with distribution %s will fail on this set until a %s profile is uploaded to it.\n", detected, typ, typ, typ) + } + return typ, "--type", nil +} + +// printSigningSetUsage says which builds read the set that was just written. +func printSigningSetUsage(typ signing.Type, set string) { + if typ == signing.TypeDevelopment { + fmt.Printf("Signed builds read the %s set unless their profile sets another distribution.\n", set) + } else { + fmt.Printf("Builds read the %s set when their builder.json profile has \"distribution\": \"%s\";\n", set, typ) + fmt.Printf("that profile needs \"configuration\": \"Release\", since a Debug build is refused by %s profiles.\n", typ) + } +} diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index 3c7aa6e..c59aa15 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -28,7 +28,10 @@ const providerSecretsDoc = "https://github.com/MobAI-App/ios-builder/blob/main/d // signingAutoResult is the JSON output of the automatic `signing setup`. type signingAutoResult struct { *signing.AutoResult - Provider string `json:"provider"` + Provider string `json:"provider"` + // SigningSet is the suffix of the secrets written (DEVELOPMENT, AD_HOC, + // APP_STORE), which builds select by their profile's distribution. + SigningSet string `json:"signing_set"` SecretsUploaded bool `json:"secrets_uploaded"` // GeneratedPassword is set when no password was given: it is printed // exactly once, here. @@ -51,6 +54,13 @@ func runSigningAuto(cmd *cobra.Command) error { if err != nil { return err } + if typ == signing.TypeEnterprise { + return errors.New("enterprise (in-house) profiles are not issued through the App Store Connect API; download the certificate and profile from the portal and pass --certificate and --profile") + } + set, err := config.SigningSet(string(typ)) + if err != nil { + return err + } client, err := getASCClient() if err != nil { return err @@ -81,21 +91,21 @@ func runSigningAuto(cmd *cobra.Command) error { if err != nil { return err } - keyPEM, keyPath, err := signingKey(cmd, outDir) + keyPEM, keyPath, err := signingKey(cmd, outDir, typ) if err != nil { return err } // The plan, then one confirmation before anything is created. fmt.Fprintf(out.log, "Bundle ID: %s\n", bundleID) - fmt.Fprintf(out.log, "Type: %s\n", typ) + fmt.Fprintf(out.log, "Type: %s (signing set %s)\n", typ, set) if typ.NeedsDevices() { fmt.Fprintf(out.log, "Devices: %s\n", describeDevices(devices)) } if keyPath != "" { fmt.Fprintf(out.log, "Key: %s (reusing its certificate if one is valid)\n", keyPath) } else { - fmt.Fprintf(out.log, "Key: new, written to %s\n", filepath.Join(outDir, signing.KeyFileName)) + fmt.Fprintf(out.log, "Key: new, written to %s\n", filepath.Join(outDir, signing.KeyFileName(typ))) } fmt.Fprintf(out.log, "Provider: %s\n", provider) if force { @@ -126,7 +136,7 @@ func runSigningAuto(cmd *cobra.Command) error { } } - res := &signingAutoResult{Provider: provider, GeneratedPassword: generated} + res := &signingAutoResult{Provider: provider, SigningSet: set, GeneratedPassword: generated} res.AutoResult, err = signing.Auto(ctx, client, &signing.AutoOptions{ BundleID: bundleID, Type: typ, Devices: devices, KeyPEM: keyPEM, CommonName: cfg.Project, Password: password, Force: force, OutDir: outDir, Log: out.log, @@ -136,7 +146,7 @@ func runSigningAuto(cmd *cobra.Command) error { } if ghClient != nil { fmt.Fprintf(out.log, "\nUploading secrets to %s/%s...\n", cfg.GitHub.Owner, cfg.GitHub.Repo) - if err := uploadSigningSecrets(ctx, ghClient, cfg, out.log, res.P12, password, res.ProfileContent); err != nil { + if err := uploadSigningSecrets(ctx, ghClient, cfg, out.log, set, res.P12, password, res.ProfileContent); err != nil { return finish(out, cmd, res, err, nil) } res.SecretsUploaded = true @@ -233,16 +243,21 @@ func mobaiSigningDevices(connected []mobai.Device) []signing.Device { return devices } -// signingKey returns --key, else the key a previous run left in outDir, else -// nil so a key is generated. keyPath is "" when generating. -func signingKey(cmd *cobra.Command, outDir string) (keyPEM []byte, keyPath string, err error) { +// signingKey returns --key, else the key a previous run of this type left in +// outDir (ios-signing-.key, or the ios-signing.key of runs before +// signing sets), else nil so a key is generated. keyPath is "" when generating. +func signingKey(cmd *cobra.Command, outDir string, typ signing.Type) (keyPEM []byte, keyPath string, err error) { keyPath, _ = cmd.Flags().GetString("key") if keyPath == "" { - candidate := filepath.Join(outDir, signing.KeyFileName) - if _, err := os.Stat(candidate); err != nil { + for _, name := range []string{signing.KeyFileName(typ), signing.LegacyKeyFileName} { + if candidate := filepath.Join(outDir, name); fileExists(candidate) { + keyPath = candidate + break + } + } + if keyPath == "" { return nil, "", nil } - keyPath = candidate } keyPath = expandPath(keyPath) keyPEM, err = os.ReadFile(keyPath) @@ -276,16 +291,30 @@ func randomPassword() (string, error) { return base64.RawURLEncoding.EncodeToString(b), nil } -// uploadSigningSecrets encrypts and stores the three signing secrets. -func uploadSigningSecrets(ctx context.Context, gh *github.Client, cfg *config.Config, log io.Writer, p12 []byte, password string, profile []byte) error { +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// secretStore is the part of the GitHub client that signing setup writes through. +type secretStore interface { + GetPublicKey(ctx context.Context, owner, repo string) (*github.PublicKey, error) + CreateOrUpdateSecret(ctx context.Context, owner, repo, name, encryptedValue, keyID string) error +} + +// uploadSigningSecrets encrypts and stores the three signing secrets of a set +// (IOS_CERTIFICATE_, ...). Other sets, and the unsuffixed secrets of +// repositories set up before signing sets, are left alone. +func uploadSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte) error { publicKey, err := gh.GetPublicKey(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo) if err != nil { return fmt.Errorf("failed to get repository public key: %w", err) } + names := config.SigningSecretNames(set) secrets := []struct{ name, value string }{ - {"IOS_CERTIFICATE", base64.StdEncoding.EncodeToString(p12)}, - {"IOS_CERTIFICATE_PASSWORD", password}, - {"IOS_PROVISIONING_PROFILE", base64.StdEncoding.EncodeToString(profile)}, + {names.Certificate, base64.StdEncoding.EncodeToString(p12)}, + {names.Password, password}, + {names.Profile, base64.StdEncoding.EncodeToString(profile)}, } for _, s := range secrets { encrypted, err := github.EncryptSecret(publicKey.Key, s.value) @@ -328,19 +357,26 @@ func printSigningSummary(cfg *config.Config, res *signingAutoResult) { } fmt.Println("Keep these out of git (add them to .gitignore); gitignored files are also left out of build snapshots.") fmt.Println() + names := config.SigningSecretNames(res.SigningSet) if res.SecretsUploaded { - fmt.Printf("Secrets uploaded to %s/%s and ios.signing enabled in builder.json.\n", cfg.GitHub.Owner, cfg.GitHub.Repo) + fmt.Printf("Secrets %s, %s and %s uploaded to %s/%s and ios.signing enabled in builder.json.\n", names.Certificate, names.Password, names.Profile, cfg.GitHub.Owner, cfg.GitHub.Repo) } else { fmt.Printf("%s secrets are set in its dashboard, not by Builder. Add:\n", res.Provider) - fmt.Printf(" IOS_CERTIFICATE base64 of %s\n", res.Files.P12) - fmt.Println(" IOS_CERTIFICATE_PASSWORD the .p12 password") - fmt.Printf(" IOS_PROVISIONING_PROFILE base64 of %s\n", res.Files.Profile) + fmt.Printf(" %-*s base64 of %s\n", len(names.Password), names.Certificate, res.Files.P12) + fmt.Printf(" %s the .p12 password\n", names.Password) + fmt.Printf(" %-*s base64 of %s\n", len(names.Password), names.Profile, res.Files.Profile) fmt.Printf("then set ios.signing to true in builder.json. Steps: %s\n", providerSecretsDoc) } fmt.Println() - fmt.Println("Next: builder ios build") + printSigningSetUsage(res.Type, res.SigningSet) + fmt.Println() + if res.Type == signing.TypeDevelopment { + fmt.Println("Next: builder ios build") + } else { + fmt.Printf("Next: builder ios build --profile \n", res.Type) + } if res.Type == signing.TypeAppStore { - fmt.Println(`App Store builds need "configuration": "Release" under ios in builder.json; then builder ios upload --wait.`) + fmt.Println("then builder ios upload --wait.") } fmt.Println("Run builder signing setup again any time: it reuses what is valid and renews only what expired or changed.") } diff --git a/cmd/builder/signing_sets_test.go b/cmd/builder/signing_sets_test.go new file mode 100644 index 0000000..fd1891d --- /dev/null +++ b/cmd/builder/signing_sets_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "io" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/github" + "github.com/MobAI-App/ios-builder/internal/signing" + "github.com/spf13/cobra" + "golang.org/x/crypto/nacl/box" +) + +// fakeSecrets stands in for the GitHub secrets API: it hands out a real +// public key and decrypts what is stored, so the test sees the values. +type fakeSecrets struct { + pub, priv *[32]byte + stored map[string]string + names []string +} + +func newFakeSecrets(t *testing.T) *fakeSecrets { + t.Helper() + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return &fakeSecrets{pub: pub, priv: priv, stored: map[string]string{}} +} + +func (f *fakeSecrets) GetPublicKey(context.Context, string, string) (*github.PublicKey, error) { + return &github.PublicKey{KeyID: "key-1", Key: base64.StdEncoding.EncodeToString(f.pub[:])}, nil +} + +func (f *fakeSecrets) CreateOrUpdateSecret(_ context.Context, _, _, name, encryptedValue, keyID string) error { + if keyID != "key-1" { + return os.ErrInvalid + } + sealed, err := base64.StdEncoding.DecodeString(encryptedValue) + if err != nil { + return err + } + value, ok := box.OpenAnonymous(nil, sealed, f.pub, f.priv) + if !ok { + return os.ErrInvalid + } + f.stored[name] = string(value) + f.names = append(f.names, name) + return nil +} + +func TestUploadSigningSecretsWritesOneSet(t *testing.T) { + store := newFakeSecrets(t) + cfg := &config.Config{GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}} + var log strings.Builder + if err := uploadSigningSecrets(context.Background(), store, cfg, &log, "APP_STORE", []byte("p12"), "pw", []byte("profile")); err != nil { + t.Fatal(err) + } + want := []string{"IOS_CERTIFICATE_APP_STORE", "IOS_CERTIFICATE_PASSWORD_APP_STORE", "IOS_PROVISIONING_PROFILE_APP_STORE"} + if !slices.Equal(store.names, want) { + t.Fatalf("secrets written: %v, want %v", store.names, want) + } + if store.stored["IOS_CERTIFICATE_APP_STORE"] != base64.StdEncoding.EncodeToString([]byte("p12")) || store.stored["IOS_CERTIFICATE_PASSWORD_APP_STORE"] != "pw" || store.stored["IOS_PROVISIONING_PROFILE_APP_STORE"] != base64.StdEncoding.EncodeToString([]byte("profile")) { + t.Fatalf("values: %v", store.stored) + } + for _, name := range want { + if !strings.Contains(log.String(), "Uploaded: "+name) { + t.Errorf("%s not reported:\n%s", name, log.String()) + } + } + + // A second set adds to the first; the legacy names are never touched. + if err := uploadSigningSecrets(context.Background(), store, cfg, io.Discard, "DEVELOPMENT", []byte("dev"), "pw2", []byte("dev-profile")); err != nil { + t.Fatal(err) + } + if len(store.stored) != 6 || store.stored["IOS_CERTIFICATE_APP_STORE"] == "" || store.stored["IOS_CERTIFICATE_DEVELOPMENT"] == "" { + t.Fatalf("second set replaced the first: %v", store.names) + } + for name := range store.stored { + if name == "IOS_CERTIFICATE" || name == "IOS_CERTIFICATE_PASSWORD" || name == "IOS_PROVISIONING_PROFILE" { + t.Errorf("legacy secret %s written", name) + } + } +} + +// profileBytes is a .mobileprovision stand-in: a plist between arbitrary +// bytes, as the CMS wrapper leaves it. +func profileBytes(body string) []byte { + return []byte("\x30\x82\x1a\x00 cms " + `` + body + `` + "\x00\xff trailer") +} + +func TestManualSigningTypeChoosesTheSet(t *testing.T) { + devices := "ProvisionedDevices00008030-1" + dev := profileBytes(devices + "Entitlementsget-task-allow") + store := profileBytes("Entitlementsget-task-allow") + + // Without --type the profile decides. + typ, source, err := manualSigningType(store, "development", false) + if err != nil || typ != signing.TypeAppStore || source != "read from the profile" { + t.Fatalf("app-store profile: %q %q %v", typ, source, err) + } + if set, _ := config.SigningSet(string(typ)); set != "APP_STORE" { + t.Fatalf("set = %s", set) + } + if typ, _, err = manualSigningType(dev, "development", false); err != nil || typ != signing.TypeDevelopment { + t.Fatalf("development profile: %q %v", typ, err) + } + // --type overrides, even when it disagrees. + if typ, source, err = manualSigningType(dev, "ad-hoc", true); err != nil || typ != signing.TypeAdHoc || source != "--type" { + t.Fatalf("--type ad-hoc: %q %q %v", typ, source, err) + } + if _, _, err = manualSigningType(dev, "distribution", true); err == nil { + t.Fatal("bad --type accepted") + } + // An unreadable profile needs --type. + if _, _, err = manualSigningType([]byte("not a profile"), "development", false); err == nil || !strings.Contains(err.Error(), "--type") { + t.Fatalf("unreadable profile: %v", err) + } + if typ, _, err = manualSigningType([]byte("not a profile"), "enterprise", true); err != nil || typ != signing.TypeEnterprise { + t.Fatalf("unreadable profile with --type: %q %v", typ, err) + } +} + +func TestSigningKeyPrefersTheTypeThenLegacy(t *testing.T) { + dir := t.TempDir() + cmd := &cobra.Command{} + cmd.Flags().String("key", "", "") + + // Nothing on disk: generate. + if pem, path, err := signingKey(cmd, dir, signing.TypeAppStore); err != nil || pem != nil || path != "" { + t.Fatalf("empty dir: %q %q %v", pem, path, err) + } + // A key from before signing sets is reused by every type. + legacy := filepath.Join(dir, signing.LegacyKeyFileName) + if err := os.WriteFile(legacy, []byte("legacy"), 0600); err != nil { + t.Fatal(err) + } + if pem, path, err := signingKey(cmd, dir, signing.TypeAppStore); err != nil || string(pem) != "legacy" || path != legacy { + t.Fatalf("legacy key: %q %q %v", pem, path, err) + } + // The type's own key wins over it. + typed := filepath.Join(dir, signing.KeyFileName(signing.TypeAppStore)) + if err := os.WriteFile(typed, []byte("typed"), 0600); err != nil { + t.Fatal(err) + } + if pem, path, err := signingKey(cmd, dir, signing.TypeAppStore); err != nil || string(pem) != "typed" || path != typed { + t.Fatalf("typed key: %q %q %v", pem, path, err) + } + if pem, path, err := signingKey(cmd, dir, signing.TypeDevelopment); err != nil || string(pem) != "legacy" || path != legacy { + t.Fatalf("other type falls back to legacy: %q %q %v", pem, path, err) + } + // --key beats both. + explicit := filepath.Join(dir, "mine.key") + if err := os.WriteFile(explicit, []byte("mine"), 0600); err != nil { + t.Fatal(err) + } + if err := cmd.Flags().Set("key", explicit); err != nil { + t.Fatal(err) + } + if pem, path, err := signingKey(cmd, dir, signing.TypeAppStore); err != nil || string(pem) != "mine" || path != explicit { + t.Fatalf("--key: %q %q %v", pem, path, err) + } +} From 0f8a66cbefb55a66b9defda40879a6c45202f724 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 15:50:59 +0200 Subject: [PATCH 33/75] 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_ 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. --- internal/signing/profile_test.go | 5 +- internal/workflow/profile_test.go | 8 +- internal/workflow/providers_test.go | 111 ++++++++++++++++++++++ internal/workflow/templates/ios-build.yml | 92 ++++++++++++++++-- internal/workflow/templates/runner.sh | 67 +++++++++++-- 5 files changed, 263 insertions(+), 20 deletions(-) diff --git a/internal/signing/profile_test.go b/internal/signing/profile_test.go index 7023136..dca61ed 100644 --- a/internal/signing/profile_test.go +++ b/internal/signing/profile_test.go @@ -57,10 +57,11 @@ func TestProfileType(t *testing.T) { func TestParseType(t *testing.T) { for in, want := range map[string]Type{ - "development": TypeDevelopment, "ad-hoc": TypeAdHoc, "adhoc": TypeAdHoc, " App-Store ": TypeAppStore, + "development": TypeDevelopment, "ad-hoc": TypeAdHoc, "adhoc": TypeAdHoc, "App-Store": TypeAppStore, "appstore": TypeAppStore, "enterprise": TypeEnterprise, "in-house": TypeEnterprise, } { - if got, err := ParseType(in); err != nil || got != want { + // Flags arrive with whatever case and spacing the user typed. + if got, err := ParseType(" " + in + " "); err != nil || got != want { t.Errorf("ParseType(%q) = %q, %v; want %q", in, got, err, want) } } diff --git a/internal/workflow/profile_test.go b/internal/workflow/profile_test.go index 7f199a2..68f5e65 100644 --- a/internal/workflow/profile_test.go +++ b/internal/workflow/profile_test.go @@ -124,7 +124,7 @@ func TestResolveParametersApplyProfiles(t *testing.T) { t.Fatalf("%v\n%s", r.err, r.log) } want := map[string]string{"build_id": "abcdef12", "ios_path": "ios", "scheme": "Top", "use_signing": "false", - "configuration": "Release", "profile": "preview", "distribution": "ad-hoc", "jdk_version": "17"} + "configuration": "Release", "profile": "preview", "distribution": "ad-hoc", "signing_set": "AD_HOC", "jdk_version": "17"} for k, v := range want { if r.outputs[k] != v { t.Errorf("%s = %q, want %q\n%s", k, r.outputs[k], v, r.log) @@ -141,7 +141,7 @@ func TestResolveParametersApplyProfiles(t *testing.T) { if r.err != nil { t.Fatalf("%v\n%s", r.err, r.log) } - if r.outputs["scheme"] != "Top" || r.outputs["use_signing"] != "true" || r.outputs["configuration"] != "Debug" || r.outputs["profile"] != "" || len(r.env) != 0 { + if r.outputs["scheme"] != "Top" || r.outputs["use_signing"] != "true" || r.outputs["configuration"] != "Debug" || r.outputs["profile"] != "" || r.outputs["signing_set"] != "DEVELOPMENT" || len(r.env) != 0 { t.Fatalf("outputs %v env %v\n%s", r.outputs, r.env, r.log) } }) @@ -156,13 +156,13 @@ func TestResolveParametersApplyProfiles(t *testing.T) { t.Fatalf("%v\n%s", r.err, r.log) } if r.outputs["build_id"] != "12345678" || r.outputs["scheme"] != "Dispatched" || r.outputs["use_signing"] != "true" || - r.outputs["profile"] != "production" || r.outputs["distribution"] != "app-store" || r.env["API_URL"] != "https://api.example.com" { + r.outputs["profile"] != "production" || r.outputs["distribution"] != "app-store" || r.outputs["signing_set"] != "APP_STORE" || r.env["API_URL"] != "https://api.example.com" { t.Fatalf("outputs %v env %v\n%s", r.outputs, r.env, r.log) } // Without a selected profile the input carries its default. env["IN_PROFILE"] = "{}" r = runResolve(t, build, "", env) - if r.err != nil || r.outputs["profile"] != "" || r.outputs["distribution"] != "" || len(r.env) != 0 { + if r.err != nil || r.outputs["profile"] != "" || r.outputs["distribution"] != "" || r.outputs["signing_set"] != "DEVELOPMENT" || len(r.env) != 0 { t.Fatalf("default profile input: %v %v %v\n%s", r.err, r.outputs, r.env, r.log) } }) diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index 2becc8d..dbc2ee1 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -312,6 +312,117 @@ func TestExportMethodFollowsProfile(t *testing.T) { } } +// TestSigningSetSelection runs the set selection and profile check the way +// the signing step does, with stub secrets, on the function bodies both +// templates carry. +func TestSigningSetSelection(t *testing.T) { + workflowTemplate, err := GetWorkflowTemplate() + if err != nil { + t.Fatal(err) + } + runner, err := GetTemplate("runner.sh") + if err != nil { + t.Fatal(err) + } + var shared string + for _, name := range []string{"signing_set", "select_signing_set", "check_signing_set"} { + fromWorkflow := shellFunc(t, string(workflowTemplate), name) + fromRunner := shellFunc(t, string(runner), name) + if fromWorkflow != fromRunner { + t.Fatalf("templates disagree on %s:\n%s\n---\n%s", name, fromWorkflow, fromRunner) + } + shared += fromRunner + "\n" + } + // Both templates feed the detected method into the check, after the + // selection, and read the set the resolve step emits. + for name, data := range map[string]string{"ios-build.yml": string(workflowTemplate), "runner.sh": string(runner)} { + for _, want := range []string{"select_signing_set\n", `check_signing_set "$EXPORT_METHOD"`} { + if !strings.Contains(data, want) { + t.Errorf("%s: missing %q", name, want) + } + } + } + if !strings.Contains(string(workflowTemplate), `SIGNING_SET: ${{ steps.params.outputs.signing_set }}`) || !strings.Contains(string(runner), `SIGNING_SET=$(signing_set "$DISTRIBUTION")`) { + t.Error("SIGNING_SET is not derived from the distribution") + } + for _, set := range []string{"DEVELOPMENT", "AD_HOC", "APP_STORE", "ENTERPRISE"} { + for _, secret := range []string{"IOS_CERTIFICATE_", "IOS_CERTIFICATE_PASSWORD_", "IOS_PROVISIONING_PROFILE_"} { + if line := secret + set + ": ${{ secrets." + secret + set + " }}"; !strings.Contains(string(workflowTemplate), line) { + t.Errorf("ios-build.yml does not pass %s%s to the signing step", secret, set) + } + } + } + + if runtime.GOOS == "windows" { + t.Skip("shell test") + } + script := "set -e\nfail() { echo \"$*\" >&2; exit 1; }\n" + shared + + "SIGNING_SET=$(signing_set \"$DISTRIBUTION\") || fail \"bad distribution $DISTRIBUTION\"\n" + + "select_signing_set\ncheck_signing_set \"$METHOD\"\n" + + "printf '%s|%s|%s|%s' \"$IOS_CERTIFICATE\" \"$IOS_CERTIFICATE_PASSWORD\" \"$IOS_PROVISIONING_PROFILE\" \"$SIGNING_SET_USED\"\n" + run := func(env map[string]string) (string, error) { + cmd := exec.Command("bash", "-c", script) + // A bare environment: none of the secrets can leak in from the host. + cmd.Env = []string{"PATH=" + os.Getenv("PATH")} + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + out, err := cmd.CombinedOutput() + return string(out), err + } + legacy := map[string]string{"IOS_CERTIFICATE": "legacy-cert", "IOS_CERTIFICATE_PASSWORD": "legacy-pw", "IOS_PROVISIONING_PROFILE": "legacy-profile"} + appStore := map[string]string{"IOS_CERTIFICATE_APP_STORE": "store-cert", "IOS_CERTIFICATE_PASSWORD_APP_STORE": "store-pw", "IOS_PROVISIONING_PROFILE_APP_STORE": "store-profile"} + with := func(sets ...map[string]string) map[string]string { + env := map[string]string{} + for _, s := range sets { + for k, v := range s { + env[k] = v + } + } + return env + } + + for _, tc := range []struct { + name string + env map[string]string + want string // "" expects a failure whose message holds wantErr + errs []string + }{ + {"suffixed set present", with(legacy, appStore, map[string]string{"DISTRIBUTION": "app-store", "METHOD": "app-store"}), "store-cert|store-pw|store-profile|APP_STORE", nil}, + {"suffixed set with empty password", with(appStore, map[string]string{"IOS_CERTIFICATE_PASSWORD_APP_STORE": "", "DISTRIBUTION": "app-store", "METHOD": "app-store"}), "store-cert||store-profile|APP_STORE", nil}, + {"only legacy, no distribution, any profile type", with(legacy, map[string]string{"DISTRIBUTION": "", "METHOD": "ad-hoc"}), "legacy-cert|legacy-pw|legacy-profile|legacy", nil}, + {"only legacy, requested distribution matches", with(legacy, map[string]string{"DISTRIBUTION": "app-store", "METHOD": "app-store"}), "legacy-cert|legacy-pw|legacy-profile|legacy", nil}, + {"development set for no distribution", with(legacy, map[string]string{"IOS_CERTIFICATE_DEVELOPMENT": "dev-cert", "IOS_CERTIFICATE_PASSWORD_DEVELOPMENT": "dev-pw", "IOS_PROVISIONING_PROFILE_DEVELOPMENT": "dev-profile", "DISTRIBUTION": "", "METHOD": "development"}), "dev-cert|dev-pw|dev-profile|DEVELOPMENT", nil}, + {"requested set absent, legacy absent", map[string]string{"DISTRIBUTION": "ad-hoc", "METHOD": "ad-hoc"}, "", []string{"IOS_CERTIFICATE_AD_HOC", "IOS_CERTIFICATE_PASSWORD_AD_HOC", "IOS_PROVISIONING_PROFILE_AD_HOC", "unsuffixed IOS_CERTIFICATE", "--type ad-hoc"}}, + {"suffixed set missing its profile", with(map[string]string{"IOS_CERTIFICATE_APP_STORE": "store-cert", "DISTRIBUTION": "app-store", "METHOD": "app-store"}), "", []string{"incomplete", "IOS_PROVISIONING_PROFILE_APP_STORE"}}, + {"legacy profile of the wrong type", with(legacy, map[string]string{"DISTRIBUTION": "app-store", "METHOD": "development"}), "", []string{"unsuffixed IOS_PROVISIONING_PROFILE", "development provisioning profile", "distribution app-store", "APP_STORE", "--type app-store"}}, + {"suffixed profile of the wrong type", with(appStore, map[string]string{"DISTRIBUTION": "app-store", "METHOD": "ad-hoc"}), "", []string{"IOS_PROVISIONING_PROFILE_APP_STORE holds a ad-hoc", "distribution app-store"}}, + {"development set holding a distribution profile", with(map[string]string{"IOS_CERTIFICATE_DEVELOPMENT": "c", "IOS_PROVISIONING_PROFILE_DEVELOPMENT": "p", "DISTRIBUTION": "", "METHOD": "app-store"}), "", []string{"IOS_PROVISIONING_PROFILE_DEVELOPMENT", "distribution development"}}, + {"unknown distribution", with(legacy, map[string]string{"DISTRIBUTION": "adhoc", "METHOD": "ad-hoc"}), "", []string{"bad distribution adhoc"}}, + } { + t.Run(tc.name, func(t *testing.T) { + out, err := run(tc.env) + if tc.want != "" { + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + if !strings.HasSuffix(out, tc.want) { + t.Fatalf("selected %q, want suffix %q", out, tc.want) + } + return + } + if err == nil { + t.Fatalf("accepted:\n%s", out) + } + for _, want := range tc.errs { + if !strings.Contains(out, want) { + t.Errorf("error does not mention %q:\n%s", want, out) + } + } + }) + } +} + func TestWorkflowTemplatesParse(t *testing.T) { for _, name := range []string{"ios-build.yml", "ios-share.yml"} { data, err := GetTemplate(name) diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index ed44068..a9b584f 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -127,8 +127,9 @@ jobs: param flutter_version "$IN_FLUTTER_VERSION" '.flutter.version' '' param jdk_version "$IN_JDK_VERSION" '.kmp.jdkVersion' '17' - # The rest of the profile: name (for the summary), distribution (for - # the export step) and env, exported to every step from here on so + # The rest of the profile: name (for the summary), distribution + # (which signing set the signing step reads and which profile type + # it expects) and env, exported to every step from here on so # dependency installs and the build see it. if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then PROFILE_JSON="$IN_PROFILE" @@ -138,14 +139,26 @@ jobs: [ -n "${PROFILE_JSON:-}" ] || PROFILE_JSON='{}' PROFILE=$(jq -r '.name // ""' <<< "$PROFILE_JSON") DISTRIBUTION=$(jq -r '.distribution // ""' <<< "$PROFILE_JSON") - case "$DISTRIBUTION" in - ''|development|ad-hoc|app-store|enterprise) ;; - *) echo "::error::distribution \"$DISTRIBUTION\" must be development, ad-hoc, app-store or enterprise"; exit 1 ;; - esac + # The suffix of the IOS_* secrets a distribution is signed with; no + # distribution is development. Same table in runner.sh. + signing_set() { + case "$1" in + ''|development) echo DEVELOPMENT ;; + ad-hoc) echo AD_HOC ;; + app-store) echo APP_STORE ;; + enterprise) echo ENTERPRISE ;; + *) return 1 ;; + esac + } + if ! SIGNING_SET=$(signing_set "$DISTRIBUTION"); then + echo "::error::distribution \"$DISTRIBUTION\" must be development, ad-hoc, app-store or enterprise"; exit 1 + fi echo "profile=$PROFILE" >> "$GITHUB_OUTPUT" echo "distribution=$DISTRIBUTION" >> "$GITHUB_OUTPUT" + echo "signing_set=$SIGNING_SET" >> "$GITHUB_OUTPUT" echo "profile=${PROFILE:-(none)}" echo "distribution=$DISTRIBUTION" + echo "signing_set=$SIGNING_SET" # Values are base64 per entry so newlines and quotes survive; the # heredoc form of GITHUB_ENV then takes them verbatim, with a random # delimiter so no value line can end it early. Names are checked so a @@ -329,15 +342,73 @@ jobs: restore-keys: | pods-${{ runner.os }}- + # One set of secrets per distribution type, IOS_*_, selected by the + # build profile's distribution; the unsuffixed names are the fallback for + # repositories set up before signing sets. A secret that does not exist + # arrives empty. - name: Install certificate and provisioning profile if: steps.params.outputs.use_signing == 'true' env: + SIGNING_SET: ${{ steps.params.outputs.signing_set }} + DISTRIBUTION: ${{ steps.params.outputs.distribution }} + CONFIGURATION: ${{ steps.params.outputs.configuration }} IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} - CONFIGURATION: ${{ steps.params.outputs.configuration }} + IOS_CERTIFICATE_DEVELOPMENT: ${{ secrets.IOS_CERTIFICATE_DEVELOPMENT }} + IOS_CERTIFICATE_PASSWORD_DEVELOPMENT: ${{ secrets.IOS_CERTIFICATE_PASSWORD_DEVELOPMENT }} + IOS_PROVISIONING_PROFILE_DEVELOPMENT: ${{ secrets.IOS_PROVISIONING_PROFILE_DEVELOPMENT }} + IOS_CERTIFICATE_AD_HOC: ${{ secrets.IOS_CERTIFICATE_AD_HOC }} + IOS_CERTIFICATE_PASSWORD_AD_HOC: ${{ secrets.IOS_CERTIFICATE_PASSWORD_AD_HOC }} + IOS_PROVISIONING_PROFILE_AD_HOC: ${{ secrets.IOS_PROVISIONING_PROFILE_AD_HOC }} + IOS_CERTIFICATE_APP_STORE: ${{ secrets.IOS_CERTIFICATE_APP_STORE }} + IOS_CERTIFICATE_PASSWORD_APP_STORE: ${{ secrets.IOS_CERTIFICATE_PASSWORD_APP_STORE }} + IOS_PROVISIONING_PROFILE_APP_STORE: ${{ secrets.IOS_PROVISIONING_PROFILE_APP_STORE }} + IOS_CERTIFICATE_ENTERPRISE: ${{ secrets.IOS_CERTIFICATE_ENTERPRISE }} + IOS_CERTIFICATE_PASSWORD_ENTERPRISE: ${{ secrets.IOS_CERTIFICATE_PASSWORD_ENTERPRISE }} + IOS_PROVISIONING_PROFILE_ENTERPRISE: ${{ secrets.IOS_PROVISIONING_PROFILE_ENTERPRISE }} run: | set -e + fail() { echo "::error::$*"; exit 1; } + + # Picks the secrets of the set the build profile's distribution names + # (IOS_CERTIFICATE_ and friends) into IOS_CERTIFICATE, + # IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE, falling back + # to those unsuffixed names when the set is absent. SIGNING_SET_USED + # says which it was. Same function in runner.sh. + select_signing_set() { + local cert="IOS_CERTIFICATE_$SIGNING_SET" pass="IOS_CERTIFICATE_PASSWORD_$SIGNING_SET" prof="IOS_PROVISIONING_PROFILE_$SIGNING_SET" + if [ -n "${!cert:-}" ] || [ -n "${!prof:-}" ]; then + if [ -z "${!cert:-}" ] || [ -z "${!prof:-}" ]; then + fail "Signing set $SIGNING_SET is incomplete: set both $cert and $prof (and $pass)." + fi + IOS_CERTIFICATE="${!cert}" + IOS_CERTIFICATE_PASSWORD="${!pass:-}" + IOS_PROVISIONING_PROFILE="${!prof}" + SIGNING_SET_USED="$SIGNING_SET" + elif [ -n "${IOS_CERTIFICATE:-}" ] && [ -n "${IOS_PROVISIONING_PROFILE:-}" ]; then + SIGNING_SET_USED=legacy + else + fail "No signing secrets for distribution ${DISTRIBUTION:-development}: set $cert, $pass and $prof (builder signing setup --type ${DISTRIBUTION:-development} does), or the unsuffixed IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE." + fi + echo "Signing set: $SIGNING_SET_USED" + } + + # The profile in the set must be the type the build profile asked + # for, or the export method, and the IPA, would not be what the + # profile promised. The unsuffixed secrets with no distribution + # requested are taken as they are, as before signing sets. + check_signing_set() { + local want="$DISTRIBUTION" secret="IOS_PROVISIONING_PROFILE_$SIGNING_SET" + if [ "$SIGNING_SET_USED" = legacy ]; then + secret="the unsuffixed IOS_PROVISIONING_PROFILE" + elif [ -z "$want" ]; then + want=development + fi + if [ -n "$want" ] && [ "$1" != "$want" ]; then + fail "$secret holds a $1 provisioning profile, but the build profile asks for distribution $want (signing set $SIGNING_SET). Upload a $want profile with builder signing setup --type $want, or set the profile's distribution to $1." + fi + } # The export method has to match the profile, or -exportArchive fails # and App Store Connect rejects the IPA. Xcode 15.3+ also accepts @@ -358,6 +429,8 @@ jobs: fi } + select_signing_set + # Create temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db KEYCHAIN_PASSWORD=$(openssl rand -base64 32) @@ -392,6 +465,7 @@ jobs: PROFILE_BUNDLE_ID=${APP_ID#"$TEAM_ID".} EXPORT_METHOD=$(detect_export_method "$PROFILE_PLIST") + check_signing_set "$EXPORT_METHOD" # A Debug archive carries get-task-allow=true, which no distribution # profile grants: the export fails, or an IPA that App Store Connect @@ -405,10 +479,12 @@ jobs: echo "DEVELOPMENT_TEAM=$TEAM_ID" >> $GITHUB_ENV echo "PROVISIONING_PROFILE_NAME=$PROFILE_NAME" >> $GITHUB_ENV echo "EXPORT_METHOD=$EXPORT_METHOD" >> $GITHUB_ENV + echo "SIGNING_SET_USED=$SIGNING_SET_USED" >> $GITHUB_ENV echo "Certificate and provisioning profile installed" echo " team: $TEAM_ID" echo " profile: $PROFILE_NAME ($PROFILE_UUID)" echo " app id: $PROFILE_BUNDLE_ID" + echo " set: $SIGNING_SET_USED" echo " export: $EXPORT_METHOD" echo "If the build fails on a provisioning mismatch, the app's PRODUCT_BUNDLE_IDENTIFIER must match the app id above." @@ -756,7 +832,7 @@ jobs: echo "- **Pods Cache:** ${{ steps.pods-cache.outputs.cache-hit == 'true' && 'Hit' || 'Miss' }}" >> $GITHUB_STEP_SUMMARY echo "- **Node Modules Cache:** ${{ steps.node-modules-cache.outputs.cache-hit == 'true' && 'Hit' || 'N/A' }}" >> $GITHUB_STEP_SUMMARY if [ "$USE_SIGNING" = "true" ]; then - echo "- **Signing:** Signed (development)" >> $GITHUB_STEP_SUMMARY + echo "- **Signing:** Signed (${EXPORT_METHOD:-unknown}, set ${SIGNING_SET_USED:-unknown})" >> $GITHUB_STEP_SUMMARY else echo "- **Signing:** Unsigned (sign locally with AltStore/Sideloadly)" >> $GITHUB_STEP_SUMMARY fi diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index a5154f9..7ef06e9 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -7,10 +7,13 @@ mkdir -p "$ci_dir" mode="${1:-build}" export IOS_PATH="${IOS_PATH:-.}" SCHEME="${SCHEME:-}" CONFIGURATION="${CONFIGURATION:-Debug}" export USE_SIGNING="${USE_SIGNING:-false}" JDK_VERSION="${JDK_VERSION:-17}" -# From the selected builder.json profile: DISTRIBUTION is reserved for the -# export step; BUILD_ENV is a JSON object exported by prepare(). +# From the selected builder.json profile: DISTRIBUTION picks the signing set +# (IOS_*_ secrets) and the profile type install_signing expects; BUILD_ENV +# is a JSON object exported by prepare(). export DISTRIBUTION="${DISTRIBUTION:-}" BUILD_ENV="${BUILD_ENV:-}" +fail() { echo "$*" >&2; exit 1; } + # Exports the profile's env before any dependency install or build, as the # GitHub workflows do. Values are base64 per entry so newlines and quotes # survive; names are checked so a value cannot become a second variable. @@ -165,10 +168,61 @@ detect_export_method() { fi } +# The suffix of the IOS_* secrets a distribution is signed with; no +# distribution is development. Same table in ios-build.yml. +signing_set() { + case "$1" in + ''|development) echo DEVELOPMENT ;; + ad-hoc) echo AD_HOC ;; + app-store) echo APP_STORE ;; + enterprise) echo ENTERPRISE ;; + *) return 1 ;; + esac +} + +# Picks the secrets of the set the build profile's distribution names +# (IOS_CERTIFICATE_ and friends) into IOS_CERTIFICATE, +# IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE, falling back to those +# unsuffixed names when the set is absent. SIGNING_SET_USED says which it was. +# Same function in ios-build.yml. +select_signing_set() { + local cert="IOS_CERTIFICATE_$SIGNING_SET" pass="IOS_CERTIFICATE_PASSWORD_$SIGNING_SET" prof="IOS_PROVISIONING_PROFILE_$SIGNING_SET" + if [ -n "${!cert:-}" ] || [ -n "${!prof:-}" ]; then + if [ -z "${!cert:-}" ] || [ -z "${!prof:-}" ]; then + fail "Signing set $SIGNING_SET is incomplete: set both $cert and $prof (and $pass)." + fi + IOS_CERTIFICATE="${!cert}" + IOS_CERTIFICATE_PASSWORD="${!pass:-}" + IOS_PROVISIONING_PROFILE="${!prof}" + SIGNING_SET_USED="$SIGNING_SET" + elif [ -n "${IOS_CERTIFICATE:-}" ] && [ -n "${IOS_PROVISIONING_PROFILE:-}" ]; then + SIGNING_SET_USED=legacy + else + fail "No signing secrets for distribution ${DISTRIBUTION:-development}: set $cert, $pass and $prof (builder signing setup --type ${DISTRIBUTION:-development} does), or the unsuffixed IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE." + fi + echo "Signing set: $SIGNING_SET_USED" +} + +# The profile in the set must be the type the build profile asked for, or the +# export method, and the IPA, would not be what the profile promised. The +# unsuffixed secrets with no distribution requested are taken as they are, as +# before signing sets. +check_signing_set() { + local want="$DISTRIBUTION" secret="IOS_PROVISIONING_PROFILE_$SIGNING_SET" + if [ "$SIGNING_SET_USED" = legacy ]; then + secret="the unsuffixed IOS_PROVISIONING_PROFILE" + elif [ -z "$want" ]; then + want=development + fi + if [ -n "$want" ] && [ "$1" != "$want" ]; then + fail "$secret holds a $1 provisioning profile, but the build profile asks for distribution $want (signing set $SIGNING_SET). Upload a $want profile with builder signing setup --type $want, or set the profile's distribution to $1." + fi +} + install_signing() { - : "${IOS_CERTIFICATE:?Set the IOS_CERTIFICATE secret on this provider}" - : "${IOS_CERTIFICATE_PASSWORD?Set IOS_CERTIFICATE_PASSWORD on this provider (may be empty)}" - : "${IOS_PROVISIONING_PROFILE:?Set IOS_PROVISIONING_PROFILE on this provider}" + SIGNING_SET=$(signing_set "$DISTRIBUTION") || fail "DISTRIBUTION \"$DISTRIBUTION\" must be development, ad-hoc, app-store or enterprise" + select_signing_set + : "${IOS_CERTIFICATE_PASSWORD=}" signing_dir=$(mktemp -d "$ci_dir/signing.XXXXXX") keychain_path="$signing_dir/signing.keychain-db" keychain_password=$(openssl rand -base64 32) @@ -189,7 +243,8 @@ install_signing() { profile_dest="$HOME/Library/MobileDevice/Provisioning Profiles/$profile_uuid.mobileprovision" cp "$signing_dir/profile.mobileprovision" "$profile_dest" export EXPORT_METHOD="$(detect_export_method "$signing_dir/profile.plist")" - echo "Signing with '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM), export method $EXPORT_METHOD" + check_signing_set "$EXPORT_METHOD" + echo "Signing with '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM, set $SIGNING_SET_USED), export method $EXPORT_METHOD" # A Debug archive carries get-task-allow=true, which no distribution profile # grants: the export fails, or an IPA that App Store Connect rejects comes # out. Say so now instead of after the whole build. From ce30801e258b0557c39ecc5538c34dc7df52e809 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 15:53:52 +0200 Subject: [PATCH 34/75] 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. --- CLAUDE.md | 51 ++++++++++++---- README.md | 129 +++++++++++++++++++++++++++++---------- docs/provider-secrets.md | 48 ++++++++++----- docs/provider-setup.md | 10 ++- docs/providers.md | 13 ++-- 5 files changed, 185 insertions(+), 66 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ab0e54..ed4e13c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,8 +120,9 @@ builder signing setup ───► Bundle ID: --bundle-id → ios.bundleId → d └─ profiles?filter[name] → reuse / DELETE + POST profiles │ ▼ - Writes key/.p12/.mobileprovision, uploads the three IOS_* - secrets (GitHub) or prints them (Codemagic/Bitrise) + Writes key/.p12/.mobileprovision (named by type), uploads the + three IOS_*_ secrets of the type's signing set (GitHub) + or prints them (Codemagic/Bitrise) builder ios upload ──────► Reads bundle ID / version / build number from dist/*.ipa │ @@ -203,8 +204,30 @@ internal/ GitHub the profile's env lands in `$GITHUB_ENV`, and step-level `env:` (the signing secrets, the build parameters) takes precedence over it. `distribution` reaches the runner as the `steps.params.outputs.distribution` output on GitHub and the `DISTRIBUTION` variable for - `runner.sh`; the export step is meant to consume it under those names. + `runner.sh`, where it selects the signing set (below). `env` is build-time configuration, not secrets: it sits in `builder.json` and in the run's inputs +- **Signing Sets**: one trio of secrets per distribution type, `IOS_CERTIFICATE_`, + `IOS_CERTIFICATE_PASSWORD_`, `IOS_PROVISIONING_PROFILE_` with SET in DEVELOPMENT, + AD_HOC, APP_STORE, ENTERPRISE; the unsuffixed names are the fallback so repositories from before + keep building. The distribution → set table exists twice and must agree: `config.SigningSet` + (Go; `config.SigningSecretNames` builds the names) and the shell function `signing_set` in + `ios-build.yml`'s `Resolve parameters` (emits the `signing_set` output) and `runner.sh` + (`install_signing` derives it from `DISTRIBUTION`). No distribution means DEVELOPMENT. The + signing step receives every set's secrets as env (GitHub hands a missing secret over as empty; + Codemagic/Bitrise users define the suffixed variables); `select_signing_set` picks the set by + bash indirect expansion, falls back to the unsuffixed names, and fails naming both when neither + exists; `check_signing_set` compares `detect_export_method`'s result with the requested + distribution (a suffixed set is always checked, the legacy set only when a distribution was + requested) before the keychain work and the build. `select_signing_set`/`check_signing_set`/ + `signing_set` are verbatim in both templates, each with its own `fail` (`::error::` vs stderr); + `TestSigningSetSelection` compares the bodies and runs them with stub secrets. `signing setup` + writes only the set of its type (automatic: `--type`; manual: `signing.ProfileType` reads the + plist out of the CMS blob, `--type` overrides) and never touches other sets or the legacy names. + Files are `ios-signing-.key/.p12`, so two types coexist in one `--out-dir`; the key lookup + is `--key`, then the type's file, then the legacy `ios-signing.key`. `Progress.Settings` prints + `Signing set:` for signed builds. Enterprise is a valid set and profile type but `Auto` refuses + it (no ASC endpoint for in-house profiles). The suffixed secret names and `SIGNING_SET*` are + reserved env names. - **Flutter Detection**: Auto-detects Flutter projects, runs `flutter pub get`, uses `Runner` scheme - **DerivedData Caching**: `restore` keys on `github.run_id` and only the prefix in `restore-keys` ever hits, so every run must pair with a `cache/save` step or later builds stay cold. `ios-share` @@ -268,9 +291,10 @@ internal/ already in it, and rewrites ASC 409/422 with a "complete the metadata" hint. - **Automatic Signing** (`signing.Auto`, behind `signing setup` without `--certificate`/ `--profile`): idempotent and never revokes. A certificate is reused only when its private key - is local (`--key`, or the `ios-signing.key` a previous run left in `--out-dir`), since a .p12 - needs the key; otherwise a new one is issued and Apple's quota error (2 Development / - 3 Distribution) gets a hint. Dev/ad-hoc profiles cover every ENABLED iOS device on the + is local (`--key`, or the `ios-signing-.key` / legacy `ios-signing.key` a previous run + left in `--out-dir`), since a .p12 needs the key; otherwise a new one is issued and Apple's + quota error (2 Development / 3 Distribution) gets a hint. Dev/ad-hoc profiles cover every + ENABLED iOS device on the account, not just the ones passed; App Store profiles send no `devices` relationship at all (an empty one is rejected). Profile membership is read from `/v1/profiles/{id}/relationships/{certificates,devices}` (paginated), not `include=`, which @@ -280,10 +304,10 @@ internal/ is checked client-side. The manual `--certificate`/`--profile` path in `runSigningSetup` is untouched; the automatic one lives in `cmd/builder/signing_auto.go`. - **Export Method Follows The Profile**: the `method` in ExportOptions.plist must match the - uploaded profile's type (`development`, `ad-hoc`, `app-store`), or xcodebuild refuses the - export. `signing setup --type ad-hoc|app-store` only produces the material; deriving the - method from the profile in `ios-build.yml` and `runner.sh` is PR #17, so those IPAs work - once both are merged. + uploaded profile's type (`development`, `ad-hoc`, `app-store`, `enterprise`), or xcodebuild + refuses the export. `detect_export_method` in `ios-build.yml` and `runner.sh` reads it from + the profile of the selected signing set, and `check_signing_set` confirms it is the type the + build profile's `distribution` asked for. - **Extension Points**: a future `ios release` (upload + TestFlight, automatic build numbers) composes `distribute.Upload` and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` for the latest build number; the `pkg/` wrappers do not expose `asc` yet. @@ -312,8 +336,9 @@ project has exactly one app target (test targets and `$(…)` values are skipped `profiles` and `defaultProfile` are optional. A profile's fields are `configuration`, `scheme`, `signing`, `provider`, `env` (string map) and `distribution` (`development`, `ad-hoc`, `app-store`, -`enterprise`; reserved for the export step, passed through but not applied yet). `runner` and -`submit` are planned for the same struct (`config.Profile`) but not read. +`enterprise`; selects the signing set and the profile type the runner expects, and with it the +export method; `signing: true` without it is development). `runner` and `submit` are planned for +the same struct (`config.Profile`) but not read. ## Workflow Features @@ -331,7 +356,7 @@ The embedded workflow template (`internal/workflow/templates/ios-build.yml`): `use_signing`, `configuration`, `flutter_version` and `jdk_version` from `builder.json` in the tagged tree, applying the profile named by `defaultProfile` (a tag cannot pick one per run); every later step reads `steps.params.outputs.*`, never `inputs.*`. The same step exports the - profile's `env` to `$GITHUB_ENV` and outputs `profile` and `distribution`. The job deletes + profile's `env` to `$GITHUB_ENV` and outputs `profile`, `distribution` and `signing_set`. The job deletes the tag when it ends (`permissions: contents: write`). Any other workflow in the repo with an unfiltered `on: push` also fires on these tags. - Runs on `macos-latest` diff --git a/README.md b/README.md index d75f32a..8dfaee6 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,7 @@ builder ios share --profile preview | `signing` | Overrides `ios.signing`; `false` in a profile turns signing off even when the top level has it on | | `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) | | `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them | -| `distribution` | Reserved: one of `development`, `ad-hoc`, `app-store`, `enterprise`. Validated and passed to the runner; the export step does not act on it yet | +| `distribution` | One of `development`, `ad-hoc`, `app-store`, `enterprise`. Selects the [signing set](#signing-sets-one-certificate-per-distribution-type) the build signs with and the type the provisioning profile in it must have; the IPA is exported with the matching method. A profile with `signing: true` and no `distribution` is `development` | How a build's settings are resolved: @@ -300,8 +300,8 @@ How a build's settings are resolved: - A profile only overrides the fields it sets; everything else comes from the top level. An unknown profile name is an error that lists the available ones. - `--unsigned` and `--provider` on the command line override the profile. -- The resolved settings (profile, configuration, scheme, signing, provider, env - names) are printed before anything is dispatched. +- The resolved settings (profile, configuration, scheme, signing, signing set, + provider, env names) are printed before anything is dispatched. - `ios share` only takes the profile's scheme, provider and env: simulator builds are always Debug and unsigned. @@ -359,6 +359,60 @@ membership — Apple only issues certificates to paid accounts. (Without one, build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free Apple ID.) +### Signing sets: one certificate per distribution type + +A repository holds up to four sets of signing secrets, one per distribution +type, so development builds for your devices and App Store builds for +TestFlight can live side by side without swapping secrets between builds: + +| Set | Secrets | Used when the build profile's `distribution` is | +|-----|---------|--------------------------------------------------| +| `DEVELOPMENT` | `IOS_CERTIFICATE_DEVELOPMENT`, `IOS_CERTIFICATE_PASSWORD_DEVELOPMENT`, `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | `development`, or not set | +| `AD_HOC` | `IOS_CERTIFICATE_AD_HOC`, `IOS_CERTIFICATE_PASSWORD_AD_HOC`, `IOS_PROVISIONING_PROFILE_AD_HOC` | `ad-hoc` | +| `APP_STORE` | `IOS_CERTIFICATE_APP_STORE`, `IOS_CERTIFICATE_PASSWORD_APP_STORE`, `IOS_PROVISIONING_PROFILE_APP_STORE` | `app-store` | +| `ENTERPRISE` | `IOS_CERTIFICATE_ENTERPRISE`, `IOS_CERTIFICATE_PASSWORD_ENTERPRISE`, `IOS_PROVISIONING_PROFILE_ENTERPRISE` | `enterprise` | +| legacy | `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD`, `IOS_PROVISIONING_PROFILE` | fallback whenever the set above is absent | + +`builder signing setup` writes the set of the type it produced (`--type`) or, +with `--certificate`/`--profile`, the type it reads from the +`.mobileprovision`. The runner picks the set named by the selected profile's +`distribution` (no profile, or no `distribution`, means `DEVELOPMENT`) and +falls back to the unsuffixed names, so a repository set up before signing sets +keeps building with the secrets it has; `setup` never deletes those. The +runner then checks that the profile in the set is the type the build asked +for and fails by name — set, requested distribution, actual profile type — +before anything is compiled. The unsuffixed secrets with no `distribution` +requested are accepted whatever their type, as before. + +A project with a device profile and a release profile: + +```bash +builder auth apple +builder signing setup --devices-from-mobai # DEVELOPMENT set: Apple Development + devices +builder signing setup --type app-store # APP_STORE set: Apple Distribution + App Store profile +``` + +```json +{ + "ios": { "path": "ios", "bundleId": "com.example.app" }, + "defaultProfile": "development", + "profiles": { + "development": { "configuration": "Debug", "signing": true }, + "production": { "configuration": "Release", "signing": true, "distribution": "app-store" } + } +} +``` + +`builder ios build` (the default profile) signs with the `DEVELOPMENT` set and +exports a development IPA for the registered devices; `builder ios build +--profile production` signs with the `APP_STORE` set and exports an App Store +IPA for `builder ios upload`. Both sets stay in place. The same works with +files from the portal: `builder signing setup --certificate dist.p12 --profile +AppStore.mobileprovision` lands in `APP_STORE` because that is what the profile +is (`--type` overrides the detection). On Codemagic and Bitrise the suffixed +names are variables you add in the dashboard, see the +[secrets guide](docs/provider-secrets.md). + ### Automatic setup ```bash @@ -376,7 +430,8 @@ Identifiers & Profiles*): Developer-role keys cannot create certificates. newest IPA in `./dist/`; in a terminal it asks as a last resort. 2. Issues a **certificate** — Apple Development for `--type development`, Apple Distribution for `ad-hoc` and `app-store` — for a private key generated on - your machine (`ios-signing.key`, or `--key` to reuse one from `signing csr`). + your machine (`ios-signing-.key`, or `--key` to reuse one from + `signing csr`; a `ios-signing.key` from an earlier version is picked up too). A valid certificate on the account is reused only when its private key is here, because that is the only way to build the `.p12`; otherwise a new one is issued. Nothing is ever revoked: when Apple's limit (2 Development, 3 @@ -393,12 +448,15 @@ Identifiers & Profiles*): Developer-role keys cannot create certificates. unexpired and still lists exactly this certificate and these devices; otherwise it is deleted and recreated, and the summary says why (`invalid`, `expired`, `certificate changed`, `devices changed`, `forced`). -5. Writes `ios-signing.key` (when generated), `ios-signing.p12` and - `Builder--.mobileprovision` to `--out-dir` (default `.`), - uploads `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and - `IOS_PROVISIONING_PROFILE` to GitHub Secrets and sets `ios.signing` to - `true`. For Codemagic and Bitrise it prints the three values to paste - instead, following the [signing and MobAI secrets guide](docs/provider-secrets.md). +5. Writes `ios-signing-.key` (when generated), `ios-signing-.p12` + and `Builder--.mobileprovision` to `--out-dir` (default + `.`) — one trio per type, so setting up a second type keeps the first — + uploads `IOS_CERTIFICATE_`, `IOS_CERTIFICATE_PASSWORD_` and + `IOS_PROVISIONING_PROFILE_` for the type's + [signing set](#signing-sets-one-certificate-per-distribution-type) to + GitHub Secrets and sets `ios.signing` to `true`. For Codemagic and Bitrise + it prints the three values to paste instead, following the + [signing and MobAI secrets guide](docs/provider-secrets.md). The command shows its plan and asks once before creating anything; `--yes` skips that (required without a terminal), and then the `.p12` password is @@ -408,7 +466,8 @@ result as JSON with progress on stderr. Keep the written files out of git. Run it again whenever you like: it reports what it found and recreates only what is missing, expired, invalid or changed — add a device, re-run, rebuild. `--force` issues a fresh certificate and profile regardless. For TestFlight use -`--type app-store` and set `ios.configuration` to `Release`. +`--type app-store` and build with a profile that has `"distribution": +"app-store"` and `"configuration": "Release"`. ### Manual path through the Apple Developer portal @@ -439,10 +498,10 @@ gitignored files are also excluded from build snapshots). builder signing p12 --certificate development.cer --key ios-signing.key ``` -This combines the key and certificate into `ios-signing.p12`, protected by a -password you choose — byte-for-byte the same kind of file Keychain Access -exports, and usable anywhere one is: `builder signing setup`, Sideloadly, -AltStore, or importing it on a Mac. Keep it, and don't commit it. +This combines the key and certificate into `ios-signing.p12` (`--out` to name +it), protected by a password you choose — byte-for-byte the same kind of file +Keychain Access exports, and usable anywhere one is: `builder signing setup`, +Sideloadly, AltStore, or importing it on a Mac. Keep it, and don't commit it. #### 4. Create a provisioning profile @@ -456,10 +515,10 @@ The build reads the profile and exports the IPA with the matching method, so the profile type alone decides what the IPA is good for: development, ad-hoc, enterprise or App Store. Everything except a development profile is a distribution build, and those must be built with the **Release** configuration -(`"configuration": "Release"` under `ios` in `builder.json`) — a Debug build is -signed with `get-task-allow`, which distribution profiles do not allow and App -Store Connect rejects. The build fails early with that message if the two -disagree. +(`"configuration": "Release"` in the build profile, or under `ios`) — a Debug +build is signed with `get-task-allow`, which distribution profiles do not allow +and App Store Connect rejects. The build fails early with that message if the +two disagree. #### 5. Upload the signing secrets @@ -468,15 +527,21 @@ builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovis ``` With `--certificate` and `--profile` given, `setup` takes the files as they are -(no App Store Connect key involved) and uploads the signing material to GitHub -Secrets: -- `IOS_CERTIFICATE` - Base64-encoded .p12 file -- `IOS_CERTIFICATE_PASSWORD` - Certificate password -- `IOS_PROVISIONING_PROFILE` - Base64-encoded .mobileprovision file +(no App Store Connect key involved), reads the type out of the +`.mobileprovision` — development, ad-hoc, app-store or enterprise; `--type` +overrides it — and uploads the signing material to the GitHub Secrets of that +type's [signing set](#signing-sets-one-certificate-per-distribution-type): +- `IOS_CERTIFICATE_` - Base64-encoded .p12 file +- `IOS_CERTIFICATE_PASSWORD_` - Certificate password +- `IOS_PROVISIONING_PROFILE_` - Base64-encoded .mobileprovision file + +It prints which set it wrote. Other sets, and the unsuffixed secrets of an +earlier setup, are left untouched. You can also skip step 3 and hand `setup` the `.cer` together with the key — `builder signing setup --certificate development.cer --key ios-signing.key ---profile MyApp.mobileprovision` — and it assembles the `.p12` on the way. +--profile MyApp.mobileprovision` — and it assembles the `.p12` on the way, +saving it as `ios-signing-.p12`. `setup` also sets `ios.signing` to `true` in `builder.json`, which is what tells `builder ios build` to sign. From then on builds produce signed IPAs; use @@ -499,12 +564,14 @@ You need: membership and an app record in App Store Connect (My Apps → +) with your bundle ID - An IPA signed with an **Apple Distribution** certificate and an **App Store** - provisioning profile: `builder signing setup --type app-store` creates both, - or pick those types on the portal in the manual path. An IPA signed for - development is rejected at upload. -- `"configuration": "Release"` under `ios` in `builder.json`: `ios build` - defaults to `Debug`, which is what the dev commands expect, not what you want - to ship. + provisioning profile: `builder signing setup --type app-store` creates both + and stores them as the `APP_STORE` signing set, or pick those types on the + portal in the manual path. An IPA signed for development is rejected at + upload. +- A build profile with `"distribution": "app-store"` and `"configuration": + "Release"` (see [Build Profiles](#build-profiles)): `ios build` defaults to + `Debug` and the development set, which is what the dev commands expect, not + what you want to ship. - An App Store Connect API key: App Store Connect → Users and Access → Integrations → App Store Connect API → Team Keys. Give it the **App Manager** role, note the **Issuer ID** and **Key ID**, and download the diff --git a/docs/provider-secrets.md b/docs/provider-secrets.md index 2ac642f..d1f21dd 100644 --- a/docs/provider-secrets.md +++ b/docs/provider-secrets.md @@ -7,7 +7,8 @@ API login, the provider's GitHub connection, and build secrets are separate: | What you want to run | Secrets needed | | --- | --- | | Unsigned IPA build (`ios build --unsigned`) | None of the secrets below | -| Signed iPhone build (`ios build`) | All three `IOS_*` secrets below | +| Signed iPhone build (`ios build`) | The three `IOS_*_DEVELOPMENT` secrets below | +| Signed App Store / ad-hoc / enterprise build (`ios build --profile `) | The three `IOS_*_` secrets of the profile's `distribution` | | Shared simulator (`ios share`) | `MOBAI_API_KEY`; no Apple signing files needed | `builder signing setup` uploads secrets to **GitHub Actions only**. For the two @@ -43,9 +44,11 @@ builder signing setup --devices-from-mobai --out-dir ~/signing ``` With `provider` set to Codemagic or Bitrise in `builder.json`, this creates the -certificate, devices and profile through the API, writes `ios-signing.p12` and -the `.mobileprovision` to `~/signing`, and prints the three values to paste -below instead of uploading them. Alternatively follow the +certificate, devices and profile through the API, writes +`ios-signing-development.p12` and the `.mobileprovision` to `~/signing`, and +prints the three secret names and values to paste below instead of uploading +them. Run it again with `--type app-store` for a second, App Store set: the +files are named by type, so nothing is overwritten. Alternatively follow the [manual certificate steps](../README.md#1-create-a-certificate-signing-request): ```sh @@ -56,36 +59,49 @@ builder signing p12 --certificate development.cer --key ios-signing.key The `p12` command prompts for the P12 password (automatic setup prompts too, or generates one with `--yes` and prints it once). Use that exact password below. -A `.cer` alone is not the value for `IOS_CERTIFICATE`; assemble the P12 first. +A `.cer` alone is not the value for `IOS_CERTIFICATE_`; assemble the P12 first. Keep private keys, P12 files, and encoded copies out of Git and build snapshots. ## 2. Prepare the secret values -Use these exact, case-sensitive names: +The signing secrets come in sets, one per distribution type, named with a +suffix: `DEVELOPMENT`, `AD_HOC`, `APP_STORE` or `ENTERPRISE`. A build reads +the set named by its `builder.json` profile's `distribution`, and +`DEVELOPMENT` when there is no profile or no `distribution`. The runner checks +that the profile in the set is that type and fails by name when it is not. +Use these exact, case-sensitive names, shown here for the development set: | Secret name | Value to paste | | --- | --- | -| `IOS_CERTIFICATE` | Base64 contents of `ios-signing.p12` | -| `IOS_CERTIFICATE_PASSWORD` | The original P12 password, as plain text | -| `IOS_PROVISIONING_PROFILE` | Base64 contents of the `.mobileprovision` file | +| `IOS_CERTIFICATE_DEVELOPMENT` | Base64 contents of `ios-signing-development.p12` | +| `IOS_CERTIFICATE_PASSWORD_DEVELOPMENT` | The original P12 password, as plain text | +| `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | Base64 contents of the `.mobileprovision` file | | `MOBAI_API_KEY` | The original API key copied from MobAI, as plain text | +For an App Store set add `IOS_CERTIFICATE_APP_STORE`, +`IOS_CERTIFICATE_PASSWORD_APP_STORE` and `IOS_PROVISIONING_PROFILE_APP_STORE` +with the Apple Distribution `.p12` and the App Store profile, and build it with +a profile that has `"distribution": "app-store"` and `"configuration": +"Release"`. The unsuffixed names `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` +and `IOS_PROVISIONING_PROFILE` from earlier setups keep working as the fallback +whenever the suffixed set of the requested distribution is absent. + Base64-encode only the two files. Paste their contents, not their filenames or paths. On macOS, copy one encoded file to the clipboard at a time: ```sh -openssl base64 -A -in /path/to/ios-signing.p12 | pbcopy -# Paste into IOS_CERTIFICATE in the provider dashboard before copying the profile. +openssl base64 -A -in /path/to/ios-signing-development.p12 | pbcopy +# Paste into IOS_CERTIFICATE_DEVELOPMENT in the provider dashboard before copying the profile. openssl base64 -A -in /path/to/Numbra.mobileprovision | pbcopy -# Paste into IOS_PROVISIONING_PROFILE. +# Paste into IOS_PROVISIONING_PROFILE_DEVELOPMENT. ``` On Linux with `xclip` installed, replace `pbcopy` with `xclip -selection clipboard`. On Windows, use PowerShell: ```powershell -[Convert]::ToBase64String([IO.File]::ReadAllBytes('C:\signing\ios-signing.p12')) | Set-Clipboard -# Paste into IOS_CERTIFICATE, then encode and paste the profile. +[Convert]::ToBase64String([IO.File]::ReadAllBytes('C:\signing\ios-signing-development.p12')) | Set-Clipboard +# Paste into IOS_CERTIFICATE_DEVELOPMENT, then encode and paste the profile. [Convert]::ToBase64String([IO.File]::ReadAllBytes('C:\signing\Numbra.mobileprovision')) | Set-Clipboard ``` @@ -155,6 +171,10 @@ A successful run should archive, export, and download an IPA. Install it on a device included in the development profile to verify signing and provisioning. If signing fails, check the P12 password, certificate/private-key pair, profile expiration, bundle ID, team, and registered devices in the provider's build log. +The log's `Signing set:` line says which set the run used (`legacy` for the +unsuffixed names); a "holds a ... provisioning profile, but the build profile +asks for distribution ..." error means the profile in that set is not the type +the selected build profile's `distribution` names. ## 7. Verify simulator sharing diff --git a/docs/provider-setup.md b/docs/provider-setup.md index c29103b..cfff891 100644 --- a/docs/provider-setup.md +++ b/docs/provider-setup.md @@ -136,11 +136,15 @@ not transferred by these commands. | Secret | Purpose | | --- | --- | -| `IOS_CERTIFICATE` | Base64 P12 signing certificate | -| `IOS_CERTIFICATE_PASSWORD` | P12 password, which may be empty | -| `IOS_PROVISIONING_PROFILE` | Base64 provisioning profile matching the app | +| `IOS_CERTIFICATE_` | Base64 P12 signing certificate | +| `IOS_CERTIFICATE_PASSWORD_` | P12 password, which may be empty | +| `IOS_PROVISIONING_PROFILE_` | Base64 provisioning profile matching the app | | `MOBAI_API_KEY` | MobAI simulator sharing | +`` is the distribution type the secrets are for: `DEVELOPMENT` (what a +build without a profile `distribution` reads), `AD_HOC`, `APP_STORE` or +`ENTERPRISE`. The unsuffixed names from earlier setups remain the fallback. + Follow the [signing and MobAI secret setup guide](provider-secrets.md) for file preparation, base64/clipboard commands, exact dashboard steps for both providers, creating the MobAI key, and signed-build/simulator verification. diff --git a/docs/providers.md b/docs/providers.md index 9ae7c37..5783436 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -150,11 +150,14 @@ same build may consume different minutes on each provider. See [step-by-step signing and MobAI secret setup](provider-secrets.md). `builder signing setup` continues to upload signing secrets to **GitHub**. -For Codemagic/Bitrise, separately configure these secrets on that provider: - -- `IOS_CERTIFICATE`: base64-encoded `.p12` -- `IOS_CERTIFICATE_PASSWORD`: the `.p12` password (can be empty) -- `IOS_PROVISIONING_PROFILE`: base64-encoded `.mobileprovision` +For Codemagic/Bitrise, separately configure these secrets on that provider, +one set per distribution type (`` is `DEVELOPMENT`, `AD_HOC`, `APP_STORE` +or `ENTERPRISE`; a build reads the set its profile's `distribution` names, +`DEVELOPMENT` by default, and falls back to the unsuffixed names): + +- `IOS_CERTIFICATE_`: base64-encoded `.p12` +- `IOS_CERTIFICATE_PASSWORD_`: the `.p12` password (can be empty) +- `IOS_PROVISIONING_PROFILE_`: base64-encoded `.mobileprovision` Set `ios.signing` to `true` after configuring the secrets. `--unsigned` disables signing for a particular build. The runner installs a temporary signing keychain From cc0c28de203a1334595be64b063188e913cbab55 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 16:02:49 +0200 Subject: [PATCH 35/75] 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. --- CLAUDE.md | 9 ++-- docs/provider-secrets.md | 11 +++-- docs/provider-setup.md | 2 +- docs/providers.md | 2 +- internal/workflow/providers_test.go | 39 ++++++++++----- internal/workflow/templates/ios-build.yml | 59 +++++++++++++---------- internal/workflow/templates/runner.sh | 46 ++++++++++-------- 7 files changed, 101 insertions(+), 67 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ed4e13c..3cf9d66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -216,9 +216,12 @@ internal/ signing step receives every set's secrets as env (GitHub hands a missing secret over as empty; Codemagic/Bitrise users define the suffixed variables); `select_signing_set` picks the set by bash indirect expansion, falls back to the unsuffixed names, and fails naming both when neither - exists; `check_signing_set` compares `detect_export_method`'s result with the requested - distribution (a suffixed set is always checked, the legacy set only when a distribution was - requested) before the keychain work and the build. `select_signing_set`/`check_signing_set`/ + exists. A suffixed set needs all three secrets, password included (Builder never writes one + without): a partial set fails naming the missing names, never falls back; only the unsuffixed + password may be empty, as before. `check_signing_set` compares `detect_export_method`'s result + with the requested distribution (a suffixed set is always checked, the legacy set only when a + distribution was requested) after the profile is decoded and before any keychain exists or + `security import` runs, so a wrong pair never lands in a keychain. `select_signing_set`/`check_signing_set`/ `signing_set` are verbatim in both templates, each with its own `fail` (`::error::` vs stderr); `TestSigningSetSelection` compares the bodies and runs them with stub secrets. `signing setup` writes only the set of its type (automatic: `--type`; manual: `signing.ProfileType` reads the diff --git a/docs/provider-secrets.md b/docs/provider-secrets.md index d1f21dd..6d49ef6 100644 --- a/docs/provider-secrets.md +++ b/docs/provider-secrets.md @@ -105,10 +105,13 @@ On Linux with `xclip` installed, replace `pbcopy` with [Convert]::ToBase64String([IO.File]::ReadAllBytes('C:\signing\Numbra.mobileprovision')) | Set-Clipboard ``` -The password variable must exist, even for a P12 with an empty password. If the -provider UI does not accept an empty secret, create a password-protected P12 and -use its password. Do not put quotes around passwords or API keys in the value -field, and do not base64-encode them. +A suffixed set needs all three variables, password included: the build fails +naming whichever is missing rather than falling back to the unsuffixed names. +Builder always protects the P12 it makes with a password; for one you made +yourself without one, create a password-protected P12 instead. Only the +unsuffixed `IOS_CERTIFICATE_PASSWORD` of an earlier setup may be empty or +absent. Do not put quotes around passwords or API keys in the value field, and +do not base64-encode them. ## 3. Create the MobAI API key diff --git a/docs/provider-setup.md b/docs/provider-setup.md index cfff891..44a6ac8 100644 --- a/docs/provider-setup.md +++ b/docs/provider-setup.md @@ -137,7 +137,7 @@ not transferred by these commands. | Secret | Purpose | | --- | --- | | `IOS_CERTIFICATE_` | Base64 P12 signing certificate | -| `IOS_CERTIFICATE_PASSWORD_` | P12 password, which may be empty | +| `IOS_CERTIFICATE_PASSWORD_` | P12 password (required; only the unsuffixed legacy one may be empty) | | `IOS_PROVISIONING_PROFILE_` | Base64 provisioning profile matching the app | | `MOBAI_API_KEY` | MobAI simulator sharing | diff --git a/docs/providers.md b/docs/providers.md index 5783436..21f5ebe 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -156,7 +156,7 @@ or `ENTERPRISE`; a build reads the set its profile's `distribution` names, `DEVELOPMENT` by default, and falls back to the unsuffixed names): - `IOS_CERTIFICATE_`: base64-encoded `.p12` -- `IOS_CERTIFICATE_PASSWORD_`: the `.p12` password (can be empty) +- `IOS_CERTIFICATE_PASSWORD_`: the `.p12` password (required; only the unsuffixed legacy one can be empty) - `IOS_PROVISIONING_PROFILE_`: base64-encoded `.mobileprovision` Set `ios.signing` to `true` after configuring the secrets. `--unsigned` disables diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index dbc2ee1..fc89950 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -9,6 +9,7 @@ import ( "testing" "text/template" + "github.com/MobAI-App/ios-builder/internal/signing" "go.yaml.in/yaml/v3" ) @@ -271,9 +272,6 @@ func TestExportMethodFollowsProfile(t *testing.T) { } } - if runtime.GOOS != "darwin" { - t.Skip("plutil is macOS only") - } profile := func(body string) string { return ` @@ -293,6 +291,18 @@ func TestExportMethodFollowsProfile(t *testing.T) { {"enterprise", profile("ProvisionsAllDevices" + allow(false)), "enterprise"}, // An enterprise profile ships devices too on some accounts; it still wins. {"enterprise with devices", profile("ProvisionsAllDevices" + devices + allow(false)), "enterprise"}, + {"explicit ProvisionsAllDevices false", profile("ProvisionsAllDevices" + allow(false)), "app-store"}, + {"no entitlements", profile(devices), "ad-hoc"}, + } + // signing setup reads the same type locally, from the plist inside the + // CMS blob, so the Go rules must agree with the shell's on every case. + for _, tc := range cases { + if got, err := signing.ProfileType([]byte("\x30\x82cms" + tc.plist + "\x00\xff")); err != nil || string(got) != tc.want { + t.Errorf("%s: signing.ProfileType = %q, %v; detect_export_method says %q", tc.name, got, err, tc.want) + } + } + if runtime.GOOS != "darwin" { + t.Skip("plutil is macOS only") } dir := t.TempDir() for _, tc := range cases { @@ -334,12 +344,14 @@ func TestSigningSetSelection(t *testing.T) { shared += fromRunner + "\n" } // Both templates feed the detected method into the check, after the - // selection, and read the set the resolve step emits. + // selection and before the certificate touches a keychain, and read the + // set the resolve step emits. for name, data := range map[string]string{"ios-build.yml": string(workflowTemplate), "runner.sh": string(runner)} { - for _, want := range []string{"select_signing_set\n", `check_signing_set "$EXPORT_METHOD"`} { - if !strings.Contains(data, want) { - t.Errorf("%s: missing %q", name, want) - } + selected, checked, imported := strings.Index(data, "select_signing_set\n"), strings.Index(data, `check_signing_set "$EXPORT_METHOD"`), strings.Index(data, "security import ") + if selected < 0 || checked < 0 || imported < 0 { + t.Errorf("%s: selection %d, check %d, import %d", name, selected, checked, imported) + } else if selected > checked || checked > imported { + t.Errorf("%s: the profile check must run after the selection and before security import (offsets %d, %d, %d)", name, selected, checked, imported) } } if !strings.Contains(string(workflowTemplate), `SIGNING_SET: ${{ steps.params.outputs.signing_set }}`) || !strings.Contains(string(runner), `SIGNING_SET=$(signing_set "$DISTRIBUTION")`) { @@ -356,7 +368,8 @@ func TestSigningSetSelection(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell test") } - script := "set -e\nfail() { echo \"$*\" >&2; exit 1; }\n" + shared + + // runner.sh runs under set -u, so an unset secret must not trip the functions. + script := "set -euo pipefail\nfail() { echo \"$*\" >&2; exit 1; }\n" + shared + "SIGNING_SET=$(signing_set \"$DISTRIBUTION\") || fail \"bad distribution $DISTRIBUTION\"\n" + "select_signing_set\ncheck_signing_set \"$METHOD\"\n" + "printf '%s|%s|%s|%s' \"$IOS_CERTIFICATE\" \"$IOS_CERTIFICATE_PASSWORD\" \"$IOS_PROVISIONING_PROFILE\" \"$SIGNING_SET_USED\"\n" @@ -389,15 +402,17 @@ func TestSigningSetSelection(t *testing.T) { errs []string }{ {"suffixed set present", with(legacy, appStore, map[string]string{"DISTRIBUTION": "app-store", "METHOD": "app-store"}), "store-cert|store-pw|store-profile|APP_STORE", nil}, - {"suffixed set with empty password", with(appStore, map[string]string{"IOS_CERTIFICATE_PASSWORD_APP_STORE": "", "DISTRIBUTION": "app-store", "METHOD": "app-store"}), "store-cert||store-profile|APP_STORE", nil}, {"only legacy, no distribution, any profile type", with(legacy, map[string]string{"DISTRIBUTION": "", "METHOD": "ad-hoc"}), "legacy-cert|legacy-pw|legacy-profile|legacy", nil}, + {"only legacy without a password", map[string]string{"IOS_CERTIFICATE": "legacy-cert", "IOS_PROVISIONING_PROFILE": "legacy-profile", "DISTRIBUTION": "", "METHOD": "development"}, "legacy-cert||legacy-profile|legacy", nil}, {"only legacy, requested distribution matches", with(legacy, map[string]string{"DISTRIBUTION": "app-store", "METHOD": "app-store"}), "legacy-cert|legacy-pw|legacy-profile|legacy", nil}, {"development set for no distribution", with(legacy, map[string]string{"IOS_CERTIFICATE_DEVELOPMENT": "dev-cert", "IOS_CERTIFICATE_PASSWORD_DEVELOPMENT": "dev-pw", "IOS_PROVISIONING_PROFILE_DEVELOPMENT": "dev-profile", "DISTRIBUTION": "", "METHOD": "development"}), "dev-cert|dev-pw|dev-profile|DEVELOPMENT", nil}, {"requested set absent, legacy absent", map[string]string{"DISTRIBUTION": "ad-hoc", "METHOD": "ad-hoc"}, "", []string{"IOS_CERTIFICATE_AD_HOC", "IOS_CERTIFICATE_PASSWORD_AD_HOC", "IOS_PROVISIONING_PROFILE_AD_HOC", "unsuffixed IOS_CERTIFICATE", "--type ad-hoc"}}, - {"suffixed set missing its profile", with(map[string]string{"IOS_CERTIFICATE_APP_STORE": "store-cert", "DISTRIBUTION": "app-store", "METHOD": "app-store"}), "", []string{"incomplete", "IOS_PROVISIONING_PROFILE_APP_STORE"}}, + {"suffixed set missing its profile", with(legacy, map[string]string{"IOS_CERTIFICATE_APP_STORE": "store-cert", "IOS_CERTIFICATE_PASSWORD_APP_STORE": "store-pw", "DISTRIBUTION": "app-store", "METHOD": "app-store"}), "", []string{"incomplete", "missing IOS_PROVISIONING_PROFILE_APP_STORE."}}, + {"suffixed set with empty password", with(appStore, map[string]string{"IOS_CERTIFICATE_PASSWORD_APP_STORE": "", "DISTRIBUTION": "app-store", "METHOD": "app-store"}), "", []string{"incomplete", "missing IOS_CERTIFICATE_PASSWORD_APP_STORE."}}, + {"suffixed password alone is not a legacy fallback", with(legacy, map[string]string{"IOS_CERTIFICATE_PASSWORD_APP_STORE": "store-pw", "DISTRIBUTION": "app-store", "METHOD": "app-store"}), "", []string{"incomplete", "missing IOS_CERTIFICATE_APP_STORE, IOS_PROVISIONING_PROFILE_APP_STORE."}}, {"legacy profile of the wrong type", with(legacy, map[string]string{"DISTRIBUTION": "app-store", "METHOD": "development"}), "", []string{"unsuffixed IOS_PROVISIONING_PROFILE", "development provisioning profile", "distribution app-store", "APP_STORE", "--type app-store"}}, {"suffixed profile of the wrong type", with(appStore, map[string]string{"DISTRIBUTION": "app-store", "METHOD": "ad-hoc"}), "", []string{"IOS_PROVISIONING_PROFILE_APP_STORE holds a ad-hoc", "distribution app-store"}}, - {"development set holding a distribution profile", with(map[string]string{"IOS_CERTIFICATE_DEVELOPMENT": "c", "IOS_PROVISIONING_PROFILE_DEVELOPMENT": "p", "DISTRIBUTION": "", "METHOD": "app-store"}), "", []string{"IOS_PROVISIONING_PROFILE_DEVELOPMENT", "distribution development"}}, + {"development set holding a distribution profile", with(map[string]string{"IOS_CERTIFICATE_DEVELOPMENT": "c", "IOS_CERTIFICATE_PASSWORD_DEVELOPMENT": "pw", "IOS_PROVISIONING_PROFILE_DEVELOPMENT": "p", "DISTRIBUTION": "", "METHOD": "app-store"}), "", []string{"IOS_PROVISIONING_PROFILE_DEVELOPMENT", "distribution development"}}, {"unknown distribution", with(legacy, map[string]string{"DISTRIBUTION": "adhoc", "METHOD": "ad-hoc"}), "", []string{"bad distribution adhoc"}}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index a9b584f..d5a8f3a 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -374,19 +374,24 @@ jobs: # Picks the secrets of the set the build profile's distribution names # (IOS_CERTIFICATE_ and friends) into IOS_CERTIFICATE, # IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE, falling back - # to those unsuffixed names when the set is absent. SIGNING_SET_USED - # says which it was. Same function in runner.sh. + # to those unsuffixed names when the set is absent. A set needs all + # three (builder signing setup always writes a password); only the + # unsuffixed password may be empty, as before signing sets. + # SIGNING_SET_USED says which it was. Same function in runner.sh. select_signing_set() { local cert="IOS_CERTIFICATE_$SIGNING_SET" pass="IOS_CERTIFICATE_PASSWORD_$SIGNING_SET" prof="IOS_PROVISIONING_PROFILE_$SIGNING_SET" - if [ -n "${!cert:-}" ] || [ -n "${!prof:-}" ]; then - if [ -z "${!cert:-}" ] || [ -z "${!prof:-}" ]; then - fail "Signing set $SIGNING_SET is incomplete: set both $cert and $prof (and $pass)." - fi + if [ -n "${!cert:-}${!pass:-}${!prof:-}" ]; then + local missing="" name + for name in "$cert" "$pass" "$prof"; do + [ -n "${!name:-}" ] || missing="${missing:+$missing, }$name" + done + [ -z "$missing" ] || fail "Signing set $SIGNING_SET is incomplete: missing $missing. builder signing setup --type ${DISTRIBUTION:-development} writes all three." IOS_CERTIFICATE="${!cert}" - IOS_CERTIFICATE_PASSWORD="${!pass:-}" + IOS_CERTIFICATE_PASSWORD="${!pass}" IOS_PROVISIONING_PROFILE="${!prof}" SIGNING_SET_USED="$SIGNING_SET" elif [ -n "${IOS_CERTIFICATE:-}" ] && [ -n "${IOS_PROVISIONING_PROFILE:-}" ]; then + IOS_CERTIFICATE_PASSWORD="${IOS_CERTIFICATE_PASSWORD:-}" SIGNING_SET_USED=legacy else fail "No signing secrets for distribution ${DISTRIBUTION:-development}: set $cert, $pass and $prof (builder signing setup --type ${DISTRIBUTION:-development} does), or the unsuffixed IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE." @@ -431,30 +436,13 @@ jobs: select_signing_set - # Create temporary keychain - KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db - KEYCHAIN_PASSWORD=$(openssl rand -base64 32) - - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - # Import certificate - CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12 - echo "$IOS_CERTIFICATE" | base64 --decode > "$CERTIFICATE_PATH" - security import "$CERTIFICATE_PATH" -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security list-keychain -d user -s "$KEYCHAIN_PATH" - - # Install provisioning profile + # Read the profile first: its type is checked against the build + # profile before any keychain exists or the certificate is imported. PROFILE_PATH=$RUNNER_TEMP/profile.mobileprovision echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > "$PROFILE_PATH" - - mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles PROFILE_PLIST=$RUNNER_TEMP/profile.plist security cms -D -i "$PROFILE_PATH" > "$PROFILE_PLIST" PROFILE_UUID=$(plutil -extract UUID raw -o - "$PROFILE_PLIST") - cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/"$PROFILE_UUID".mobileprovision # xcodebuild will not pick a team on its own on a CI machine, so read # the team and profile name out of the profile and pass them to the @@ -475,6 +463,25 @@ jobs: exit 1 fi + # Create temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Import certificate + CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12 + echo "$IOS_CERTIFICATE" | base64 --decode > "$CERTIFICATE_PATH" + security import "$CERTIFICATE_PATH" -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" + + # Install provisioning profile + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/"$PROFILE_UUID".mobileprovision + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV echo "DEVELOPMENT_TEAM=$TEAM_ID" >> $GITHUB_ENV echo "PROVISIONING_PROFILE_NAME=$PROFILE_NAME" >> $GITHUB_ENV diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 7ef06e9..479420d 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -183,19 +183,24 @@ signing_set() { # Picks the secrets of the set the build profile's distribution names # (IOS_CERTIFICATE_ and friends) into IOS_CERTIFICATE, # IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE, falling back to those -# unsuffixed names when the set is absent. SIGNING_SET_USED says which it was. -# Same function in ios-build.yml. +# unsuffixed names when the set is absent. A set needs all three (builder +# signing setup always writes a password); only the unsuffixed password may be +# empty, as before signing sets. SIGNING_SET_USED says which it was. Same +# function in ios-build.yml. select_signing_set() { local cert="IOS_CERTIFICATE_$SIGNING_SET" pass="IOS_CERTIFICATE_PASSWORD_$SIGNING_SET" prof="IOS_PROVISIONING_PROFILE_$SIGNING_SET" - if [ -n "${!cert:-}" ] || [ -n "${!prof:-}" ]; then - if [ -z "${!cert:-}" ] || [ -z "${!prof:-}" ]; then - fail "Signing set $SIGNING_SET is incomplete: set both $cert and $prof (and $pass)." - fi + if [ -n "${!cert:-}${!pass:-}${!prof:-}" ]; then + local missing="" name + for name in "$cert" "$pass" "$prof"; do + [ -n "${!name:-}" ] || missing="${missing:+$missing, }$name" + done + [ -z "$missing" ] || fail "Signing set $SIGNING_SET is incomplete: missing $missing. builder signing setup --type ${DISTRIBUTION:-development} writes all three." IOS_CERTIFICATE="${!cert}" - IOS_CERTIFICATE_PASSWORD="${!pass:-}" + IOS_CERTIFICATE_PASSWORD="${!pass}" IOS_PROVISIONING_PROFILE="${!prof}" SIGNING_SET_USED="$SIGNING_SET" elif [ -n "${IOS_CERTIFICATE:-}" ] && [ -n "${IOS_PROVISIONING_PROFILE:-}" ]; then + IOS_CERTIFICATE_PASSWORD="${IOS_CERTIFICATE_PASSWORD:-}" SIGNING_SET_USED=legacy else fail "No signing secrets for distribution ${DISTRIBUTION:-development}: set $cert, $pass and $prof (builder signing setup --type ${DISTRIBUTION:-development} does), or the unsuffixed IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE." @@ -222,26 +227,15 @@ check_signing_set() { install_signing() { SIGNING_SET=$(signing_set "$DISTRIBUTION") || fail "DISTRIBUTION \"$DISTRIBUTION\" must be development, ad-hoc, app-store or enterprise" select_signing_set - : "${IOS_CERTIFICATE_PASSWORD=}" signing_dir=$(mktemp -d "$ci_dir/signing.XXXXXX") - keychain_path="$signing_dir/signing.keychain-db" - keychain_password=$(openssl rand -base64 32) trap cleanup_signing EXIT - security create-keychain -p "$keychain_password" "$keychain_path" - security set-keychain-settings -lut 7200 "$keychain_path" - security unlock-keychain -p "$keychain_password" "$keychain_path" - printf '%s' "$IOS_CERTIFICATE" | base64 --decode > "$signing_dir/certificate.p12" - security import "$signing_dir/certificate.p12" -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path" - security set-key-partition-list -S apple-tool:,apple: -k "$keychain_password" "$keychain_path" - security list-keychains -d user -s "$keychain_path" "$HOME/Library/Keychains/login.keychain-db" + # Read the profile first: its type is checked against the build profile + # before any keychain exists or the certificate is imported. printf '%s' "$IOS_PROVISIONING_PROFILE" | base64 --decode > "$signing_dir/profile.mobileprovision" security cms -D -i "$signing_dir/profile.mobileprovision" > "$signing_dir/profile.plist" profile_uuid=$(plutil -extract UUID raw -o - "$signing_dir/profile.plist") export DEVELOPMENT_TEAM="$(plutil -extract TeamIdentifier.0 raw -o - "$signing_dir/profile.plist")" export PROVISIONING_PROFILE_NAME="$(plutil -extract Name raw -o - "$signing_dir/profile.plist")" - mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" - profile_dest="$HOME/Library/MobileDevice/Provisioning Profiles/$profile_uuid.mobileprovision" - cp "$signing_dir/profile.mobileprovision" "$profile_dest" export EXPORT_METHOD="$(detect_export_method "$signing_dir/profile.plist")" check_signing_set "$EXPORT_METHOD" echo "Signing with '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM, set $SIGNING_SET_USED), export method $EXPORT_METHOD" @@ -252,6 +246,18 @@ install_signing() { echo "The provisioning profile is an $EXPORT_METHOD profile, but the build configuration is Debug. A Debug build is signed with get-task-allow, which distribution profiles do not allow and App Store Connect rejects. Set \"configuration\": \"Release\" under \"ios\" in builder.json, or use a development profile." >&2 exit 1 fi + keychain_path="$signing_dir/signing.keychain-db" + keychain_password=$(openssl rand -base64 32) + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 7200 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + printf '%s' "$IOS_CERTIFICATE" | base64 --decode > "$signing_dir/certificate.p12" + security import "$signing_dir/certificate.p12" -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path" + security set-key-partition-list -S apple-tool:,apple: -k "$keychain_password" "$keychain_path" + security list-keychains -d user -s "$keychain_path" "$HOME/Library/Keychains/login.keychain-db" + mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" + profile_dest="$HOME/Library/MobileDevice/Provisioning Profiles/$profile_uuid.mobileprovision" + cp "$signing_dir/profile.mobileprovision" "$profile_dest" } build_ipa() { From afd81cf59877f46a85a66c9d349b8e764938d6fc Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 16:06:17 +0200 Subject: [PATCH 36/75] workflow: tolerate CRLF checkouts in the signing set test --- internal/workflow/providers_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index fc89950..d70cf3d 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -347,6 +347,7 @@ func TestSigningSetSelection(t *testing.T) { // selection and before the certificate touches a keychain, and read the // set the resolve step emits. for name, data := range map[string]string{"ios-build.yml": string(workflowTemplate), "runner.sh": string(runner)} { + data = strings.ReplaceAll(data, "\r\n", "\n") // Windows checkouts selected, checked, imported := strings.Index(data, "select_signing_set\n"), strings.Index(data, `check_signing_set "$EXPORT_METHOD"`), strings.Index(data, "security import ") if selected < 0 || checked < 0 || imported < 0 { t.Errorf("%s: selection %d, check %d, import %d", name, selected, checked, imported) From aedb6c9239ccc19f00e4beec6179e2fa2621e005 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 16:11:24 +0200 Subject: [PATCH 37/75] docs: explain signing sets in the provider secrets guide --- docs/provider-secrets.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/provider-secrets.md b/docs/provider-secrets.md index 6d49ef6..f18b436 100644 --- a/docs/provider-secrets.md +++ b/docs/provider-secrets.md @@ -17,8 +17,12 @@ Existing GitHub secret values cannot be downloaded for copying to another servic ## 1. Prepare your signing files -The generated runner exports the IPA with the method the profile calls for, so -the profile you upload decides what the build is. For on-device testing prepare: +A repository holds one signing set per distribution type (development, ad-hoc, +app-store, enterprise), and the build profile's `distribution` in `builder.json` +chooses which set a build uses; without one, builds use the development set. +Each set is a certificate, its password and a matching profile, and the runner +refuses a set whose profile is of another type. Start with the development set, +which is what on-device testing needs: - An **Apple Development** certificate in a `.p12` file, including its matching private key, and the P12 password. @@ -29,10 +33,11 @@ the profile you upload decides what the build is. For on-device testing prepare: Use [Apple Certificates](https://developer.apple.com/account/resources/certificates/list) and [Apple Profiles](https://developer.apple.com/account/resources/profiles/list). Apple's [development profile guide](https://developer.apple.com/help/account/provisioning-profiles/create-a-development-provisioning-profile) -explains selecting the App ID, certificate, and devices. An Ad Hoc, In House or -App Store profile works too — pair it with an **Apple Distribution** certificate -and set `"configuration": "Release"` under `ios` in `builder.json`, since those -profiles reject the `get-task-allow` a Debug build is signed with. +explains selecting the App ID, certificate, and devices. For an ad-hoc, +app-store or enterprise set, pair that profile with an **Apple Distribution** +certificate and select it from a build profile that has the matching +`distribution` and `"configuration": "Release"`, since those profiles reject the +`get-task-allow` entitlement a Debug build is signed with. If you already have the P12 and profile, reuse them. If you have no certificate, the quickest way is the [automatic setup](../README.md#automatic-setup) with an From b5ed28b116782ee4276a4ff3a4a82c65d3985bb3 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 17:14:41 +0200 Subject: [PATCH 38/75] 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. --- internal/github/repo.go | 19 +++++++++++++++++++ internal/github/types.go | 14 ++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/internal/github/repo.go b/internal/github/repo.go index 90df44b..34d18f5 100644 --- a/internal/github/repo.go +++ b/internal/github/repo.go @@ -30,6 +30,25 @@ func (c *Client) GetPublicKey(ctx context.Context, owner, repo string) (*PublicK return &key, nil } +// ListSecretNames returns the names of the repository's Actions secrets +// (values are never readable). It follows the pages GitHub returns. +func (c *Client) ListSecretNames(ctx context.Context, owner, repo string) ([]string, error) { + var names []string + for page := 1; ; page++ { + path := fmt.Sprintf("/repos/%s/%s/actions/secrets?per_page=100&page=%d", owner, repo, page) + var list SecretsResponse + if err := c.do(ctx, path, &list); err != nil { + return nil, fmt.Errorf("failed to list secrets: %w", err) + } + for _, s := range list.Secrets { + names = append(names, s.Name) + } + if len(list.Secrets) == 0 || len(names) >= list.TotalCount { + return names, nil + } + } +} + // CreateOrUpdateSecret creates or updates a repository secret // The value should be encrypted using the repository's public key func (c *Client) CreateOrUpdateSecret(ctx context.Context, owner, repo, name, encryptedValue, keyID string) error { diff --git a/internal/github/types.go b/internal/github/types.go index 1e5ab9c..d9a4b98 100644 --- a/internal/github/types.go +++ b/internal/github/types.go @@ -71,6 +71,20 @@ type PublicKey struct { Key string `json:"key"` } +// Secret is a repository secret as listed by the API: its name and dates, +// never its value. +type Secret struct { + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SecretsResponse is the response from listing repository secrets +type SecretsResponse struct { + TotalCount int `json:"total_count"` + Secrets []Secret `json:"secrets"` +} + // CreateSecretRequest is the request body for creating/updating a secret type CreateSecretRequest struct { EncryptedValue string `json:"encrypted_value"` From be0c51d28fc6e7f641499fe980b6fdff4eedb4a0 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 17:14:41 +0200 Subject: [PATCH 39/75] 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. --- internal/config/profile.go | 37 +++++++++++-------- internal/config/profile_test.go | 49 ++++++++++++++++++------- internal/config/signing.go | 63 ++++++++++++++++++++++++--------- internal/config/signing_test.go | 23 ++++++++---- internal/config/types.go | 12 +++---- 5 files changed, 128 insertions(+), 56 deletions(-) diff --git a/internal/config/profile.go b/internal/config/profile.go index 81e6ca1..79b6e6e 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -16,15 +16,16 @@ type BuildSettings struct { Profile string // selected profile name, empty when none applies Configuration string Scheme string - Signing bool - Provider string // profile provider, else the top-level provider; may be empty (GitHub) - Env map[string]string - Distribution string + // Signing is true when the profile has a distribution, or, with no + // profile, when ios.signing is set (the legacy path). + Signing bool + Provider string // profile provider, else the top-level provider; may be empty (GitHub) + Env map[string]string + // Distribution is the profile's distribution, canonical (internal is + // ad-hoc); empty for unsigned builds and the legacy path. + Distribution string } -// Distributions are the accepted values of a profile's distribution field. -var Distributions = []string{"development", "ad-hoc", "app-store", "enterprise"} - // reservedEnv names the variables the runners read their parameters and // secrets from, and the ones the shell and the CI services own. A profile that // set one of these would silently change the build, or on runner.sh replace a @@ -73,6 +74,10 @@ func (c *Config) ProfileNames() []string { // empty, over the top-level ios.* and provider settings. With neither, the // result is the top-level settings unchanged, so projects without profiles // build exactly as before. +// +// A profile signs exactly when it has a distribution; ios.signing does not +// apply to it. Its configuration is the one it sets, else Debug for +// development and Release for every other distribution, else ios.configuration. func (c *Config) ResolveProfile(name string) (BuildSettings, error) { s := BuildSettings{ Configuration: c.IOS.Configuration, @@ -94,8 +99,9 @@ func (c *Config) ResolveProfile(name string) (BuildSettings, error) { } return s, fmt.Errorf("%s %q is not defined; available profiles: %s", source, name, strings.Join(c.ProfileNames(), ", ")) } - if p.Distribution != "" && !slices.Contains(Distributions, p.Distribution) { - return s, fmt.Errorf("profile %q: distribution %q must be one of %s", name, p.Distribution, strings.Join(Distributions, ", ")) + distribution, err := ParseDistribution(p.Distribution) + if err != nil { + return s, fmt.Errorf("profile %q: %w", name, err) } for k := range p.Env { if !envNameRe.MatchString(k) { @@ -106,22 +112,25 @@ func (c *Config) ResolveProfile(name string) (BuildSettings, error) { } } s.Profile = name - if p.Configuration != "" { + s.Distribution = distribution + s.Signing = distribution != "" + switch { + case p.Configuration != "": s.Configuration = p.Configuration + case distribution == DistributionDevelopment: + s.Configuration = "Debug" + case distribution != "": + s.Configuration = "Release" } if p.Scheme != "" { s.Scheme = p.Scheme } - if p.Signing != nil { - s.Signing = *p.Signing - } if p.Provider != "" { s.Provider = p.Provider } if len(p.Env) > 0 { s.Env = p.Env } - s.Distribution = p.Distribution return s, nil } diff --git a/internal/config/profile_test.go b/internal/config/profile_test.go index 50a7179..e54c3b7 100644 --- a/internal/config/profile_test.go +++ b/internal/config/profile_test.go @@ -6,16 +6,16 @@ import ( "testing" ) -func boolPtr(b bool) *bool { return &b } - func profileConfig() *Config { return &Config{ Provider: "github", IOS: IOSConfig{Path: "ios", Scheme: "Top", Signing: true, Configuration: "Debug"}, Profiles: map[string]Profile{ - "development": {Configuration: "Debug", Signing: boolPtr(false)}, - "preview": {Configuration: "Release", Env: map[string]string{"API_URL": "https://staging.example.com"}}, - "production": {Configuration: "Release", Scheme: "MyApp", Provider: "codemagic", Distribution: "app-store"}, + "unsigned": {Configuration: "Release"}, + "development": {Distribution: "development"}, + "preview": {Distribution: "internal", Env: map[string]string{"API_URL": "https://staging.example.com"}}, + "production": {Scheme: "MyApp", Provider: "codemagic", Distribution: "store"}, + "debug-store": {Configuration: "Debug", Distribution: "store"}, }, } } @@ -27,9 +27,12 @@ func TestResolveProfile(t *testing.T) { want BuildSettings }{ {"no profile keeps top-level settings", "", BuildSettings{Configuration: "Debug", Scheme: "Top", Signing: true, Provider: "github"}}, - {"false overrides true", "development", BuildSettings{Profile: "development", Configuration: "Debug", Scheme: "Top", Signing: false, Provider: "github"}}, - {"unset fields inherit", "preview", BuildSettings{Profile: "preview", Configuration: "Release", Scheme: "Top", Signing: true, Provider: "github", Env: map[string]string{"API_URL": "https://staging.example.com"}}}, - {"every field overrides", "production", BuildSettings{Profile: "production", Configuration: "Release", Scheme: "MyApp", Signing: true, Provider: "codemagic", Distribution: "app-store"}}, + // A profile without a distribution is unsigned, whatever ios.signing says. + {"no distribution is unsigned", "unsigned", BuildSettings{Profile: "unsigned", Configuration: "Release", Scheme: "Top", Provider: "github"}}, + {"development derives Debug", "development", BuildSettings{Profile: "development", Configuration: "Debug", Scheme: "Top", Signing: true, Provider: "github", Distribution: "development"}}, + {"internal is ad-hoc and derives Release", "preview", BuildSettings{Profile: "preview", Configuration: "Release", Scheme: "Top", Signing: true, Provider: "github", Distribution: "ad-hoc", Env: map[string]string{"API_URL": "https://staging.example.com"}}}, + {"store derives Release and overrides the rest", "production", BuildSettings{Profile: "production", Configuration: "Release", Scheme: "MyApp", Signing: true, Provider: "codemagic", Distribution: "store"}}, + {"an explicit configuration wins", "debug-store", BuildSettings{Profile: "debug-store", Configuration: "Debug", Scheme: "Top", Signing: true, Provider: "github", Distribution: "store"}}, } { t.Run(tt.name, func(t *testing.T) { got, err := cfg.ResolveProfile(tt.profile) @@ -45,6 +48,22 @@ func TestResolveProfile(t *testing.T) { } } +func TestParseDistribution(t *testing.T) { + for in, want := range map[string]string{ + "": "", "development": "development", "ad-hoc": "ad-hoc", "internal": "ad-hoc", "store": "store", "enterprise": "enterprise", + } { + // Flag values arrive with whatever spacing the user typed. + if got, err := ParseDistribution(" " + in + " "); err != nil || got != want { + t.Errorf("ParseDistribution(%q) = %q, %v; want %q", in, got, err, want) + } + } + for _, bad := range []string{"adhoc", "app-store", "AD_HOC", "Development", "distribution"} { + if _, err := ParseDistribution(bad); err == nil { + t.Errorf("ParseDistribution(%q) accepted", bad) + } + } +} + func TestResolveProfileDefault(t *testing.T) { cfg := profileConfig() cfg.DefaultProfile = "preview" @@ -66,7 +85,7 @@ func TestResolveProfileDefault(t *testing.T) { func TestResolveProfileErrors(t *testing.T) { cfg := profileConfig() _, err := cfg.ResolveProfile("staging") - if err == nil || !strings.Contains(err.Error(), "development, preview, production") { + if err == nil || !strings.Contains(err.Error(), "debug-store, development, preview, production, unsigned") { t.Fatalf("unknown profile should list the available names: %v", err) } if _, err := (&Config{}).ResolveProfile("staging"); err == nil || !strings.Contains(err.Error(), "no profiles") { @@ -74,11 +93,12 @@ func TestResolveProfileErrors(t *testing.T) { } for name, p := range map[string]Profile{ "bad distribution": {Distribution: "adhoc"}, + "old app-store": {Distribution: "app-store"}, "bad env name": {Env: map[string]string{"API-URL": "x"}}, "env with equals": {Env: map[string]string{"A=B": "x"}}, "reserved env": {Env: map[string]string{"SCHEME": "Other"}}, "reserved secret": {Env: map[string]string{"IOS_CERTIFICATE": "x"}}, - "reserved set": {Env: map[string]string{"IOS_PROVISIONING_PROFILE_APP_STORE": "x"}}, + "reserved set": {Env: map[string]string{"IOS_PROVISIONING_PROFILE_STORE": "x"}}, "reserved SIGNING": {Env: map[string]string{"SIGNING_SET": "AD_HOC"}}, "reserved PATH": {Env: map[string]string{"PATH": "/tmp"}}, "GitHub namespace": {Env: map[string]string{"GITHUB_TOKEN": "x"}}, @@ -94,19 +114,24 @@ func TestResolveProfileErrors(t *testing.T) { func TestProfileJSONRoundTrip(t *testing.T) { raw := `{"project":"App","github":{"owner":"o","repo":"r"},"defaultProfile":"preview", - "profiles":{"preview":{"configuration":"Release","signing":false,"env":{"API_URL":"https://staging.example.com"},"distribution":"ad-hoc"}}}` + "profiles":{"preview":{"configuration":"Release","env":{"API_URL":"https://staging.example.com"},"distribution":"ad-hoc"}}}` var cfg Config if err := json.Unmarshal([]byte(raw), &cfg); err != nil { t.Fatal(err) } p := cfg.Profiles["preview"] - if p.Signing == nil || *p.Signing || p.Distribution != "ad-hoc" || cfg.DefaultProfile != "preview" { + if p.Distribution != "ad-hoc" || p.Configuration != "Release" || cfg.DefaultProfile != "preview" { t.Fatalf("parsed %+v", cfg) } out, err := json.Marshal(&Config{Project: "App"}) if err != nil || strings.Contains(string(out), "profiles") || strings.Contains(string(out), "defaultProfile") { t.Fatalf("empty profiles should be omitted: %s %v", out, err) } + // A profile written by signing setup is just its distribution. + out, err = json.Marshal(Profile{Distribution: "store"}) + if err != nil || string(out) != `{"distribution":"store"}` { + t.Fatalf("profile encoding: %s %v", out, err) + } } func TestProfileEncodings(t *testing.T) { diff --git a/internal/config/signing.go b/internal/config/signing.go index 0639c67..245f6eb 100644 --- a/internal/config/signing.go +++ b/internal/config/signing.go @@ -5,26 +5,51 @@ import ( "strings" ) -// signingSets maps a profile's distribution to the suffix of the IOS_* secrets -// the runner reads for it. No distribution means development, so a repository -// set up before signing sets keeps building with the secrets it has. The shell -// function signing_set in ios-build.yml and runner.sh is the same table. -var signingSets = map[string]string{ - "": "DEVELOPMENT", - "development": "DEVELOPMENT", - "ad-hoc": "AD_HOC", - "app-store": "APP_STORE", - "enterprise": "ENTERPRISE", +// Canonical distribution names: what a build profile's distribution field +// means once aliases are resolved, and the names the runner compares with the +// provisioning profile it is handed. +const ( + DistributionDevelopment = "development" + DistributionAdHoc = "ad-hoc" + DistributionStore = "store" + DistributionEnterprise = "enterprise" +) + +// Distributions are the canonical values of a profile's distribution field. +var Distributions = []string{DistributionDevelopment, DistributionAdHoc, DistributionStore, DistributionEnterprise} + +// distributionAliases are accepted spellings of a canonical distribution. +var distributionAliases = map[string]string{"internal": DistributionAdHoc} + +// ParseDistribution canonicalizes a distribution value (internal is ad-hoc). +// Empty stays empty: it means an unsigned build. +func ParseDistribution(s string) (string, error) { + s = strings.TrimSpace(s) + if alias, ok := distributionAliases[s]; ok { + return alias, nil + } + for _, d := range Distributions { + if s == d { + return d, nil + } + } + if s == "" { + return "", nil + } + return "", fmt.Errorf("distribution %q must be one of %s (internal is ad-hoc)", s, strings.Join(Distributions, ", ")) } // SigningSet returns the suffix of the secrets a distribution is signed with: -// DEVELOPMENT, AD_HOC, APP_STORE or ENTERPRISE. +// the canonical name upper-cased with - as _ (DEVELOPMENT, AD_HOC, STORE, +// ENTERPRISE). No distribution has no set: that is the legacy ios.signing +// path, which reads the unsuffixed secrets. The shell function signing_set in +// ios-build.yml and runner.sh is the same table. func SigningSet(distribution string) (string, error) { - set, ok := signingSets[distribution] - if !ok { - return "", fmt.Errorf("distribution %q must be one of %s", distribution, strings.Join(Distributions, ", ")) + d, err := ParseDistribution(distribution) + if err != nil { + return "", err } - return set, nil + return strings.ToUpper(strings.ReplaceAll(d, "-", "_")), nil } // SigningSecrets names the three secrets of a signing set. @@ -34,9 +59,12 @@ type SigningSecrets struct { Profile string // base64 .mobileprovision } +// Names lists the three secret names in the order they are written. +func (s SigningSecrets) Names() []string { return []string{s.Certificate, s.Password, s.Profile} } + // SigningSecretNames returns the secret names of a set: IOS_CERTIFICATE_, // IOS_CERTIFICATE_PASSWORD_ and IOS_PROVISIONING_PROFILE_. The empty -// set names the unsuffixed secrets, which every set falls back to. +// set names the unsuffixed legacy secrets. func SigningSecretNames(set string) SigningSecrets { suffix := "" if set != "" { @@ -49,7 +77,8 @@ func SigningSecretNames(set string) SigningSecrets { } } -// SigningSet is the secret set the build signs with, from its distribution. +// SigningSet is the secret set the build signs with, from its distribution; +// empty for the legacy path. func (s *BuildSettings) SigningSet() string { set, _ := SigningSet(s.Distribution) // validated by ResolveProfile return set diff --git a/internal/config/signing_test.go b/internal/config/signing_test.go index fd34125..ef2cba3 100644 --- a/internal/config/signing_test.go +++ b/internal/config/signing_test.go @@ -1,42 +1,51 @@ package config -import "testing" +import ( + "slices" + "testing" +) func TestSigningSet(t *testing.T) { for distribution, want := range map[string]string{ - "": "DEVELOPMENT", "development": "DEVELOPMENT", "ad-hoc": "AD_HOC", "app-store": "APP_STORE", "enterprise": "ENTERPRISE", + "": "", "development": "DEVELOPMENT", "ad-hoc": "AD_HOC", "internal": "AD_HOC", "store": "STORE", "enterprise": "ENTERPRISE", } { got, err := SigningSet(distribution) if err != nil || got != want { t.Errorf("SigningSet(%q) = %q, %v; want %q", distribution, got, err, want) } } - for _, bad := range []string{"adhoc", "AD_HOC", "Development"} { + for _, bad := range []string{"adhoc", "app-store", "AD_HOC", "Development"} { if _, err := SigningSet(bad); err == nil { t.Errorf("SigningSet(%q) accepted", bad) } } - // Every accepted distribution has a set, and the settings expose it. + // Every canonical distribution has a set, and the settings expose it. for _, d := range Distributions { s := BuildSettings{Distribution: d} if s.SigningSet() == "" { t.Errorf("no set for %s", d) } } + if (&BuildSettings{Signing: true}).SigningSet() != "" { + t.Error("the legacy path has no set") + } } func TestSigningSecretNames(t *testing.T) { - got := SigningSecretNames("APP_STORE") - want := SigningSecrets{"IOS_CERTIFICATE_APP_STORE", "IOS_CERTIFICATE_PASSWORD_APP_STORE", "IOS_PROVISIONING_PROFILE_APP_STORE"} + got := SigningSecretNames("STORE") + want := SigningSecrets{"IOS_CERTIFICATE_STORE", "IOS_CERTIFICATE_PASSWORD_STORE", "IOS_PROVISIONING_PROFILE_STORE"} if got != want { t.Errorf("suffixed = %+v, want %+v", got, want) } + if !slices.Equal(got.Names(), []string{"IOS_CERTIFICATE_STORE", "IOS_CERTIFICATE_PASSWORD_STORE", "IOS_PROVISIONING_PROFILE_STORE"}) { + t.Errorf("Names = %v", got.Names()) + } legacy := SigningSecretNames("") if legacy != (SigningSecrets{"IOS_CERTIFICATE", "IOS_CERTIFICATE_PASSWORD", "IOS_PROVISIONING_PROFILE"}) { t.Errorf("legacy = %+v", legacy) } // Every name is one a profile's env may not set. - for _, name := range []string{got.Certificate, got.Password, got.Profile, legacy.Certificate, legacy.Password, legacy.Profile} { + for _, name := range append(got.Names(), legacy.Names()...) { if !reservedEnvName(name) { t.Errorf("%s is not reserved", name) } diff --git a/internal/config/types.go b/internal/config/types.go index cd75be5..153a9a8 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -28,14 +28,14 @@ type Config struct { // field is optional and overrides the matching top-level setting; unset fields // keep the top-level value. Runner and submit settings are planned here too. type Profile struct { - Configuration string `json:"configuration,omitempty"` // overrides ios.configuration + Configuration string `json:"configuration,omitempty"` // overrides ios.configuration; derived from distribution when empty Scheme string `json:"scheme,omitempty"` // overrides ios.scheme - Signing *bool `json:"signing,omitempty"` // overrides ios.signing; a pointer so false can override true Provider string `json:"provider,omitempty"` // overrides provider Env map[string]string `json:"env,omitempty"` // exported on the runner before dependencies and the build - // Distribution (development, ad-hoc, app-store, enterprise) selects the signing - // set the runner reads (IOS_*_ secrets, see SigningSet) and the type the - // provisioning profile in it must have. Empty means development. + // Distribution is the only signing setting of a profile: development, + // ad-hoc (or internal), store or enterprise. It selects the signing set the + // runner reads (IOS_*_ secrets, see SigningSet) and the type the + // provisioning profile in it must have. Empty means an unsigned build. Distribution string `json:"distribution,omitempty"` } @@ -130,7 +130,7 @@ type IOSConfig struct { Path string `json:"path,omitempty"` Scheme string `json:"scheme,omitempty"` // Xcode scheme to build (auto-detected if empty) BundleID string `json:"bundleId,omitempty"` // App bundle identifier, for signing setup (detected by init when unambiguous) - Signing bool `json:"signing,omitempty"` // Whether code signing is configured + Signing bool `json:"signing,omitempty"` // Legacy: sign builds without a profile with the unsuffixed IOS_* secrets Configuration string `json:"configuration,omitempty"` // Build configuration: Debug (faster) or Release (production) } From 61de30cdcf2f0565978eac8a8f24f085007bec95 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 17:14:41 +0200 Subject: [PATCH 40/75] 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. --- internal/signing/auto.go | 40 ++- internal/signing/auto_test.go | 448 ++++--------------------- internal/signing/profile.go | 4 +- internal/signing/profile_test.go | 19 +- internal/signing/signingtest/portal.go | 366 ++++++++++++++++++++ 5 files changed, 465 insertions(+), 412 deletions(-) create mode 100644 internal/signing/signingtest/portal.go diff --git a/internal/signing/auto.go b/internal/signing/auto.go index 3e5c591..9d82919 100644 --- a/internal/signing/auto.go +++ b/internal/signing/auto.go @@ -13,37 +13,35 @@ import ( "unicode" "github.com/MobAI-App/ios-builder/internal/asc" + "github.com/MobAI-App/ios-builder/internal/config" ) // Type is what the signing material is for: which certificate is issued and -// which profile type wraps it. +// which profile type wraps it. Its values are the canonical distributions of +// a build profile, and each one has a signing set of secrets. type Type string -// Signing types, as accepted by --type. They are the values of a build -// profile's distribution, and each one has a signing set of secrets. +// Signing types, as accepted by --distribution. const ( - TypeDevelopment Type = "development" - TypeAdHoc Type = "ad-hoc" - TypeAppStore Type = "app-store" + TypeDevelopment Type = config.DistributionDevelopment + TypeAdHoc Type = config.DistributionAdHoc + TypeStore Type = config.DistributionStore // TypeEnterprise is an in-house profile. Auto cannot issue one; it is // only reached with --certificate/--profile. - TypeEnterprise Type = "enterprise" + TypeEnterprise Type = config.DistributionEnterprise ) -// ParseType validates a --type value. +// ParseType validates a --distribution value (internal is ad-hoc). Empty is +// an error here: signing material is always of some type. func ParseType(s string) (Type, error) { - switch t := Type(strings.ToLower(strings.TrimSpace(s))); t { - case TypeDevelopment, TypeAdHoc, TypeAppStore, TypeEnterprise: - return t, nil - case "adhoc": - return TypeAdHoc, nil - case "appstore": - return TypeAppStore, nil - case "in-house", "inhouse": - return TypeEnterprise, nil - default: - return "", fmt.Errorf("--type must be development, ad-hoc, app-store or enterprise, got %q", s) + d, err := config.ParseDistribution(s) + if err != nil { + return "", err + } + if d == "" { + return "", fmt.Errorf("distribution must be one of %s (internal is ad-hoc)", strings.Join(config.Distributions, ", ")) } + return Type(d), nil } // NeedsDevices reports whether profiles of this type list the devices the @@ -61,7 +59,7 @@ func (t Type) profileType() string { switch t { case TypeAdHoc: return asc.ProfileTypeIOSAppAdHoc - case TypeAppStore: + case TypeStore: return asc.ProfileTypeIOSAppStore default: return asc.ProfileTypeIOSAppDevelopment @@ -389,7 +387,7 @@ func ensureDevices(ctx context.Context, client *asc.Client, opts *AutoOptions, o } } if len(ids) == 0 { - return nil, fmt.Errorf("no iOS devices are registered on the account and a %s profile needs at least one: pass --device (repeatable) or --devices-from-mobai", opts.Type) + return nil, fmt.Errorf("no iOS devices are registered on the account and a %s profile needs at least one: run builder signing setup --distribution %s --devices-from-mobai, or --device (repeatable)", opts.Type, opts.Type) } slices.Sort(ids) out.InProfile = len(ids) diff --git a/internal/signing/auto_test.go b/internal/signing/auto_test.go index 0e5685b..e5c47d5 100644 --- a/internal/signing/auto_test.go +++ b/internal/signing/auto_test.go @@ -2,331 +2,19 @@ package signing import ( "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" "crypto/rsa" - "crypto/x509" - "encoding/base64" - "encoding/json" - "encoding/pem" - "fmt" - "net/http" - "net/http/httptest" "os" "path/filepath" "slices" "strings" - "sync" "testing" "time" "github.com/MobAI-App/ios-builder/internal/asc" + "github.com/MobAI-App/ios-builder/internal/signing/signingtest" pkcs12 "software.sslmate.com/src/go-pkcs12" ) -var testNow = time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC) - -type certRec struct { - id, typ string - der []byte - exp time.Time -} - -type deviceRec struct{ id, name, udid, status string } - -type profileRec struct { - id, name, typ, state string - certIDs, deviceIDs []string - exp time.Time -} - -// portal is an in-memory Apple Developer portal behind the ASC endpoints Auto uses. -type portal struct { - t *testing.T - srv *httptest.Server - signer *rsa.PrivateKey - mu sync.Mutex - calls []string - seq int - - bundleIDs []string // registered identifiers - certs []certRec - devices []deviceRec - profiles []profileRec - // refuseCertificates / refuseDevices make the POST fail with Apple's quota wording. - refuseCertificates, refuseDevices bool -} - -func newPortal(t *testing.T) *portal { - t.Helper() - signer, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatal(err) - } - p := &portal{t: t, signer: signer} - mux := http.NewServeMux() - res := func(typ, id string, attrs map[string]any) map[string]any { - return map[string]any{"type": typ, "id": id, "attributes": attrs} - } - many := func(w http.ResponseWriter, rs ...any) { - if rs == nil { - rs = []any{} - } - writeJSON(w, 200, map[string]any{"data": rs}) - } - refuse := func(w http.ResponseWriter, detail string) { - writeJSON(w, 409, map[string]any{"errors": []map[string]any{{"status": "409", "code": "ENTITY_ERROR.ATTRIBUTE.INVALID", "title": "There is a problem with the request entity", "detail": detail}}}) - } - body := func(r *http.Request) map[string]any { - var b map[string]any - _ = json.NewDecoder(r.Body).Decode(&b) - return b - } - attrs := func(b map[string]any) map[string]any { return obj(p.t, b, "data", "attributes") } - str := func(m map[string]any, key string) string { - s, _ := m[key].(string) - return s - } - linkIDs := func(b map[string]any, rel string) []string { - rels := obj(p.t, b, "data", "relationships") - raw, ok := rels[rel] - if !ok { - return nil - } - var ids []string - for _, l := range arr(p.t, raw, "data") { - ids = append(ids, str(obj(p.t, l), "id")) - } - return ids - } - wrap := func(h func(w http.ResponseWriter, r *http.Request)) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { - p.t.Errorf("%s %s without bearer token", r.Method, r.URL.Path) - } - p.mu.Lock() - defer p.mu.Unlock() - p.calls = append(p.calls, r.Method+" "+r.URL.Path) - h(w, r) - } - } - certRes := func(c certRec) map[string]any { - return res("certificates", c.id, map[string]any{"certificateType": c.typ, "name": "Apple " + c.typ + ": Builder", "serialNumber": c.id, "certificateContent": base64.StdEncoding.EncodeToString(c.der), "expirationDate": c.exp.Format(time.RFC3339)}) - } - deviceRes := func(d deviceRec) map[string]any { - return res("devices", d.id, map[string]any{"name": d.name, "udid": d.udid, "platform": "IOS", "status": d.status, "deviceClass": "IPHONE"}) - } - profileRes := func(pr profileRec) map[string]any { - return res("profiles", pr.id, map[string]any{"name": pr.name, "profileType": pr.typ, "profileState": pr.state, "uuid": "uuid-" + pr.id, "platform": "IOS", "profileContent": base64.StdEncoding.EncodeToString([]byte("profile:" + pr.id)), "expirationDate": pr.exp.Format(time.RFC3339)}) - } - - mux.HandleFunc("GET /v1/bundleIds", wrap(func(w http.ResponseWriter, r *http.Request) { - want := r.URL.Query().Get("filter[identifier]") - var rs []any - for _, id := range p.bundleIDs { - if strings.HasPrefix(id, want) { - rs = append(rs, res("bundleIds", "bid-"+id, map[string]any{"identifier": id, "name": bundleIDName(id), "platform": "IOS"})) - } - } - many(w, rs...) - })) - mux.HandleFunc("POST /v1/bundleIds", wrap(func(w http.ResponseWriter, r *http.Request) { - a := attrs(body(r)) - if a["platform"] != "IOS" || a["name"] == "" { - p.t.Errorf("bundleIds POST attributes = %v", a) - } - id := str(a, "identifier") - p.bundleIDs = append(p.bundleIDs, id) - writeJSON(w, 201, map[string]any{"data": res("bundleIds", "bid-"+id, a)}) - })) - mux.HandleFunc("GET /v1/certificates", wrap(func(w http.ResponseWriter, r *http.Request) { - var rs []any - for _, c := range p.certs { - if c.typ == r.URL.Query().Get("filter[certificateType]") { - rs = append(rs, certRes(c)) - } - } - many(w, rs...) - })) - mux.HandleFunc("POST /v1/certificates", wrap(func(w http.ResponseWriter, r *http.Request) { - if p.refuseCertificates { - refuse(w, "You already have a current Development certificate or a pending certificate request; the maximum number of certificates has been reached.") - return - } - a := attrs(body(r)) - block, _ := pem.Decode([]byte(str(a, "csrContent"))) - if block == nil { - p.t.Fatalf("csrContent is not PEM: %v", a["csrContent"]) - } - csr, err := x509.ParseCertificateRequest(block.Bytes) - if err != nil { - p.t.Fatalf("parse CSR: %v", err) - } - if err := csr.CheckSignature(); err != nil { - p.t.Fatalf("CSR signature: %v", err) - } - pub, ok := csr.PublicKey.(*rsa.PublicKey) - if !ok { - p.t.Fatalf("CSR public key is %T, want RSA", csr.PublicKey) - } - c := p.issue(str(a, "certificateType"), pub, testNow.AddDate(1, 0, 0)) - writeJSON(w, 201, map[string]any{"data": certRes(c)}) - })) - mux.HandleFunc("GET /v1/devices", wrap(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("filter[platform]") != "IOS" { - p.t.Errorf("devices query = %v", r.URL.Query()) - } - var rs []any - for _, d := range p.devices { - rs = append(rs, deviceRes(d)) - } - many(w, rs...) - })) - mux.HandleFunc("POST /v1/devices", wrap(func(w http.ResponseWriter, r *http.Request) { - if p.refuseDevices { - refuse(w, "You have reached the maximum number of devices for this membership year.") - return - } - a := attrs(body(r)) - d := deviceRec{id: p.nextID("dev"), name: str(a, "name"), udid: str(a, "udid"), status: "ENABLED"} - p.devices = append(p.devices, d) - writeJSON(w, 201, map[string]any{"data": deviceRes(d)}) - })) - mux.HandleFunc("GET /v1/profiles", wrap(func(w http.ResponseWriter, r *http.Request) { - var rs []any - for i := range p.profiles { - if p.profiles[i].name == r.URL.Query().Get("filter[name]") { - rs = append(rs, profileRes(p.profiles[i])) - } - } - many(w, rs...) - })) - mux.HandleFunc("GET /v1/profiles/{id}/relationships/{rel}", wrap(func(w http.ResponseWriter, r *http.Request) { - for i := range p.profiles { - pr := &p.profiles[i] - if pr.id != r.PathValue("id") { - continue - } - ids, typ := pr.certIDs, "certificates" - if r.PathValue("rel") == "devices" { - ids, typ = pr.deviceIDs, "devices" - } - var rs []any - for _, id := range ids { - rs = append(rs, map[string]any{"type": typ, "id": id}) - } - many(w, rs...) - return - } - writeJSON(w, 404, map[string]any{"errors": []map[string]any{{"code": "NOT_FOUND", "title": "not found"}}}) - })) - mux.HandleFunc("POST /v1/profiles", wrap(func(w http.ResponseWriter, r *http.Request) { - b := body(r) - a := attrs(b) - bundle, ok := obj(p.t, b, "data", "relationships", "bundleId", "data")["id"].(string) - if !ok || !slices.Contains(p.bundleIDs, strings.TrimPrefix(bundle, "bid-")) { - p.t.Errorf("profile POST for unknown bundle ID %q", bundle) - } - pr := profileRec{id: p.nextID("prof"), name: str(a, "name"), typ: str(a, "profileType"), state: "ACTIVE", certIDs: linkIDs(b, "certificates"), deviceIDs: linkIDs(b, "devices"), exp: testNow.AddDate(1, 0, 0)} - if pr.typ == asc.ProfileTypeIOSAppStore && pr.deviceIDs != nil { - p.t.Errorf("App Store profile POST carries devices: %v", pr.deviceIDs) - } - p.profiles = append(p.profiles, pr) - writeJSON(w, 201, map[string]any{"data": profileRes(pr)}) - })) - mux.HandleFunc("DELETE /v1/profiles/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { - p.profiles = slices.DeleteFunc(p.profiles, func(pr profileRec) bool { return pr.id == r.PathValue("id") }) - w.WriteHeader(204) - })) - mux.HandleFunc("/", wrap(func(w http.ResponseWriter, r *http.Request) { - p.t.Errorf("unexpected request %s %s", r.Method, r.URL) - w.WriteHeader(404) - })) - p.srv = httptest.NewServer(mux) - t.Cleanup(p.srv.Close) - return p -} - -func (p *portal) nextID(prefix string) string { - p.seq++ - return fmt.Sprintf("%s-%d", prefix, p.seq) -} - -// issue signs a certificate for pub and records it; callers hold p.mu or run before the server. -func (p *portal) issue(typ string, pub *rsa.PublicKey, exp time.Time) certRec { - c := certRec{id: p.nextID("cert"), typ: typ, der: issueCert(p.t, pub, p.signer), exp: exp} - p.certs = append(p.certs, c) - return c -} - -func (p *portal) client(t *testing.T) *asc.Client { - t.Helper() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } - der, _ := x509.MarshalPKCS8PrivateKey(key) - creds := asc.Credentials{IssuerID: "iss", KeyID: "kid", PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))} - c, err := asc.NewClient(creds, asc.WithBaseURL(p.srv.URL), asc.WithRetryDelay(time.Millisecond)) - if err != nil { - t.Fatal(err) - } - return c -} - -// count returns how many recorded calls match "METHOD /path". -func (p *portal) count(key string) int { - p.mu.Lock() - defer p.mu.Unlock() - n := 0 - for _, c := range p.calls { - if c == key { - n++ - } - } - return n -} - -func (p *portal) reset() { - p.mu.Lock() - defer p.mu.Unlock() - p.calls = nil -} - -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) -} - -func obj(t *testing.T, v any, keys ...string) map[string]any { - t.Helper() - for i := 0; ; i++ { - m, ok := v.(map[string]any) - if !ok { - t.Errorf("JSON path %v: %T is not an object", keys[:i], v) - return nil - } - if i == len(keys) { - return m - } - v = m[keys[i]] - } -} - -func arr(t *testing.T, v any, keys ...string) []any { - t.Helper() - if len(keys) > 0 { - v = obj(t, v, keys[:len(keys)-1]...)[keys[len(keys)-1]] - } - a, ok := v.([]any) - if !ok { - t.Errorf("JSON path %v: %T is not an array", keys, v) - } - return a -} - func devOpts(dir string) *AutoOptions { return &AutoOptions{ BundleID: "com.example.app", @@ -334,14 +22,14 @@ func devOpts(dir string) *AutoOptions { Devices: []Device{{Name: "Jane's iPhone", UDID: "00008030-000000000000001E"}}, Password: "secret", OutDir: dir, - now: func() time.Time { return testNow }, + now: func() time.Time { return signingtest.Now }, } } -func run(t *testing.T, p *portal, opts *AutoOptions) *AutoResult { +func run(t *testing.T, p *signingtest.Portal, opts *AutoOptions) *AutoResult { t.Helper() - p.reset() - res, err := Auto(context.Background(), p.client(t), opts) + p.Reset() + res, err := Auto(context.Background(), p.Client(t), opts) if err != nil { t.Fatalf("Auto: %v", err) } @@ -349,7 +37,7 @@ func run(t *testing.T, p *portal, opts *AutoOptions) *AutoResult { } func TestAutoFirstRunCreatesEverything(t *testing.T) { - p := newPortal(t) + p := signingtest.New(t) dir := t.TempDir() res := run(t, p, devOpts(dir)) @@ -365,8 +53,8 @@ func TestAutoFirstRunCreatesEverything(t *testing.T) { if !res.Profile.Created || res.Profile.Reason != "missing" || res.Profile.Name != "Builder development com.example.app" || res.Profile.Type != asc.ProfileTypeIOSAppDevelopment || res.Profile.State != asc.ProfileStateActive { t.Errorf("profile = %+v", res.Profile) } - if p.count("DELETE /v1/profiles/prof-3") != 0 || p.count("POST /v1/profiles") != 1 || p.count("POST /v1/certificates") != 1 || p.count("POST /v1/devices") != 1 || p.count("POST /v1/bundleIds") != 1 { - t.Errorf("calls = %v", p.calls) + if p.Count("DELETE /v1/profiles/prof-3") != 0 || p.Count("POST /v1/profiles") != 1 || p.Count("POST /v1/certificates") != 1 || p.Count("POST /v1/devices") != 1 || p.Count("POST /v1/bundleIds") != 1 { + t.Errorf("calls = %v", p.Calls()) } // Files: key, .p12 and profile in the output directory; the .p12 opens with the password and holds the issued certificate. @@ -389,7 +77,7 @@ func TestAutoFirstRunCreatesEverything(t *testing.T) { if !ok || !KeyMatchesCertificate(keyPEM, gotCert.Raw) || !rsaKey.PublicKey.Equal(gotCert.PublicKey) { t.Error(".p12 key and certificate do not match the written key") } - if !slices.Equal(gotCert.Raw, p.certs[0].der) { + if !slices.Equal(gotCert.Raw, p.Certs[0].DER) { t.Error(".p12 holds a different certificate than the portal issued") } profile, err := os.ReadFile(res.Files.Profile) @@ -399,7 +87,7 @@ func TestAutoFirstRunCreatesEverything(t *testing.T) { } func TestAutoSecondRunReusesEverything(t *testing.T) { - p := newPortal(t) + p := signingtest.New(t) dir := t.TempDir() first := run(t, p, devOpts(dir)) keyPEM, err := os.ReadFile(first.Files.Key) @@ -419,7 +107,7 @@ func TestAutoSecondRunReusesEverything(t *testing.T) { if res.Files.Key != "" { t.Errorf("a supplied key must not be rewritten: %+v", res.Files) } - for _, call := range p.calls { + for _, call := range p.Calls() { if strings.HasPrefix(call, "POST") || strings.HasPrefix(call, "DELETE") { t.Errorf("second run made %s", call) } @@ -430,7 +118,7 @@ func TestAutoSecondRunReusesEverything(t *testing.T) { } func TestAutoRecreatesProfileWhenDevicesChange(t *testing.T) { - p := newPortal(t) + p := signingtest.New(t) dir := t.TempDir() first := run(t, p, devOpts(dir)) keyPEM, _ := os.ReadFile(first.Files.Key) @@ -445,20 +133,20 @@ func TestAutoRecreatesProfileWhenDevicesChange(t *testing.T) { if len(res.Devices.Registered) != 1 || res.Devices.Registered[0].Name != "iPhone 0000AB" { t.Errorf("registered = %+v (want the default name from the UDID)", res.Devices.Registered) } - if p.count("DELETE /v1/profiles/"+first.Profile.ID) != 1 || p.count("POST /v1/profiles") != 1 || len(p.profiles) != 1 { - t.Errorf("calls = %v, profiles = %+v", p.calls, p.profiles) + if p.Count("DELETE /v1/profiles/"+first.Profile.ID) != 1 || p.Count("POST /v1/profiles") != 1 || len(p.Profiles) != 1 { + t.Errorf("calls = %v, profiles = %+v", p.Calls(), p.Profiles) } - if len(p.profiles[0].deviceIDs) != 2 { - t.Errorf("new profile devices = %v", p.profiles[0].deviceIDs) + if len(p.Profiles[0].DeviceIDs) != 2 { + t.Errorf("new profile devices = %v", p.Profiles[0].DeviceIDs) } } func TestAutoRecreatesInvalidProfile(t *testing.T) { - p := newPortal(t) + p := signingtest.New(t) dir := t.TempDir() first := run(t, p, devOpts(dir)) keyPEM, _ := os.ReadFile(first.Files.Key) - p.profiles[0].state = asc.ProfileStateInvalid + p.Profiles[0].State = asc.ProfileStateInvalid opts := devOpts(dir) opts.KeyPEM = keyPEM @@ -467,13 +155,13 @@ func TestAutoRecreatesInvalidProfile(t *testing.T) { t.Errorf("result = %+v", res) } // An INVALID profile needs no relationship lookups to be condemned. - if p.count("GET /v1/profiles/"+first.Profile.ID+"/relationships/certificates") != 0 { - t.Errorf("calls = %v", p.calls) + if p.Count("GET /v1/profiles/"+first.Profile.ID+"/relationships/certificates") != 0 { + t.Errorf("calls = %v", p.Calls()) } } func TestAutoRecreatesProfileWhenCertificateChanges(t *testing.T) { - p := newPortal(t) + p := signingtest.New(t) dir := t.TempDir() first := run(t, p, devOpts(dir)) @@ -486,13 +174,13 @@ func TestAutoRecreatesProfileWhenCertificateChanges(t *testing.T) { if !res.Profile.Created || res.Profile.Reason != "certificate changed" { t.Errorf("profile = %+v", res.Profile) } - if len(p.certs) != 2 || p.profiles[0].certIDs[0] != res.Certificate.ID { - t.Errorf("certs = %d, profile certs = %v", len(p.certs), p.profiles[0].certIDs) + if len(p.Certs) != 2 || p.Profiles[0].CertIDs[0] != res.Certificate.ID { + t.Errorf("certs = %d, profile certs = %v", len(p.Certs), p.Profiles[0].CertIDs) } } func TestAutoForceIssuesNewCertificateAndProfile(t *testing.T) { - p := newPortal(t) + p := signingtest.New(t) dir := t.TempDir() first := run(t, p, devOpts(dir)) keyPEM, _ := os.ReadFile(first.Files.Key) @@ -504,44 +192,44 @@ func TestAutoForceIssuesNewCertificateAndProfile(t *testing.T) { if !res.Certificate.Created || res.Certificate.ID == first.Certificate.ID || !res.Profile.Created || res.Profile.Reason != "forced" { t.Errorf("result = %+v", res) } - if len(p.certs) != 2 { - t.Errorf("force must not revoke the old certificate: %d certificates left", len(p.certs)) + if len(p.Certs) != 2 { + t.Errorf("force must not revoke the old certificate: %d certificates left", len(p.Certs)) } - if !KeyMatchesCertificate(keyPEM, p.certs[1].der) { + if !KeyMatchesCertificate(keyPEM, p.Certs[1].DER) { t.Error("the new certificate must be issued for the supplied key") } } func TestAutoAppStoreNeedsNoDevices(t *testing.T) { - p := newPortal(t) - p.bundleIDs = []string{"com.example.app.widget", "com.example.app"} + p := signingtest.New(t) + p.BundleIDs = []string{"com.example.app.widget", "com.example.app"} opts := devOpts(t.TempDir()) - opts.Type = TypeAppStore + opts.Type = TypeStore opts.Devices = nil res := run(t, p, opts) if res.BundleID.Created || res.BundleID.ID != "bid-com.example.app" { t.Errorf("bundle ID = %+v (must match the exact identifier)", res.BundleID) } - if res.Certificate.Type != asc.CertificateTypeDistribution || res.Profile.Type != asc.ProfileTypeIOSAppStore || res.Profile.Name != "Builder app-store com.example.app" { + if res.Certificate.Type != asc.CertificateTypeDistribution || res.Profile.Type != asc.ProfileTypeIOSAppStore || res.Profile.Name != "Builder store com.example.app" { t.Errorf("result = %+v", res) } - if res.Devices.InProfile != 0 || p.count("GET /v1/devices") != 0 || p.profiles[0].deviceIDs != nil { - t.Errorf("App Store setup touched devices: %+v, calls %v", res.Devices, p.calls) + if res.Devices.InProfile != 0 || p.Count("GET /v1/devices") != 0 || p.Profiles[0].DeviceIDs != nil { + t.Errorf("App Store setup touched devices: %+v, calls %v", res.Devices, p.Calls()) } } func TestAutoDevelopmentWithoutDevicesFails(t *testing.T) { - p := newPortal(t) + p := signingtest.New(t) opts := devOpts(t.TempDir()) opts.Devices = nil - _, err := Auto(context.Background(), p.client(t), opts) + _, err := Auto(context.Background(), p.Client(t), opts) if err == nil || !strings.Contains(err.Error(), "--device") || !strings.Contains(err.Error(), "--devices-from-mobai") { t.Errorf("err = %v", err) } // Devices are checked before the certificate: no device means no key // written and no certificate slot spent. - if p.count("POST /v1/certificates") != 0 || p.count("POST /v1/profiles") != 0 { - t.Errorf("certificate or profile created without devices: %v", p.calls) + if p.Count("POST /v1/certificates") != 0 || p.Count("POST /v1/profiles") != 0 { + t.Errorf("certificate or profile created without devices: %v", p.Calls()) } if _, err := os.Stat(filepath.Join(opts.OutDir, KeyFileName(TypeDevelopment))); err == nil { t.Error("a key was written although no certificate was requested") @@ -549,47 +237,47 @@ func TestAutoDevelopmentWithoutDevicesFails(t *testing.T) { } func TestAutoDisabledDevicesStayOutOfProfile(t *testing.T) { - p := newPortal(t) - p.devices = []deviceRec{ - {id: "dev-old", name: "Old", udid: "00008020-0000000000000001", status: "DISABLED"}, - {id: "dev-ok", name: "Jane's iPhone", udid: "00008030-000000000000001e", status: "ENABLED"}, + p := signingtest.New(t) + p.Devices = []signingtest.Device{ + {ID: "dev-old", Name: "Old", UDID: "00008020-0000000000000001", Status: "DISABLED"}, + {ID: "dev-ok", Name: "Jane's iPhone", UDID: "00008030-000000000000001e", Status: "ENABLED"}, } res := run(t, p, devOpts(t.TempDir())) if len(res.Devices.Registered) != 0 { t.Errorf("UDID matching must be case-insensitive: registered %+v", res.Devices.Registered) } - if res.Devices.InProfile != 1 || !slices.Equal(p.profiles[0].deviceIDs, []string{"dev-ok"}) { - t.Errorf("profile devices = %v", p.profiles[0].deviceIDs) + if res.Devices.InProfile != 1 || !slices.Equal(p.Profiles[0].DeviceIDs, []string{"dev-ok"}) { + t.Errorf("profile devices = %v", p.Profiles[0].DeviceIDs) } } func TestAutoWithSuppliedKeyReusesMatchingCertificate(t *testing.T) { - p := newPortal(t) + p := signingtest.New(t) keyPEM, _, err := GenerateKeyAndCSR("Jane", "jane@example.com") if err != nil { t.Fatal(err) } key, _ := parseKey(keyPEM) - p.issue(asc.CertificateTypeDevelopment, &key.PublicKey, testNow.AddDate(0, 6, 0)) + p.Issue(asc.CertificateTypeDevelopment, &key.PublicKey, signingtest.Now.AddDate(0, 6, 0)) // An expired one for the same key must not be picked. - expired := p.issue(asc.CertificateTypeDevelopment, &key.PublicKey, testNow.AddDate(0, -1, 0)) + expired := p.Issue(asc.CertificateTypeDevelopment, &key.PublicKey, signingtest.Now.AddDate(0, -1, 0)) opts := devOpts(t.TempDir()) opts.KeyPEM = keyPEM res := run(t, p, opts) - if res.Certificate.Created || res.Certificate.ID != "cert-1" || res.Certificate.ID == expired.id || res.Certificate.ValidOnAccount != 1 { + if res.Certificate.Created || res.Certificate.ID != "cert-1" || res.Certificate.ID == expired.ID || res.Certificate.ValidOnAccount != 1 { t.Errorf("certificate = %+v", res.Certificate) } - if p.count("POST /v1/certificates") != 0 { - t.Errorf("calls = %v", p.calls) + if p.Count("POST /v1/certificates") != 0 { + t.Errorf("calls = %v", p.Calls()) } } func TestAutoCertificateLimitHint(t *testing.T) { - p := newPortal(t) - p.refuseCertificates = true + p := signingtest.New(t) + p.RefuseCertificates = true dir := t.TempDir() - res, err := Auto(context.Background(), p.client(t), devOpts(dir)) + res, err := Auto(context.Background(), p.Client(t), devOpts(dir)) if err == nil || !strings.Contains(err.Error(), "maximum number of certificates") || !strings.Contains(err.Error(), "Revoke one") || !strings.Contains(err.Error(), "--key") { t.Errorf("err = %v", err) } @@ -599,26 +287,26 @@ func TestAutoCertificateLimitHint(t *testing.T) { if readErr != nil || res.Files.Key != filepath.Join(dir, KeyFileName(TypeDevelopment)) { t.Fatalf("key after a refused certificate: %+v, %v", res.Files, readErr) } - p.refuseCertificates = false + p.RefuseCertificates = false opts := devOpts(dir) opts.KeyPEM = keyPEM res = run(t, p, opts) - if !res.Certificate.Created || !KeyMatchesCertificate(keyPEM, p.certs[0].der) || res.Files.Key != "" { + if !res.Certificate.Created || !KeyMatchesCertificate(keyPEM, p.Certs[0].DER) || res.Files.Key != "" { t.Errorf("retry = %+v", res) } } func TestAutoDeviceLimitHint(t *testing.T) { - p := newPortal(t) - p.refuseDevices = true - _, err := Auto(context.Background(), p.client(t), devOpts(t.TempDir())) + p := signingtest.New(t) + p.RefuseDevices = true + _, err := Auto(context.Background(), p.Client(t), devOpts(t.TempDir())) if err == nil || !strings.Contains(err.Error(), "00008030-000000000000001E") || !strings.Contains(err.Error(), "100 iOS devices") { t.Errorf("err = %v", err) } } func TestAutoRejectsBadOptions(t *testing.T) { - p := newPortal(t) + p := signingtest.New(t) for name, mutate := range map[string]func(*AutoOptions){ "no bundle ID": func(o *AutoOptions) { o.BundleID = "" }, "bad type": func(o *AutoOptions) { o.Type = "enterprise" }, @@ -626,23 +314,23 @@ func TestAutoRejectsBadOptions(t *testing.T) { } { opts := devOpts(t.TempDir()) mutate(opts) - if _, err := Auto(context.Background(), p.client(t), opts); err == nil { + if _, err := Auto(context.Background(), p.Client(t), opts); err == nil { t.Errorf("%s: no error", name) } } - if len(p.calls) != 0 { - t.Errorf("invalid options reached the API: %v", p.calls) + if len(p.Calls()) != 0 { + t.Errorf("invalid options reached the API: %v", p.Calls()) } } // A second type in the same directory leaves the first type's key, .p12 and // profile in place: each set is a separate file trio. func TestAutoTypesKeepSeparateFiles(t *testing.T) { - p := newPortal(t) + p := signingtest.New(t) dir := t.TempDir() dev := run(t, p, devOpts(dir)) opts := devOpts(dir) - opts.Type, opts.Devices = TypeAppStore, nil + opts.Type, opts.Devices = TypeStore, nil store := run(t, p, opts) if dev.Files.Key == store.Files.Key || dev.Files.P12 == store.Files.P12 || dev.Files.Profile == store.Files.Profile { t.Fatalf("files collide: %+v vs %+v", dev.Files, store.Files) @@ -652,17 +340,17 @@ func TestAutoTypesKeepSeparateFiles(t *testing.T) { t.Errorf("%s: %v", f, err) } } - if len(p.profiles) != 2 || len(p.certs) != 2 { - t.Errorf("one certificate and profile per type expected: %d profiles, %d certificates", len(p.profiles), len(p.certs)) + if len(p.Profiles) != 2 || len(p.Certs) != 2 { + t.Errorf("one certificate and profile per type expected: %d profiles, %d certificates", len(p.Profiles), len(p.Certs)) } } func TestAutoRefusesEnterprise(t *testing.T) { - p := newPortal(t) + p := signingtest.New(t) opts := devOpts(t.TempDir()) opts.Type = TypeEnterprise - if _, err := Auto(context.Background(), p.client(t), opts); err == nil || !strings.Contains(err.Error(), "--certificate") || len(p.calls) != 0 { - t.Errorf("err = %v, calls %v", err, p.calls) + if _, err := Auto(context.Background(), p.Client(t), opts); err == nil || !strings.Contains(err.Error(), "--certificate") || len(p.Calls()) != 0 { + t.Errorf("err = %v, calls %v", err, p.Calls()) } } diff --git a/internal/signing/profile.go b/internal/signing/profile.go index d51da66..380228b 100644 --- a/internal/signing/profile.go +++ b/internal/signing/profile.go @@ -12,7 +12,7 @@ import ( // signature wrapping an XML plist; the plist alone decides the type, by the // rules detect_export_method applies on the runner: ProvisionsAllDevices is // enterprise, ProvisionedDevices with get-task-allow is development and -// without it ad-hoc, and a profile with neither is App Store. +// without it ad-hoc, and a profile with neither is App Store (store). func ProfileType(data []byte) (Type, error) { start := bytes.Index(data, []byte("")) @@ -33,5 +33,5 @@ func ProfileType(data []byte) (Type, error) { } return TypeAdHoc, nil } - return TypeAppStore, nil + return TypeStore, nil } diff --git a/internal/signing/profile_test.go b/internal/signing/profile_test.go index dca61ed..c37c804 100644 --- a/internal/signing/profile_test.go +++ b/internal/signing/profile_test.go @@ -32,7 +32,7 @@ func TestProfileType(t *testing.T) { }{ {"development", devices + allow(true), TypeDevelopment}, {"ad-hoc", devices + allow(false), TypeAdHoc}, - {"app-store", allow(false), TypeAppStore}, + {"store", allow(false), TypeStore}, {"enterprise", "ProvisionsAllDevices" + allow(false), TypeEnterprise}, {"enterprise with devices", "ProvisionsAllDevices" + devices + allow(false), TypeEnterprise}, {"empty device list is still ad-hoc", "ProvisionedDevices" + allow(false), TypeAdHoc}, @@ -57,21 +57,22 @@ func TestProfileType(t *testing.T) { func TestParseType(t *testing.T) { for in, want := range map[string]Type{ - "development": TypeDevelopment, "ad-hoc": TypeAdHoc, "adhoc": TypeAdHoc, "App-Store": TypeAppStore, - "appstore": TypeAppStore, "enterprise": TypeEnterprise, "in-house": TypeEnterprise, + "development": TypeDevelopment, "ad-hoc": TypeAdHoc, "internal": TypeAdHoc, "store": TypeStore, "enterprise": TypeEnterprise, } { - // Flags arrive with whatever case and spacing the user typed. + // Flags arrive with whatever spacing the user typed. if got, err := ParseType(" " + in + " "); err != nil || got != want { t.Errorf("ParseType(%q) = %q, %v; want %q", in, got, err, want) } } - if _, err := ParseType("distribution"); err == nil { - t.Error("unknown type accepted") + for _, bad := range []string{"", "distribution", "app-store", "adhoc"} { + if _, err := ParseType(bad); err == nil { + t.Errorf("ParseType(%q) accepted", bad) + } } - if TypeAppStore.NeedsDevices() || TypeEnterprise.NeedsDevices() || !TypeDevelopment.NeedsDevices() || !TypeAdHoc.NeedsDevices() { + if TypeStore.NeedsDevices() || TypeEnterprise.NeedsDevices() || !TypeDevelopment.NeedsDevices() || !TypeAdHoc.NeedsDevices() { t.Error("NeedsDevices: only development and ad-hoc profiles list devices") } - if KeyFileName(TypeAppStore) != "ios-signing-app-store.key" || P12FileName(TypeAdHoc) != "ios-signing-ad-hoc.p12" { - t.Errorf("file names: %s %s", KeyFileName(TypeAppStore), P12FileName(TypeAdHoc)) + if KeyFileName(TypeStore) != "ios-signing-store.key" || P12FileName(TypeAdHoc) != "ios-signing-ad-hoc.p12" { + t.Errorf("file names: %s %s", KeyFileName(TypeStore), P12FileName(TypeAdHoc)) } } diff --git a/internal/signing/signingtest/portal.go b/internal/signing/signingtest/portal.go new file mode 100644 index 0000000..f294d18 --- /dev/null +++ b/internal/signing/signingtest/portal.go @@ -0,0 +1,366 @@ +// Package signingtest is an in-memory Apple Developer portal behind the App +// Store Connect endpoints signing.Auto uses, for tests of the provisioning +// flow in any package. It must not import internal/signing, whose own tests +// use it. +package signingtest + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// Now is the clock the portal dates its certificates and profiles from; pass +// it as the AutoOptions clock so expiry checks agree with the portal. +var Now = time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC) + +// Cert is a certificate the portal issued. +type Cert struct { + ID, Type string + DER []byte + Exp time.Time +} + +// Device is a registered device. +type Device struct{ ID, Name, UDID, Status string } + +// Profile is a provisioning profile on the portal; its content is +// "profile:". +type Profile struct { + ID, Name, Type, State string + CertIDs, DeviceIDs []string + Exp time.Time +} + +// Portal serves the endpoints and records every call. +type Portal struct { + t *testing.T + srv *httptest.Server + signer *rsa.PrivateKey + mu sync.Mutex + calls []string + seq int + + BundleIDs []string // registered identifiers + Certs []Cert + Devices []Device + Profiles []Profile + // RefuseCertificates / RefuseDevices make the POST fail with Apple's quota wording. + RefuseCertificates, RefuseDevices bool +} + +// New starts a portal that closes with the test. +func New(t *testing.T) *Portal { + t.Helper() + signer, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + p := &Portal{t: t, signer: signer} + mux := http.NewServeMux() + res := func(typ, id string, attrs map[string]any) map[string]any { + return map[string]any{"type": typ, "id": id, "attributes": attrs} + } + many := func(w http.ResponseWriter, rs ...any) { + if rs == nil { + rs = []any{} + } + writeJSON(w, 200, map[string]any{"data": rs}) + } + refuse := func(w http.ResponseWriter, detail string) { + writeJSON(w, 409, map[string]any{"errors": []map[string]any{{"status": "409", "code": "ENTITY_ERROR.ATTRIBUTE.INVALID", "title": "There is a problem with the request entity", "detail": detail}}}) + } + body := func(r *http.Request) map[string]any { + var b map[string]any + _ = json.NewDecoder(r.Body).Decode(&b) + return b + } + attrs := func(b map[string]any) map[string]any { return Obj(p.t, b, "data", "attributes") } + str := func(m map[string]any, key string) string { + s, _ := m[key].(string) + return s + } + linkIDs := func(b map[string]any, rel string) []string { + rels := Obj(p.t, b, "data", "relationships") + raw, ok := rels[rel] + if !ok { + return nil + } + var ids []string + for _, l := range Arr(p.t, raw, "data") { + ids = append(ids, str(Obj(p.t, l), "id")) + } + return ids + } + wrap := func(h func(w http.ResponseWriter, r *http.Request)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + p.t.Errorf("%s %s without bearer token", r.Method, r.URL.Path) + } + p.mu.Lock() + defer p.mu.Unlock() + p.calls = append(p.calls, r.Method+" "+r.URL.Path) + h(w, r) + } + } + certRes := func(c Cert) map[string]any { + return res("certificates", c.ID, map[string]any{"certificateType": c.Type, "name": "Apple " + c.Type + ": Builder", "serialNumber": c.ID, "certificateContent": base64.StdEncoding.EncodeToString(c.DER), "expirationDate": c.Exp.Format(time.RFC3339)}) + } + deviceRes := func(d Device) map[string]any { + return res("devices", d.ID, map[string]any{"name": d.Name, "udid": d.UDID, "platform": "IOS", "status": d.Status, "deviceClass": "IPHONE"}) + } + profileRes := func(pr Profile) map[string]any { + return res("profiles", pr.ID, map[string]any{"name": pr.Name, "profileType": pr.Type, "profileState": pr.State, "uuid": "uuid-" + pr.ID, "platform": "IOS", "profileContent": base64.StdEncoding.EncodeToString([]byte("profile:" + pr.ID)), "expirationDate": pr.Exp.Format(time.RFC3339)}) + } + + mux.HandleFunc("GET /v1/bundleIds", wrap(func(w http.ResponseWriter, r *http.Request) { + want := r.URL.Query().Get("filter[identifier]") + var rs []any + for _, id := range p.BundleIDs { + if strings.HasPrefix(id, want) { + rs = append(rs, res("bundleIds", "bid-"+id, map[string]any{"identifier": id, "name": strings.ReplaceAll(id, ".", " "), "platform": "IOS"})) + } + } + many(w, rs...) + })) + mux.HandleFunc("POST /v1/bundleIds", wrap(func(w http.ResponseWriter, r *http.Request) { + a := attrs(body(r)) + if a["platform"] != "IOS" || a["name"] == "" { + p.t.Errorf("bundleIds POST attributes = %v", a) + } + id := str(a, "identifier") + p.BundleIDs = append(p.BundleIDs, id) + writeJSON(w, 201, map[string]any{"data": res("bundleIds", "bid-"+id, a)}) + })) + mux.HandleFunc("GET /v1/certificates", wrap(func(w http.ResponseWriter, r *http.Request) { + var rs []any + for _, c := range p.Certs { + if c.Type == r.URL.Query().Get("filter[certificateType]") { + rs = append(rs, certRes(c)) + } + } + many(w, rs...) + })) + mux.HandleFunc("POST /v1/certificates", wrap(func(w http.ResponseWriter, r *http.Request) { + if p.RefuseCertificates { + refuse(w, "You already have a current Development certificate or a pending certificate request; the maximum number of certificates has been reached.") + return + } + a := attrs(body(r)) + block, _ := pem.Decode([]byte(str(a, "csrContent"))) + if block == nil { + p.t.Fatalf("csrContent is not PEM: %v", a["csrContent"]) + } + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + p.t.Fatalf("parse CSR: %v", err) + } + if err := csr.CheckSignature(); err != nil { + p.t.Fatalf("CSR signature: %v", err) + } + pub, ok := csr.PublicKey.(*rsa.PublicKey) + if !ok { + p.t.Fatalf("CSR public key is %T, want RSA", csr.PublicKey) + } + c := p.issue(str(a, "certificateType"), pub, Now.AddDate(1, 0, 0)) + writeJSON(w, 201, map[string]any{"data": certRes(c)}) + })) + mux.HandleFunc("GET /v1/devices", wrap(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("filter[platform]") != "IOS" { + p.t.Errorf("devices query = %v", r.URL.Query()) + } + var rs []any + for _, d := range p.Devices { + rs = append(rs, deviceRes(d)) + } + many(w, rs...) + })) + mux.HandleFunc("POST /v1/devices", wrap(func(w http.ResponseWriter, r *http.Request) { + if p.RefuseDevices { + refuse(w, "You have reached the maximum number of devices for this membership year.") + return + } + a := attrs(body(r)) + d := Device{ID: p.nextID("dev"), Name: str(a, "name"), UDID: str(a, "udid"), Status: "ENABLED"} + p.Devices = append(p.Devices, d) + writeJSON(w, 201, map[string]any{"data": deviceRes(d)}) + })) + mux.HandleFunc("GET /v1/profiles", wrap(func(w http.ResponseWriter, r *http.Request) { + var rs []any + for i := range p.Profiles { + if p.Profiles[i].Name == r.URL.Query().Get("filter[name]") { + rs = append(rs, profileRes(p.Profiles[i])) + } + } + many(w, rs...) + })) + mux.HandleFunc("GET /v1/profiles/{id}/relationships/{rel}", wrap(func(w http.ResponseWriter, r *http.Request) { + for i := range p.Profiles { + pr := &p.Profiles[i] + if pr.ID != r.PathValue("id") { + continue + } + ids, typ := pr.CertIDs, "certificates" + if r.PathValue("rel") == "devices" { + ids, typ = pr.DeviceIDs, "devices" + } + var rs []any + for _, id := range ids { + rs = append(rs, map[string]any{"type": typ, "id": id}) + } + many(w, rs...) + return + } + writeJSON(w, 404, map[string]any{"errors": []map[string]any{{"code": "NOT_FOUND", "title": "not found"}}}) + })) + mux.HandleFunc("POST /v1/profiles", wrap(func(w http.ResponseWriter, r *http.Request) { + b := body(r) + a := attrs(b) + bundle, ok := Obj(p.t, b, "data", "relationships", "bundleId", "data")["id"].(string) + if !ok || !slices.Contains(p.BundleIDs, strings.TrimPrefix(bundle, "bid-")) { + p.t.Errorf("profile POST for unknown bundle ID %q", bundle) + } + pr := Profile{ID: p.nextID("prof"), Name: str(a, "name"), Type: str(a, "profileType"), State: "ACTIVE", CertIDs: linkIDs(b, "certificates"), DeviceIDs: linkIDs(b, "devices"), Exp: Now.AddDate(1, 0, 0)} + if pr.Type == asc.ProfileTypeIOSAppStore && pr.DeviceIDs != nil { + p.t.Errorf("App Store profile POST carries devices: %v", pr.DeviceIDs) + } + p.Profiles = append(p.Profiles, pr) + writeJSON(w, 201, map[string]any{"data": profileRes(pr)}) + })) + mux.HandleFunc("DELETE /v1/profiles/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + p.Profiles = slices.DeleteFunc(p.Profiles, func(pr Profile) bool { return pr.ID == r.PathValue("id") }) + w.WriteHeader(204) + })) + mux.HandleFunc("/", wrap(func(w http.ResponseWriter, r *http.Request) { + p.t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + })) + p.srv = httptest.NewServer(mux) + t.Cleanup(p.srv.Close) + return p +} + +func (p *Portal) nextID(prefix string) string { + p.seq++ + return fmt.Sprintf("%s-%d", prefix, p.seq) +} + +// issue signs a certificate for pub and records it; callers hold p.mu or run before the server. +func (p *Portal) issue(typ string, pub *rsa.PublicKey, exp time.Time) Cert { + c := Cert{ID: p.nextID("cert"), Type: typ, DER: IssueCert(p.t, pub, p.signer), Exp: exp} + p.Certs = append(p.Certs, c) + return c +} + +// Issue records a certificate of typ for pub, as if Apple had issued it +// earlier; call it before the client makes requests. +func (p *Portal) Issue(typ string, pub *rsa.PublicKey, exp time.Time) Cert { + return p.issue(typ, pub, exp) +} + +// Client is an ASC client pointed at the portal, with instant retries. +func (p *Portal) Client(t *testing.T) *asc.Client { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, _ := x509.MarshalPKCS8PrivateKey(key) + creds := asc.Credentials{IssuerID: "iss", KeyID: "kid", PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))} + c, err := asc.NewClient(creds, asc.WithBaseURL(p.srv.URL), asc.WithRetryDelay(time.Millisecond)) + if err != nil { + t.Fatal(err) + } + return c +} + +// Calls lists the recorded "METHOD /path" calls. +func (p *Portal) Calls() []string { + p.mu.Lock() + defer p.mu.Unlock() + return slices.Clone(p.calls) +} + +// Count returns how many recorded calls match "METHOD /path". +func (p *Portal) Count(key string) int { + n := 0 + for _, c := range p.Calls() { + if c == key { + n++ + } + } + return n +} + +// Reset forgets the recorded calls. +func (p *Portal) Reset() { + p.mu.Lock() + defer p.mu.Unlock() + p.calls = nil +} + +// IssueCert signs a certificate for pub with signer and returns its DER. +func IssueCert(t *testing.T, pub *rsa.PublicKey, signer *rsa.PrivateKey) []byte { + t.Helper() + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Apple Development: Jane Developer"}, + } + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, pub, signer) + if err != nil { + t.Fatalf("CreateCertificate: %v", err) + } + return certDER +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// Obj walks keys into nested JSON objects. +func Obj(t *testing.T, v any, keys ...string) map[string]any { + t.Helper() + for i := 0; ; i++ { + m, ok := v.(map[string]any) + if !ok { + t.Errorf("JSON path %v: %T is not an object", keys[:i], v) + return nil + } + if i == len(keys) { + return m + } + v = m[keys[i]] + } +} + +// Arr walks keys and returns the JSON array at the end. +func Arr(t *testing.T, v any, keys ...string) []any { + t.Helper() + if len(keys) > 0 { + v = Obj(t, v, keys[:len(keys)-1]...)[keys[len(keys)-1]] + } + a, ok := v.([]any) + if !ok { + t.Errorf("JSON path %v: %T is not an array", keys, v) + } + return a +} From 8d54df856a1c2856889915d7907f0e7cbfbb74c6 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 17:14:41 +0200 Subject: [PATCH 41/75] 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. --- internal/build/inputs_test.go | 20 ++++++++++---------- internal/build/progress.go | 10 +++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/build/inputs_test.go b/internal/build/inputs_test.go index f045cfc..c9c13eb 100644 --- a/internal/build/inputs_test.go +++ b/internal/build/inputs_test.go @@ -13,7 +13,6 @@ import ( ) func profiledConfig() *config.Config { - signed := true return &config.Config{ Project: "App", GitHub: config.GitHubConfig{Owner: "owner", Repo: "repo"}, @@ -21,7 +20,7 @@ func profiledConfig() *config.Config { Flutter: config.FlutterConfig{Version: "3.24.0"}, Profiles: map[string]config.Profile{ "preview": { - Configuration: "Release", Signing: &signed, Scheme: "AppPreview", Distribution: "ad-hoc", + Scheme: "AppPreview", Distribution: "internal", Env: map[string]string{"API_URL": "https://staging.example.com", "FLAGS": "a b"}, }, "ci": {Provider: "codemagic"}, @@ -36,8 +35,9 @@ func TestSettingsPrecedence(t *testing.T) { if err != nil || name != "github" || s.Profile != "" || s.Configuration != "Debug" || s.Signing { t.Fatalf("top-level settings: %+v %s %v", s, name, err) } + // The distribution signs the build and derives Release; internal is ad-hoc. s, name, err = c.settings("preview", "", false) - if err != nil || name != "github" || !s.Signing || s.Configuration != "Release" { + if err != nil || name != "github" || !s.Signing || s.Configuration != "Release" || s.Distribution != "ad-hoc" { t.Fatalf("profile settings: %+v %s %v", s, name, err) } // --unsigned beats the profile's signing. @@ -149,7 +149,7 @@ func TestSettingsPrinted(t *testing.T) { p := NewProgress(&out) p.Start("abcdef12") p.Settings(&config.BuildSettings{Profile: "preview", Configuration: "Release", Signing: true, Env: map[string]string{"B": "2", "A": "1"}, Distribution: "ad-hoc"}, "github") - for _, want := range []string{"Profile: preview", "Configuration: Release", "Scheme: (auto-detected)", "Signing: signed", "Signing set: AD_HOC", "Provider: github", "Env: A, B", "Distribution: ad-hoc"} { + for _, want := range []string{"Profile: preview", "Configuration: Release", "Scheme: (auto-detected)", "Signing: signed (set AD_HOC)", "Provider: github", "Env: A, B", "Distribution: ad-hoc"} { if !strings.Contains(out.String(), want) { t.Errorf("missing %q in:\n%s", want, out.String()) } @@ -158,16 +158,16 @@ func TestSettingsPrinted(t *testing.T) { t.Fatal("env values should not be printed, only names") } - // Signed without a distribution reads the development set; unsigned - // builds read none. + // Signed without a distribution is the legacy path with the unsuffixed + // secrets; --unsigned leaves a distribution build unsigned. out.Reset() p.Settings(&config.BuildSettings{Signing: true}, "github") - if !strings.Contains(out.String(), "Signing set: DEVELOPMENT") { - t.Errorf("default set not printed:\n%s", out.String()) + if !strings.Contains(out.String(), "Signing: signed (unsuffixed IOS_* secrets)") { + t.Errorf("legacy path not printed:\n%s", out.String()) } out.Reset() - p.Settings(&config.BuildSettings{Distribution: "app-store"}, "github") - if strings.Contains(out.String(), "Signing set") { + p.Settings(&config.BuildSettings{Distribution: "store"}, "github") + if !strings.Contains(out.String(), "Signing: unsigned") || strings.Contains(out.String(), "set STORE") { t.Errorf("unsigned build printed a signing set:\n%s", out.String()) } } diff --git a/internal/build/progress.go b/internal/build/progress.go index 30adeca..f4c736a 100644 --- a/internal/build/progress.go +++ b/internal/build/progress.go @@ -84,16 +84,16 @@ func (p *Progress) Settings(s *config.BuildSettings, provider string) { return v } signing := "unsigned" - if s.Signing { - signing = "signed" + switch { + case s.Signing && s.Distribution != "": + signing = fmt.Sprintf("signed (set %s)", s.SigningSet()) + case s.Signing: + signing = "signed (unsuffixed IOS_* secrets)" } fmt.Fprintf(p.writer, " Profile: %s\n", orDefault(s.Profile, "(none)")) fmt.Fprintf(p.writer, " Configuration: %s\n", orDefault(s.Configuration, "Debug")) fmt.Fprintf(p.writer, " Scheme: %s\n", orDefault(s.Scheme, "(auto-detected)")) fmt.Fprintf(p.writer, " Signing: %s\n", signing) - if s.Signing { - fmt.Fprintf(p.writer, " Signing set: %s\n", s.SigningSet()) - } fmt.Fprintf(p.writer, " Provider: %s\n", provider) if len(s.Env) > 0 { keys := slices.Sorted(maps.Keys(s.Env)) From f4f38bdc9ed7c381b4af475f600f0eaf7c3f3bbe Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 17:14:41 +0200 Subject: [PATCH 42/75] 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..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. --- cmd/builder/root.go | 8 + cmd/builder/signing.go | 126 +++++++------- cmd/builder/signing_auto.go | 278 ++++++++++++++++++++++++------- cmd/builder/signing_sets_test.go | 269 ++++++++++++++++++++++++++---- 4 files changed, 523 insertions(+), 158 deletions(-) diff --git a/cmd/builder/root.go b/cmd/builder/root.go index eadd34f..5042fc5 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -727,6 +727,14 @@ func runBuild(ctx context.Context, cfg *config.Config, opts *build.BuildOptions) if err != nil { return err } + // A GitHub build with a distribution needs its signing set in the + // repository; Codemagic and Bitrise have no secrets API, so their runner + // reports a missing set itself. + if ghClient != nil && !opts.Unsigned { + if err := ensureSigningSecrets(ctx, cfg, ghClient, getASCClient, opts.Profile, os.Stdout); err != nil { + return err + } + } coordinator := build.NewCoordinator(cfg, ghClient) diff --git a/cmd/builder/signing.go b/cmd/builder/signing.go index 244b461..cfb4e05 100644 --- a/cmd/builder/signing.go +++ b/cmd/builder/signing.go @@ -21,38 +21,38 @@ var signingCmd = &cobra.Command{ var signingSetupCmd = &cobra.Command{ Use: "setup", Short: "Set up code signing for iOS builds", - Long: `Sets up code signing for iOS builds and uploads the material to GitHub Secrets. + Long: `Sets up code signing for one distribution and writes the build profile that uses it. Without --certificate/--profile the whole thing is automatic, using the App Store Connect API key from 'builder auth apple': the App ID is registered if missing, a certificate is issued for a private key generated here (or --key), devices are registered (--device, --devices-from-mobai) and a provisioning -profile named "Builder " is created. Running it again is -safe: valid material is reused and only what is missing, expired, invalid or -changed is recreated. Nothing is ever revoked. +profile named "Builder " is created. Running it +again is safe: valid material is reused and only what is missing, expired, +invalid or changed is recreated. Nothing is ever revoked. - --type development Apple Development certificate, devices required (default) - --type ad-hoc Apple Distribution certificate, devices required - --type app-store Apple Distribution certificate, no devices; TestFlight/App - Store uploads need this and a Release configuration + --distribution development Apple Development certificate, devices required (default) + --distribution ad-hoc Apple Distribution certificate, devices required + (internal is the same thing) + --distribution store Apple Distribution certificate, no devices; + TestFlight and App Store uploads need this With --certificate and --profile the files are taken as they are: - A .p12 file (exported from Keychain Access on a Mac) - A .cer file downloaded from the Apple Developer portal, together with the private key from 'builder signing csr' (--key) — the .p12 is then assembled locally, so no Mac is needed at any point -The type is read from the .mobileprovision (development, ad-hoc, app-store or -enterprise); --type overrides it. +The distribution is read from the .mobileprovision (development, ad-hoc, +store or enterprise). Either way the command uploads the three GitHub repository secrets of the -type's signing set — IOS_CERTIFICATE_, IOS_CERTIFICATE_PASSWORD_, -IOS_PROVISIONING_PROFILE_, with SET one of DEVELOPMENT, AD_HOC, -APP_STORE, ENTERPRISE — and sets ios.signing in builder.json. A build reads -the set named by its profile's distribution (development when there is -none), so one repository can hold a development set for devices and an -App Store set for releases; existing unsuffixed secrets stay in place and -remain the fallback. For Codemagic and Bitrise it writes the files and points -at docs/provider-secrets.md instead.`, +distribution's signing set — IOS_CERTIFICATE_, IOS_CERTIFICATE_PASSWORD_, +IOS_PROVISIONING_PROFILE_, with SET one of DEVELOPMENT, AD_HOC, STORE, +ENTERPRISE — and writes a profile in builder.json (--name, default the +distribution name) with that distribution. 'builder ios build --profile ' +then signs with the set, and provisions it the same way when it is missing. +For Codemagic and Bitrise the command writes the files and prints the secret +names to add in the dashboard instead.`, RunE: runSigningSetup, } @@ -90,7 +90,8 @@ func init() { signingSetupCmd.Flags().StringP("profile", "p", "", "Path to .mobileprovision file") signingSetupCmd.Flags().StringP("key", "k", "", "Path to the private key from 'builder signing csr' (required with a .cer; automatic mode reuses it and its certificate)") signingSetupCmd.Flags().String("bundle-id", "", "App bundle ID (default: ios.bundleId in builder.json, else the newest IPA in ./dist)") - signingSetupCmd.Flags().String("type", string(signing.TypeDevelopment), "Signing type: development, ad-hoc, app-store or enterprise (with --profile: read from the profile unless given)") + signingSetupCmd.Flags().String("distribution", "", "Distribution to sign for: development, ad-hoc (internal), store or enterprise (default: the --name profile's, else development; with --profile: read from the file)") + signingSetupCmd.Flags().String("name", "", "builder.json profile to write the distribution to (default: the distribution name)") signingSetupCmd.Flags().StringArray("device", nil, "Device UDID to register (repeatable)") signingSetupCmd.Flags().Bool("devices-from-mobai", false, "Register the physical iOS devices connected to MobAI") signingSetupCmd.Flags().String("out-dir", ".", "Directory for the private key, .p12 and .mobileprovision") @@ -267,11 +268,18 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { if err != nil { return err } - - ghClient, err := getGitHubClient() + provider, err := cfg.ProviderName("") if err != nil { return err } + var store secretStore + if provider == "github" { + ghClient, err := getGitHubClient() + if err != nil { + return err + } + store = ghClient + } // Get certificate path certPath, _ := cmd.Flags().GetString("certificate") @@ -307,8 +315,8 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { } fmt.Printf("Profile: %s (%.1f KB)\n", profilePath, float64(len(profileData))/1024) - typeFlag, _ := cmd.Flags().GetString("type") - typ, source, err := manualSigningType(profileData, typeFlag, cmd.Flags().Changed("type")) + distributionFlag, _ := cmd.Flags().GetString("distribution") + typ, err := manualSigningType(profileData, distributionFlag) if err != nil { return err } @@ -316,9 +324,14 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { if err != nil { return err } - fmt.Printf("Type: %s (%s), signing set %s\n", typ, source, set) + profileName, _ := cmd.Flags().GetString("name") + if profileName == "" { + profileName = string(typ) + } + fmt.Printf("Distribution: %s (read from the profile), signing set %s, build profile %q\n", typ, set, profileName) var password string + p12Path := certPath if isPortalCertificate(certPath) { // A .cer from the Apple Developer portal: assemble the .p12 locally // from the private key that produced the CSR. @@ -343,7 +356,7 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { } // Save the .p12: it is the reusable signing identity (Sideloadly, // another machine, re-running setup), not a throwaway. - p12Path := signing.P12FileName(typ) + p12Path = signing.P12FileName(typ) if err := os.WriteFile(p12Path, certData, 0600); err != nil { return fmt.Errorf("failed to write .p12: %w", err) } @@ -355,61 +368,52 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { } } - fmt.Println() - fmt.Printf("Uploading secrets to %s/%s...\n", cfg.GitHub.Owner, cfg.GitHub.Repo) - ctx := cmd.Context() if ctx == nil { ctx = context.Background() } - if err := uploadSigningSecrets(ctx, ghClient, cfg, os.Stdout, set, certData, password, profileData); err != nil { - return err + fmt.Println() + if store != nil { + fmt.Printf("Uploading secrets to %s/%s...\n", cfg.GitHub.Owner, cfg.GitHub.Repo) + if err := uploadSigningSecrets(ctx, store, cfg, os.Stdout, set, certData, password, profileData); err != nil { + return err + } + } else { + printProviderSecrets(provider, config.SigningSecretNames(set), p12Path, profilePath) } - // Update config to indicate signing is enabled - cfg.IOS.Signing = true - mgr := config.NewManager() - if err := mgr.Save(cfg); err != nil { + writeSigningProfile(cfg, profileName, typ) + if err := config.NewManager().Save(cfg); err != nil { return fmt.Errorf("failed to update config: %w", err) } - fmt.Println(" Updated: builder.json (signing enabled)") + fmt.Printf(" Updated: builder.json (profile %q, distribution %s)\n", profileName, typ) fmt.Println() fmt.Println("Code signing configured successfully!") fmt.Println() - printSigningSetUsage(typ, set) + printSigningNext(profileName, typ) fmt.Println("To build unsigned, use:") - fmt.Println(" builder ios build --unsigned") + fmt.Printf(" builder ios build --profile %s --unsigned\n", profileName) return nil } -// manualSigningType is the type of the profile being uploaded: --type when -// given, else what the .mobileprovision says. A --type that disagrees with the -// profile is taken, with a warning, since the runner will refuse the pair. -func manualSigningType(profileData []byte, typeFlag string, typeGiven bool) (typ signing.Type, source string, err error) { - detected, detectErr := signing.ProfileType(profileData) - if !typeGiven { - if detectErr != nil { - return "", "", fmt.Errorf("%w; pass --type development|ad-hoc|app-store|enterprise", detectErr) - } - return detected, "read from the profile", nil +// manualSigningType is what the .mobileprovision says it is. A --distribution +// that disagrees is an error, since the runner refuses such a pair. +func manualSigningType(profileData []byte, distributionFlag string) (signing.Type, error) { + typ, err := signing.ProfileType(profileData) + if err != nil { + return "", err } - if typ, err = signing.ParseType(typeFlag); err != nil { - return "", "", err + if distributionFlag == "" { + return typ, nil } - if detectErr == nil && detected != typ { - fmt.Printf("Warning: the profile is a %s profile but --type %s was given; builds with distribution %s will fail on this set until a %s profile is uploaded to it.\n", detected, typ, typ, typ) + want, err := signing.ParseType(distributionFlag) + if err != nil { + return "", err } - return typ, "--type", nil -} - -// printSigningSetUsage says which builds read the set that was just written. -func printSigningSetUsage(typ signing.Type, set string) { - if typ == signing.TypeDevelopment { - fmt.Printf("Signed builds read the %s set unless their profile sets another distribution.\n", set) - } else { - fmt.Printf("Builds read the %s set when their builder.json profile has \"distribution\": \"%s\";\n", set, typ) - fmt.Printf("that profile needs \"configuration\": \"Release\", since a Debug build is refused by %s profiles.\n", typ) + if want != typ { + return "", fmt.Errorf("the profile is a %s profile, but --distribution %s was given; builds with distribution %s would refuse it", typ, want, want) } + return typ, nil } diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index c59aa15..37d0c5c 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -10,8 +10,10 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" + "github.com/MobAI-App/ios-builder/internal/asc" "github.com/MobAI-App/ios-builder/internal/config" "github.com/MobAI-App/ios-builder/internal/github" "github.com/MobAI-App/ios-builder/internal/ipa" @@ -30,9 +32,11 @@ type signingAutoResult struct { *signing.AutoResult Provider string `json:"provider"` // SigningSet is the suffix of the secrets written (DEVELOPMENT, AD_HOC, - // APP_STORE), which builds select by their profile's distribution. + // STORE), which builds select by their profile's distribution. SigningSet string `json:"signing_set"` SecretsUploaded bool `json:"secrets_uploaded"` + // BuildProfile is the builder.json profile written with the distribution. + BuildProfile string `json:"build_profile"` // GeneratedPassword is set when no password was given: it is printed // exactly once, here. GeneratedPassword string `json:"generated_password,omitempty"` @@ -49,14 +53,18 @@ func runSigningAuto(cmd *cobra.Command) error { if err != nil { return err } - typeFlag, _ := cmd.Flags().GetString("type") - typ, err := signing.ParseType(typeFlag) + profileName, _ := cmd.Flags().GetString("name") + distributionFlag, _ := cmd.Flags().GetString("distribution") + typ, err := setupDistribution(cfg, profileName, distributionFlag) if err != nil { return err } if typ == signing.TypeEnterprise { return errors.New("enterprise (in-house) profiles are not issued through the App Store Connect API; download the certificate and profile from the portal and pass --certificate and --profile") } + if profileName == "" { + profileName = string(typ) + } set, err := config.SigningSet(string(typ)) if err != nil { return err @@ -69,11 +77,13 @@ func runSigningAuto(cmd *cobra.Command) error { if err != nil { return err } - var ghClient *github.Client + var store secretStore if provider == "github" { - if ghClient, err = getGitHubClient(); err != nil { + ghClient, err := getGitHubClient() + if err != nil { return err } + store = ghClient } out := newOutput(cmd) yes, _ := cmd.Flags().GetBool("yes") @@ -91,25 +101,27 @@ func runSigningAuto(cmd *cobra.Command) error { if err != nil { return err } - keyPEM, keyPath, err := signingKey(cmd, outDir, typ) + keyFlag, _ := cmd.Flags().GetString("key") + keyPEM, keyPath, err := signingKey(keyFlag, outDir, typ) if err != nil { return err } // The plan, then one confirmation before anything is created. - fmt.Fprintf(out.log, "Bundle ID: %s\n", bundleID) - fmt.Fprintf(out.log, "Type: %s (signing set %s)\n", typ, set) + fmt.Fprintf(out.log, "Bundle ID: %s\n", bundleID) + fmt.Fprintf(out.log, "Distribution: %s (signing set %s)\n", typ, set) + fmt.Fprintf(out.log, "Profile: %s (builder.json)\n", profileName) if typ.NeedsDevices() { - fmt.Fprintf(out.log, "Devices: %s\n", describeDevices(devices)) + fmt.Fprintf(out.log, "Devices: %s\n", describeDevices(devices)) } if keyPath != "" { - fmt.Fprintf(out.log, "Key: %s (reusing its certificate if one is valid)\n", keyPath) + fmt.Fprintf(out.log, "Key: %s (reusing its certificate if one is valid)\n", keyPath) } else { - fmt.Fprintf(out.log, "Key: new, written to %s\n", filepath.Join(outDir, signing.KeyFileName(typ))) + fmt.Fprintf(out.log, "Key: new, written to %s\n", filepath.Join(outDir, signing.KeyFileName(typ))) } - fmt.Fprintf(out.log, "Provider: %s\n", provider) + fmt.Fprintf(out.log, "Provider: %s\n", provider) if force { - fmt.Fprintln(out.log, "Force: a new certificate and profile will be issued") + fmt.Fprintln(out.log, "Force: a new certificate and profile will be issued") } fmt.Fprintln(out.log) if !yes { @@ -136,47 +148,81 @@ func runSigningAuto(cmd *cobra.Command) error { } } - res := &signingAutoResult{Provider: provider, SigningSet: set, GeneratedPassword: generated} - res.AutoResult, err = signing.Auto(ctx, client, &signing.AutoOptions{ + res := &signingAutoResult{Provider: provider, SigningSet: set, BuildProfile: profileName, GeneratedPassword: generated} + res.AutoResult, err = provisionSigning(ctx, client, store, cfg, out.log, &signing.AutoOptions{ BundleID: bundleID, Type: typ, Devices: devices, KeyPEM: keyPEM, CommonName: cfg.Project, Password: password, Force: force, OutDir: outDir, Log: out.log, }) if err != nil { return finish(out, cmd, res, err, nil) } - if ghClient != nil { - fmt.Fprintf(out.log, "\nUploading secrets to %s/%s...\n", cfg.GitHub.Owner, cfg.GitHub.Repo) - if err := uploadSigningSecrets(ctx, ghClient, cfg, out.log, set, res.P12, password, res.ProfileContent); err != nil { - return finish(out, cmd, res, err, nil) - } - res.SecretsUploaded = true - cfg.IOS.Signing = true - } + res.SecretsUploaded = store != nil + writeSigningProfile(cfg, profileName, typ) if cfg.IOS.BundleID == "" { cfg.IOS.BundleID = bundleID } if err := config.NewManager().Save(cfg); err != nil { return finish(out, cmd, res, fmt.Errorf("failed to update config: %w", err), nil) } - fmt.Fprintln(out.log, " Updated: builder.json") + fmt.Fprintf(out.log, " Updated: builder.json (profile %q, distribution %s)\n", profileName, typ) return finish(out, cmd, res, nil, func() { printSigningSummary(cfg, res) }) } +// setupDistribution is --distribution, else the distribution of the +// builder.json profile --name points at, else development. +func setupDistribution(cfg *config.Config, profileName, flag string) (signing.Type, error) { + if flag != "" { + return signing.ParseType(flag) + } + if p, ok := cfg.Profiles[profileName]; ok && p.Distribution != "" { + return signing.ParseType(p.Distribution) + } + return signing.TypeDevelopment, nil +} + +// provisionSigning issues (or reuses) the certificate and profile of a +// distribution through App Store Connect and, when store is a GitHub +// repository, uploads them as the distribution's signing set. `signing setup` +// runs it, and so does `ios build` when a profile's set is missing. +func provisionSigning(ctx context.Context, client *asc.Client, store secretStore, cfg *config.Config, log io.Writer, opts *signing.AutoOptions) (*signing.AutoResult, error) { + res, err := signing.Auto(ctx, client, opts) + if err != nil { + return res, err + } + if store == nil { + return res, nil + } + set, err := config.SigningSet(string(opts.Type)) + if err != nil { + return res, err + } + fmt.Fprintf(log, "\nUploading secrets to %s/%s...\n", cfg.GitHub.Owner, cfg.GitHub.Repo) + if err := uploadSigningSecrets(ctx, store, cfg, log, set, res.P12, opts.Password, res.ProfileContent); err != nil { + return res, err + } + return res, nil +} + +// writeSigningProfile creates or updates the builder.json profile that builds +// with this distribution; other fields of an existing profile are kept. +func writeSigningProfile(cfg *config.Config, name string, typ signing.Type) { + if cfg.Profiles == nil { + cfg.Profiles = map[string]config.Profile{} + } + p := cfg.Profiles[name] + p.Distribution = string(typ) + cfg.Profiles[name] = p +} + // resolveSigningBundleID takes the flag, then builder.json, then the newest // IPA in ./dist, then asks (only in a terminal). func resolveSigningBundleID(cmd *cobra.Command, cfg *config.Config, out output) (string, error) { if id, _ := cmd.Flags().GetString("bundle-id"); id != "" { return strings.TrimSpace(id), nil } - if cfg.IOS.BundleID != "" { - return cfg.IOS.BundleID, nil - } - if path, err := ipa.Newest("dist"); err == nil { - if id := ipa.BundleID(path); id != "" { - fmt.Fprintf(out.log, "Bundle ID %s read from %s\n", id, path) - return id, nil - } + if id := configuredBundleID(cfg, out.log); id != "" { + return id, nil } if !stdinIsTerminal() || out.json { return "", errors.New("bundle ID unknown: pass --bundle-id, set ios.bundleId in builder.json, or build once so ./dist has an IPA to read it from") @@ -191,13 +237,28 @@ func resolveSigningBundleID(cmd *cobra.Command, cfg *config.Config, out output) return id, nil } +// configuredBundleID is ios.bundleId, else the bundle ID of the newest IPA in +// ./dist; empty when neither is there. +func configuredBundleID(cfg *config.Config, log io.Writer) string { + if cfg.IOS.BundleID != "" { + return cfg.IOS.BundleID + } + if path, err := ipa.Newest("dist"); err == nil { + if id := ipa.BundleID(path); id != "" { + fmt.Fprintf(log, "Bundle ID %s read from %s\n", id, path) + return id + } + } + return "" +} + // signingDevices collects --device UDIDs and, with --devices-from-mobai, the // physical iOS devices MobAI has connected. func signingDevices(ctx context.Context, cmd *cobra.Command, cfg *config.Config, typ signing.Type) ([]signing.Device, error) { udids, _ := cmd.Flags().GetStringArray("device") fromMobAI, _ := cmd.Flags().GetBool("devices-from-mobai") if !typ.NeedsDevices() && (len(udids) > 0 || fromMobAI) { - return nil, fmt.Errorf("--type %s profiles list no devices; drop --device/--devices-from-mobai", typ) + return nil, fmt.Errorf("%s profiles list no devices; drop --device/--devices-from-mobai", typ) } var devices []signing.Device for _, u := range udids { @@ -243,11 +304,11 @@ func mobaiSigningDevices(connected []mobai.Device) []signing.Device { return devices } -// signingKey returns --key, else the key a previous run of this type left in -// outDir (ios-signing-.key, or the ios-signing.key of runs before -// signing sets), else nil so a key is generated. keyPath is "" when generating. -func signingKey(cmd *cobra.Command, outDir string, typ signing.Type) (keyPEM []byte, keyPath string, err error) { - keyPath, _ = cmd.Flags().GetString("key") +// signingKey returns the key at keyPath (--key), else the key a previous run +// of this type left in outDir (ios-signing-.key, or the ios-signing.key +// of runs before signing sets), else nil so a key is generated. The returned +// path is "" when generating. +func signingKey(keyPath, outDir string, typ signing.Type) (keyPEM []byte, path string, err error) { if keyPath == "" { for _, name := range []string{signing.KeyFileName(typ), signing.LegacyKeyFileName} { if candidate := filepath.Join(outDir, name); fileExists(candidate) { @@ -296,10 +357,12 @@ func fileExists(path string) bool { return err == nil } -// secretStore is the part of the GitHub client that signing setup writes through. +// secretStore is the part of the GitHub client that signing writes through +// and reads the secret names back from. type secretStore interface { GetPublicKey(ctx context.Context, owner, repo string) (*github.PublicKey, error) CreateOrUpdateSecret(ctx context.Context, owner, repo, name, encryptedValue, keyID string) error + ListSecretNames(ctx context.Context, owner, repo string) ([]string, error) } // uploadSigningSecrets encrypts and stores the three signing secrets of a set @@ -329,6 +392,97 @@ func uploadSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Confi return nil } +// missingSigningSecrets names the secrets of a set that the repository does +// not hold. +func missingSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Config, set string) ([]string, error) { + have, err := gh.ListSecretNames(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo) + if err != nil { + return nil, fmt.Errorf("failed to list the secrets of %s/%s: %w", cfg.GitHub.Owner, cfg.GitHub.Repo, err) + } + var missing []string + for _, name := range config.SigningSecretNames(set).Names() { + if !slices.Contains(have, name) { + missing = append(missing, name) + } + } + return missing, nil +} + +// ensureSigningSecrets runs before a GitHub build is dispatched: when the +// selected profile has a distribution, its signing set must be in the +// repository. A missing or partial set is provisioned through App Store +// Connect the way `signing setup` does, without prompts; without Apple +// credentials the build stops here, before anything is pushed. +func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretStore, ascClient func() (*asc.Client, error), profile string, log io.Writer) error { + s, err := cfg.ResolveProfile(profile) + if err != nil { + return err + } + if s.Distribution == "" { + return nil + } + typ, set := signing.Type(s.Distribution), s.SigningSet() + missing, err := missingSigningSecrets(ctx, store, cfg, set) + if err != nil { + return err + } + if len(missing) == 0 { + return nil + } + fmt.Fprintf(log, "Profile %q signs with set %s, but %s/%s is missing %s.\n", s.Profile, set, cfg.GitHub.Owner, cfg.GitHub.Repo, strings.Join(missing, ", ")) + manual := fmt.Sprintf("builder signing setup --certificate --profile --name %s", s.Profile) + if typ == signing.TypeEnterprise { + return fmt.Errorf("enterprise (in-house) profiles are not issued through the App Store Connect API; upload the files from the portal with %s", manual) + } + client, err := ascClient() + if err != nil { + return fmt.Errorf("%w\nRun builder auth apple and build again to provision the %s set automatically, or upload your own files with %s", err, set, manual) + } + bundleID := configuredBundleID(cfg, log) + if bundleID == "" { + return fmt.Errorf("bundle ID unknown: set ios.bundleId in builder.json, or run builder signing setup --distribution %s --bundle-id ", typ) + } + keyPEM, _, err := signingKey("", ".", typ) + if err != nil { + return err + } + password, err := randomPassword() + if err != nil { + return err + } + fmt.Fprintf(log, "Provisioning %s signing for %s through App Store Connect...\n", typ, bundleID) + res, err := provisionSigning(ctx, client, store, cfg, log, &signing.AutoOptions{ + BundleID: bundleID, Type: typ, KeyPEM: keyPEM, CommonName: cfg.Project, Password: password, OutDir: ".", Log: log, + }) + if err != nil { + return err + } + fmt.Fprintln(log) + printSigningFiles(log, res, password) + if cfg.IOS.BundleID == "" { + cfg.IOS.BundleID = bundleID + if err := config.NewManager().Save(cfg); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + } + fmt.Fprintln(log) + return nil +} + +// printSigningFiles lists what was written and, when Builder made it up, the +// .p12 password: it is printed exactly once. +func printSigningFiles(w io.Writer, res *signing.AutoResult, generatedPassword string) { + if res.Files.Key != "" { + fmt.Fprintf(w, "Private key: %s\n", res.Files.Key) + } + fmt.Fprintf(w, "Certificate: %s\n", res.Files.P12) + fmt.Fprintf(w, "Profile: %s\n", res.Files.Profile) + if generatedPassword != "" { + fmt.Fprintf(w, "Password: %s (generated; shown only now)\n", generatedPassword) + } + fmt.Fprintln(w, "Keep these out of git (add them to .gitignore); gitignored files are also left out of build snapshots.") +} + func printSigningSummary(cfg *config.Config, res *signingAutoResult) { state := func(created bool, reason string) string { if !created { @@ -347,36 +501,32 @@ func printSigningSummary(cfg *config.Config, res *signingAutoResult) { } fmt.Printf("Profile: %s (%s, %s, expires %s)\n", res.Profile.Name, state(res.Profile.Created, res.Profile.Reason), strings.ToLower(res.Profile.State), res.Profile.ExpirationDate.Format("2006-01-02")) fmt.Println() - if res.Files.Key != "" { - fmt.Printf("Private key: %s\n", res.Files.Key) - } - fmt.Printf("Certificate: %s\n", res.Files.P12) - fmt.Printf("Profile: %s\n", res.Files.Profile) - if res.GeneratedPassword != "" { - fmt.Printf("Password: %s (generated; shown only now)\n", res.GeneratedPassword) - } - fmt.Println("Keep these out of git (add them to .gitignore); gitignored files are also left out of build snapshots.") + printSigningFiles(os.Stdout, res.AutoResult, res.GeneratedPassword) fmt.Println() names := config.SigningSecretNames(res.SigningSet) if res.SecretsUploaded { - fmt.Printf("Secrets %s, %s and %s uploaded to %s/%s and ios.signing enabled in builder.json.\n", names.Certificate, names.Password, names.Profile, cfg.GitHub.Owner, cfg.GitHub.Repo) + fmt.Printf("Secrets %s, %s and %s uploaded to %s/%s.\n", names.Certificate, names.Password, names.Profile, cfg.GitHub.Owner, cfg.GitHub.Repo) } else { - fmt.Printf("%s secrets are set in its dashboard, not by Builder. Add:\n", res.Provider) - fmt.Printf(" %-*s base64 of %s\n", len(names.Password), names.Certificate, res.Files.P12) - fmt.Printf(" %s the .p12 password\n", names.Password) - fmt.Printf(" %-*s base64 of %s\n", len(names.Password), names.Profile, res.Files.Profile) - fmt.Printf("then set ios.signing to true in builder.json. Steps: %s\n", providerSecretsDoc) + printProviderSecrets(res.Provider, names, res.Files.P12, res.Files.Profile) } - fmt.Println() - printSigningSetUsage(res.Type, res.SigningSet) - fmt.Println() - if res.Type == signing.TypeDevelopment { - fmt.Println("Next: builder ios build") - } else { - fmt.Printf("Next: builder ios build --profile \n", res.Type) - } - if res.Type == signing.TypeAppStore { + printSigningNext(res.BuildProfile, res.Type) + fmt.Println("Run builder signing setup again any time: it reuses what is valid and renews only what expired or changed.") +} + +// printProviderSecrets tells Codemagic and Bitrise users what to paste into +// the dashboard, since Builder cannot write secrets there. +func printProviderSecrets(provider string, names config.SigningSecrets, p12Path, profilePath string) { + fmt.Printf("%s secrets are set in its dashboard, not by Builder. Add:\n", provider) + fmt.Printf(" %-*s base64 of %s\n", len(names.Password), names.Certificate, p12Path) + fmt.Printf(" %s the .p12 password\n", names.Password) + fmt.Printf(" %-*s base64 of %s\n", len(names.Password), names.Profile, profilePath) + fmt.Printf("Steps: %s\n", providerSecretsDoc) +} + +// printSigningNext names the build that reads the set just written. +func printSigningNext(buildProfile string, typ signing.Type) { + fmt.Printf("Next: builder ios build --profile %s\n", buildProfile) + if typ == signing.TypeStore { fmt.Println("then builder ios upload --wait.") } - fmt.Println("Run builder signing setup again any time: it reuses what is valid and renews only what expired or changed.") } diff --git a/cmd/builder/signing_sets_test.go b/cmd/builder/signing_sets_test.go index fd1891d..ed2e8f8 100644 --- a/cmd/builder/signing_sets_test.go +++ b/cmd/builder/signing_sets_test.go @@ -4,6 +4,8 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/json" + "errors" "io" "os" "path/filepath" @@ -11,19 +13,23 @@ import ( "strings" "testing" + "github.com/MobAI-App/ios-builder/internal/asc" "github.com/MobAI-App/ios-builder/internal/config" "github.com/MobAI-App/ios-builder/internal/github" "github.com/MobAI-App/ios-builder/internal/signing" - "github.com/spf13/cobra" + "github.com/MobAI-App/ios-builder/internal/signing/signingtest" "golang.org/x/crypto/nacl/box" ) // fakeSecrets stands in for the GitHub secrets API: it hands out a real -// public key and decrypts what is stored, so the test sees the values. +// public key, decrypts what is stored so the test sees the values, and lists +// the names it holds. type fakeSecrets struct { pub, priv *[32]byte stored map[string]string names []string + listErr error + listed int } func newFakeSecrets(t *testing.T) *fakeSecrets { @@ -56,18 +62,30 @@ func (f *fakeSecrets) CreateOrUpdateSecret(_ context.Context, _, _, name, encryp return nil } +func (f *fakeSecrets) ListSecretNames(context.Context, string, string) ([]string, error) { + f.listed++ + if f.listErr != nil { + return nil, f.listErr + } + names := make([]string, 0, len(f.stored)) + for name := range f.stored { + names = append(names, name) + } + return names, nil +} + func TestUploadSigningSecretsWritesOneSet(t *testing.T) { store := newFakeSecrets(t) cfg := &config.Config{GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}} var log strings.Builder - if err := uploadSigningSecrets(context.Background(), store, cfg, &log, "APP_STORE", []byte("p12"), "pw", []byte("profile")); err != nil { + if err := uploadSigningSecrets(context.Background(), store, cfg, &log, "STORE", []byte("p12"), "pw", []byte("profile")); err != nil { t.Fatal(err) } - want := []string{"IOS_CERTIFICATE_APP_STORE", "IOS_CERTIFICATE_PASSWORD_APP_STORE", "IOS_PROVISIONING_PROFILE_APP_STORE"} + want := []string{"IOS_CERTIFICATE_STORE", "IOS_CERTIFICATE_PASSWORD_STORE", "IOS_PROVISIONING_PROFILE_STORE"} if !slices.Equal(store.names, want) { t.Fatalf("secrets written: %v, want %v", store.names, want) } - if store.stored["IOS_CERTIFICATE_APP_STORE"] != base64.StdEncoding.EncodeToString([]byte("p12")) || store.stored["IOS_CERTIFICATE_PASSWORD_APP_STORE"] != "pw" || store.stored["IOS_PROVISIONING_PROFILE_APP_STORE"] != base64.StdEncoding.EncodeToString([]byte("profile")) { + if store.stored["IOS_CERTIFICATE_STORE"] != base64.StdEncoding.EncodeToString([]byte("p12")) || store.stored["IOS_CERTIFICATE_PASSWORD_STORE"] != "pw" || store.stored["IOS_PROVISIONING_PROFILE_STORE"] != base64.StdEncoding.EncodeToString([]byte("profile")) { t.Fatalf("values: %v", store.stored) } for _, name := range want { @@ -80,7 +98,7 @@ func TestUploadSigningSecretsWritesOneSet(t *testing.T) { if err := uploadSigningSecrets(context.Background(), store, cfg, io.Discard, "DEVELOPMENT", []byte("dev"), "pw2", []byte("dev-profile")); err != nil { t.Fatal(err) } - if len(store.stored) != 6 || store.stored["IOS_CERTIFICATE_APP_STORE"] == "" || store.stored["IOS_CERTIFICATE_DEVELOPMENT"] == "" { + if len(store.stored) != 6 || store.stored["IOS_CERTIFICATE_STORE"] == "" || store.stored["IOS_CERTIFICATE_DEVELOPMENT"] == "" { t.Fatalf("second set replaced the first: %v", store.names) } for name := range store.stored { @@ -96,45 +114,63 @@ func profileBytes(body string) []byte { return []byte("\x30\x82\x1a\x00 cms " + `` + body + `` + "\x00\xff trailer") } -func TestManualSigningTypeChoosesTheSet(t *testing.T) { +func TestManualSigningTypeReadsTheProfile(t *testing.T) { devices := "ProvisionedDevices00008030-1" dev := profileBytes(devices + "Entitlementsget-task-allow") store := profileBytes("Entitlementsget-task-allow") - // Without --type the profile decides. - typ, source, err := manualSigningType(store, "development", false) - if err != nil || typ != signing.TypeAppStore || source != "read from the profile" { - t.Fatalf("app-store profile: %q %q %v", typ, source, err) + typ, err := manualSigningType(store, "") + if err != nil || typ != signing.TypeStore { + t.Fatalf("store profile: %q %v", typ, err) } - if set, _ := config.SigningSet(string(typ)); set != "APP_STORE" { + if set, _ := config.SigningSet(string(typ)); set != "STORE" { t.Fatalf("set = %s", set) } - if typ, _, err = manualSigningType(dev, "development", false); err != nil || typ != signing.TypeDevelopment { + if typ, err = manualSigningType(dev, ""); err != nil || typ != signing.TypeDevelopment { t.Fatalf("development profile: %q %v", typ, err) } - // --type overrides, even when it disagrees. - if typ, source, err = manualSigningType(dev, "ad-hoc", true); err != nil || typ != signing.TypeAdHoc || source != "--type" { - t.Fatalf("--type ad-hoc: %q %q %v", typ, source, err) + // --distribution may confirm the type, aliases included, but not change it. + if typ, err = manualSigningType(dev, "development"); err != nil || typ != signing.TypeDevelopment { + t.Fatalf("--distribution development: %q %v", typ, err) + } + if _, err = manualSigningType(dev, "internal"); err == nil || !strings.Contains(err.Error(), "ad-hoc") { + t.Fatalf("disagreeing --distribution accepted: %v", err) } - if _, _, err = manualSigningType(dev, "distribution", true); err == nil { - t.Fatal("bad --type accepted") + if _, err = manualSigningType(dev, "app-store"); err == nil { + t.Fatal("bad --distribution accepted") } - // An unreadable profile needs --type. - if _, _, err = manualSigningType([]byte("not a profile"), "development", false); err == nil || !strings.Contains(err.Error(), "--type") { - t.Fatalf("unreadable profile: %v", err) + // An unreadable profile is an error: there is no override. + if _, err = manualSigningType([]byte("not a profile"), ""); err == nil { + t.Fatal("unreadable profile accepted") + } +} + +func TestSetupDistribution(t *testing.T) { + cfg := &config.Config{Profiles: map[string]config.Profile{"beta": {Distribution: "internal"}, "plain": {Scheme: "App"}}} + for _, tc := range []struct { + name, flag string + want signing.Type + }{ + {"", "", signing.TypeDevelopment}, + {"beta", "", signing.TypeAdHoc}, + {"plain", "", signing.TypeDevelopment}, + {"beta", "store", signing.TypeStore}, + {"new", "internal", signing.TypeAdHoc}, + } { + if got, err := setupDistribution(cfg, tc.name, tc.flag); err != nil || got != tc.want { + t.Errorf("setupDistribution(%q, %q) = %q, %v; want %q", tc.name, tc.flag, got, err, tc.want) + } } - if typ, _, err = manualSigningType([]byte("not a profile"), "enterprise", true); err != nil || typ != signing.TypeEnterprise { - t.Fatalf("unreadable profile with --type: %q %v", typ, err) + if _, err := setupDistribution(cfg, "", "app-store"); err == nil { + t.Error("bad --distribution accepted") } } func TestSigningKeyPrefersTheTypeThenLegacy(t *testing.T) { dir := t.TempDir() - cmd := &cobra.Command{} - cmd.Flags().String("key", "", "") // Nothing on disk: generate. - if pem, path, err := signingKey(cmd, dir, signing.TypeAppStore); err != nil || pem != nil || path != "" { + if pem, path, err := signingKey("", dir, signing.TypeStore); err != nil || pem != nil || path != "" { t.Fatalf("empty dir: %q %q %v", pem, path, err) } // A key from before signing sets is reused by every type. @@ -142,18 +178,18 @@ func TestSigningKeyPrefersTheTypeThenLegacy(t *testing.T) { if err := os.WriteFile(legacy, []byte("legacy"), 0600); err != nil { t.Fatal(err) } - if pem, path, err := signingKey(cmd, dir, signing.TypeAppStore); err != nil || string(pem) != "legacy" || path != legacy { + if pem, path, err := signingKey("", dir, signing.TypeStore); err != nil || string(pem) != "legacy" || path != legacy { t.Fatalf("legacy key: %q %q %v", pem, path, err) } // The type's own key wins over it. - typed := filepath.Join(dir, signing.KeyFileName(signing.TypeAppStore)) + typed := filepath.Join(dir, signing.KeyFileName(signing.TypeStore)) if err := os.WriteFile(typed, []byte("typed"), 0600); err != nil { t.Fatal(err) } - if pem, path, err := signingKey(cmd, dir, signing.TypeAppStore); err != nil || string(pem) != "typed" || path != typed { + if pem, path, err := signingKey("", dir, signing.TypeStore); err != nil || string(pem) != "typed" || path != typed { t.Fatalf("typed key: %q %q %v", pem, path, err) } - if pem, path, err := signingKey(cmd, dir, signing.TypeDevelopment); err != nil || string(pem) != "legacy" || path != legacy { + if pem, path, err := signingKey("", dir, signing.TypeDevelopment); err != nil || string(pem) != "legacy" || path != legacy { t.Fatalf("other type falls back to legacy: %q %q %v", pem, path, err) } // --key beats both. @@ -161,10 +197,177 @@ func TestSigningKeyPrefersTheTypeThenLegacy(t *testing.T) { if err := os.WriteFile(explicit, []byte("mine"), 0600); err != nil { t.Fatal(err) } - if err := cmd.Flags().Set("key", explicit); err != nil { + if pem, path, err := signingKey(explicit, dir, signing.TypeStore); err != nil || string(pem) != "mine" || path != explicit { + t.Fatalf("--key: %q %q %v", pem, path, err) + } +} + +// TestWriteSigningProfile checks what signing setup leaves in builder.json: +// a profile holding the distribution, other fields kept, ios.signing untouched. +func TestWriteSigningProfile(t *testing.T) { + t.Chdir(t.TempDir()) + cfg := &config.Config{Project: "App", Platform: "ios", GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}, + Profiles: map[string]config.Profile{"beta": {Scheme: "AppBeta", Env: map[string]string{"API_URL": "x"}}}} + writeSigningProfile(cfg, "store", signing.TypeStore) + writeSigningProfile(cfg, "beta", signing.TypeAdHoc) + if err := config.NewManager().Save(cfg); err != nil { t.Fatal(err) } - if pem, path, err := signingKey(cmd, dir, signing.TypeAppStore); err != nil || string(pem) != "mine" || path != explicit { - t.Fatalf("--key: %q %q %v", pem, path, err) + raw, err := os.ReadFile("builder.json") + if err != nil { + t.Fatal(err) + } + var doc struct { + IOS map[string]any `json:"ios"` + Profiles map[string]config.Profile `json:"profiles"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + if doc.Profiles["store"].Distribution != "store" || doc.Profiles["beta"].Distribution != "ad-hoc" || doc.Profiles["beta"].Scheme != "AppBeta" || doc.Profiles["beta"].Env["API_URL"] != "x" { + t.Fatalf("profiles written: %s", raw) + } + if _, ok := doc.IOS["signing"]; ok || strings.Contains(string(raw), `"signing"`) { + t.Fatalf("ios.signing written for a profile setup: %s", raw) + } + // From nothing: the profiles map is created. + empty := &config.Config{} + writeSigningProfile(empty, "development", signing.TypeDevelopment) + if empty.Profiles["development"].Distribution != "development" { + t.Fatalf("profile not created: %+v", empty.Profiles) + } +} + +func signedConfig() *config.Config { + return &config.Config{Project: "App", Platform: "ios", GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}, + IOS: config.IOSConfig{BundleID: "com.example.app"}, + Profiles: map[string]config.Profile{ + "store": {Distribution: "store"}, + "development": {Distribution: "development"}, + "unsigned": {Configuration: "Release"}, + "inhouse": {Distribution: "enterprise"}, + }} +} + +func noASC() (*asc.Client, error) { + return nil, errors.New("no App Store Connect API key configured. Run: builder auth apple") +} + +func TestEnsureSigningSecretsChecksTheSet(t *testing.T) { + ctx := context.Background() + cfg := signedConfig() + store := newFakeSecrets(t) + + // No distribution: nothing to check, the API is not even called. + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "unsigned", io.Discard); err != nil || store.listed != 0 { + t.Fatalf("unsigned profile: %v, listed %d", err, store.listed) + } + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "", io.Discard); err != nil || store.listed != 0 { + t.Fatalf("no profile: %v, listed %d", err, store.listed) + } + + // The set is complete: dispatch as today, no Apple credentials needed. + for _, name := range config.SigningSecretNames("STORE").Names() { + store.stored[name] = "x" + } + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", io.Discard); err != nil { + t.Fatalf("complete set: %v", err) + } + + // A partial set without Apple credentials stops before the dispatch and + // names both ways out. + delete(store.stored, "IOS_PROVISIONING_PROFILE_STORE") + var log strings.Builder + err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", &log) + if err == nil { + t.Fatal("missing profile secret accepted") + } + for _, want := range []string{"builder auth apple", "builder signing setup --certificate --profile --name store"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error does not mention %q: %v", want, err) + } + } + if !strings.Contains(log.String(), "missing IOS_PROVISIONING_PROFILE_STORE") { + t.Errorf("missing secret not named:\n%s", log.String()) + } + + // Enterprise is never provisioned through the API. + err = ensureSigningSecrets(ctx, cfg, store, noASC, "inhouse", io.Discard) + if err == nil || !strings.Contains(err.Error(), "--certificate") || strings.Contains(err.Error(), "auth apple") { + t.Fatalf("enterprise: %v", err) + } + + // A listing failure is reported, not treated as "missing". + store.listErr = errors.New("403") + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", io.Discard); err == nil || !strings.Contains(err.Error(), "403") { + t.Fatalf("listing failure: %v", err) + } +} + +func TestEnsureSigningSecretsProvisionsOnDemand(t *testing.T) { + t.Chdir(t.TempDir()) + ctx := context.Background() + cfg := signedConfig() + store := newFakeSecrets(t) + portal := signingtest.New(t) + withPortal := func() (*asc.Client, error) { return portal.Client(t), nil } + var log strings.Builder + + if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "store", &log); err != nil { + t.Fatalf("on-demand provisioning: %v\n%s", err, log.String()) + } + // The whole store set is in the repository now, made from what the + // portal issued, and the key, .p12 and profile are on disk. + want := config.SigningSecretNames("STORE").Names() + if !slices.Equal(store.names, want) { + t.Fatalf("secrets written: %v, want %v", store.names, want) + } + if store.stored["IOS_PROVISIONING_PROFILE_STORE"] != base64.StdEncoding.EncodeToString([]byte("profile:prof-2")) { + t.Fatalf("profile secret: %q", store.stored["IOS_PROVISIONING_PROFILE_STORE"]) + } + for _, f := range []string{"ios-signing-store.key", "ios-signing-store.p12", "Builder-store-com.example.app.mobileprovision"} { + if _, err := os.Stat(f); err != nil { + t.Errorf("%s not written: %v", f, err) + } + } + if !strings.Contains(log.String(), "Password:") || !strings.Contains(log.String(), store.stored["IOS_CERTIFICATE_PASSWORD_STORE"]) { + t.Errorf("generated password not shown:\n%s", log.String()) + } + if len(portal.Profiles) != 1 || portal.Profiles[0].Type != asc.ProfileTypeIOSAppStore || portal.Profiles[0].DeviceIDs != nil { + t.Errorf("portal profiles: %+v", portal.Profiles) + } + + // Second build: the set is there, nothing is provisioned again. + portal.Reset() + if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "store", io.Discard); err != nil || len(portal.Calls()) != 0 || len(store.names) != 3 { + t.Fatalf("second build: %v, calls %v, uploads %v", err, portal.Calls(), store.names) + } + + // Development with no device anywhere cannot be provisioned without + // prompting: the error sends the user to signing setup. + err := ensureSigningSecrets(ctx, cfg, store, withPortal, "development", io.Discard) + if err == nil || !strings.Contains(err.Error(), "builder signing setup --distribution development --devices-from-mobai") { + t.Fatalf("development without devices: %v", err) + } + if portal.Count("POST /v1/certificates") != 0 || len(store.names) != 3 { + t.Fatalf("devices are checked before anything is issued: %v", portal.Calls()) + } + + // With a device on the account the development set follows. + portal.Devices = []signingtest.Device{{ID: "dev-1", Name: "Jane's iPhone", UDID: "00008030-000000000000001E", Status: "ENABLED"}} + if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "development", io.Discard); err != nil { + t.Fatalf("development with a registered device: %v", err) + } + if len(store.stored) != 6 || store.stored["IOS_CERTIFICATE_DEVELOPMENT"] == "" { + t.Fatalf("development set not uploaded: %v", store.names) + } + + // Without a bundle ID anywhere the build stops before touching Apple. + cfg.IOS.BundleID = "" + delete(store.stored, "IOS_CERTIFICATE_DEVELOPMENT") + portal.Reset() + err = ensureSigningSecrets(ctx, cfg, store, withPortal, "development", io.Discard) + if err == nil || !strings.Contains(err.Error(), "ios.bundleId") || len(portal.Calls()) != 0 { + t.Fatalf("no bundle ID: %v, calls %v", err, portal.Calls()) } } From a1932eea830833ee2d05f370300b12238102d7ef Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 17:14:41 +0200 Subject: [PATCH 43/75] 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. --- internal/workflow/profile_test.go | 42 +++++++++-- internal/workflow/providers_test.go | 34 +++++---- internal/workflow/templates/ios-build.yml | 90 ++++++++++++----------- internal/workflow/templates/runner.sh | 71 +++++++++--------- 4 files changed, 139 insertions(+), 98 deletions(-) diff --git a/internal/workflow/profile_test.go b/internal/workflow/profile_test.go index 68f5e65..6d0d0f1 100644 --- a/internal/workflow/profile_test.go +++ b/internal/workflow/profile_test.go @@ -101,8 +101,11 @@ const profiledBuilderJSON = `{ "ios": {"path": "ios", "scheme": "Top", "signing": true, "configuration": "Debug"}, "defaultProfile": "preview", "profiles": { - "preview": {"configuration": "Release", "signing": false, "distribution": "ad-hoc", - "env": {"API_URL": "https://staging.example.com", "NOTES": "line one\n__BUILDER_ENV__\nline \"two\""}} + "preview": {"distribution": "internal", + "env": {"API_URL": "https://staging.example.com", "NOTES": "line one\n__BUILDER_ENV__\nline \"two\""}}, + "dev": {"distribution": "development"}, + "debug-store": {"configuration": "Debug", "distribution": "store"}, + "unsigned": {"scheme": "Other"} } }` @@ -119,11 +122,13 @@ func TestResolveParametersApplyProfiles(t *testing.T) { share := resolveStep(t, "ios-share.yml") t.Run("tag build applies defaultProfile", func(t *testing.T) { + // A distribution signs the build and derives Release; internal is + // ad-hoc and its set is AD_HOC. r := runResolve(t, build, profiledBuilderJSON, map[string]string{"GITHUB_EVENT_NAME": "push"}) if r.err != nil { t.Fatalf("%v\n%s", r.err, r.log) } - want := map[string]string{"build_id": "abcdef12", "ios_path": "ios", "scheme": "Top", "use_signing": "false", + want := map[string]string{"build_id": "abcdef12", "ios_path": "ios", "scheme": "Top", "use_signing": "true", "configuration": "Release", "profile": "preview", "distribution": "ad-hoc", "signing_set": "AD_HOC", "jdk_version": "17"} for k, v := range want { if r.outputs[k] != v { @@ -135,13 +140,33 @@ func TestResolveParametersApplyProfiles(t *testing.T) { } }) + t.Run("tag build derives configuration and signing from the distribution", func(t *testing.T) { + for name, want := range map[string]map[string]string{ + "dev": {"use_signing": "true", "configuration": "Debug", "signing_set": "DEVELOPMENT"}, + "debug-store": {"use_signing": "true", "configuration": "Debug", "signing_set": "STORE", "distribution": "store"}, + // No distribution: unsigned whatever ios.signing says, ios.configuration applies. + "unsigned": {"use_signing": "false", "configuration": "Debug", "signing_set": "", "scheme": "Other"}, + } { + withDefault := strings.Replace(profiledBuilderJSON, `"defaultProfile": "preview"`, `"defaultProfile": "`+name+`"`, 1) + r := runResolve(t, build, withDefault, map[string]string{"GITHUB_EVENT_NAME": "push"}) + if r.err != nil { + t.Fatalf("%s: %v\n%s", name, r.err, r.log) + } + for k, v := range want { + if r.outputs[k] != v { + t.Errorf("%s: %s = %q, want %q\n%s", name, k, r.outputs[k], v, r.log) + } + } + } + }) + t.Run("tag build without profiles is unchanged", func(t *testing.T) { plain := `{"ios": {"scheme": "Top", "signing": true}}` r := runResolve(t, build, plain, map[string]string{"GITHUB_EVENT_NAME": "push"}) if r.err != nil { t.Fatalf("%v\n%s", r.err, r.log) } - if r.outputs["scheme"] != "Top" || r.outputs["use_signing"] != "true" || r.outputs["configuration"] != "Debug" || r.outputs["profile"] != "" || r.outputs["signing_set"] != "DEVELOPMENT" || len(r.env) != 0 { + if r.outputs["scheme"] != "Top" || r.outputs["use_signing"] != "true" || r.outputs["configuration"] != "Debug" || r.outputs["profile"] != "" || r.outputs["signing_set"] != "" || len(r.env) != 0 { t.Fatalf("outputs %v env %v\n%s", r.outputs, r.env, r.log) } }) @@ -149,26 +174,26 @@ func TestResolveParametersApplyProfiles(t *testing.T) { t.Run("dispatch uses the profile input", func(t *testing.T) { env := map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_BUILD_ID": "12345678", "IN_SCHEME": "Dispatched", "IN_USE_SIGNING": "true", "IN_CONFIGURATION": "Release", - "IN_PROFILE": `{"name":"production","env":{"API_URL":"https://api.example.com"},"distribution":"app-store"}`} + "IN_PROFILE": `{"name":"production","env":{"API_URL":"https://api.example.com"},"distribution":"store"}`} // builder.json on disk must be ignored for a dispatch. r := runResolve(t, build, profiledBuilderJSON, env) if r.err != nil { t.Fatalf("%v\n%s", r.err, r.log) } if r.outputs["build_id"] != "12345678" || r.outputs["scheme"] != "Dispatched" || r.outputs["use_signing"] != "true" || - r.outputs["profile"] != "production" || r.outputs["distribution"] != "app-store" || r.outputs["signing_set"] != "APP_STORE" || r.env["API_URL"] != "https://api.example.com" { + r.outputs["profile"] != "production" || r.outputs["distribution"] != "store" || r.outputs["signing_set"] != "STORE" || r.env["API_URL"] != "https://api.example.com" { t.Fatalf("outputs %v env %v\n%s", r.outputs, r.env, r.log) } // Without a selected profile the input carries its default. env["IN_PROFILE"] = "{}" r = runResolve(t, build, "", env) - if r.err != nil || r.outputs["profile"] != "" || r.outputs["distribution"] != "" || r.outputs["signing_set"] != "DEVELOPMENT" || len(r.env) != 0 { + if r.err != nil || r.outputs["profile"] != "" || r.outputs["distribution"] != "" || r.outputs["signing_set"] != "" || len(r.env) != 0 { t.Fatalf("default profile input: %v %v %v\n%s", r.err, r.outputs, r.env, r.log) } }) t.Run("share exports env and profile scheme", func(t *testing.T) { - withScheme := strings.Replace(profiledBuilderJSON, `"configuration": "Release",`, `"configuration": "Release", "scheme": "Preview",`, 1) + withScheme := strings.Replace(profiledBuilderJSON, `"preview": {"distribution": "internal",`, `"preview": {"distribution": "internal", "scheme": "Preview",`, 1) r := runResolve(t, share, withScheme, map[string]string{"GITHUB_EVENT_NAME": "push"}) if r.err != nil { t.Fatalf("%v\n%s", r.err, r.log) @@ -185,6 +210,7 @@ func TestResolveParametersApplyProfiles(t *testing.T) { }{ "unknown defaultProfile": {`{"defaultProfile": "nightly", "profiles": {"preview": {}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}}, "bad distribution": {`{"defaultProfile": "p", "profiles": {"p": {"distribution": "adhoc"}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}}, + "old app-store": {`{"defaultProfile": "p", "profiles": {"p": {"distribution": "app-store"}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}}, "bad env name": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `{"name":"p","env":{"A B":"x"}}`}}, "env not an object": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `{"name":"p","env":"A=x"}`}}, "profile not JSON": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `preview`}}, diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index d70cf3d..3b090fa 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -295,9 +295,14 @@ func TestExportMethodFollowsProfile(t *testing.T) { {"no entitlements", profile(devices), "ad-hoc"}, } // signing setup reads the same type locally, from the plist inside the - // CMS blob, so the Go rules must agree with the shell's on every case. + // CMS blob, so the Go rules must agree with the shell's on every case; + // the export method's app-store is the store distribution. for _, tc := range cases { - if got, err := signing.ProfileType([]byte("\x30\x82cms" + tc.plist + "\x00\xff")); err != nil || string(got) != tc.want { + want := tc.want + if want == "app-store" { + want = "store" + } + if got, err := signing.ProfileType([]byte("\x30\x82cms" + tc.plist + "\x00\xff")); err != nil || string(got) != want { t.Errorf("%s: signing.ProfileType = %q, %v; detect_export_method says %q", tc.name, got, err, tc.want) } } @@ -358,7 +363,7 @@ func TestSigningSetSelection(t *testing.T) { if !strings.Contains(string(workflowTemplate), `SIGNING_SET: ${{ steps.params.outputs.signing_set }}`) || !strings.Contains(string(runner), `SIGNING_SET=$(signing_set "$DISTRIBUTION")`) { t.Error("SIGNING_SET is not derived from the distribution") } - for _, set := range []string{"DEVELOPMENT", "AD_HOC", "APP_STORE", "ENTERPRISE"} { + for _, set := range []string{"DEVELOPMENT", "AD_HOC", "STORE", "ENTERPRISE"} { for _, secret := range []string{"IOS_CERTIFICATE_", "IOS_CERTIFICATE_PASSWORD_", "IOS_PROVISIONING_PROFILE_"} { if line := secret + set + ": ${{ secrets." + secret + set + " }}"; !strings.Contains(string(workflowTemplate), line) { t.Errorf("ios-build.yml does not pass %s%s to the signing step", secret, set) @@ -385,7 +390,8 @@ func TestSigningSetSelection(t *testing.T) { return string(out), err } legacy := map[string]string{"IOS_CERTIFICATE": "legacy-cert", "IOS_CERTIFICATE_PASSWORD": "legacy-pw", "IOS_PROVISIONING_PROFILE": "legacy-profile"} - appStore := map[string]string{"IOS_CERTIFICATE_APP_STORE": "store-cert", "IOS_CERTIFICATE_PASSWORD_APP_STORE": "store-pw", "IOS_PROVISIONING_PROFILE_APP_STORE": "store-profile"} + store := map[string]string{"IOS_CERTIFICATE_STORE": "store-cert", "IOS_CERTIFICATE_PASSWORD_STORE": "store-pw", "IOS_PROVISIONING_PROFILE_STORE": "store-profile"} + adHoc := map[string]string{"IOS_CERTIFICATE_AD_HOC": "adhoc-cert", "IOS_CERTIFICATE_PASSWORD_AD_HOC": "adhoc-pw", "IOS_PROVISIONING_PROFILE_AD_HOC": "adhoc-profile"} with := func(sets ...map[string]string) map[string]string { env := map[string]string{} for _, s := range sets { @@ -402,19 +408,17 @@ func TestSigningSetSelection(t *testing.T) { want string // "" expects a failure whose message holds wantErr errs []string }{ - {"suffixed set present", with(legacy, appStore, map[string]string{"DISTRIBUTION": "app-store", "METHOD": "app-store"}), "store-cert|store-pw|store-profile|APP_STORE", nil}, + {"suffixed set present", with(legacy, store, map[string]string{"DISTRIBUTION": "store", "METHOD": "app-store"}), "store-cert|store-pw|store-profile|STORE", nil}, + {"internal reads the ad-hoc set", with(adHoc, map[string]string{"DISTRIBUTION": "internal", "METHOD": "ad-hoc"}), "adhoc-cert|adhoc-pw|adhoc-profile|AD_HOC", nil}, {"only legacy, no distribution, any profile type", with(legacy, map[string]string{"DISTRIBUTION": "", "METHOD": "ad-hoc"}), "legacy-cert|legacy-pw|legacy-profile|legacy", nil}, {"only legacy without a password", map[string]string{"IOS_CERTIFICATE": "legacy-cert", "IOS_PROVISIONING_PROFILE": "legacy-profile", "DISTRIBUTION": "", "METHOD": "development"}, "legacy-cert||legacy-profile|legacy", nil}, - {"only legacy, requested distribution matches", with(legacy, map[string]string{"DISTRIBUTION": "app-store", "METHOD": "app-store"}), "legacy-cert|legacy-pw|legacy-profile|legacy", nil}, - {"development set for no distribution", with(legacy, map[string]string{"IOS_CERTIFICATE_DEVELOPMENT": "dev-cert", "IOS_CERTIFICATE_PASSWORD_DEVELOPMENT": "dev-pw", "IOS_PROVISIONING_PROFILE_DEVELOPMENT": "dev-profile", "DISTRIBUTION": "", "METHOD": "development"}), "dev-cert|dev-pw|dev-profile|DEVELOPMENT", nil}, - {"requested set absent, legacy absent", map[string]string{"DISTRIBUTION": "ad-hoc", "METHOD": "ad-hoc"}, "", []string{"IOS_CERTIFICATE_AD_HOC", "IOS_CERTIFICATE_PASSWORD_AD_HOC", "IOS_PROVISIONING_PROFILE_AD_HOC", "unsuffixed IOS_CERTIFICATE", "--type ad-hoc"}}, - {"suffixed set missing its profile", with(legacy, map[string]string{"IOS_CERTIFICATE_APP_STORE": "store-cert", "IOS_CERTIFICATE_PASSWORD_APP_STORE": "store-pw", "DISTRIBUTION": "app-store", "METHOD": "app-store"}), "", []string{"incomplete", "missing IOS_PROVISIONING_PROFILE_APP_STORE."}}, - {"suffixed set with empty password", with(appStore, map[string]string{"IOS_CERTIFICATE_PASSWORD_APP_STORE": "", "DISTRIBUTION": "app-store", "METHOD": "app-store"}), "", []string{"incomplete", "missing IOS_CERTIFICATE_PASSWORD_APP_STORE."}}, - {"suffixed password alone is not a legacy fallback", with(legacy, map[string]string{"IOS_CERTIFICATE_PASSWORD_APP_STORE": "store-pw", "DISTRIBUTION": "app-store", "METHOD": "app-store"}), "", []string{"incomplete", "missing IOS_CERTIFICATE_APP_STORE, IOS_PROVISIONING_PROFILE_APP_STORE."}}, - {"legacy profile of the wrong type", with(legacy, map[string]string{"DISTRIBUTION": "app-store", "METHOD": "development"}), "", []string{"unsuffixed IOS_PROVISIONING_PROFILE", "development provisioning profile", "distribution app-store", "APP_STORE", "--type app-store"}}, - {"suffixed profile of the wrong type", with(appStore, map[string]string{"DISTRIBUTION": "app-store", "METHOD": "ad-hoc"}), "", []string{"IOS_PROVISIONING_PROFILE_APP_STORE holds a ad-hoc", "distribution app-store"}}, - {"development set holding a distribution profile", with(map[string]string{"IOS_CERTIFICATE_DEVELOPMENT": "c", "IOS_CERTIFICATE_PASSWORD_DEVELOPMENT": "pw", "IOS_PROVISIONING_PROFILE_DEVELOPMENT": "p", "DISTRIBUTION": "", "METHOD": "app-store"}), "", []string{"IOS_PROVISIONING_PROFILE_DEVELOPMENT", "distribution development"}}, - {"unknown distribution", with(legacy, map[string]string{"DISTRIBUTION": "adhoc", "METHOD": "ad-hoc"}), "", []string{"bad distribution adhoc"}}, + {"no distribution ignores the suffixed sets", with(store, map[string]string{"IOS_CERTIFICATE_DEVELOPMENT": "dev-cert", "IOS_CERTIFICATE_PASSWORD_DEVELOPMENT": "dev-pw", "IOS_PROVISIONING_PROFILE_DEVELOPMENT": "dev-profile", "DISTRIBUTION": "", "METHOD": "development"}), "", []string{"ios.signing needs IOS_CERTIFICATE", "IOS_*_"}}, + {"requested set absent, legacy present", with(legacy, map[string]string{"DISTRIBUTION": "ad-hoc", "METHOD": "ad-hoc"}), "", []string{"Signing set AD_HOC for distribution ad-hoc is missing IOS_CERTIFICATE_AD_HOC, IOS_CERTIFICATE_PASSWORD_AD_HOC, IOS_PROVISIONING_PROFILE_AD_HOC", "--distribution ad-hoc"}}, + {"suffixed set missing its profile", with(legacy, map[string]string{"IOS_CERTIFICATE_STORE": "store-cert", "IOS_CERTIFICATE_PASSWORD_STORE": "store-pw", "DISTRIBUTION": "store", "METHOD": "app-store"}), "", []string{"missing IOS_PROVISIONING_PROFILE_STORE.", "--distribution store"}}, + {"suffixed set with empty password", with(store, map[string]string{"IOS_CERTIFICATE_PASSWORD_STORE": "", "DISTRIBUTION": "store", "METHOD": "app-store"}), "", []string{"missing IOS_CERTIFICATE_PASSWORD_STORE."}}, + {"suffixed profile of the wrong type", with(store, map[string]string{"DISTRIBUTION": "store", "METHOD": "ad-hoc"}), "", []string{"IOS_PROVISIONING_PROFILE_STORE holds a ad-hoc", "distribution store", "--distribution store", "distribution to ad-hoc"}}, + {"ad-hoc set holding a store profile, requested as internal", with(adHoc, map[string]string{"DISTRIBUTION": "internal", "METHOD": "app-store"}), "", []string{"IOS_PROVISIONING_PROFILE_AD_HOC holds a app-store", "distribution ad-hoc", "distribution to store"}}, + {"unknown distribution", with(legacy, map[string]string{"DISTRIBUTION": "app-store", "METHOD": "app-store"}), "", []string{"bad distribution app-store"}}, } { t.Run(tc.name, func(t *testing.T) { out, err := run(tc.env) diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index d5a8f3a..815f08d 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -117,13 +117,15 @@ jobs: echo "$1=$v" >> "$GITHUB_OUTPUT" echo "$1=$v" } - # Profile fields override ios.*. signing needs the explicit null test: - # jq's // would let a profile's `false` fall through to ios.signing. + # Profile fields override ios.*. A selected profile signs exactly + # when it has a distribution (ios.signing is the no-profile path), + # and its configuration defaults to Debug for development and + # Release for every other distribution. param build_id "$IN_BUILD_ID" '""' "${GITHUB_REF_NAME##*/}" param ios_path "$IN_IOS_PATH" '.ios.path' '.' param scheme "$IN_SCHEME" '(.profiles[$p].scheme // .ios.scheme)' '' - param use_signing "$IN_USE_SIGNING" '(if .profiles[$p].signing != null then .profiles[$p].signing else .ios.signing end)' 'false' - param configuration "$IN_CONFIGURATION" '(.profiles[$p].configuration // .ios.configuration)' 'Debug' + param use_signing "$IN_USE_SIGNING" '(if $p != "" then ((.profiles[$p].distribution // "") != "") else .ios.signing end)' 'false' + param configuration "$IN_CONFIGURATION" '(.profiles[$p].configuration // (if $p != "" and (.profiles[$p].distribution // "") != "" then (if .profiles[$p].distribution == "development" then "Debug" else "Release" end) else .ios.configuration end))' 'Debug' param flutter_version "$IN_FLUTTER_VERSION" '.flutter.version' '' param jdk_version "$IN_JDK_VERSION" '.kmp.jdkVersion' '17' @@ -139,19 +141,24 @@ jobs: [ -n "${PROFILE_JSON:-}" ] || PROFILE_JSON='{}' PROFILE=$(jq -r '.name // ""' <<< "$PROFILE_JSON") DISTRIBUTION=$(jq -r '.distribution // ""' <<< "$PROFILE_JSON") - # The suffix of the IOS_* secrets a distribution is signed with; no - # distribution is development. Same table in runner.sh. + # internal is an alias of ad-hoc; the CLI sends the canonical name, + # a tag build reads whatever builder.json says. + case "$DISTRIBUTION" in internal) DISTRIBUTION=ad-hoc ;; esac + # The suffix of the IOS_* secrets a distribution is signed with: its + # canonical name upper-cased. No distribution has no set (the legacy + # ios.signing path reads the unsuffixed secrets). Same table in runner.sh. signing_set() { case "$1" in - ''|development) echo DEVELOPMENT ;; - ad-hoc) echo AD_HOC ;; - app-store) echo APP_STORE ;; + '') echo '' ;; + development) echo DEVELOPMENT ;; + ad-hoc|internal) echo AD_HOC ;; + store) echo STORE ;; enterprise) echo ENTERPRISE ;; *) return 1 ;; esac } if ! SIGNING_SET=$(signing_set "$DISTRIBUTION"); then - echo "::error::distribution \"$DISTRIBUTION\" must be development, ad-hoc, app-store or enterprise"; exit 1 + echo "::error::distribution \"$DISTRIBUTION\" must be development, ad-hoc (or internal), store or enterprise"; exit 1 fi echo "profile=$PROFILE" >> "$GITHUB_OUTPUT" echo "distribution=$DISTRIBUTION" >> "$GITHUB_OUTPUT" @@ -342,10 +349,10 @@ jobs: restore-keys: | pods-${{ runner.os }}- - # One set of secrets per distribution type, IOS_*_, selected by the - # build profile's distribution; the unsuffixed names are the fallback for - # repositories set up before signing sets. A secret that does not exist - # arrives empty. + # One set of secrets per distribution, IOS_*_, selected by the + # build profile's distribution; the unsuffixed names serve builds + # without a profile (ios.signing). A secret that does not exist arrives + # empty. - name: Install certificate and provisioning profile if: steps.params.outputs.use_signing == 'true' env: @@ -361,9 +368,9 @@ jobs: IOS_CERTIFICATE_AD_HOC: ${{ secrets.IOS_CERTIFICATE_AD_HOC }} IOS_CERTIFICATE_PASSWORD_AD_HOC: ${{ secrets.IOS_CERTIFICATE_PASSWORD_AD_HOC }} IOS_PROVISIONING_PROFILE_AD_HOC: ${{ secrets.IOS_PROVISIONING_PROFILE_AD_HOC }} - IOS_CERTIFICATE_APP_STORE: ${{ secrets.IOS_CERTIFICATE_APP_STORE }} - IOS_CERTIFICATE_PASSWORD_APP_STORE: ${{ secrets.IOS_CERTIFICATE_PASSWORD_APP_STORE }} - IOS_PROVISIONING_PROFILE_APP_STORE: ${{ secrets.IOS_PROVISIONING_PROFILE_APP_STORE }} + IOS_CERTIFICATE_STORE: ${{ secrets.IOS_CERTIFICATE_STORE }} + IOS_CERTIFICATE_PASSWORD_STORE: ${{ secrets.IOS_CERTIFICATE_PASSWORD_STORE }} + IOS_PROVISIONING_PROFILE_STORE: ${{ secrets.IOS_PROVISIONING_PROFILE_STORE }} IOS_CERTIFICATE_ENTERPRISE: ${{ secrets.IOS_CERTIFICATE_ENTERPRISE }} IOS_CERTIFICATE_PASSWORD_ENTERPRISE: ${{ secrets.IOS_CERTIFICATE_PASSWORD_ENTERPRISE }} IOS_PROVISIONING_PROFILE_ENTERPRISE: ${{ secrets.IOS_PROVISIONING_PROFILE_ENTERPRISE }} @@ -373,45 +380,46 @@ jobs: # Picks the secrets of the set the build profile's distribution names # (IOS_CERTIFICATE_ and friends) into IOS_CERTIFICATE, - # IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE, falling back - # to those unsuffixed names when the set is absent. A set needs all - # three (builder signing setup always writes a password); only the - # unsuffixed password may be empty, as before signing sets. - # SIGNING_SET_USED says which it was. Same function in runner.sh. + # IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE. A set needs + # all three (builder signing setup always writes a password). With + # no distribution — ios.signing without a profile — the unsuffixed + # secrets are used as they are, password optional. SIGNING_SET_USED + # says which it was. Same function in runner.sh. select_signing_set() { - local cert="IOS_CERTIFICATE_$SIGNING_SET" pass="IOS_CERTIFICATE_PASSWORD_$SIGNING_SET" prof="IOS_PROVISIONING_PROFILE_$SIGNING_SET" - if [ -n "${!cert:-}${!pass:-}${!prof:-}" ]; then + if [ -z "$SIGNING_SET" ]; then + if [ -z "${IOS_CERTIFICATE:-}" ] || [ -z "${IOS_PROVISIONING_PROFILE:-}" ]; then + fail "No signing secrets: ios.signing needs IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE; a build profile with a distribution reads its own IOS_*_ secrets instead (builder signing setup --distribution writes them)." + fi + IOS_CERTIFICATE_PASSWORD="${IOS_CERTIFICATE_PASSWORD:-}" + SIGNING_SET_USED=legacy + else + local cert="IOS_CERTIFICATE_$SIGNING_SET" pass="IOS_CERTIFICATE_PASSWORD_$SIGNING_SET" prof="IOS_PROVISIONING_PROFILE_$SIGNING_SET" local missing="" name for name in "$cert" "$pass" "$prof"; do [ -n "${!name:-}" ] || missing="${missing:+$missing, }$name" done - [ -z "$missing" ] || fail "Signing set $SIGNING_SET is incomplete: missing $missing. builder signing setup --type ${DISTRIBUTION:-development} writes all three." + [ -z "$missing" ] || fail "Signing set $SIGNING_SET for distribution $DISTRIBUTION is missing $missing. Run builder signing setup --distribution $DISTRIBUTION (builder ios build does it too when an App Store Connect key is configured)." IOS_CERTIFICATE="${!cert}" IOS_CERTIFICATE_PASSWORD="${!pass}" IOS_PROVISIONING_PROFILE="${!prof}" SIGNING_SET_USED="$SIGNING_SET" - elif [ -n "${IOS_CERTIFICATE:-}" ] && [ -n "${IOS_PROVISIONING_PROFILE:-}" ]; then - IOS_CERTIFICATE_PASSWORD="${IOS_CERTIFICATE_PASSWORD:-}" - SIGNING_SET_USED=legacy - else - fail "No signing secrets for distribution ${DISTRIBUTION:-development}: set $cert, $pass and $prof (builder signing setup --type ${DISTRIBUTION:-development} does), or the unsuffixed IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE." fi echo "Signing set: $SIGNING_SET_USED" } # The profile in the set must be the type the build profile asked # for, or the export method, and the IPA, would not be what the - # profile promised. The unsuffixed secrets with no distribution - # requested are taken as they are, as before signing sets. + # profile promised. Names are compared canonically: the export + # method calls the store distribution app-store, and a tag build may + # say internal for ad-hoc. The unsuffixed secrets (no distribution) + # are taken as they are. check_signing_set() { - local want="$DISTRIBUTION" secret="IOS_PROVISIONING_PROFILE_$SIGNING_SET" - if [ "$SIGNING_SET_USED" = legacy ]; then - secret="the unsuffixed IOS_PROVISIONING_PROFILE" - elif [ -z "$want" ]; then - want=development - fi - if [ -n "$want" ] && [ "$1" != "$want" ]; then - fail "$secret holds a $1 provisioning profile, but the build profile asks for distribution $want (signing set $SIGNING_SET). Upload a $want profile with builder signing setup --type $want, or set the profile's distribution to $1." + [ "$SIGNING_SET_USED" != legacy ] || return 0 + local have="$1" want="$DISTRIBUTION" + case "$have" in app-store) have=store ;; esac + case "$want" in internal) want=ad-hoc ;; esac + if [ "$have" != "$want" ]; then + fail "IOS_PROVISIONING_PROFILE_$SIGNING_SET holds a $1 provisioning profile, but the build profile asks for distribution $want. Run builder signing setup --distribution $want, or set the profile's distribution to $have." fi } @@ -459,7 +467,7 @@ jobs: # profile grants: the export fails, or an IPA that App Store Connect # rejects comes out. Say so now instead of after the whole build. if [ "$EXPORT_METHOD" != "development" ] && [ "$CONFIGURATION" = "Debug" ]; then - echo "::error::The provisioning profile is an $EXPORT_METHOD profile, but the build configuration is Debug. A Debug build is signed with get-task-allow, which distribution profiles do not allow and App Store Connect rejects. Set \"configuration\": \"Release\" under \"ios\" in builder.json, or use a development profile." + echo "::error::The provisioning profile is an $EXPORT_METHOD profile, but the build configuration is Debug. A Debug build is signed with get-task-allow, which distribution profiles do not allow and App Store Connect rejects. Drop the profile's \"configuration\" (a distribution build defaults to Release) or set \"configuration\": \"Release\", or build with a development profile." exit 1 fi diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 479420d..e0429b4 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -7,9 +7,10 @@ mkdir -p "$ci_dir" mode="${1:-build}" export IOS_PATH="${IOS_PATH:-.}" SCHEME="${SCHEME:-}" CONFIGURATION="${CONFIGURATION:-Debug}" export USE_SIGNING="${USE_SIGNING:-false}" JDK_VERSION="${JDK_VERSION:-17}" -# From the selected builder.json profile: DISTRIBUTION picks the signing set -# (IOS_*_ secrets) and the profile type install_signing expects; BUILD_ENV -# is a JSON object exported by prepare(). +# From the selected builder.json profile: DISTRIBUTION (canonical: internal +# arrives as ad-hoc) picks the signing set (IOS_*_ secrets) and the +# profile type install_signing expects; BUILD_ENV is a JSON object exported by +# prepare(). export DISTRIBUTION="${DISTRIBUTION:-}" BUILD_ENV="${BUILD_ENV:-}" fail() { echo "$*" >&2; exit 1; } @@ -168,13 +169,15 @@ detect_export_method() { fi } -# The suffix of the IOS_* secrets a distribution is signed with; no -# distribution is development. Same table in ios-build.yml. +# The suffix of the IOS_* secrets a distribution is signed with: its +# canonical name upper-cased. No distribution has no set (the legacy +# ios.signing path reads the unsuffixed secrets). Same table in ios-build.yml. signing_set() { case "$1" in - ''|development) echo DEVELOPMENT ;; - ad-hoc) echo AD_HOC ;; - app-store) echo APP_STORE ;; + '') echo '' ;; + development) echo DEVELOPMENT ;; + ad-hoc|internal) echo AD_HOC ;; + store) echo STORE ;; enterprise) echo ENTERPRISE ;; *) return 1 ;; esac @@ -182,50 +185,50 @@ signing_set() { # Picks the secrets of the set the build profile's distribution names # (IOS_CERTIFICATE_ and friends) into IOS_CERTIFICATE, -# IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE, falling back to those -# unsuffixed names when the set is absent. A set needs all three (builder -# signing setup always writes a password); only the unsuffixed password may be -# empty, as before signing sets. SIGNING_SET_USED says which it was. Same -# function in ios-build.yml. +# IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE. A set needs all three +# (builder signing setup always writes a password). With no distribution — +# ios.signing without a profile — the unsuffixed secrets are used as they are, +# password optional. SIGNING_SET_USED says which it was. Same function in +# ios-build.yml. select_signing_set() { - local cert="IOS_CERTIFICATE_$SIGNING_SET" pass="IOS_CERTIFICATE_PASSWORD_$SIGNING_SET" prof="IOS_PROVISIONING_PROFILE_$SIGNING_SET" - if [ -n "${!cert:-}${!pass:-}${!prof:-}" ]; then + if [ -z "$SIGNING_SET" ]; then + if [ -z "${IOS_CERTIFICATE:-}" ] || [ -z "${IOS_PROVISIONING_PROFILE:-}" ]; then + fail "No signing secrets: ios.signing needs IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE; a build profile with a distribution reads its own IOS_*_ secrets instead (builder signing setup --distribution writes them)." + fi + IOS_CERTIFICATE_PASSWORD="${IOS_CERTIFICATE_PASSWORD:-}" + SIGNING_SET_USED=legacy + else + local cert="IOS_CERTIFICATE_$SIGNING_SET" pass="IOS_CERTIFICATE_PASSWORD_$SIGNING_SET" prof="IOS_PROVISIONING_PROFILE_$SIGNING_SET" local missing="" name for name in "$cert" "$pass" "$prof"; do [ -n "${!name:-}" ] || missing="${missing:+$missing, }$name" done - [ -z "$missing" ] || fail "Signing set $SIGNING_SET is incomplete: missing $missing. builder signing setup --type ${DISTRIBUTION:-development} writes all three." + [ -z "$missing" ] || fail "Signing set $SIGNING_SET for distribution $DISTRIBUTION is missing $missing. Run builder signing setup --distribution $DISTRIBUTION (builder ios build does it too when an App Store Connect key is configured)." IOS_CERTIFICATE="${!cert}" IOS_CERTIFICATE_PASSWORD="${!pass}" IOS_PROVISIONING_PROFILE="${!prof}" SIGNING_SET_USED="$SIGNING_SET" - elif [ -n "${IOS_CERTIFICATE:-}" ] && [ -n "${IOS_PROVISIONING_PROFILE:-}" ]; then - IOS_CERTIFICATE_PASSWORD="${IOS_CERTIFICATE_PASSWORD:-}" - SIGNING_SET_USED=legacy - else - fail "No signing secrets for distribution ${DISTRIBUTION:-development}: set $cert, $pass and $prof (builder signing setup --type ${DISTRIBUTION:-development} does), or the unsuffixed IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE." fi echo "Signing set: $SIGNING_SET_USED" } # The profile in the set must be the type the build profile asked for, or the -# export method, and the IPA, would not be what the profile promised. The -# unsuffixed secrets with no distribution requested are taken as they are, as -# before signing sets. +# export method, and the IPA, would not be what the profile promised. Names +# are compared canonically: the export method calls the store distribution +# app-store, and a tag build may say internal for ad-hoc. The unsuffixed +# secrets (no distribution) are taken as they are. check_signing_set() { - local want="$DISTRIBUTION" secret="IOS_PROVISIONING_PROFILE_$SIGNING_SET" - if [ "$SIGNING_SET_USED" = legacy ]; then - secret="the unsuffixed IOS_PROVISIONING_PROFILE" - elif [ -z "$want" ]; then - want=development - fi - if [ -n "$want" ] && [ "$1" != "$want" ]; then - fail "$secret holds a $1 provisioning profile, but the build profile asks for distribution $want (signing set $SIGNING_SET). Upload a $want profile with builder signing setup --type $want, or set the profile's distribution to $1." + [ "$SIGNING_SET_USED" != legacy ] || return 0 + local have="$1" want="$DISTRIBUTION" + case "$have" in app-store) have=store ;; esac + case "$want" in internal) want=ad-hoc ;; esac + if [ "$have" != "$want" ]; then + fail "IOS_PROVISIONING_PROFILE_$SIGNING_SET holds a $1 provisioning profile, but the build profile asks for distribution $want. Run builder signing setup --distribution $want, or set the profile's distribution to $have." fi } install_signing() { - SIGNING_SET=$(signing_set "$DISTRIBUTION") || fail "DISTRIBUTION \"$DISTRIBUTION\" must be development, ad-hoc, app-store or enterprise" + SIGNING_SET=$(signing_set "$DISTRIBUTION") || fail "DISTRIBUTION \"$DISTRIBUTION\" must be development, ad-hoc (or internal), store or enterprise" select_signing_set signing_dir=$(mktemp -d "$ci_dir/signing.XXXXXX") trap cleanup_signing EXIT @@ -243,7 +246,7 @@ install_signing() { # grants: the export fails, or an IPA that App Store Connect rejects comes # out. Say so now instead of after the whole build. if [ "$EXPORT_METHOD" != development ] && [ "$CONFIGURATION" = Debug ]; then - echo "The provisioning profile is an $EXPORT_METHOD profile, but the build configuration is Debug. A Debug build is signed with get-task-allow, which distribution profiles do not allow and App Store Connect rejects. Set \"configuration\": \"Release\" under \"ios\" in builder.json, or use a development profile." >&2 + echo "The provisioning profile is an $EXPORT_METHOD profile, but the build configuration is Debug. A Debug build is signed with get-task-allow, which distribution profiles do not allow and App Store Connect rejects. Drop the profile's \"configuration\" (a distribution build defaults to Release) or set \"configuration\": \"Release\", or build with a development profile." >&2 exit 1 fi keychain_path="$signing_dir/signing.keychain-db" From a08adcdc0308bd64c754c0b22a88fdb88298be88 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 17:14:41 +0200 Subject: [PATCH 44/75] 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. --- CLAUDE.md | 145 ++-- README.md | 1657 +++++++++++++++++++------------------- docs/provider-secrets.md | 93 ++- docs/provider-setup.md | 7 +- docs/providers.md | 17 +- 5 files changed, 965 insertions(+), 954 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3cf9d66..aa350e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,8 +32,9 @@ go install ./cmd/builder ./builder dev flutter --skip-install --bundle-id # Use already installed app ./builder dev rn --skip-install --bundle-id # Use already installed app ./builder auth apple # Save an App Store Connect API key -./builder signing setup --devices-from-mobai # Certificate + devices + profile via the ASC API, secrets to GitHub -./builder signing setup --type app-store --yes --json # Distribution certificate + App Store profile, no prompts +./builder signing setup --devices-from-mobai # development: certificate + devices + profile via the ASC API, secrets to GitHub, profile in builder.json +./builder signing setup --distribution store --yes --json # Distribution certificate + App Store profile, no prompts +./builder ios build --profile store # Signs with the STORE set; provisions it first when the secrets are missing ./builder ios upload --wait # Upload dist/*.ipa to App Store Connect, wait for processing ./builder ios submit --testflight --group --notes # TestFlight ./builder ios submit --app-store --release after-approval # App Review @@ -120,9 +121,16 @@ builder signing setup ───► Bundle ID: --bundle-id → ios.bundleId → d └─ profiles?filter[name] → reuse / DELETE + POST profiles │ ▼ - Writes key/.p12/.mobileprovision (named by type), uploads the - three IOS_*_ secrets of the type's signing set (GitHub) - or prints them (Codemagic/Bitrise) + Writes key/.p12/.mobileprovision (named by distribution), uploads + the three IOS_*_ secrets of the distribution's set (GitHub) + or prints them (Codemagic/Bitrise), writes profiles..distribution + +builder ios build --profile X ─► ResolveProfile: distribution → set, signing, configuration + │ + ▼ + GitHub: ListSecretNames; all three IOS_*_ present → dispatch + else ASC key → signing.Auto (no prompts) + upload → dispatch + else fail naming `auth apple` / `signing setup --certificate` builder ios upload ──────► Reads bundle ID / version / build number from dist/*.ipa │ @@ -182,14 +190,17 @@ internal/ submodule commit that only exists locally fails checkout on the runner. - **Run Correlation**: `run-name` carries the build ID so concurrent builds cannot adopt each other's runs -- **Build Profiles**: `profiles.` in `builder.json` overrides `ios.configuration`, `ios.scheme`, - `ios.signing` and `provider`, and adds `env` and the reserved `distribution`. `ios build` and - `ios share` take `--profile`; without it `defaultProfile` applies, and without that the top-level - settings are used unchanged. `config.ResolveProfile` does the merge, `Coordinator.settings` layers - `--unsigned`/`--provider` on top, and `Progress.Settings` prints the result before dispatch. - `Profile.Signing` is a `*bool` so a profile's `false` can override a top-level `true`; the jq in - `Resolve parameters` needs an explicit `!= null` test for the same reason, since `//` treats - `false` as missing. The runner receives env as one JSON object: the `profile` dispatch input +- **Build Profiles**: `profiles.` in `builder.json` overrides `ios.configuration`, `ios.scheme` + and `provider`, and adds `env` and `distribution`. `ios build` and `ios share` take `--profile`; + without it `defaultProfile` applies, and without that the top-level settings are used unchanged. + `config.ResolveProfile` does the merge, `Coordinator.settings` layers `--unsigned`/`--provider` on + top, and `Progress.Settings` prints the result before dispatch. `distribution` is the only + signing field of a profile (EAS-style): `development`, `ad-hoc` (alias `internal`, canonical + `ad-hoc`; `config.ParseDistribution`), `store`, `enterprise`. A profile signs iff it has one; + `ios.signing` applies only when no profile is selected (legacy, unsuffixed secrets). Its + configuration is the one it sets, else Debug for `development` and Release for the rest, else + `ios.configuration`. The jq in `Resolve parameters` derives the same for tag builds (`$p != ""` + guards, since `.profiles[""]` is null). The runner receives env as one JSON object: the `profile` dispatch input (`{"name","env","distribution"}`, one input to stay under the ten-input limit) on GitHub, and `BUILD_ENV` plus `DISTRIBUTION` variables for `runner.sh`. Each entry is base64-encoded per key and value on the runner (jq drops NUL bytes, and a key with a space must not split), the @@ -206,31 +217,44 @@ internal/ `steps.params.outputs.distribution` output on GitHub and the `DISTRIBUTION` variable for `runner.sh`, where it selects the signing set (below). `env` is build-time configuration, not secrets: it sits in `builder.json` and in the run's inputs -- **Signing Sets**: one trio of secrets per distribution type, `IOS_CERTIFICATE_`, - `IOS_CERTIFICATE_PASSWORD_`, `IOS_PROVISIONING_PROFILE_` with SET in DEVELOPMENT, - AD_HOC, APP_STORE, ENTERPRISE; the unsuffixed names are the fallback so repositories from before - keep building. The distribution → set table exists twice and must agree: `config.SigningSet` - (Go; `config.SigningSecretNames` builds the names) and the shell function `signing_set` in - `ios-build.yml`'s `Resolve parameters` (emits the `signing_set` output) and `runner.sh` - (`install_signing` derives it from `DISTRIBUTION`). No distribution means DEVELOPMENT. The - signing step receives every set's secrets as env (GitHub hands a missing secret over as empty; - Codemagic/Bitrise users define the suffixed variables); `select_signing_set` picks the set by - bash indirect expansion, falls back to the unsuffixed names, and fails naming both when neither - exists. A suffixed set needs all three secrets, password included (Builder never writes one - without): a partial set fails naming the missing names, never falls back; only the unsuffixed - password may be empty, as before. `check_signing_set` compares `detect_export_method`'s result - with the requested distribution (a suffixed set is always checked, the legacy set only when a - distribution was requested) after the profile is decoded and before any keychain exists or - `security import` runs, so a wrong pair never lands in a keychain. `select_signing_set`/`check_signing_set`/ - `signing_set` are verbatim in both templates, each with its own `fail` (`::error::` vs stderr); - `TestSigningSetSelection` compares the bodies and runs them with stub secrets. `signing setup` - writes only the set of its type (automatic: `--type`; manual: `signing.ProfileType` reads the - plist out of the CMS blob, `--type` overrides) and never touches other sets or the legacy names. - Files are `ios-signing-.key/.p12`, so two types coexist in one `--out-dir`; the key lookup - is `--key`, then the type's file, then the legacy `ios-signing.key`. `Progress.Settings` prints - `Signing set:` for signed builds. Enterprise is a valid set and profile type but `Auto` refuses - it (no ASC endpoint for in-house profiles). The suffixed secret names and `SIGNING_SET*` are - reserved env names. +- **Signing Sets**: one trio of secrets per distribution, `IOS_CERTIFICATE_`, + `IOS_CERTIFICATE_PASSWORD_`, `IOS_PROVISIONING_PROFILE_` with SET the canonical + distribution upper-cased, `-` → `_`: DEVELOPMENT, AD_HOC (also for `internal`), STORE, + ENTERPRISE. The unsuffixed names serve only the legacy no-profile path (`ios.signing`); a + profile never falls back to them. The distribution → set table exists twice and must agree: + `config.SigningSet` (Go; `config.SigningSecretNames` builds the names, `""` → legacy) and the + shell function `signing_set` in `ios-build.yml`'s `Resolve parameters` (emits the `signing_set` + output; canonicalizes `internal`) and `runner.sh` (`install_signing` derives it from + `DISTRIBUTION`). The signing step receives every set's secrets as env (GitHub hands a missing + secret over as empty; Codemagic/Bitrise users define the suffixed variables); + `select_signing_set` picks the set by bash indirect expansion, or with an empty set the + unsuffixed names. A suffixed set needs all three secrets, password included (Builder never + writes one without): a partial set fails naming the missing names; only the unsuffixed password + may be empty, as before. `check_signing_set` compares `detect_export_method`'s result with the + requested distribution canonically (`app-store` → `store`, `internal` → `ad-hoc`; the legacy set + is never checked) after the profile is decoded and before any keychain exists or `security + import` runs. `select_signing_set`/`check_signing_set`/`signing_set` are verbatim in both + templates, each with its own `fail` (`::error::` vs stderr); `TestSigningSetSelection` compares + the bodies and runs them with stub secrets. `signing setup` writes only the set of its + distribution (automatic: `--distribution`, else the `--name` profile's, else development; + manual: `signing.ProfileType` reads the plist out of the CMS blob and a disagreeing + `--distribution` is an error) and never touches other sets or the legacy names, then writes + `profiles.<--name or distribution>.distribution` (`writeSigningProfile`, other fields kept) + and never `ios.signing`. Files are `ios-signing-.key/.p12`, so two coexist in + one `--out-dir`; the key lookup is `--key`, then the distribution's file, then the legacy + `ios-signing.key`. `Progress.Settings` prints `signed (set X)` / `signed (unsuffixed IOS_* + secrets)`. Enterprise is a valid set and profile type but `Auto` refuses it (no ASC endpoint + for in-house profiles). The suffixed secret names and `SIGNING_SET*` are reserved env names. +- **On-Demand Provisioning** (`ensureSigningSecrets` in `cmd/builder/signing_auto.go`, called by + `runBuild` for GitHub builds without `--unsigned`): when the selected profile has a distribution, + `github.Client.ListSecretNames` (`GET /repos/{o}/{r}/actions/secrets`, paginated) is checked for + the three names; all present → dispatch. Otherwise, with an ASC key (`getASCClient` passed as a + factory so tests inject the `signingtest` portal), `provisionSigning` (= `signing.Auto` + upload, + shared with `signing setup`) runs with no prompts: bundle ID from `ios.bundleId` or `dist/*.ipa`, + key from `.`, generated password (printed once), no devices given (Auto covers the enabled ones + and fails naming `signing setup --distribution development --devices-from-mobai` when there are + none). Without an ASC key the error names `builder auth apple` and `signing setup --certificate + ... --profile ...`, before anything is pushed. Codemagic/Bitrise skip the check (no secrets API). - **Flutter Detection**: Auto-detects Flutter projects, runs `flutter pub get`, uses `Runner` scheme - **DerivedData Caching**: `restore` keys on `github.run_id` and only the prefix in `restore-keys` ever hits, so every run must pair with a `cache/save` step or later builds stay cold. `ios-share` @@ -293,24 +317,25 @@ internal/ `reviewSubmission` (READY_FOR_REVIEW/UNRESOLVED_ISSUES), skips the item when the version is already in it, and rewrites ASC 409/422 with a "complete the metadata" hint. - **Automatic Signing** (`signing.Auto`, behind `signing setup` without `--certificate`/ - `--profile`): idempotent and never revokes. A certificate is reused only when its private key - is local (`--key`, or the `ios-signing-.key` / legacy `ios-signing.key` a previous run - left in `--out-dir`), since a .p12 needs the key; otherwise a new one is issued and Apple's - quota error (2 Development / 3 Distribution) gets a hint. Dev/ad-hoc profiles cover every - ENABLED iOS device on the - account, not just the ones passed; App Store profiles send no `devices` relationship at - all (an empty one is rejected). Profile membership is read from + `--profile` and behind on-demand provisioning): idempotent and never revokes. `signing.Type`'s + values are the canonical distributions (`signing.ParseType` wraps `config.ParseDistribution`). + A certificate is reused only when its private key is local (`--key`, or the + `ios-signing-.key` / legacy `ios-signing.key` a previous run left in + `--out-dir`), since a .p12 needs the key; otherwise a new one is issued and Apple's quota error + (2 Development / 3 Distribution) gets a hint. Dev/ad-hoc profiles cover every ENABLED iOS + device on the account, not just the ones passed; App Store profiles send no `devices` + relationship at all (an empty one is rejected). Profile membership is read from `/v1/profiles/{id}/relationships/{certificates,devices}` (paginated), not `include=`, which - caps linkage arrays. The profile `Builder ` is recreated when INVALID, - expired, `--force`, or when the certificate/device set differs; same-named duplicates are - deleted with it. `filter[identifier]` on bundleIds is a prefix match, so the exact identifier - is checked client-side. The manual `--certificate`/`--profile` path in `runSigningSetup` is - untouched; the automatic one lives in `cmd/builder/signing_auto.go`. + caps linkage arrays. The profile `Builder ` is recreated when + INVALID, expired, `--force`, or when the certificate/device set differs; same-named duplicates + are deleted with it. `filter[identifier]` on bundleIds is a prefix match, so the exact + identifier is checked client-side. The in-memory portal for tests is + `internal/signing/signingtest` (must not import `signing`: the signing package's own tests use it). - **Export Method Follows The Profile**: the `method` in ExportOptions.plist must match the uploaded profile's type (`development`, `ad-hoc`, `app-store`, `enterprise`), or xcodebuild refuses the export. `detect_export_method` in `ios-build.yml` and `runner.sh` reads it from the profile of the selected signing set, and `check_signing_set` confirms it is the type the - build profile's `distribution` asked for. + build profile's `distribution` asked for (`app-store` is the `store` distribution). - **Extension Points**: a future `ios release` (upload + TestFlight, automatic build numbers) composes `distribute.Upload` and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` for the latest build number; the `pkg/` wrappers do not expose `asc` yet. @@ -326,9 +351,9 @@ internal/ "ios": { "path": "ios", "scheme": "", "bundleId": "com.example.app" }, "defaultProfile": "development", "profiles": { - "development": { "configuration": "Debug", "signing": false }, - "preview": { "configuration": "Release", "signing": true, "env": { "API_URL": "https://staging.example.com" } }, - "production": { "configuration": "Release", "signing": true, "scheme": "MyApp", "provider": "codemagic", "distribution": "app-store" } + "development": { "distribution": "development" }, + "preview": { "distribution": "internal", "env": { "API_URL": "https://staging.example.com" } }, + "production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic" } } } ``` @@ -337,11 +362,13 @@ internal/ project has exactly one app target (test targets and `$(…)` values are skipped), and `signing setup` saves whatever it resolved. -`profiles` and `defaultProfile` are optional. A profile's fields are `configuration`, `scheme`, -`signing`, `provider`, `env` (string map) and `distribution` (`development`, `ad-hoc`, `app-store`, -`enterprise`; selects the signing set and the profile type the runner expects, and with it the -export method; `signing: true` without it is development). `runner` and `submit` are planned for -the same struct (`config.Profile`) but not read. +`profiles` and `defaultProfile` are optional. A profile's fields are `distribution` +(`development`, `ad-hoc`/`internal`, `store`, `enterprise`; the only signing field: selects the +signing set and the profile type the runner expects, and with it the export method; omitted is +unsigned), `configuration` (derived from the distribution when omitted: Debug for development, +Release otherwise), `scheme`, `provider` and `env` (string map). `ios.signing` is the legacy +no-profile path with the unsuffixed secrets. `runner` and `submit` are planned for the same struct +(`config.Profile`) but not read. ## Workflow Features diff --git a/README.md b/README.md index 8dfaee6..286e41e 100644 --- a/README.md +++ b/README.md @@ -1,840 +1,817 @@ -# Builder - -Build and develop iOS apps from Windows, Linux, or any platform. - -Builder is a CLI tool for iOS development without a Mac. It uses GitHub Actions (default), Codemagic, or Bitrise for remote builds and [MobAI](https://mobai.run) for on-device development. - -![Builder Demo](assets/ios-builder-demo.gif) - -## Features - -- **Build from anywhere**: Build iOS apps via GitHub Actions, Codemagic, or Bitrise -- **Independent provider logins**: Stay signed in to all three and choose where each build runs -- **Try it on a simulator**: Use your build on an iOS simulator from Windows or Linux -- **Flutter & React Native dev tools**: Hot reload on real iOS devices from Windows/Linux -- **Simple setup**: One command to add the workflow to your repo -- **Code signing**: Optional signing with your certificate and provisioning profile -- **TestFlight and App Store**: Upload builds and submit them for review through the App Store Connect API, from any platform -- **Device integration**: Install and run apps via MobAI - -## How It Works - -``` -Your Repository GitHub Actions (macOS) - └─ .github/workflows/ └─ ios-build.yml - └─ ios-build.yml ├─ Check out the snapshot - ├─ Build with Xcode -builder ios build ───────────────────► Upload IPA artifact - │ pushes a snapshot of - │ your working tree - └─ Downloads IPA ◄─────────────── artifact: ipa -``` - -`builder ios build` builds what is on disk, not your last commit: uncommitted -and untracked files are included, so you can try a change without committing -it. The snapshot is a throwaway commit pushed to a hidden ref that is deleted -when the build finishes; no branch is created and nothing is committed on your -behalf. `.gitignore` still applies, so ignored files such as `.env` or -`GoogleService-Info.plist` are absent from the build. - -## Quick Start - -### 1. Authenticate with GitHub - -```bash -builder auth github -``` - -### 2. Initialize (in your project directory) - -```bash -cd your-ios-project -builder init -``` - -This detects your GitHub repo, creates the workflow files, and offers to commit, push, and trigger your first build - all interactively. - -### 3. Build - -```bash -builder ios build -``` - -The CLI triggers the workflow and downloads the IPA to `./dist/`. - -### 4. Try it on a simulator (optional) - -```bash -builder ios share -``` - -Builds the working tree for the iOS simulator and makes that simulator usable -from the [MobAI](https://mobai.run) app, so you can tap through a build without -a Mac. It shows up under CI Devices, stays available while you are using it, and -closes when you release it there or leave it unused (30 minutes by default, use -`--duration` to change). A coding agent connected to MobAI (Claude Code, Codex, -Cursor) can drive the simulator the same way. - -Free with any MobAI account, on [MobAI 3.0 or later](https://mobai.run). Needs -a `MOBAI_API_KEY` repository secret: create the key in the MobAI app under -Account → API Keys, then: - -```bash -gh secret set MOBAI_API_KEY -``` - -### Triggering from git only - -Where the GitHub API is not reachable, both workflows can also be started by -pushing a tag. Commit the tree you want built, then: - -```bash -git tag ios-build/my-build && git push origin ios-build/my-build # IPA build -git tag ios-share/my-build && git push origin ios-share/my-build # simulator -``` - -The run is named after the tag. Build settings come from `builder.json` in the -tagged commit (`ios.path`, `ios.scheme`, `ios.signing`, `ios.configuration`, -`flutter.version`, `kmp.jdkVersion`), the simulator stays available for the -default 30 minutes, and the tag is deleted when the run ends. The IPA is -attached to the run as an artifact. A tag carries no flags, so a tag build -cannot pick a [profile](#build-profiles) per run; it applies the profile named -by `defaultProfile`, if there is one. - -## Additional macOS Providers - -GitHub Actions remains the default, so existing commands continue to work. Add -Codemagic and Bitrise without logging out of GitHub. First follow the -[app creation and repository connection guide](docs/provider-setup.md) to create -each provider app, authorize GitHub access, and find its app ID: - -```bash -builder auth codemagic -builder auth bitrise -builder auth status -builder init --provider codemagic --app-id YOUR_APP_ID --branch main -builder init --provider bitrise --app-id YOUR_APP_SLUG --branch main -builder ios build --provider codemagic -builder ios build --provider bitrise -``` - -`init` writes `codemagic.yaml` or `bitrise.yml` at the repo root plus the shared -runner script `.builder/ci/runner.sh`. Commit them to the configured branch -and connect the same repository to each provider before building. See -[provider setup, signing, simulator sessions, and free allowances](docs/providers.md). - -## Supported Frameworks - -| Framework | iOS Path | Auto-detected | -|-----------|----------|---------------| -| Native iOS/Swift | `.` (root) | Yes | -| React Native | `ios/` | Yes | -| Expo (ejected) | `ios/` | Yes | -| Flutter | `ios/` | Yes | -| Kotlin Multiplatform | `iosApp/` | Yes | -| Cordova/Ionic | `platforms/ios/` | Yes | - -## Installation - -### Windows - -Download `builder-windows-amd64.exe` from [Releases](https://github.com/MobAI-App/ios-builder/releases), rename it to `builder.exe`, and add it to PATH. - -### Homebrew (macOS/Linux) - -```bash -brew install mobai-app/tap/ios-builder -``` - -The formula is named `ios-builder`; the command it installs is `builder`. - -### macOS/Linux/WSL - -```bash -curl -sSL https://raw.githubusercontent.com/MobAI-App/ios-builder/main/install.sh | bash -``` - -### From Source - -```bash -git clone https://github.com/MobAI-App/ios-builder.git -cd ios-builder -go build -o builder ./cmd/builder -``` - -## Commands - -```bash -# Setup -builder auth github # Authenticate with GitHub -builder auth codemagic # Authenticate with Codemagic (also: bitrise) -builder auth apple # Save an App Store Connect API key -builder auth status # Show which providers you are signed in to -builder auth logout [name] # Remove stored credentials (github, codemagic, bitrise, apple) -builder init # Set up workflows in current repo -builder update # Update builder to the latest release - -# Building (builds the working tree, including uncommitted changes) -builder ios build # Trigger build and download IPA to ./dist/ -builder ios build --unsigned # Build without code signing (if signing is configured) -builder ios build --provider codemagic # Build on another provider (also: bitrise) -builder ios build --profile production # Build with a profile from builder.json - -# Simulator (free, needs a MOBAI_API_KEY secret) -builder ios share # Try the build on a simulator in the MobAI app -builder ios share --duration 1h # Keep it available longer while unused - -# Development (requires MobAI) -builder dev flutter # Flutter hot reload with file watching -builder dev flutter --no-watch # Disable automatic file watching -builder dev flutter --no-attach # Print flutter attach command instead of running it -builder dev rn # React Native hot reload (short for: dev react-native) -builder dev kmp # Kotlin Multiplatform install + launch (alias: kotlin) -builder dev kmp --logs # Also stream the app's output -builder dev flutter --skip-install --bundle-id # Use already installed app -builder dev rn --metro-port 8082 # Use custom Metro port - -# MobAI (used by the dev commands; handy for troubleshooting) -builder mobai ping # Check MobAI connectivity -builder mobai install # Install an IPA on the device -builder mobai run-debug # Launch an app with the debugger attached -builder mobai forward # Forward a device port - -# Code signing (automatic mode needs builder auth apple) -builder signing setup --devices-from-mobai # Certificate, devices, profile and GitHub secrets, no portal -builder signing setup --type app-store # Apple Distribution certificate + App Store profile -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision # Upload your own files -builder signing csr # Manual path: create a private key + certificate signing request -builder signing p12 # Manual path: assemble a .p12 from the key and Apple's certificate - -# TestFlight and App Store (needs builder auth apple) -builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing -builder ios submit --testflight --group "Beta Testers" --notes "What to test" -builder ios submit --app-store --release after-approval # Submit the version for App Review -``` - -Every `upload`/`submit` command takes `--json` for machine-readable output and -never prompts, so agents and CI jobs can drive them. - -## Configuration - -`builder.json`: - -```json -{ - "project": "MyApp", - "platform": "ios", - "github": { - "owner": "username", - "repo": "my-ios-app" - }, - "ios": { - "path": "ios", - "scheme": "", - "signing": true, - "configuration": "Debug" - }, - "mobai": { - "url": "http://localhost:8686", - "device_id": "" - }, - "flutter": { - "watch": { - "dirs": ["lib"], - "patterns": [".dart"], - "ignore": [".g.dart", ".freezed.dart"], - "debounce": 100 - } - } -} -``` - -### iOS Build Configuration - -| Field | Description | Default | -|-------|-------------|---------| -| `ios.path` | Path to the Xcode project relative to the repo root | detected by `init` | -| `ios.scheme` | Xcode scheme to build | auto-detected | -| `ios.bundleId` | App bundle identifier, used by `signing setup` | detected by `init` when the project has one app target; else saved by `signing setup` | -| `ios.signing` | Sign the IPA with the uploaded certificate and profile | `false` | -| `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` | - -### Build Profiles - -Profiles are named sets of build settings, in the spirit of `eas.json`, selected -with `--profile` on `ios build` and `ios share`: - -```json -{ - "ios": { "path": "ios", "configuration": "Debug" }, - "defaultProfile": "development", - "profiles": { - "development": { "configuration": "Debug", "signing": false }, - "preview": { "configuration": "Release", "signing": true, - "env": { "API_URL": "https://staging.example.com" } }, - "production": { "configuration": "Release", "signing": true, "scheme": "MyApp", - "provider": "codemagic", "distribution": "app-store" } - } -} -``` - -```bash -builder ios build --profile preview -builder ios share --profile preview -``` - -| Field | Description | -|-------|-------------| -| `configuration` | Overrides `ios.configuration` | -| `scheme` | Overrides `ios.scheme` | -| `signing` | Overrides `ios.signing`; `false` in a profile turns signing off even when the top level has it on | -| `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) | -| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them | -| `distribution` | One of `development`, `ad-hoc`, `app-store`, `enterprise`. Selects the [signing set](#signing-sets-one-certificate-per-distribution-type) the build signs with and the type the provisioning profile in it must have; the IPA is exported with the matching method. A profile with `signing: true` and no `distribution` is `development` | - -How a build's settings are resolved: - -- Without `--profile`, the profile named by `defaultProfile` applies. With - neither, the top-level `ios.*` and `provider` settings are used exactly as - before, so existing projects are unaffected. -- A profile only overrides the fields it sets; everything else comes from the - top level. An unknown profile name is an error that lists the available ones. -- `--unsigned` and `--provider` on the command line override the profile. -- The resolved settings (profile, configuration, scheme, signing, signing set, - provider, env names) are printed before anything is dispatched. -- `ios share` only takes the profile's scheme, provider and env: simulator - builds are always Debug and unsigned. - -**`env` values are build-time configuration, not secrets.** They are stored in -`builder.json`, sent to the CI provider as plain workflow inputs, and visible in -the run's inputs and logs. Keep tokens and passwords in the provider's secrets -instead (`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets -guide](docs/provider-secrets.md)); the build reads those as environment -variables too. Names the runner owns are rejected: its own parameters -(`SCHEME`, `CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing -secrets, `PATH`, `HOME`, `DEVELOPER_DIR`, and anything starting with `GITHUB_`, -`RUNNER_`, `CM_`, `BITRISE_` or `BUILDER_`. - -Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow -files from this version of Builder, which declare a `profile` input; an older -committed workflow rejects the dispatch. Run `builder init` again to refresh -`.github/workflows/ios-build.yml` and `ios-share.yml` (or `builder init ---provider ...` for `runner.sh`) in a project set up earlier, then commit and -push them to the default branch. - -### MobAI Configuration - -| Field | Description | Default | -|-------|-------------|---------| -| `mobai.url` | MobAI API URL | `http://localhost:8686` | -| `mobai.device_id` | Preferred device ID (uses first available if empty) | `""` | - -**WSL users**: MobAI runs on Windows, and WSL has its own network by default. On -Windows 11, turn on -[mirrored networking](https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking) -and builder reaches MobAI on the default `http://localhost:8686`. See -[Using Builder from WSL](docs/wsl.md) for the steps, and for the setup without -mirrored networking. - -### Flutter File Watcher - -| Field | Description | Default | -|-------|-------------|---------| -| `flutter.watch.dirs` | Directories to watch | `["lib"]` | -| `flutter.watch.patterns` | File patterns to match | `[".dart"]` | -| `flutter.watch.ignore` | Patterns to ignore | `[".g.dart", ".freezed.dart"]` | -| `flutter.watch.debounce` | Debounce delay in ms | `100` | - -## Code Signing - -By default, builds are unsigned. Signed builds need a signing certificate and a -provisioning profile — and despite what many guides claim, **you do not need a -Mac to create either one**, nor a tour of the Apple Developer portal. With an -App Store Connect API key, `builder signing setup` does the whole thing through -the API; the [manual path](#manual-path-through-the-apple-developer-portal) -below is the fallback when you would rather click, or already have the files. - -You need a paid [Apple Developer Program](https://developer.apple.com/programs/) -membership — Apple only issues certificates to paid accounts. (Without one, -build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free -Apple ID.) - -### Signing sets: one certificate per distribution type - -A repository holds up to four sets of signing secrets, one per distribution -type, so development builds for your devices and App Store builds for -TestFlight can live side by side without swapping secrets between builds: - -| Set | Secrets | Used when the build profile's `distribution` is | -|-----|---------|--------------------------------------------------| -| `DEVELOPMENT` | `IOS_CERTIFICATE_DEVELOPMENT`, `IOS_CERTIFICATE_PASSWORD_DEVELOPMENT`, `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | `development`, or not set | -| `AD_HOC` | `IOS_CERTIFICATE_AD_HOC`, `IOS_CERTIFICATE_PASSWORD_AD_HOC`, `IOS_PROVISIONING_PROFILE_AD_HOC` | `ad-hoc` | -| `APP_STORE` | `IOS_CERTIFICATE_APP_STORE`, `IOS_CERTIFICATE_PASSWORD_APP_STORE`, `IOS_PROVISIONING_PROFILE_APP_STORE` | `app-store` | -| `ENTERPRISE` | `IOS_CERTIFICATE_ENTERPRISE`, `IOS_CERTIFICATE_PASSWORD_ENTERPRISE`, `IOS_PROVISIONING_PROFILE_ENTERPRISE` | `enterprise` | -| legacy | `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD`, `IOS_PROVISIONING_PROFILE` | fallback whenever the set above is absent | - -`builder signing setup` writes the set of the type it produced (`--type`) or, -with `--certificate`/`--profile`, the type it reads from the -`.mobileprovision`. The runner picks the set named by the selected profile's -`distribution` (no profile, or no `distribution`, means `DEVELOPMENT`) and -falls back to the unsuffixed names, so a repository set up before signing sets -keeps building with the secrets it has; `setup` never deletes those. The -runner then checks that the profile in the set is the type the build asked -for and fails by name — set, requested distribution, actual profile type — -before anything is compiled. The unsuffixed secrets with no `distribution` -requested are accepted whatever their type, as before. - -A project with a device profile and a release profile: - -```bash -builder auth apple -builder signing setup --devices-from-mobai # DEVELOPMENT set: Apple Development + devices -builder signing setup --type app-store # APP_STORE set: Apple Distribution + App Store profile -``` - -```json -{ - "ios": { "path": "ios", "bundleId": "com.example.app" }, - "defaultProfile": "development", - "profiles": { - "development": { "configuration": "Debug", "signing": true }, - "production": { "configuration": "Release", "signing": true, "distribution": "app-store" } - } -} -``` - -`builder ios build` (the default profile) signs with the `DEVELOPMENT` set and -exports a development IPA for the registered devices; `builder ios build ---profile production` signs with the `APP_STORE` set and exports an App Store -IPA for `builder ios upload`. Both sets stay in place. The same works with -files from the portal: `builder signing setup --certificate dist.p12 --profile -AppStore.mobileprovision` lands in `APP_STORE` because that is what the profile -is (`--type` overrides the detection). On Codemagic and Bitrise the suffixed -names are variables you add in the dashboard, see the -[secrets guide](docs/provider-secrets.md). - -### Automatic setup - -```bash -builder auth apple # once: save the App Store Connect API key -builder signing setup --devices-from-mobai # development signing for the devices MobAI sees -``` - -The key needs the **Admin** role (or App Manager plus *Access to Certificates, -Identifiers & Profiles*): Developer-role keys cannot create certificates. -`setup` then: - -1. Registers the **App ID** if the bundle identifier is not on the account yet. - The bundle ID comes from `--bundle-id`, `ios.bundleId` in `builder.json` - (which `init` fills when the Xcode project has a single app target), or the - newest IPA in `./dist/`; in a terminal it asks as a last resort. -2. Issues a **certificate** — Apple Development for `--type development`, Apple - Distribution for `ad-hoc` and `app-store` — for a private key generated on - your machine (`ios-signing-.key`, or `--key` to reuse one from - `signing csr`; a `ios-signing.key` from an earlier version is picked up too). - A valid certificate on the account is reused only when its private key is - here, because that is the only way to build the `.p12`; otherwise a new one - is issued. Nothing is ever revoked: when Apple's limit (2 Development, 3 - Distribution) is hit, the error names it and points at the portal. -3. Registers **devices** from `--device ` (repeatable) and - `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has - connected; simulators and cloud farm devices are skipped). Development and - ad-hoc profiles cover every enabled iOS device on the account, so with none - given and none registered the command stops and - says so. App Store profiles take no devices. Apple allows 100 devices per - membership year and never frees a slot; that error is passed through too. -4. Creates the **profile** `Builder ` (iOS App Development, - Ad Hoc or App Store). An existing one is reused while it is `ACTIVE`, - unexpired and still lists exactly this certificate and these devices; - otherwise it is deleted and recreated, and the summary says why (`invalid`, - `expired`, `certificate changed`, `devices changed`, `forced`). -5. Writes `ios-signing-.key` (when generated), `ios-signing-.p12` - and `Builder--.mobileprovision` to `--out-dir` (default - `.`) — one trio per type, so setting up a second type keeps the first — - uploads `IOS_CERTIFICATE_`, `IOS_CERTIFICATE_PASSWORD_` and - `IOS_PROVISIONING_PROFILE_` for the type's - [signing set](#signing-sets-one-certificate-per-distribution-type) to - GitHub Secrets and sets `ios.signing` to `true`. For Codemagic and Bitrise - it prints the three values to paste instead, following the - [signing and MobAI secrets guide](docs/provider-secrets.md). - -The command shows its plan and asks once before creating anything; `--yes` -skips that (required without a terminal), and then the `.p12` password is -generated and printed once unless `--password` is given. `--json` prints the -result as JSON with progress on stderr. Keep the written files out of git. - -Run it again whenever you like: it reports what it found and recreates only what -is missing, expired, invalid or changed — add a device, re-run, rebuild. -`--force` issues a fresh certificate and profile regardless. For TestFlight use -`--type app-store` and build with a profile that has `"distribution": -"app-store"` and `"configuration": "Release"`. - -### Manual path through the Apple Developer portal - -The `.p12` certificate is normally created through Keychain Access, but Builder -does the same thing itself: it generates the private key and certificate -signing request, and assembles the `.p12` from the certificate Apple issues. - -#### 1. Create a certificate signing request - -```bash -builder signing csr -``` - -This asks for your name and email and writes two files to the current -directory: `ios-signing.key` (your private key) and `ios-signing.csr`. Keep -the key wherever suits you — just don't commit it (add it to `.gitignore`; -gitignored files are also excluded from build snapshots). - -#### 2. Create the certificate - -1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal -2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc). TestFlight and App Store uploads need **Apple Distribution** together with an App Store profile in step 4 -3. Upload `ios-signing.csr` and download the resulting `.cer` file - -#### 3. Assemble the .p12 - -```bash -builder signing p12 --certificate development.cer --key ios-signing.key -``` - -This combines the key and certificate into `ios-signing.p12` (`--out` to name -it), protected by a password you choose — byte-for-byte the same kind of file -Keychain Access exports, and usable anywhere one is: `builder signing setup`, -Sideloadly, AltStore, or importing it on a Mac. Keep it, and don't commit it. - -#### 4. Create a provisioning profile - -On the portal: - -1. **Identifiers** → register an App ID matching your app's bundle identifier -2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page) -3. **Profiles** → create an **iOS App Development** (or Ad Hoc, App Store) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file - -The build reads the profile and exports the IPA with the matching method, so the -profile type alone decides what the IPA is good for: development, ad-hoc, -enterprise or App Store. Everything except a development profile is a -distribution build, and those must be built with the **Release** configuration -(`"configuration": "Release"` in the build profile, or under `ios`) — a Debug -build is signed with `get-task-allow`, which distribution profiles do not allow -and App Store Connect rejects. The build fails early with that message if the -two disagree. - -#### 5. Upload the signing secrets - -```bash -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision -``` - -With `--certificate` and `--profile` given, `setup` takes the files as they are -(no App Store Connect key involved), reads the type out of the -`.mobileprovision` — development, ad-hoc, app-store or enterprise; `--type` -overrides it — and uploads the signing material to the GitHub Secrets of that -type's [signing set](#signing-sets-one-certificate-per-distribution-type): -- `IOS_CERTIFICATE_` - Base64-encoded .p12 file -- `IOS_CERTIFICATE_PASSWORD_` - Certificate password -- `IOS_PROVISIONING_PROFILE_` - Base64-encoded .mobileprovision file - -It prints which set it wrote. Other sets, and the unsuffixed secrets of an -earlier setup, are left untouched. - -You can also skip step 3 and hand `setup` the `.cer` together with the key — -`builder signing setup --certificate development.cer --key ios-signing.key ---profile MyApp.mobileprovision` — and it assembles the `.p12` on the way, -saving it as `ios-signing-.p12`. - -`setup` also sets `ios.signing` to `true` in `builder.json`, which is what -tells `builder ios build` to sign. From then on builds produce signed IPAs; use -`--unsigned` to skip signing for one build. For Codemagic and Bitrise, add the -secrets by hand as described in the -[signing and MobAI secrets guide](docs/provider-secrets.md), then set -`ios.signing` to `true` yourself. - -## TestFlight and App Store - -Builder uploads builds to App Store Connect and submits them to TestFlight or -App Review through the App Store Connect API, from Windows, Linux or macOS. No -Transporter, `altool` or Xcode is involved, and the API key never leaves your -machine: the CI runner only builds and signs, the upload happens locally from -the IPA in `./dist/`. - -You need: - -- A paid [Apple Developer Program](https://developer.apple.com/programs/) - membership and an app record in App Store Connect (My Apps → +) with your - bundle ID -- An IPA signed with an **Apple Distribution** certificate and an **App Store** - provisioning profile: `builder signing setup --type app-store` creates both - and stores them as the `APP_STORE` signing set, or pick those types on the - portal in the manual path. An IPA signed for development is rejected at - upload. -- A build profile with `"distribution": "app-store"` and `"configuration": - "Release"` (see [Build Profiles](#build-profiles)): `ios build` defaults to - `Debug` and the development set, which is what the dev commands expect, not - what you want to ship. -- An App Store Connect API key: App Store Connect → Users and Access → - Integrations → App Store Connect API → Team Keys. Give it the **App Manager** - role, note the **Issuer ID** and **Key ID**, and download the - `AuthKey_.p8` file (Apple offers the download once). - -### 1. Save the API key - -```bash -builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key AuthKey_ABC123DEFG.p8 -``` - -Flags you leave out are prompted for. Builder verifies the key against App -Store Connect and stores it like the other logins (keychain, or a `0600` file on -Linux/WSL); `builder auth status` shows it and `builder auth logout apple` -removes it. In CI or for a coding agent, set `ASC_ISSUER_ID`, `ASC_KEY_ID` and -either `ASC_PRIVATE_KEY` (the .p8 contents; literal `\n` is fine) or -`ASC_KEY_PATH` instead — they take precedence over the saved login. - -### 2. Upload the build - -```bash -builder ios build # produces a signed dist/*.ipa -builder ios upload --wait -``` - -`upload` reads the bundle ID, version and build number from the newest IPA in -`./dist/` (or `--ipa `), finds the app, uploads the archive in chunks -and, with `--wait`, follows App Store Connect until the build has finished -processing and prints its build ID and TestFlight link. Without `--wait` it -returns as soon as Apple has the file. - -Two things Apple checks on every upload: - -- **Build numbers must increase.** A second upload with the same - `CFBundleVersion` for the same version is rejected (`ITMS-90189`), so bump - it before rebuilding. -- **Export compliance.** A build shows as *Missing Compliance* in TestFlight - until you say whether it uses non-exempt encryption. If your Info.plist sets - `ITSAppUsesNonExemptEncryption` to `false`, `upload --wait` answers that - automatically; otherwise pass `--no-encryption` (here or to `submit`) when - your app only uses standard iOS encryption. - -### 3. Distribute to TestFlight - -```bash -builder ios submit --testflight --group "Beta Testers" --notes "New login flow" -``` - -This takes the newest processed build (or `--build-number N`), sets the *What -to Test* notes and adds it to the named groups (`--group` repeats). Internal -groups get the build immediately; the first external group triggers Apple's -beta review, which Builder submits for you (`--wait` follows the decision). Run -it without `--group` to see the build and the groups the app has. - -### 4. Submit to the App Store - -```bash -builder ios submit --app-store --release after-approval -``` - -Builder finds or creates the App Store version matching the IPA's marketing -version (or `--version X.Y.Z`), attaches the build, sets the release type -(`manual` or `after-approval`) and submits it for review. The version's -metadata — description, screenshots, age rating, pricing, privacy — must -already be complete: App Store Connect refuses the submission otherwise and -Builder prints Apple's reasons verbatim. Builder does not manage metadata, -screenshots or in-app purchases; fill them in App Store Connect, or on a Mac -with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of -the `buildUploads` API also proved that the Mac-free upload path works and -served as the reference for Builder's implementation. - -## Installing the IPA - -Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account). - -## Development on Windows/Linux - -Builder supports hot reload for Flutter and React Native on Windows/Linux using [MobAI](https://mobai.run) for iOS device control. This allows you to develop iOS apps without a Mac. - -## Flutter Development - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` - This creates an IPA in `./dist/` -3. Start development with hot reload: - ```bash - builder dev flutter - ``` - Builder installs the IPA through MobAI and asks whether to re-sign it. Re-signing requires an iCloud account - we highly recommend creating a new one at [icloud.com](https://icloud.com) instead of using your primary account. A re-signed app gets a new bundle ID with a team ID suffix (e.g., `com.example.myapp.TEAMID`); Builder prefills it in the prompt that follows. - -### Subsequent Runs - -Once the app is installed, skip the install step: -```bash -builder dev flutter --skip-install --bundle-id com.example.myapp.TEAMID -``` - -### File Watching - -By default, `builder dev flutter` watches for Dart file changes and automatically triggers hot reload. When flutter attach connects, it also sends an initial hot restart to ensure your latest code is running. - -- **Automatic hot reload**: Edit a `.dart` file and save - hot reload triggers automatically -- **Generated files ignored**: Files like `.g.dart` and `.freezed.dart` are ignored by default -- **Configurable**: Customize watched directories, patterns, and debounce via `builder.json` - -To disable file watching: -```bash -builder dev flutter --no-watch -``` - -To print the `flutter attach` command instead of running it (useful for IDE integration): -```bash -builder dev flutter --no-attach -``` - -### When to Rebuild - -- **Native code changes** (Swift, Objective-C, Podfile, native dependencies): Run `builder ios build` and reinstall -- **Dart code changes only**: No rebuild needed - file watcher triggers hot reload automatically - -If you don't see your recent Dart changes after launching, press `R` in the terminal to perform a hot restart. - -### Troubleshooting - -**App won't launch / connection error** -- Close the app on your device before running `builder dev flutter` -- Reconnect the device (unplug/replug USB) -- Restart MobAI -- Run `builder mobai ping` to verify connection - -**"No devices found" error** -- Ensure MobAI is running and device is connected -- Only physical iOS devices are supported (no simulators) - -**Hot reload not working** -- Make sure you're using the correct bundle ID (with team ID suffix) -- Try hot restart with `R` key -- Check that MobAI shows the device as connected - -**File watcher not triggering** -- Ensure you're editing files in watched directories (default: `lib/`) -- Check if the file matches watch patterns (default: `.dart`) -- Generated files (`.g.dart`, `.freezed.dart`) are ignored by default -- Try running without `--no-watch` flag - -## React Native Development - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` -3. Start development with hot reload: - ```bash - builder dev rn - ``` - This will: - - Start Metro bundler if not running - - Install the IPA on your device (with optional re-signing) - - Launch the app with Metro URL configured automatically - -### Subsequent Runs - -Once the app is installed: -```bash -builder dev rn --skip-install --bundle-id com.example.myapp.TEAMID -``` - -### Custom Metro Port - -If port 8081 is in use: -```bash -builder dev rn --metro-port 8082 -``` - -### When to Rebuild - -- **Native code changes** (Swift, Objective-C, Podfile, native modules): Run `builder ios build` and reinstall -- **JavaScript changes only**: No rebuild needed - Metro handles it automatically - -### Troubleshooting - -**Metro not starting** -- Ensure Node.js and React Native CLI are installed -- Try starting Metro manually: `npx react-native start` - -**App not connecting to Metro** -- Device must be on the same WiFi network as the computer running Metro -- Check that Metro is running and accessible -- Verify the Metro port is correct (default: 8081) -- On WSL with mirrored networking, the Hyper-V firewall blocks the phone from reaching Metro by default; see [Using Builder from WSL](docs/wsl.md#react-native) for the firewall rule - -**Hot reload not working** -- Shake device or press `d` in Metro terminal to open dev menu -- Enable "Fast Refresh" in dev menu -- Try reloading with `r` in Metro terminal - -## Kotlin Multiplatform Development - -KMP iOS apps build and run on a device like any other project, with one -difference: **there is no hot reload.** Shared Kotlin is compiled into a native -framework at build time, so there is no runtime to swap code into — every code -change needs a rebuild. - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` -3. Install and launch it on the device: - ```bash - builder dev kmp - ``` - -`builder init` detects Kotlin Multiplatform projects by looking for the -multiplatform Gradle plugin in the root and module build files, and asks which -JDK the CI build should use (default 17): - -```json -{ - "kmp": { "jdkVersion": "17" } -} -``` - -On CI, the iOS app is built with `xcodebuild`, whose run script phase (or -CocoaPods) invokes Gradle to compile the shared framework — which is why the -JDK version matters. Gradle output is cached between builds. - -### When to Rebuild - -Every change to Kotlin or Swift code needs `builder ios build` followed by -`builder dev kmp` again. Use `--skip-install --bundle-id ` to relaunch an -app that is already installed. - -### Troubleshooting - -**Build fails with "Unsupported class file major version" or a Gradle JDK error** -- The project needs a different JDK than the default: set `kmp.jdkVersion` in `builder.json` to match what the project uses locally - -**Build fails with "SDK does not contain 'libarclite'"** -- An old Kotlin/Native version against a newer Xcode; upgrade the Kotlin plugin in Gradle - -**App launches then immediately exits** -- Launch with `builder dev kmp --logs` to see the device output - -## Build Limits - -Free allowances belong to each provider account and depend on the plan and -machine. As published in September 2026: Codemagic personal accounts include -500 macOS M2 minutes per month; Bitrise Hobby includes 300 credits. GitHub has -separate allowances for private repositories and free standard runners for -public repositories. Providers change these, so check the -[current allowance links and switching guidance](docs/providers.md#free-allowances). - -## License - -[MIT License](LICENSE) +# Builder + +Build and develop iOS apps from Windows, Linux, or any platform. + +Builder is a CLI tool for iOS development without a Mac. It uses GitHub Actions (default), Codemagic, or Bitrise for remote builds and [MobAI](https://mobai.run) for on-device development. + +![Builder Demo](assets/ios-builder-demo.gif) + +## Features + +- **Build from anywhere**: Build iOS apps via GitHub Actions, Codemagic, or Bitrise +- **Independent provider logins**: Stay signed in to all three and choose where each build runs +- **Try it on a simulator**: Use your build on an iOS simulator from Windows or Linux +- **Flutter & React Native dev tools**: Hot reload on real iOS devices from Windows/Linux +- **Simple setup**: One command to add the workflow to your repo +- **Code signing**: Optional signing with your certificate and provisioning profile +- **TestFlight and App Store**: Upload builds and submit them for review through the App Store Connect API, from any platform +- **Device integration**: Install and run apps via MobAI + +## How It Works + +``` +Your Repository GitHub Actions (macOS) + └─ .github/workflows/ └─ ios-build.yml + └─ ios-build.yml ├─ Check out the snapshot + ├─ Build with Xcode +builder ios build ───────────────────► Upload IPA artifact + │ pushes a snapshot of + │ your working tree + └─ Downloads IPA ◄─────────────── artifact: ipa +``` + +`builder ios build` builds what is on disk, not your last commit: uncommitted +and untracked files are included, so you can try a change without committing +it. The snapshot is a throwaway commit pushed to a hidden ref that is deleted +when the build finishes; no branch is created and nothing is committed on your +behalf. `.gitignore` still applies, so ignored files such as `.env` or +`GoogleService-Info.plist` are absent from the build. + +## Quick Start + +### 1. Authenticate with GitHub + +```bash +builder auth github +``` + +### 2. Initialize (in your project directory) + +```bash +cd your-ios-project +builder init +``` + +This detects your GitHub repo, creates the workflow files, and offers to commit, push, and trigger your first build - all interactively. + +### 3. Build + +```bash +builder ios build +``` + +The CLI triggers the workflow and downloads the IPA to `./dist/`. + +### 4. Try it on a simulator (optional) + +```bash +builder ios share +``` + +Builds the working tree for the iOS simulator and makes that simulator usable +from the [MobAI](https://mobai.run) app, so you can tap through a build without +a Mac. It shows up under CI Devices, stays available while you are using it, and +closes when you release it there or leave it unused (30 minutes by default, use +`--duration` to change). A coding agent connected to MobAI (Claude Code, Codex, +Cursor) can drive the simulator the same way. + +Free with any MobAI account, on [MobAI 3.0 or later](https://mobai.run). Needs +a `MOBAI_API_KEY` repository secret: create the key in the MobAI app under +Account → API Keys, then: + +```bash +gh secret set MOBAI_API_KEY +``` + +### Triggering from git only + +Where the GitHub API is not reachable, both workflows can also be started by +pushing a tag. Commit the tree you want built, then: + +```bash +git tag ios-build/my-build && git push origin ios-build/my-build # IPA build +git tag ios-share/my-build && git push origin ios-share/my-build # simulator +``` + +The run is named after the tag. Build settings come from `builder.json` in the +tagged commit (`ios.path`, `ios.scheme`, `ios.signing`, `ios.configuration`, +`flutter.version`, `kmp.jdkVersion`), the simulator stays available for the +default 30 minutes, and the tag is deleted when the run ends. The IPA is +attached to the run as an artifact. A tag carries no flags, so a tag build +cannot pick a [profile](#build-profiles) per run; it applies the profile named +by `defaultProfile`, if there is one. + +## Additional macOS Providers + +GitHub Actions remains the default, so existing commands continue to work. Add +Codemagic and Bitrise without logging out of GitHub. First follow the +[app creation and repository connection guide](docs/provider-setup.md) to create +each provider app, authorize GitHub access, and find its app ID: + +```bash +builder auth codemagic +builder auth bitrise +builder auth status +builder init --provider codemagic --app-id YOUR_APP_ID --branch main +builder init --provider bitrise --app-id YOUR_APP_SLUG --branch main +builder ios build --provider codemagic +builder ios build --provider bitrise +``` + +`init` writes `codemagic.yaml` or `bitrise.yml` at the repo root plus the shared +runner script `.builder/ci/runner.sh`. Commit them to the configured branch +and connect the same repository to each provider before building. See +[provider setup, signing, simulator sessions, and free allowances](docs/providers.md). + +## Supported Frameworks + +| Framework | iOS Path | Auto-detected | +|-----------|----------|---------------| +| Native iOS/Swift | `.` (root) | Yes | +| React Native | `ios/` | Yes | +| Expo (ejected) | `ios/` | Yes | +| Flutter | `ios/` | Yes | +| Kotlin Multiplatform | `iosApp/` | Yes | +| Cordova/Ionic | `platforms/ios/` | Yes | + +## Installation + +### Windows + +Download `builder-windows-amd64.exe` from [Releases](https://github.com/MobAI-App/ios-builder/releases), rename it to `builder.exe`, and add it to PATH. + +### Homebrew (macOS/Linux) + +```bash +brew install mobai-app/tap/ios-builder +``` + +The formula is named `ios-builder`; the command it installs is `builder`. + +### macOS/Linux/WSL + +```bash +curl -sSL https://raw.githubusercontent.com/MobAI-App/ios-builder/main/install.sh | bash +``` + +### From Source + +```bash +git clone https://github.com/MobAI-App/ios-builder.git +cd ios-builder +go build -o builder ./cmd/builder +``` + +## Commands + +```bash +# Setup +builder auth github # Authenticate with GitHub +builder auth codemagic # Authenticate with Codemagic (also: bitrise) +builder auth apple # Save an App Store Connect API key +builder auth status # Show which providers you are signed in to +builder auth logout [name] # Remove stored credentials (github, codemagic, bitrise, apple) +builder init # Set up workflows in current repo +builder update # Update builder to the latest release + +# Building (builds the working tree, including uncommitted changes) +builder ios build # Trigger build and download IPA to ./dist/ +builder ios build --unsigned # Build without code signing (if signing is configured) +builder ios build --provider codemagic # Build on another provider (also: bitrise) +builder ios build --profile production # Build with a profile from builder.json + +# Simulator (free, needs a MOBAI_API_KEY secret) +builder ios share # Try the build on a simulator in the MobAI app +builder ios share --duration 1h # Keep it available longer while unused + +# Development (requires MobAI) +builder dev flutter # Flutter hot reload with file watching +builder dev flutter --no-watch # Disable automatic file watching +builder dev flutter --no-attach # Print flutter attach command instead of running it +builder dev rn # React Native hot reload (short for: dev react-native) +builder dev kmp # Kotlin Multiplatform install + launch (alias: kotlin) +builder dev kmp --logs # Also stream the app's output +builder dev flutter --skip-install --bundle-id # Use already installed app +builder dev rn --metro-port 8082 # Use custom Metro port + +# MobAI (used by the dev commands; handy for troubleshooting) +builder mobai ping # Check MobAI connectivity +builder mobai install # Install an IPA on the device +builder mobai run-debug # Launch an app with the debugger attached +builder mobai forward # Forward a device port + +# Code signing (automatic mode needs builder auth apple) +builder signing setup --devices-from-mobai # development: certificate, devices, profile, GitHub secrets, no portal +builder signing setup --distribution store # Apple Distribution certificate + App Store profile +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision # Upload your own files +builder ios build --profile store # Signs with the set; provisions it first when missing +builder signing csr # Manual path: create a private key + certificate signing request +builder signing p12 # Manual path: assemble a .p12 from the key and Apple's certificate + +# TestFlight and App Store (needs builder auth apple) +builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing +builder ios submit --testflight --group "Beta Testers" --notes "What to test" +builder ios submit --app-store --release after-approval # Submit the version for App Review +``` + +Every `upload`/`submit` command takes `--json` for machine-readable output and +never prompts, so agents and CI jobs can drive them. + +## Configuration + +`builder.json`: + +```json +{ + "project": "MyApp", + "platform": "ios", + "github": { + "owner": "username", + "repo": "my-ios-app" + }, + "ios": { + "path": "ios", + "scheme": "", + "bundleId": "com.example.app", + "configuration": "Debug" + }, + "profiles": { + "development": { "distribution": "development" }, + "store": { "distribution": "store" } + }, + "mobai": { + "url": "http://localhost:8686", + "device_id": "" + }, + "flutter": { + "watch": { + "dirs": ["lib"], + "patterns": [".dart"], + "ignore": [".g.dart", ".freezed.dart"], + "debounce": 100 + } + } +} +``` + +### iOS Build Configuration + +| Field | Description | Default | +|-------|-------------|---------| +| `ios.path` | Path to the Xcode project relative to the repo root | detected by `init` | +| `ios.scheme` | Xcode scheme to build | auto-detected | +| `ios.bundleId` | App bundle identifier, used by `signing setup` | detected by `init` when the project has one app target; else saved by `signing setup` | +| `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` | +| `ios.signing` | Legacy: sign builds that select no profile, with the unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and `IOS_PROVISIONING_PROFILE` secrets. Profiles ignore it; use `distribution` there | `false` | + +### Build Profiles + +Profiles are named sets of build settings, in the spirit of `eas.json`, selected +with `--profile` on `ios build` and `ios share`: + +```json +{ + "ios": { "path": "ios", "bundleId": "com.example.app" }, + "defaultProfile": "development", + "profiles": { + "development": { "distribution": "development" }, + "preview": { "distribution": "internal", + "env": { "API_URL": "https://staging.example.com" } }, + "production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic" } + } +} +``` + +```bash +builder ios build --profile preview +builder ios share --profile preview +``` + +| Field | Description | +|-------|-------------| +| `distribution` | The only signing setting: `development`, `ad-hoc` (or `internal`, the same thing), `store` or `enterprise`. The build signs with that distribution's [signing set](#code-signing) and its provisioning profile must be of that type; the IPA is exported with the matching method. Omitted means an unsigned build | +| `configuration` | Overrides the derived configuration: `Debug` for `development`, `Release` for every other distribution, `ios.configuration` for unsigned profiles | +| `scheme` | Overrides `ios.scheme` | +| `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) | +| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them | + +How a build's settings are resolved: + +- Without `--profile`, the profile named by `defaultProfile` applies. With + neither, the top-level `ios.*` and `provider` settings are used exactly as + before, so existing projects are unaffected. +- A profile only overrides the fields it sets; everything else comes from the + top level. An unknown profile name is an error that lists the available ones. +- `--unsigned` and `--provider` on the command line override the profile. +- The resolved settings (profile, configuration, scheme, signing set, provider, + env names) are printed before anything is dispatched. +- `ios share` only takes the profile's scheme, provider and env: simulator + builds are always Debug and unsigned. + +**`env` values are build-time configuration, not secrets.** They are stored in +`builder.json`, sent to the CI provider as plain workflow inputs, and visible in +the run's inputs and logs. Keep tokens and passwords in the provider's secrets +instead (`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets +guide](docs/provider-secrets.md)); the build reads those as environment +variables too. Names the runner owns are rejected: its own parameters +(`SCHEME`, `CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing +secrets, `PATH`, `HOME`, `DEVELOPER_DIR`, and anything starting with `GITHUB_`, +`RUNNER_`, `CM_`, `BITRISE_` or `BUILDER_`. + +Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow +files from this version of Builder, which declare a `profile` input; an older +committed workflow rejects the dispatch. Run `builder init` again to refresh +`.github/workflows/ios-build.yml` and `ios-share.yml` (or `builder init +--provider ...` for `runner.sh`) in a project set up earlier, then commit and +push them to the default branch. + +### MobAI Configuration + +| Field | Description | Default | +|-------|-------------|---------| +| `mobai.url` | MobAI API URL | `http://localhost:8686` | +| `mobai.device_id` | Preferred device ID (uses first available if empty) | `""` | + +**WSL users**: MobAI runs on Windows, and WSL has its own network by default. On +Windows 11, turn on +[mirrored networking](https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking) +and builder reaches MobAI on the default `http://localhost:8686`. See +[Using Builder from WSL](docs/wsl.md) for the steps, and for the setup without +mirrored networking. + +### Flutter File Watcher + +| Field | Description | Default | +|-------|-------------|---------| +| `flutter.watch.dirs` | Directories to watch | `["lib"]` | +| `flutter.watch.patterns` | File patterns to match | `[".dart"]` | +| `flutter.watch.ignore` | Patterns to ignore | `[".g.dart", ".freezed.dart"]` | +| `flutter.watch.debounce` | Debounce delay in ms | `100` | + +## Code Signing + +By default, builds are unsigned. A signed build needs a certificate and a +provisioning profile — and despite what many guides claim, **you do not need a +Mac to create either one**, nor a tour of the Apple Developer portal. Signing +is configured per [build profile](#build-profiles) with one field, +`distribution`, and `builder signing setup` produces the material for it +through the App Store Connect API (or takes your own files). + +You need a paid [Apple Developer Program](https://developer.apple.com/programs/) +membership — Apple only issues certificates to paid accounts. (Without one, +build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free +Apple ID.) + +### Profiles and signing sets + +Each distribution has its own set of three GitHub secrets, so a development set +for your devices and a store set for TestFlight live side by side: + +| `distribution` | Certificate, profile | Secrets | +|----------------|----------------------|---------| +| `development` | Apple Development, iOS App Development (devices required) | `IOS_CERTIFICATE_DEVELOPMENT`, `IOS_CERTIFICATE_PASSWORD_DEVELOPMENT`, `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | +| `ad-hoc` or `internal` | Apple Distribution, Ad Hoc (devices required) | `IOS_CERTIFICATE_AD_HOC`, `IOS_CERTIFICATE_PASSWORD_AD_HOC`, `IOS_PROVISIONING_PROFILE_AD_HOC` | +| `store` | Apple Distribution, App Store | `IOS_CERTIFICATE_STORE`, `IOS_CERTIFICATE_PASSWORD_STORE`, `IOS_PROVISIONING_PROFILE_STORE` | +| `enterprise` | In-house (portal only) | `IOS_CERTIFICATE_ENTERPRISE`, `IOS_CERTIFICATE_PASSWORD_ENTERPRISE`, `IOS_PROVISIONING_PROFILE_ENTERPRISE` | + +A build with `--profile ` signs with the set of that profile's +`distribution`; `configuration` follows it (`Debug` for `development`, +`Release` otherwise) unless the profile sets one. The runner checks that the +profile in the set is of the requested type and fails by name before compiling +anything, and a distribution profile refuses a `Debug` configuration. On +Codemagic and Bitrise the same names are variables you add in the dashboard, +see the [secrets guide](docs/provider-secrets.md). + +### `builder signing setup` + +```bash +builder auth apple # once: save the App Store Connect API key +builder signing setup --devices-from-mobai # development set for the devices MobAI sees +builder signing setup --distribution store # store set for TestFlight / App Store +``` + +Without files, `setup` works through the App Store Connect API for the given +`--distribution` (default `development`; `--name ` reads it from an +existing profile). The key needs the **Admin** role (or App Manager plus +*Access to Certificates, Identifiers & Profiles*): Developer-role keys cannot +create certificates. It then: + +1. Registers the **App ID** if the bundle identifier is not on the account yet. + The bundle ID comes from `--bundle-id`, `ios.bundleId` in `builder.json` + (which `init` fills when the Xcode project has a single app target), or the + newest IPA in `./dist/`; in a terminal it asks as a last resort. +2. Issues a **certificate** — Apple Development for `development`, Apple + Distribution for `ad-hoc` and `store` — for a private key generated on your + machine (`ios-signing-.key`, or `--key` to reuse one from + `signing csr`; a `ios-signing.key` from an earlier version is picked up + too). A valid certificate on the account is reused only when its private + key is here, because that is the only way to build the `.p12`; otherwise a + new one is issued. Nothing is ever revoked: when Apple's limit (2 + Development, 3 Distribution) is hit, the error names it and points at the + portal. +3. Registers **devices** from `--device ` (repeatable) and + `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has + connected; simulators and cloud farm devices are skipped). Development and + ad-hoc profiles cover every enabled iOS device on the account, so with none + given and none registered the command stops and says so. Store profiles + take no devices. Apple allows 100 devices per membership year and never + frees a slot; that error is passed through too. +4. Creates the **profile** `Builder `. An existing + one is reused while it is `ACTIVE`, unexpired and still lists exactly this + certificate and these devices; otherwise it is deleted and recreated, and + the summary says why (`invalid`, `expired`, `certificate changed`, `devices + changed`, `forced`). +5. Writes `ios-signing-.key` (when generated), + `ios-signing-.p12` and `Builder--.mobileprovision` to `--out-dir` (default `.`), uploads the three secrets + of the set to GitHub, and writes the build profile in `builder.json`: + `--name` (default: the distribution name) with `"distribution": + ""`. Other fields of an existing profile are kept. For + Codemagic and Bitrise it prints the three secret names and file paths to + paste instead, following the [secrets guide](docs/provider-secrets.md). + +The command shows its plan and asks once before creating anything; `--yes` +skips that (required without a terminal), and then the `.p12` password is +generated and printed once unless `--password` is given. `--json` prints the +result as JSON with progress on stderr. Keep the written files out of git. +Run it again whenever you like: it reports what it found and recreates only +what is missing, expired, invalid or changed — add a device, re-run, rebuild. +`--force` issues a fresh certificate and profile regardless. + +With `--certificate` and `--profile`, `setup` takes your own files instead — a +`.p12` (from Keychain Access, or [assembled here](#manual-path-through-the-apple-developer-portal)) +and a `.mobileprovision` — reads the distribution out of the profile +(development, ad-hoc, store or enterprise; this is the only way in for +enterprise), uploads that set and writes the build profile the same way: + +```bash +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision +``` + +### Provisioning from `ios build` + +`builder ios build --profile ` checks, before dispatching to GitHub, +that the repository holds all three secrets of the profile's set. When any is +missing and an App Store Connect key is saved, it runs the same provisioning as +`signing setup` without prompts, uploads the set and then builds. A development +or ad-hoc profile needs at least one registered device; with none, the build +stops and points at `builder signing setup --distribution development +--devices-from-mobai`. Without an Apple key the build stops before anything is +pushed and names both ways out: `builder auth apple`, or `builder signing setup +--certificate ... --profile ...`. `--unsigned` skips all of this, and +Codemagic/Bitrise builds skip the check (no secrets API): their runner +reports a missing set itself. + +### Legacy: `ios.signing` without profiles + +A project set up before build profiles has `ios.signing: true` and the +unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and +`IOS_PROVISIONING_PROFILE` secrets. Builds that select no profile still sign +with those, whatever the profile type, exactly as before; `setup` never +touches them. Profiles ignore `ios.signing` and read their own set. + +### Manual path through the Apple Developer portal + +The `.p12` certificate is normally created through Keychain Access, but Builder +does the same thing itself: it generates the private key and certificate +signing request, and assembles the `.p12` from the certificate Apple issues. + +#### 1. Create a certificate signing request + +```bash +builder signing csr +``` + +This asks for your name and email and writes two files to the current +directory: `ios-signing.key` (your private key) and `ios-signing.csr`. Keep +the key wherever suits you — just don't commit it (add it to `.gitignore`; +gitignored files are also excluded from build snapshots). + +#### 2. Create the certificate + +1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal +2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc). TestFlight and App Store uploads need **Apple Distribution** together with an App Store profile in step 4 +3. Upload `ios-signing.csr` and download the resulting `.cer` file + +#### 3. Assemble the .p12 + +```bash +builder signing p12 --certificate development.cer --key ios-signing.key +``` + +This combines the key and certificate into `ios-signing.p12` (`--out` to name +it), protected by a password you choose — byte-for-byte the same kind of file +Keychain Access exports, and usable anywhere one is: `builder signing setup`, +Sideloadly, AltStore, or importing it on a Mac. Keep it, and don't commit it. + +#### 4. Create a provisioning profile + +On the portal: + +1. **Identifiers** → register an App ID matching your app's bundle identifier +2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page) +3. **Profiles** → create an **iOS App Development** (or Ad Hoc, App Store) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file + +The profile type decides the distribution the set is written to and the method +the IPA is exported with: development, ad-hoc, enterprise or store. + +#### 5. Upload the signing secrets + +```bash +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision +``` + +You can also skip step 3 and hand `setup` the `.cer` together with the key — +`builder signing setup --certificate development.cer --key ios-signing.key +--profile MyApp.mobileprovision` — and it assembles the `.p12` on the way, +saving it as `ios-signing-.p12`. Then `builder ios build +--profile `; `--unsigned` skips signing for one build. + +## TestFlight and App Store + +Builder uploads builds to App Store Connect and submits them to TestFlight or +App Review through the App Store Connect API, from Windows, Linux or macOS. No +Transporter, `altool` or Xcode is involved, and the API key never leaves your +machine: the CI runner only builds and signs, the upload happens locally from +the IPA in `./dist/`. + +You need: + +- A paid [Apple Developer Program](https://developer.apple.com/programs/) + membership and an app record in App Store Connect (My Apps → +) with your + bundle ID +- An IPA signed with an **Apple Distribution** certificate and an **App Store** + provisioning profile: `builder signing setup --distribution store` creates + both, stores them as the `STORE` signing set and writes a `store` build + profile, or pick those types on the portal in the manual path. An IPA signed + for development is rejected at upload. +- `builder ios build --profile store` (see [Build Profiles](#build-profiles)): + a `store` profile builds `Release` and signs with that set; a plain `ios + build` is Debug and unsigned, which is what the dev commands expect, not + what you want to ship. +- An App Store Connect API key: App Store Connect → Users and Access → + Integrations → App Store Connect API → Team Keys. Give it the **App Manager** + role, note the **Issuer ID** and **Key ID**, and download the + `AuthKey_.p8` file (Apple offers the download once). + +### 1. Save the API key + +```bash +builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key AuthKey_ABC123DEFG.p8 +``` + +Flags you leave out are prompted for. Builder verifies the key against App +Store Connect and stores it like the other logins (keychain, or a `0600` file on +Linux/WSL); `builder auth status` shows it and `builder auth logout apple` +removes it. In CI or for a coding agent, set `ASC_ISSUER_ID`, `ASC_KEY_ID` and +either `ASC_PRIVATE_KEY` (the .p8 contents; literal `\n` is fine) or +`ASC_KEY_PATH` instead — they take precedence over the saved login. + +### 2. Upload the build + +```bash +builder ios build # produces a signed dist/*.ipa +builder ios upload --wait +``` + +`upload` reads the bundle ID, version and build number from the newest IPA in +`./dist/` (or `--ipa `), finds the app, uploads the archive in chunks +and, with `--wait`, follows App Store Connect until the build has finished +processing and prints its build ID and TestFlight link. Without `--wait` it +returns as soon as Apple has the file. + +Two things Apple checks on every upload: + +- **Build numbers must increase.** A second upload with the same + `CFBundleVersion` for the same version is rejected (`ITMS-90189`), so bump + it before rebuilding. +- **Export compliance.** A build shows as *Missing Compliance* in TestFlight + until you say whether it uses non-exempt encryption. If your Info.plist sets + `ITSAppUsesNonExemptEncryption` to `false`, `upload --wait` answers that + automatically; otherwise pass `--no-encryption` (here or to `submit`) when + your app only uses standard iOS encryption. + +### 3. Distribute to TestFlight + +```bash +builder ios submit --testflight --group "Beta Testers" --notes "New login flow" +``` + +This takes the newest processed build (or `--build-number N`), sets the *What +to Test* notes and adds it to the named groups (`--group` repeats). Internal +groups get the build immediately; the first external group triggers Apple's +beta review, which Builder submits for you (`--wait` follows the decision). Run +it without `--group` to see the build and the groups the app has. + +### 4. Submit to the App Store + +```bash +builder ios submit --app-store --release after-approval +``` + +Builder finds or creates the App Store version matching the IPA's marketing +version (or `--version X.Y.Z`), attaches the build, sets the release type +(`manual` or `after-approval`) and submits it for review. The version's +metadata — description, screenshots, age rating, pricing, privacy — must +already be complete: App Store Connect refuses the submission otherwise and +Builder prints Apple's reasons verbatim. Builder does not manage metadata, +screenshots or in-app purchases; fill them in App Store Connect, or on a Mac +with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of +the `buildUploads` API also proved that the Mac-free upload path works and +served as the reference for Builder's implementation. + +## Installing the IPA + +Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account). + +## Development on Windows/Linux + +Builder supports hot reload for Flutter and React Native on Windows/Linux using [MobAI](https://mobai.run) for iOS device control. This allows you to develop iOS apps without a Mac. + +## Flutter Development + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` + This creates an IPA in `./dist/` +3. Start development with hot reload: + ```bash + builder dev flutter + ``` + Builder installs the IPA through MobAI and asks whether to re-sign it. Re-signing requires an iCloud account - we highly recommend creating a new one at [icloud.com](https://icloud.com) instead of using your primary account. A re-signed app gets a new bundle ID with a team ID suffix (e.g., `com.example.myapp.TEAMID`); Builder prefills it in the prompt that follows. + +### Subsequent Runs + +Once the app is installed, skip the install step: +```bash +builder dev flutter --skip-install --bundle-id com.example.myapp.TEAMID +``` + +### File Watching + +By default, `builder dev flutter` watches for Dart file changes and automatically triggers hot reload. When flutter attach connects, it also sends an initial hot restart to ensure your latest code is running. + +- **Automatic hot reload**: Edit a `.dart` file and save - hot reload triggers automatically +- **Generated files ignored**: Files like `.g.dart` and `.freezed.dart` are ignored by default +- **Configurable**: Customize watched directories, patterns, and debounce via `builder.json` + +To disable file watching: +```bash +builder dev flutter --no-watch +``` + +To print the `flutter attach` command instead of running it (useful for IDE integration): +```bash +builder dev flutter --no-attach +``` + +### When to Rebuild + +- **Native code changes** (Swift, Objective-C, Podfile, native dependencies): Run `builder ios build` and reinstall +- **Dart code changes only**: No rebuild needed - file watcher triggers hot reload automatically + +If you don't see your recent Dart changes after launching, press `R` in the terminal to perform a hot restart. + +### Troubleshooting + +**App won't launch / connection error** +- Close the app on your device before running `builder dev flutter` +- Reconnect the device (unplug/replug USB) +- Restart MobAI +- Run `builder mobai ping` to verify connection + +**"No devices found" error** +- Ensure MobAI is running and device is connected +- Only physical iOS devices are supported (no simulators) + +**Hot reload not working** +- Make sure you're using the correct bundle ID (with team ID suffix) +- Try hot restart with `R` key +- Check that MobAI shows the device as connected + +**File watcher not triggering** +- Ensure you're editing files in watched directories (default: `lib/`) +- Check if the file matches watch patterns (default: `.dart`) +- Generated files (`.g.dart`, `.freezed.dart`) are ignored by default +- Try running without `--no-watch` flag + +## React Native Development + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` +3. Start development with hot reload: + ```bash + builder dev rn + ``` + This will: + - Start Metro bundler if not running + - Install the IPA on your device (with optional re-signing) + - Launch the app with Metro URL configured automatically + +### Subsequent Runs + +Once the app is installed: +```bash +builder dev rn --skip-install --bundle-id com.example.myapp.TEAMID +``` + +### Custom Metro Port + +If port 8081 is in use: +```bash +builder dev rn --metro-port 8082 +``` + +### When to Rebuild + +- **Native code changes** (Swift, Objective-C, Podfile, native modules): Run `builder ios build` and reinstall +- **JavaScript changes only**: No rebuild needed - Metro handles it automatically + +### Troubleshooting + +**Metro not starting** +- Ensure Node.js and React Native CLI are installed +- Try starting Metro manually: `npx react-native start` + +**App not connecting to Metro** +- Device must be on the same WiFi network as the computer running Metro +- Check that Metro is running and accessible +- Verify the Metro port is correct (default: 8081) +- On WSL with mirrored networking, the Hyper-V firewall blocks the phone from reaching Metro by default; see [Using Builder from WSL](docs/wsl.md#react-native) for the firewall rule + +**Hot reload not working** +- Shake device or press `d` in Metro terminal to open dev menu +- Enable "Fast Refresh" in dev menu +- Try reloading with `r` in Metro terminal + +## Kotlin Multiplatform Development + +KMP iOS apps build and run on a device like any other project, with one +difference: **there is no hot reload.** Shared Kotlin is compiled into a native +framework at build time, so there is no runtime to swap code into — every code +change needs a rebuild. + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` +3. Install and launch it on the device: + ```bash + builder dev kmp + ``` + +`builder init` detects Kotlin Multiplatform projects by looking for the +multiplatform Gradle plugin in the root and module build files, and asks which +JDK the CI build should use (default 17): + +```json +{ + "kmp": { "jdkVersion": "17" } +} +``` + +On CI, the iOS app is built with `xcodebuild`, whose run script phase (or +CocoaPods) invokes Gradle to compile the shared framework — which is why the +JDK version matters. Gradle output is cached between builds. + +### When to Rebuild + +Every change to Kotlin or Swift code needs `builder ios build` followed by +`builder dev kmp` again. Use `--skip-install --bundle-id ` to relaunch an +app that is already installed. + +### Troubleshooting + +**Build fails with "Unsupported class file major version" or a Gradle JDK error** +- The project needs a different JDK than the default: set `kmp.jdkVersion` in `builder.json` to match what the project uses locally + +**Build fails with "SDK does not contain 'libarclite'"** +- An old Kotlin/Native version against a newer Xcode; upgrade the Kotlin plugin in Gradle + +**App launches then immediately exits** +- Launch with `builder dev kmp --logs` to see the device output + +## Build Limits + +Free allowances belong to each provider account and depend on the plan and +machine. As published in September 2026: Codemagic personal accounts include +500 macOS M2 minutes per month; Bitrise Hobby includes 300 credits. GitHub has +separate allowances for private repositories and free standard runners for +public repositories. Providers change these, so check the +[current allowance links and switching guidance](docs/providers.md#free-allowances). + +## License + +[MIT License](LICENSE) diff --git a/docs/provider-secrets.md b/docs/provider-secrets.md index f18b436..6536e12 100644 --- a/docs/provider-secrets.md +++ b/docs/provider-secrets.md @@ -6,9 +6,9 @@ API login, the provider's GitHub connection, and build secrets are separate: | What you want to run | Secrets needed | | --- | --- | -| Unsigned IPA build (`ios build --unsigned`) | None of the secrets below | -| Signed iPhone build (`ios build`) | The three `IOS_*_DEVELOPMENT` secrets below | -| Signed App Store / ad-hoc / enterprise build (`ios build --profile `) | The three `IOS_*_` secrets of the profile's `distribution` | +| Unsigned IPA build (`ios build`, or a profile without `distribution`) | None of the secrets below | +| Signed build (`ios build --profile `) | The three `IOS_*_` secrets of the profile's `distribution` | +| Legacy signed build without a profile (`ios.signing: true`) | The unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD`, `IOS_PROVISIONING_PROFILE` | | Shared simulator (`ios share`) | `MOBAI_API_KEY`; no Apple signing files needed | `builder signing setup` uploads secrets to **GitHub Actions only**. For the two @@ -17,12 +17,12 @@ Existing GitHub secret values cannot be downloaded for copying to another servic ## 1. Prepare your signing files -A repository holds one signing set per distribution type (development, ad-hoc, -app-store, enterprise), and the build profile's `distribution` in `builder.json` -chooses which set a build uses; without one, builds use the development set. -Each set is a certificate, its password and a matching profile, and the runner -refuses a set whose profile is of another type. Start with the development set, -which is what on-device testing needs: +A repository holds one signing set per distribution (`development`, `ad-hoc` +— also spelled `internal` — `store`, `enterprise`), and a build profile's +`distribution` in `builder.json` chooses which set a build uses; a profile +without one builds unsigned. Each set is a certificate, its password and a +matching profile, and the runner refuses a set whose profile is of another +type. Start with the development set, which is what on-device testing needs: - An **Apple Development** certificate in a `.p12` file, including its matching private key, and the P12 password. @@ -33,16 +33,16 @@ which is what on-device testing needs: Use [Apple Certificates](https://developer.apple.com/account/resources/certificates/list) and [Apple Profiles](https://developer.apple.com/account/resources/profiles/list). Apple's [development profile guide](https://developer.apple.com/help/account/provisioning-profiles/create-a-development-provisioning-profile) -explains selecting the App ID, certificate, and devices. For an ad-hoc, -app-store or enterprise set, pair that profile with an **Apple Distribution** -certificate and select it from a build profile that has the matching -`distribution` and `"configuration": "Release"`, since those profiles reject the +explains selecting the App ID, certificate, and devices. For an ad-hoc, store +or enterprise set, pair that profile with an **Apple Distribution** certificate +and select it from a build profile with the matching `distribution`; such a +profile builds `Release` by default, since distribution profiles reject the `get-task-allow` entitlement a Debug build is signed with. If you already have the P12 and profile, reuse them. If you have no certificate, -the quickest way is the [automatic setup](../README.md#automatic-setup) with an -App Store Connect API key (`builder auth apple`), pointed at a private directory -outside your source checkout: +the quickest way is the [automatic setup](../README.md#builder-signing-setup) with +an App Store Connect API key (`builder auth apple`), pointed at a private +directory outside your source checkout: ```sh builder signing setup --devices-from-mobai --out-dir ~/signing @@ -50,10 +50,11 @@ builder signing setup --devices-from-mobai --out-dir ~/signing With `provider` set to Codemagic or Bitrise in `builder.json`, this creates the certificate, devices and profile through the API, writes -`ios-signing-development.p12` and the `.mobileprovision` to `~/signing`, and -prints the three secret names and values to paste below instead of uploading -them. Run it again with `--type app-store` for a second, App Store set: the -files are named by type, so nothing is overwritten. Alternatively follow the +`ios-signing-development.p12` and the `.mobileprovision` to `~/signing`, prints +the three secret names and file paths to paste below instead of uploading them, +and writes the `development` build profile. Run it again with `--distribution +store` for a second, App Store set: the files are named by distribution, so +nothing is overwritten. Alternatively follow the [manual certificate steps](../README.md#1-create-a-certificate-signing-request): ```sh @@ -69,12 +70,12 @@ Keep private keys, P12 files, and encoded copies out of Git and build snapshots. ## 2. Prepare the secret values -The signing secrets come in sets, one per distribution type, named with a -suffix: `DEVELOPMENT`, `AD_HOC`, `APP_STORE` or `ENTERPRISE`. A build reads -the set named by its `builder.json` profile's `distribution`, and -`DEVELOPMENT` when there is no profile or no `distribution`. The runner checks -that the profile in the set is that type and fails by name when it is not. -Use these exact, case-sensitive names, shown here for the development set: +The signing secrets come in sets, one per distribution, named with a suffix: +`DEVELOPMENT`, `AD_HOC` (for `ad-hoc` and `internal`), `STORE` or +`ENTERPRISE`. A build reads the set named by its `builder.json` profile's +`distribution`. The runner checks that the profile in the set is that type and +fails by name when it is not. Use these exact, case-sensitive names, shown +here for the development set: | Secret name | Value to paste | | --- | --- | @@ -83,13 +84,12 @@ Use these exact, case-sensitive names, shown here for the development set: | `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | Base64 contents of the `.mobileprovision` file | | `MOBAI_API_KEY` | The original API key copied from MobAI, as plain text | -For an App Store set add `IOS_CERTIFICATE_APP_STORE`, -`IOS_CERTIFICATE_PASSWORD_APP_STORE` and `IOS_PROVISIONING_PROFILE_APP_STORE` -with the Apple Distribution `.p12` and the App Store profile, and build it with -a profile that has `"distribution": "app-store"` and `"configuration": -"Release"`. The unsuffixed names `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` -and `IOS_PROVISIONING_PROFILE` from earlier setups keep working as the fallback -whenever the suffixed set of the requested distribution is absent. +For a store set add `IOS_CERTIFICATE_STORE`, `IOS_CERTIFICATE_PASSWORD_STORE` +and `IOS_PROVISIONING_PROFILE_STORE` with the Apple Distribution `.p12` and the +App Store profile, and build it with a profile that has `"distribution": +"store"`. The unsuffixed names `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` +and `IOS_PROVISIONING_PROFILE` from earlier setups serve only builds that select +no profile (`ios.signing: true`); a profile never falls back to them. Base64-encode only the two files. Paste their contents, not their filenames or paths. On macOS, copy one encoded file to the clipboard at a time: @@ -111,12 +111,11 @@ On Linux with `xclip` installed, replace `pbcopy` with ``` A suffixed set needs all three variables, password included: the build fails -naming whichever is missing rather than falling back to the unsuffixed names. -Builder always protects the P12 it makes with a password; for one you made -yourself without one, create a password-protected P12 instead. Only the -unsuffixed `IOS_CERTIFICATE_PASSWORD` of an earlier setup may be empty or -absent. Do not put quotes around passwords or API keys in the value field, and -do not base64-encode them. +naming whichever is missing. Builder always protects the P12 it makes with a +password; for one you made yourself without one, create a password-protected +P12 instead. Only the unsuffixed `IOS_CERTIFICATE_PASSWORD` of an earlier setup +may be empty or absent. Do not put quotes around passwords or API keys in the +value field, and do not base64-encode them. ## 3. Create the MobAI API key @@ -166,15 +165,21 @@ See [Bitrise's Secrets instructions](https://docs.bitrise.io/en/bitrise-ci/confi ## 6. Verify a signed build -In your existing `builder.json`, set `ios.signing` to `true`, preserving the other -project and provider settings. Numbra already has this enabled. Then run from -the app checkout, **without `--unsigned`**: +In your existing `builder.json`, make sure a profile names the distribution of +the set you added (`signing setup` writes one; by hand it is +`"profiles": {"development": {"distribution": "development"}}`), preserving +the other project and provider settings. Then run from the app checkout, +**without `--unsigned`**: ```sh -builder ios build --provider codemagic -builder ios build --provider bitrise +builder ios build --profile development --provider codemagic +builder ios build --profile development --provider bitrise ``` +Codemagic and Bitrise have no secrets API, so `ios build` cannot check or +provision the set the way it does on GitHub; a missing variable fails in the +runner's signing step by name. + A successful run should archive, export, and download an IPA. Install it on a device included in the development profile to verify signing and provisioning. If signing fails, check the P12 password, certificate/private-key pair, profile diff --git a/docs/provider-setup.md b/docs/provider-setup.md index 44a6ac8..81de29b 100644 --- a/docs/provider-setup.md +++ b/docs/provider-setup.md @@ -141,9 +141,10 @@ not transferred by these commands. | `IOS_PROVISIONING_PROFILE_` | Base64 provisioning profile matching the app | | `MOBAI_API_KEY` | MobAI simulator sharing | -`` is the distribution type the secrets are for: `DEVELOPMENT` (what a -build without a profile `distribution` reads), `AD_HOC`, `APP_STORE` or -`ENTERPRISE`. The unsuffixed names from earlier setups remain the fallback. +`` is the distribution the secrets are for, as named by the build +profile's `distribution`: `DEVELOPMENT`, `AD_HOC` (`ad-hoc` or `internal`), +`STORE` or `ENTERPRISE`. The unsuffixed names from earlier setups serve only +builds that select no profile. Follow the [signing and MobAI secret setup guide](provider-secrets.md) for file preparation, base64/clipboard commands, exact dashboard steps for both diff --git a/docs/providers.md b/docs/providers.md index 21f5ebe..89698e4 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -149,19 +149,20 @@ same build may consume different minutes on each provider. See [step-by-step signing and MobAI secret setup](provider-secrets.md). -`builder signing setup` continues to upload signing secrets to **GitHub**. -For Codemagic/Bitrise, separately configure these secrets on that provider, -one set per distribution type (`` is `DEVELOPMENT`, `AD_HOC`, `APP_STORE` -or `ENTERPRISE`; a build reads the set its profile's `distribution` names, -`DEVELOPMENT` by default, and falls back to the unsuffixed names): +`builder signing setup` uploads signing secrets to **GitHub** only. For +Codemagic/Bitrise, configure these secrets on that provider yourself, one set +per distribution (`` is `DEVELOPMENT`, `AD_HOC`, `STORE` or `ENTERPRISE`; +a build reads the set its profile's `distribution` names; a build without a +profile and with `ios.signing: true` reads the unsuffixed legacy names): - `IOS_CERTIFICATE_`: base64-encoded `.p12` - `IOS_CERTIFICATE_PASSWORD_`: the `.p12` password (required; only the unsuffixed legacy one can be empty) - `IOS_PROVISIONING_PROFILE_`: base64-encoded `.mobileprovision` -Set `ios.signing` to `true` after configuring the secrets. `--unsigned` disables -signing for a particular build. The runner installs a temporary signing keychain -and removes it on exit. The CSR and P12 commands remain usable for all providers. +Build with a profile whose `distribution` names the set (`signing setup` +writes one). `--unsigned` disables signing for a particular build. The runner +installs a temporary signing keychain and removes it on exit. The CSR and P12 +commands remain usable for all providers. ## Simulator sessions From 340d98b88c6f1754212ca619a4ffb52366131c81 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 17:24:30 +0200 Subject: [PATCH 45/75] 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. --- internal/github/client.go | 4 ++ internal/github/repo.go | 12 ++++- internal/github/repo_test.go | 95 ++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 internal/github/repo_test.go diff --git a/internal/github/client.go b/internal/github/client.go index f2468f0..8d7804b 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "net/http" + "strconv" "golang.org/x/crypto/nacl/box" ) @@ -88,6 +89,9 @@ func (c *Client) do(ctx context.Context, path string, result any) error { if err := json.Unmarshal(respBody, &apiErr); err != nil { return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) } + if apiErr.Status == "" { // GitHub sends it as a string; keep it so even when it does not + apiErr.Status = strconv.Itoa(resp.StatusCode) + } return &apiErr } diff --git a/internal/github/repo.go b/internal/github/repo.go index 34d18f5..2c68c4e 100644 --- a/internal/github/repo.go +++ b/internal/github/repo.go @@ -2,6 +2,7 @@ package github import ( "context" + "errors" "fmt" "net/http" ) @@ -31,14 +32,21 @@ func (c *Client) GetPublicKey(ctx context.Context, owner, repo string) (*PublicK } // ListSecretNames returns the names of the repository's Actions secrets -// (values are never readable). It follows the pages GitHub returns. +// (values are never readable). It follows the pages GitHub returns. GitHub +// answers 404 (token without the repo scope) or 403 (no admin access) rather +// than an empty list, so those name the cause instead of reading as "no +// secrets". func (c *Client) ListSecretNames(ctx context.Context, owner, repo string) ([]string, error) { var names []string for page := 1; ; page++ { path := fmt.Sprintf("/repos/%s/%s/actions/secrets?per_page=100&page=%d", owner, repo, page) var list SecretsResponse if err := c.do(ctx, path, &list); err != nil { - return nil, fmt.Errorf("failed to list secrets: %w", err) + var apiErr *APIError + if errors.As(err, &apiErr) && (apiErr.Status == "403" || apiErr.Status == "404") { + return nil, fmt.Errorf("cannot list the secrets of %s/%s (%s): the GitHub token needs the repo scope and admin access to the repository; run builder auth github as an admin of it", owner, repo, apiErr.Message) + } + return nil, fmt.Errorf("failed to list the secrets of %s/%s: %w", owner, repo, err) } for _, s := range list.Secrets { names = append(names, s.Name) diff --git a/internal/github/repo_test.go b/internal/github/repo_test.go new file mode 100644 index 0000000..5a8fa8b --- /dev/null +++ b/internal/github/repo_test.go @@ -0,0 +1,95 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" +) + +// secretsServer answers the secrets listing of o/r with handler and returns a +// client pointed at it. +func secretsServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/repos/o/r/actions/secrets" || r.Header.Get("Authorization") != "Bearer tok" { + t.Errorf("unexpected request: %s %s (auth %q)", r.Method, r.URL, r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + handler(w, r) + })) + t.Cleanup(srv.Close) + c := NewClient("tok") + c.baseURL = srv.URL + return c +} + +func TestListSecretNamesFollowsPages(t *testing.T) { + var queries []string + c := secretsServer(t, func(w http.ResponseWriter, r *http.Request) { + queries = append(queries, r.URL.RawQuery) + switch r.URL.Query().Get("page") { + case "1": + fmt.Fprint(w, `{"total_count":3,"secrets":[{"name":"IOS_CERTIFICATE_STORE","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"},{"name":"IOS_CERTIFICATE_PASSWORD_STORE","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}]}`) + case "2": + fmt.Fprint(w, `{"total_count":3,"secrets":[{"name":"IOS_PROVISIONING_PROFILE_STORE","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}]}`) + default: + t.Errorf("page %q requested after the last one", r.URL.Query().Get("page")) + fmt.Fprint(w, `{"total_count":3,"secrets":[]}`) + } + }) + names, err := c.ListSecretNames(context.Background(), "o", "r") + if err != nil { + t.Fatal(err) + } + if want := []string{"IOS_CERTIFICATE_STORE", "IOS_CERTIFICATE_PASSWORD_STORE", "IOS_PROVISIONING_PROFILE_STORE"}; !slices.Equal(names, want) { + t.Errorf("names = %v, want %v", names, want) + } + if want := []string{"per_page=100&page=1", "per_page=100&page=2"}; !slices.Equal(queries, want) { + t.Errorf("pages requested: %v, want %v", queries, want) + } +} + +func TestListSecretNamesEmpty(t *testing.T) { + c := secretsServer(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"total_count":0,"secrets":[]}`) + }) + if names, err := c.ListSecretNames(context.Background(), "o", "r"); err != nil || len(names) != 0 { + t.Fatalf("empty repository: %v, %v", names, err) + } +} + +// A token without the repo scope gets 404, a non-admin 403; neither is an +// empty list, and the error says what the token lacks. The 403 body carries +// no status field, so the HTTP status must fill it in. +func TestListSecretNamesNeedsRepoAccess(t *testing.T) { + for status, body := range map[int]string{ + 404: `{"message":"Not Found","documentation_url":"https://docs.github.com/rest/actions/secrets#list-repository-secrets","status":"404"}`, + 403: `{"message":"Must have admin rights to Repository.","documentation_url":"https://docs.github.com/rest"}`, + } { + c := secretsServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + fmt.Fprint(w, body) + }) + names, err := c.ListSecretNames(context.Background(), "o", "r") + if err == nil || names != nil { + t.Fatalf("%d: %v, %v", status, names, err) + } + for _, want := range []string{"o/r", "repo scope", "admin access", "builder auth github"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%d: error lacks %q: %v", status, want, err) + } + } + } + // Other failures are passed through as they are. + c := secretsServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"message":"boom"}`) + }) + if _, err := c.ListSecretNames(context.Background(), "o", "r"); err == nil || !strings.Contains(err.Error(), "boom") || strings.Contains(err.Error(), "repo scope") { + t.Fatalf("500: %v", err) + } +} From 8743b3551ba68cff6f7cc342c03faebeffd26479 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 17:24:30 +0200 Subject: [PATCH 46/75] config: point app-store at store The old distribution name gets its own message naming the new one instead of the generic list. --- internal/config/profile_test.go | 4 ++++ internal/config/signing.go | 3 +++ 2 files changed, 7 insertions(+) diff --git a/internal/config/profile_test.go b/internal/config/profile_test.go index e54c3b7..d025923 100644 --- a/internal/config/profile_test.go +++ b/internal/config/profile_test.go @@ -62,6 +62,10 @@ func TestParseDistribution(t *testing.T) { t.Errorf("ParseDistribution(%q) accepted", bad) } } + // The old name of store points at the new one. + if _, err := ParseDistribution("app-store"); err == nil || !strings.Contains(err.Error(), `is now "store"`) { + t.Errorf("app-store: %v", err) + } } func TestResolveProfileDefault(t *testing.T) { diff --git a/internal/config/signing.go b/internal/config/signing.go index 245f6eb..f153b61 100644 --- a/internal/config/signing.go +++ b/internal/config/signing.go @@ -36,6 +36,9 @@ func ParseDistribution(s string) (string, error) { if s == "" { return "", nil } + if s == "app-store" { + return "", fmt.Errorf("distribution %q is now %q", s, DistributionStore) + } return "", fmt.Errorf("distribution %q must be one of %s (internal is ad-hoc)", s, strings.Join(Distributions, ", ")) } From f70abcd20697d594e33fe4f40b3a6a7940dc4d70 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 17:24:46 +0200 Subject: [PATCH 47/75] 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. --- cmd/builder/root.go | 4 ++-- cmd/builder/signing_auto.go | 20 ++++++++++++---- cmd/builder/signing_sets_test.go | 40 +++++++++++++++++++++++--------- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/cmd/builder/root.go b/cmd/builder/root.go index 5042fc5..e6321ba 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -729,9 +729,9 @@ func runBuild(ctx context.Context, cfg *config.Config, opts *build.BuildOptions) } // A GitHub build with a distribution needs its signing set in the // repository; Codemagic and Bitrise have no secrets API, so their runner - // reports a missing set itself. + // reports a missing set itself (the check knows the profile may pick them). if ghClient != nil && !opts.Unsigned { - if err := ensureSigningSecrets(ctx, cfg, ghClient, getASCClient, opts.Profile, os.Stdout); err != nil { + if err := ensureSigningSecrets(ctx, cfg, ghClient, getASCClient, opts.Profile, opts.Provider, os.Stdout); err != nil { return err } } diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index 37d0c5c..a81164f 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -397,7 +397,7 @@ func uploadSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Confi func missingSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Config, set string) ([]string, error) { have, err := gh.ListSecretNames(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo) if err != nil { - return nil, fmt.Errorf("failed to list the secrets of %s/%s: %w", cfg.GitHub.Owner, cfg.GitHub.Repo, err) + return nil, err // names the repository already } var missing []string for _, name := range config.SigningSecretNames(set).Names() { @@ -408,17 +408,27 @@ func missingSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Conf return missing, nil } -// ensureSigningSecrets runs before a GitHub build is dispatched: when the +// ensureSigningSecrets runs before a build is dispatched to GitHub: when the // selected profile has a distribution, its signing set must be in the // repository. A missing or partial set is provisioned through App Store // Connect the way `signing setup` does, without prompts; without Apple -// credentials the build stops here, before anything is pushed. -func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretStore, ascClient func() (*asc.Client, error), profile string, log io.Writer) error { +// credentials the build stops here, before anything is pushed. The provider +// that will run the job is --provider, else the profile's, else the top-level +// one (as the coordinator resolves it); Codemagic and Bitrise have no secrets +// API, so their builds are left to the runner, which reports a missing set. +func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretStore, ascClient func() (*asc.Client, error), profile, provider string, log io.Writer) error { s, err := cfg.ResolveProfile(profile) if err != nil { return err } - if s.Distribution == "" { + if provider == "" { + provider = s.Provider + } + name, err := cfg.ProviderName(provider) + if err != nil { + return err + } + if name != "github" || s.Distribution == "" { return nil } typ, set := signing.Type(s.Distribution), s.SigningSet() diff --git a/cmd/builder/signing_sets_test.go b/cmd/builder/signing_sets_test.go index ed2e8f8..7a0daa8 100644 --- a/cmd/builder/signing_sets_test.go +++ b/cmd/builder/signing_sets_test.go @@ -259,10 +259,10 @@ func TestEnsureSigningSecretsChecksTheSet(t *testing.T) { store := newFakeSecrets(t) // No distribution: nothing to check, the API is not even called. - if err := ensureSigningSecrets(ctx, cfg, store, noASC, "unsigned", io.Discard); err != nil || store.listed != 0 { + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "unsigned", "", io.Discard); err != nil || store.listed != 0 { t.Fatalf("unsigned profile: %v, listed %d", err, store.listed) } - if err := ensureSigningSecrets(ctx, cfg, store, noASC, "", io.Discard); err != nil || store.listed != 0 { + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "", "", io.Discard); err != nil || store.listed != 0 { t.Fatalf("no profile: %v, listed %d", err, store.listed) } @@ -270,7 +270,7 @@ func TestEnsureSigningSecretsChecksTheSet(t *testing.T) { for _, name := range config.SigningSecretNames("STORE").Names() { store.stored[name] = "x" } - if err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", io.Discard); err != nil { + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", "", io.Discard); err != nil { t.Fatalf("complete set: %v", err) } @@ -278,7 +278,7 @@ func TestEnsureSigningSecretsChecksTheSet(t *testing.T) { // names both ways out. delete(store.stored, "IOS_PROVISIONING_PROFILE_STORE") var log strings.Builder - err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", &log) + err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", "", &log) if err == nil { t.Fatal("missing profile secret accepted") } @@ -292,16 +292,34 @@ func TestEnsureSigningSecretsChecksTheSet(t *testing.T) { } // Enterprise is never provisioned through the API. - err = ensureSigningSecrets(ctx, cfg, store, noASC, "inhouse", io.Discard) + err = ensureSigningSecrets(ctx, cfg, store, noASC, "inhouse", "", io.Discard) if err == nil || !strings.Contains(err.Error(), "--certificate") || strings.Contains(err.Error(), "auth apple") { t.Fatalf("enterprise: %v", err) } // A listing failure is reported, not treated as "missing". store.listErr = errors.New("403") - if err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", io.Discard); err == nil || !strings.Contains(err.Error(), "403") { + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", "", io.Discard); err == nil || !strings.Contains(err.Error(), "403") { t.Fatalf("listing failure: %v", err) } + + // A profile that builds on Codemagic or Bitrise has no GitHub set to + // check, whatever the top-level provider; --provider decides over it. + store.listErr = nil + store.listed = 0 + cfg.Profiles["cm"] = config.Profile{Distribution: "store", Provider: "codemagic"} + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "cm", "", io.Discard); err != nil || store.listed != 0 { + t.Fatalf("codemagic profile: %v, listed %d", err, store.listed) + } + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", "bitrise", io.Discard); err != nil || store.listed != 0 { + t.Fatalf("--provider bitrise: %v, listed %d", err, store.listed) + } + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "cm", "github", io.Discard); err == nil || store.listed != 1 { + t.Fatalf("--provider github over a codemagic profile: %v, listed %d", err, store.listed) + } + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "cm", "circle", io.Discard); err == nil || !strings.Contains(err.Error(), "unknown provider") { + t.Fatalf("bad --provider: %v", err) + } } func TestEnsureSigningSecretsProvisionsOnDemand(t *testing.T) { @@ -313,7 +331,7 @@ func TestEnsureSigningSecretsProvisionsOnDemand(t *testing.T) { withPortal := func() (*asc.Client, error) { return portal.Client(t), nil } var log strings.Builder - if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "store", &log); err != nil { + if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "store", "", &log); err != nil { t.Fatalf("on-demand provisioning: %v\n%s", err, log.String()) } // The whole store set is in the repository now, made from what the @@ -339,13 +357,13 @@ func TestEnsureSigningSecretsProvisionsOnDemand(t *testing.T) { // Second build: the set is there, nothing is provisioned again. portal.Reset() - if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "store", io.Discard); err != nil || len(portal.Calls()) != 0 || len(store.names) != 3 { + if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "store", "", io.Discard); err != nil || len(portal.Calls()) != 0 || len(store.names) != 3 { t.Fatalf("second build: %v, calls %v, uploads %v", err, portal.Calls(), store.names) } // Development with no device anywhere cannot be provisioned without // prompting: the error sends the user to signing setup. - err := ensureSigningSecrets(ctx, cfg, store, withPortal, "development", io.Discard) + err := ensureSigningSecrets(ctx, cfg, store, withPortal, "development", "", io.Discard) if err == nil || !strings.Contains(err.Error(), "builder signing setup --distribution development --devices-from-mobai") { t.Fatalf("development without devices: %v", err) } @@ -355,7 +373,7 @@ func TestEnsureSigningSecretsProvisionsOnDemand(t *testing.T) { // With a device on the account the development set follows. portal.Devices = []signingtest.Device{{ID: "dev-1", Name: "Jane's iPhone", UDID: "00008030-000000000000001E", Status: "ENABLED"}} - if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "development", io.Discard); err != nil { + if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "development", "", io.Discard); err != nil { t.Fatalf("development with a registered device: %v", err) } if len(store.stored) != 6 || store.stored["IOS_CERTIFICATE_DEVELOPMENT"] == "" { @@ -366,7 +384,7 @@ func TestEnsureSigningSecretsProvisionsOnDemand(t *testing.T) { cfg.IOS.BundleID = "" delete(store.stored, "IOS_CERTIFICATE_DEVELOPMENT") portal.Reset() - err = ensureSigningSecrets(ctx, cfg, store, withPortal, "development", io.Discard) + err = ensureSigningSecrets(ctx, cfg, store, withPortal, "development", "", io.Discard) if err == nil || !strings.Contains(err.Error(), "ios.bundleId") || len(portal.Calls()) != 0 { t.Fatalf("no bundle ID: %v, calls %v", err, portal.Calls()) } From 56b89b1b39516f319c8fc4c52c3211b940e72a85 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 17:24:46 +0200 Subject: [PATCH 48/75] 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. --- cmd/builder/signing.go | 6 +++--- cmd/builder/signing_auto.go | 24 ++++++++++++++++++++---- cmd/builder/signing_sets_test.go | 20 +++++++++++++++++--- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/cmd/builder/signing.go b/cmd/builder/signing.go index cfb4e05..d3d2672 100644 --- a/cmd/builder/signing.go +++ b/cmd/builder/signing.go @@ -91,7 +91,7 @@ func init() { signingSetupCmd.Flags().StringP("key", "k", "", "Path to the private key from 'builder signing csr' (required with a .cer; automatic mode reuses it and its certificate)") signingSetupCmd.Flags().String("bundle-id", "", "App bundle ID (default: ios.bundleId in builder.json, else the newest IPA in ./dist)") signingSetupCmd.Flags().String("distribution", "", "Distribution to sign for: development, ad-hoc (internal), store or enterprise (default: the --name profile's, else development; with --profile: read from the file)") - signingSetupCmd.Flags().String("name", "", "builder.json profile to write the distribution to (default: the distribution name)") + signingSetupCmd.Flags().String("name", "", "builder.json profile to write the distribution to (default: the distribution name; an existing profile keeps its other fields, a different distribution in it is replaced)") signingSetupCmd.Flags().StringArray("device", nil, "Device UDID to register (repeatable)") signingSetupCmd.Flags().Bool("devices-from-mobai", false, "Register the physical iOS devices connected to MobAI") signingSetupCmd.Flags().String("out-dir", ".", "Directory for the private key, .p12 and .mobileprovision") @@ -382,11 +382,11 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { printProviderSecrets(provider, config.SigningSecretNames(set), p12Path, profilePath) } - writeSigningProfile(cfg, profileName, typ) + replaced := writeSigningProfile(cfg, profileName, typ) if err := config.NewManager().Save(cfg); err != nil { return fmt.Errorf("failed to update config: %w", err) } - fmt.Printf(" Updated: builder.json (profile %q, distribution %s)\n", profileName, typ) + fmt.Println(profileWritten(profileName, typ, replaced)) fmt.Println() fmt.Println("Code signing configured successfully!") diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index a81164f..5a9d457 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -157,14 +157,14 @@ func runSigningAuto(cmd *cobra.Command) error { return finish(out, cmd, res, err, nil) } res.SecretsUploaded = store != nil - writeSigningProfile(cfg, profileName, typ) + replaced := writeSigningProfile(cfg, profileName, typ) if cfg.IOS.BundleID == "" { cfg.IOS.BundleID = bundleID } if err := config.NewManager().Save(cfg); err != nil { return finish(out, cmd, res, fmt.Errorf("failed to update config: %w", err), nil) } - fmt.Fprintf(out.log, " Updated: builder.json (profile %q, distribution %s)\n", profileName, typ) + fmt.Fprintln(out.log, profileWritten(profileName, typ, replaced)) return finish(out, cmd, res, nil, func() { printSigningSummary(cfg, res) }) } @@ -205,14 +205,30 @@ func provisionSigning(ctx context.Context, client *asc.Client, store secretStore } // writeSigningProfile creates or updates the builder.json profile that builds -// with this distribution; other fields of an existing profile are kept. -func writeSigningProfile(cfg *config.Config, name string, typ signing.Type) { +// with this distribution. Other fields of an existing profile are kept, and so +// is its own spelling of the same distribution (internal stays internal); a +// different distribution is replaced and returned so the caller can say so. +func writeSigningProfile(cfg *config.Config, name string, typ signing.Type) (replaced string) { if cfg.Profiles == nil { cfg.Profiles = map[string]config.Profile{} } p := cfg.Profiles[name] + if d, err := config.ParseDistribution(p.Distribution); err == nil && d == string(typ) { + return "" + } + replaced = p.Distribution p.Distribution = string(typ) cfg.Profiles[name] = p + return replaced +} + +// profileWritten is the "Updated: builder.json" line of both setup modes. +func profileWritten(name string, typ signing.Type, replaced string) string { + line := fmt.Sprintf(" Updated: builder.json (profile %q, distribution %s", name, typ) + if replaced != "" { + line += ", was " + replaced + } + return line + ")" } // resolveSigningBundleID takes the flag, then builder.json, then the newest diff --git a/cmd/builder/signing_sets_test.go b/cmd/builder/signing_sets_test.go index 7a0daa8..c2c346b 100644 --- a/cmd/builder/signing_sets_test.go +++ b/cmd/builder/signing_sets_test.go @@ -232,9 +232,23 @@ func TestWriteSigningProfile(t *testing.T) { } // From nothing: the profiles map is created. empty := &config.Config{} - writeSigningProfile(empty, "development", signing.TypeDevelopment) - if empty.Profiles["development"].Distribution != "development" { - t.Fatalf("profile not created: %+v", empty.Profiles) + if replaced := writeSigningProfile(empty, "development", signing.TypeDevelopment); replaced != "" || empty.Profiles["development"].Distribution != "development" { + t.Fatalf("profile not created: %q %+v", replaced, empty.Profiles) + } + // The same distribution keeps the user's spelling; a different one is + // replaced, the other fields stay, and the old value is reported. + cfg.Profiles["beta"] = config.Profile{Distribution: "internal", Scheme: "AppBeta"} + if replaced := writeSigningProfile(cfg, "beta", signing.TypeAdHoc); replaced != "" || cfg.Profiles["beta"].Distribution != "internal" { + t.Fatalf("same distribution rewritten: %q %+v", replaced, cfg.Profiles["beta"]) + } + if replaced := writeSigningProfile(cfg, "beta", signing.TypeStore); replaced != "internal" || cfg.Profiles["beta"].Distribution != "store" || cfg.Profiles["beta"].Scheme != "AppBeta" { + t.Fatalf("different distribution: %q %+v", replaced, cfg.Profiles["beta"]) + } + if line := profileWritten("beta", signing.TypeStore, "internal"); line != ` Updated: builder.json (profile "beta", distribution store, was internal)` { + t.Fatalf("line: %s", line) + } + if line := profileWritten("beta", signing.TypeStore, ""); strings.Contains(line, "was") { + t.Fatalf("line: %s", line) } } From 2d62ef9fd807350d9127a7e173ee33103893997c Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 17:24:46 +0200 Subject: [PATCH 49/75] 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. --- CLAUDE.md | 14 +- README.md | 1637 +++++++++++++++++++++++++++-------------------------- 2 files changed, 829 insertions(+), 822 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa350e7..6d667eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -239,16 +239,20 @@ internal/ distribution (automatic: `--distribution`, else the `--name` profile's, else development; manual: `signing.ProfileType` reads the plist out of the CMS blob and a disagreeing `--distribution` is an error) and never touches other sets or the legacy names, then writes - `profiles.<--name or distribution>.distribution` (`writeSigningProfile`, other fields kept) - and never `ios.signing`. Files are `ios-signing-.key/.p12`, so two coexist in + `profiles.<--name or distribution>.distribution` (`writeSigningProfile`: other fields kept, an + equal distribution keeps the user's spelling, a different one is replaced and the old value + printed; `defaultProfile` is never set) and never `ios.signing`. Files are `ios-signing-.key/.p12`, so two coexist in one `--out-dir`; the key lookup is `--key`, then the distribution's file, then the legacy `ios-signing.key`. `Progress.Settings` prints `signed (set X)` / `signed (unsuffixed IOS_* secrets)`. Enterprise is a valid set and profile type but `Auto` refuses it (no ASC endpoint for in-house profiles). The suffixed secret names and `SIGNING_SET*` are reserved env names. - **On-Demand Provisioning** (`ensureSigningSecrets` in `cmd/builder/signing_auto.go`, called by - `runBuild` for GitHub builds without `--unsigned`): when the selected profile has a distribution, - `github.Client.ListSecretNames` (`GET /repos/{o}/{r}/actions/secrets`, paginated) is checked for - the three names; all present → dispatch. Otherwise, with an ASC key (`getASCClient` passed as a + `runBuild` without `--unsigned`; it returns early unless the provider that will run the job — + `--provider`, else the profile's, else the top level, as `Coordinator.settings` resolves it — is + GitHub): when the selected profile has a distribution, `github.Client.ListSecretNames` + (`GET /repos/{o}/{r}/actions/secrets`, paginated; 403/404 are reported as a token without the + `repo` scope or admin access, never as "no secrets") is checked for the three names; all + present → dispatch. Otherwise, with an ASC key (`getASCClient` passed as a factory so tests inject the `signingtest` portal), `provisionSigning` (= `signing.Auto` + upload, shared with `signing setup`) runs with no prompts: bundle ID from `ios.bundleId` or `dist/*.ipa`, key from `.`, generated password (printed once), no devices given (Auto covers the enabled ones diff --git a/README.md b/README.md index 286e41e..0636148 100644 --- a/README.md +++ b/README.md @@ -1,817 +1,820 @@ -# Builder - -Build and develop iOS apps from Windows, Linux, or any platform. - -Builder is a CLI tool for iOS development without a Mac. It uses GitHub Actions (default), Codemagic, or Bitrise for remote builds and [MobAI](https://mobai.run) for on-device development. - -![Builder Demo](assets/ios-builder-demo.gif) - -## Features - -- **Build from anywhere**: Build iOS apps via GitHub Actions, Codemagic, or Bitrise -- **Independent provider logins**: Stay signed in to all three and choose where each build runs -- **Try it on a simulator**: Use your build on an iOS simulator from Windows or Linux -- **Flutter & React Native dev tools**: Hot reload on real iOS devices from Windows/Linux -- **Simple setup**: One command to add the workflow to your repo -- **Code signing**: Optional signing with your certificate and provisioning profile -- **TestFlight and App Store**: Upload builds and submit them for review through the App Store Connect API, from any platform -- **Device integration**: Install and run apps via MobAI - -## How It Works - -``` -Your Repository GitHub Actions (macOS) - └─ .github/workflows/ └─ ios-build.yml - └─ ios-build.yml ├─ Check out the snapshot - ├─ Build with Xcode -builder ios build ───────────────────► Upload IPA artifact - │ pushes a snapshot of - │ your working tree - └─ Downloads IPA ◄─────────────── artifact: ipa -``` - -`builder ios build` builds what is on disk, not your last commit: uncommitted -and untracked files are included, so you can try a change without committing -it. The snapshot is a throwaway commit pushed to a hidden ref that is deleted -when the build finishes; no branch is created and nothing is committed on your -behalf. `.gitignore` still applies, so ignored files such as `.env` or -`GoogleService-Info.plist` are absent from the build. - -## Quick Start - -### 1. Authenticate with GitHub - -```bash -builder auth github -``` - -### 2. Initialize (in your project directory) - -```bash -cd your-ios-project -builder init -``` - -This detects your GitHub repo, creates the workflow files, and offers to commit, push, and trigger your first build - all interactively. - -### 3. Build - -```bash -builder ios build -``` - -The CLI triggers the workflow and downloads the IPA to `./dist/`. - -### 4. Try it on a simulator (optional) - -```bash -builder ios share -``` - -Builds the working tree for the iOS simulator and makes that simulator usable -from the [MobAI](https://mobai.run) app, so you can tap through a build without -a Mac. It shows up under CI Devices, stays available while you are using it, and -closes when you release it there or leave it unused (30 minutes by default, use -`--duration` to change). A coding agent connected to MobAI (Claude Code, Codex, -Cursor) can drive the simulator the same way. - -Free with any MobAI account, on [MobAI 3.0 or later](https://mobai.run). Needs -a `MOBAI_API_KEY` repository secret: create the key in the MobAI app under -Account → API Keys, then: - -```bash -gh secret set MOBAI_API_KEY -``` - -### Triggering from git only - -Where the GitHub API is not reachable, both workflows can also be started by -pushing a tag. Commit the tree you want built, then: - -```bash -git tag ios-build/my-build && git push origin ios-build/my-build # IPA build -git tag ios-share/my-build && git push origin ios-share/my-build # simulator -``` - -The run is named after the tag. Build settings come from `builder.json` in the -tagged commit (`ios.path`, `ios.scheme`, `ios.signing`, `ios.configuration`, -`flutter.version`, `kmp.jdkVersion`), the simulator stays available for the -default 30 minutes, and the tag is deleted when the run ends. The IPA is -attached to the run as an artifact. A tag carries no flags, so a tag build -cannot pick a [profile](#build-profiles) per run; it applies the profile named -by `defaultProfile`, if there is one. - -## Additional macOS Providers - -GitHub Actions remains the default, so existing commands continue to work. Add -Codemagic and Bitrise without logging out of GitHub. First follow the -[app creation and repository connection guide](docs/provider-setup.md) to create -each provider app, authorize GitHub access, and find its app ID: - -```bash -builder auth codemagic -builder auth bitrise -builder auth status -builder init --provider codemagic --app-id YOUR_APP_ID --branch main -builder init --provider bitrise --app-id YOUR_APP_SLUG --branch main -builder ios build --provider codemagic -builder ios build --provider bitrise -``` - -`init` writes `codemagic.yaml` or `bitrise.yml` at the repo root plus the shared -runner script `.builder/ci/runner.sh`. Commit them to the configured branch -and connect the same repository to each provider before building. See -[provider setup, signing, simulator sessions, and free allowances](docs/providers.md). - -## Supported Frameworks - -| Framework | iOS Path | Auto-detected | -|-----------|----------|---------------| -| Native iOS/Swift | `.` (root) | Yes | -| React Native | `ios/` | Yes | -| Expo (ejected) | `ios/` | Yes | -| Flutter | `ios/` | Yes | -| Kotlin Multiplatform | `iosApp/` | Yes | -| Cordova/Ionic | `platforms/ios/` | Yes | - -## Installation - -### Windows - -Download `builder-windows-amd64.exe` from [Releases](https://github.com/MobAI-App/ios-builder/releases), rename it to `builder.exe`, and add it to PATH. - -### Homebrew (macOS/Linux) - -```bash -brew install mobai-app/tap/ios-builder -``` - -The formula is named `ios-builder`; the command it installs is `builder`. - -### macOS/Linux/WSL - -```bash -curl -sSL https://raw.githubusercontent.com/MobAI-App/ios-builder/main/install.sh | bash -``` - -### From Source - -```bash -git clone https://github.com/MobAI-App/ios-builder.git -cd ios-builder -go build -o builder ./cmd/builder -``` - -## Commands - -```bash -# Setup -builder auth github # Authenticate with GitHub -builder auth codemagic # Authenticate with Codemagic (also: bitrise) -builder auth apple # Save an App Store Connect API key -builder auth status # Show which providers you are signed in to -builder auth logout [name] # Remove stored credentials (github, codemagic, bitrise, apple) -builder init # Set up workflows in current repo -builder update # Update builder to the latest release - -# Building (builds the working tree, including uncommitted changes) -builder ios build # Trigger build and download IPA to ./dist/ -builder ios build --unsigned # Build without code signing (if signing is configured) -builder ios build --provider codemagic # Build on another provider (also: bitrise) -builder ios build --profile production # Build with a profile from builder.json - -# Simulator (free, needs a MOBAI_API_KEY secret) -builder ios share # Try the build on a simulator in the MobAI app -builder ios share --duration 1h # Keep it available longer while unused - -# Development (requires MobAI) -builder dev flutter # Flutter hot reload with file watching -builder dev flutter --no-watch # Disable automatic file watching -builder dev flutter --no-attach # Print flutter attach command instead of running it -builder dev rn # React Native hot reload (short for: dev react-native) -builder dev kmp # Kotlin Multiplatform install + launch (alias: kotlin) -builder dev kmp --logs # Also stream the app's output -builder dev flutter --skip-install --bundle-id # Use already installed app -builder dev rn --metro-port 8082 # Use custom Metro port - -# MobAI (used by the dev commands; handy for troubleshooting) -builder mobai ping # Check MobAI connectivity -builder mobai install # Install an IPA on the device -builder mobai run-debug # Launch an app with the debugger attached -builder mobai forward # Forward a device port - -# Code signing (automatic mode needs builder auth apple) -builder signing setup --devices-from-mobai # development: certificate, devices, profile, GitHub secrets, no portal -builder signing setup --distribution store # Apple Distribution certificate + App Store profile -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision # Upload your own files -builder ios build --profile store # Signs with the set; provisions it first when missing -builder signing csr # Manual path: create a private key + certificate signing request -builder signing p12 # Manual path: assemble a .p12 from the key and Apple's certificate - -# TestFlight and App Store (needs builder auth apple) -builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing -builder ios submit --testflight --group "Beta Testers" --notes "What to test" -builder ios submit --app-store --release after-approval # Submit the version for App Review -``` - -Every `upload`/`submit` command takes `--json` for machine-readable output and -never prompts, so agents and CI jobs can drive them. - -## Configuration - -`builder.json`: - -```json -{ - "project": "MyApp", - "platform": "ios", - "github": { - "owner": "username", - "repo": "my-ios-app" - }, - "ios": { - "path": "ios", - "scheme": "", - "bundleId": "com.example.app", - "configuration": "Debug" - }, - "profiles": { - "development": { "distribution": "development" }, - "store": { "distribution": "store" } - }, - "mobai": { - "url": "http://localhost:8686", - "device_id": "" - }, - "flutter": { - "watch": { - "dirs": ["lib"], - "patterns": [".dart"], - "ignore": [".g.dart", ".freezed.dart"], - "debounce": 100 - } - } -} -``` - -### iOS Build Configuration - -| Field | Description | Default | -|-------|-------------|---------| -| `ios.path` | Path to the Xcode project relative to the repo root | detected by `init` | -| `ios.scheme` | Xcode scheme to build | auto-detected | -| `ios.bundleId` | App bundle identifier, used by `signing setup` | detected by `init` when the project has one app target; else saved by `signing setup` | -| `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` | -| `ios.signing` | Legacy: sign builds that select no profile, with the unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and `IOS_PROVISIONING_PROFILE` secrets. Profiles ignore it; use `distribution` there | `false` | - -### Build Profiles - -Profiles are named sets of build settings, in the spirit of `eas.json`, selected -with `--profile` on `ios build` and `ios share`: - -```json -{ - "ios": { "path": "ios", "bundleId": "com.example.app" }, - "defaultProfile": "development", - "profiles": { - "development": { "distribution": "development" }, - "preview": { "distribution": "internal", - "env": { "API_URL": "https://staging.example.com" } }, - "production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic" } - } -} -``` - -```bash -builder ios build --profile preview -builder ios share --profile preview -``` - -| Field | Description | -|-------|-------------| -| `distribution` | The only signing setting: `development`, `ad-hoc` (or `internal`, the same thing), `store` or `enterprise`. The build signs with that distribution's [signing set](#code-signing) and its provisioning profile must be of that type; the IPA is exported with the matching method. Omitted means an unsigned build | -| `configuration` | Overrides the derived configuration: `Debug` for `development`, `Release` for every other distribution, `ios.configuration` for unsigned profiles | -| `scheme` | Overrides `ios.scheme` | -| `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) | -| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them | - -How a build's settings are resolved: - -- Without `--profile`, the profile named by `defaultProfile` applies. With - neither, the top-level `ios.*` and `provider` settings are used exactly as - before, so existing projects are unaffected. -- A profile only overrides the fields it sets; everything else comes from the - top level. An unknown profile name is an error that lists the available ones. -- `--unsigned` and `--provider` on the command line override the profile. -- The resolved settings (profile, configuration, scheme, signing set, provider, - env names) are printed before anything is dispatched. -- `ios share` only takes the profile's scheme, provider and env: simulator - builds are always Debug and unsigned. - -**`env` values are build-time configuration, not secrets.** They are stored in -`builder.json`, sent to the CI provider as plain workflow inputs, and visible in -the run's inputs and logs. Keep tokens and passwords in the provider's secrets -instead (`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets -guide](docs/provider-secrets.md)); the build reads those as environment -variables too. Names the runner owns are rejected: its own parameters -(`SCHEME`, `CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing -secrets, `PATH`, `HOME`, `DEVELOPER_DIR`, and anything starting with `GITHUB_`, -`RUNNER_`, `CM_`, `BITRISE_` or `BUILDER_`. - -Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow -files from this version of Builder, which declare a `profile` input; an older -committed workflow rejects the dispatch. Run `builder init` again to refresh -`.github/workflows/ios-build.yml` and `ios-share.yml` (or `builder init ---provider ...` for `runner.sh`) in a project set up earlier, then commit and -push them to the default branch. - -### MobAI Configuration - -| Field | Description | Default | -|-------|-------------|---------| -| `mobai.url` | MobAI API URL | `http://localhost:8686` | -| `mobai.device_id` | Preferred device ID (uses first available if empty) | `""` | - -**WSL users**: MobAI runs on Windows, and WSL has its own network by default. On -Windows 11, turn on -[mirrored networking](https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking) -and builder reaches MobAI on the default `http://localhost:8686`. See -[Using Builder from WSL](docs/wsl.md) for the steps, and for the setup without -mirrored networking. - -### Flutter File Watcher - -| Field | Description | Default | -|-------|-------------|---------| -| `flutter.watch.dirs` | Directories to watch | `["lib"]` | -| `flutter.watch.patterns` | File patterns to match | `[".dart"]` | -| `flutter.watch.ignore` | Patterns to ignore | `[".g.dart", ".freezed.dart"]` | -| `flutter.watch.debounce` | Debounce delay in ms | `100` | - -## Code Signing - -By default, builds are unsigned. A signed build needs a certificate and a -provisioning profile — and despite what many guides claim, **you do not need a -Mac to create either one**, nor a tour of the Apple Developer portal. Signing -is configured per [build profile](#build-profiles) with one field, -`distribution`, and `builder signing setup` produces the material for it -through the App Store Connect API (or takes your own files). - -You need a paid [Apple Developer Program](https://developer.apple.com/programs/) -membership — Apple only issues certificates to paid accounts. (Without one, -build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free -Apple ID.) - -### Profiles and signing sets - -Each distribution has its own set of three GitHub secrets, so a development set -for your devices and a store set for TestFlight live side by side: - -| `distribution` | Certificate, profile | Secrets | -|----------------|----------------------|---------| -| `development` | Apple Development, iOS App Development (devices required) | `IOS_CERTIFICATE_DEVELOPMENT`, `IOS_CERTIFICATE_PASSWORD_DEVELOPMENT`, `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | -| `ad-hoc` or `internal` | Apple Distribution, Ad Hoc (devices required) | `IOS_CERTIFICATE_AD_HOC`, `IOS_CERTIFICATE_PASSWORD_AD_HOC`, `IOS_PROVISIONING_PROFILE_AD_HOC` | -| `store` | Apple Distribution, App Store | `IOS_CERTIFICATE_STORE`, `IOS_CERTIFICATE_PASSWORD_STORE`, `IOS_PROVISIONING_PROFILE_STORE` | -| `enterprise` | In-house (portal only) | `IOS_CERTIFICATE_ENTERPRISE`, `IOS_CERTIFICATE_PASSWORD_ENTERPRISE`, `IOS_PROVISIONING_PROFILE_ENTERPRISE` | - -A build with `--profile ` signs with the set of that profile's -`distribution`; `configuration` follows it (`Debug` for `development`, -`Release` otherwise) unless the profile sets one. The runner checks that the -profile in the set is of the requested type and fails by name before compiling -anything, and a distribution profile refuses a `Debug` configuration. On -Codemagic and Bitrise the same names are variables you add in the dashboard, -see the [secrets guide](docs/provider-secrets.md). - -### `builder signing setup` - -```bash -builder auth apple # once: save the App Store Connect API key -builder signing setup --devices-from-mobai # development set for the devices MobAI sees -builder signing setup --distribution store # store set for TestFlight / App Store -``` - -Without files, `setup` works through the App Store Connect API for the given -`--distribution` (default `development`; `--name ` reads it from an -existing profile). The key needs the **Admin** role (or App Manager plus -*Access to Certificates, Identifiers & Profiles*): Developer-role keys cannot -create certificates. It then: - -1. Registers the **App ID** if the bundle identifier is not on the account yet. - The bundle ID comes from `--bundle-id`, `ios.bundleId` in `builder.json` - (which `init` fills when the Xcode project has a single app target), or the - newest IPA in `./dist/`; in a terminal it asks as a last resort. -2. Issues a **certificate** — Apple Development for `development`, Apple - Distribution for `ad-hoc` and `store` — for a private key generated on your - machine (`ios-signing-.key`, or `--key` to reuse one from - `signing csr`; a `ios-signing.key` from an earlier version is picked up - too). A valid certificate on the account is reused only when its private - key is here, because that is the only way to build the `.p12`; otherwise a - new one is issued. Nothing is ever revoked: when Apple's limit (2 - Development, 3 Distribution) is hit, the error names it and points at the - portal. -3. Registers **devices** from `--device ` (repeatable) and - `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has - connected; simulators and cloud farm devices are skipped). Development and - ad-hoc profiles cover every enabled iOS device on the account, so with none - given and none registered the command stops and says so. Store profiles - take no devices. Apple allows 100 devices per membership year and never - frees a slot; that error is passed through too. -4. Creates the **profile** `Builder `. An existing - one is reused while it is `ACTIVE`, unexpired and still lists exactly this - certificate and these devices; otherwise it is deleted and recreated, and - the summary says why (`invalid`, `expired`, `certificate changed`, `devices - changed`, `forced`). -5. Writes `ios-signing-.key` (when generated), - `ios-signing-.p12` and `Builder--.mobileprovision` to `--out-dir` (default `.`), uploads the three secrets - of the set to GitHub, and writes the build profile in `builder.json`: - `--name` (default: the distribution name) with `"distribution": - ""`. Other fields of an existing profile are kept. For - Codemagic and Bitrise it prints the three secret names and file paths to - paste instead, following the [secrets guide](docs/provider-secrets.md). - -The command shows its plan and asks once before creating anything; `--yes` -skips that (required without a terminal), and then the `.p12` password is -generated and printed once unless `--password` is given. `--json` prints the -result as JSON with progress on stderr. Keep the written files out of git. -Run it again whenever you like: it reports what it found and recreates only -what is missing, expired, invalid or changed — add a device, re-run, rebuild. -`--force` issues a fresh certificate and profile regardless. - -With `--certificate` and `--profile`, `setup` takes your own files instead — a -`.p12` (from Keychain Access, or [assembled here](#manual-path-through-the-apple-developer-portal)) -and a `.mobileprovision` — reads the distribution out of the profile -(development, ad-hoc, store or enterprise; this is the only way in for -enterprise), uploads that set and writes the build profile the same way: - -```bash -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision -``` - -### Provisioning from `ios build` - -`builder ios build --profile ` checks, before dispatching to GitHub, -that the repository holds all three secrets of the profile's set. When any is -missing and an App Store Connect key is saved, it runs the same provisioning as -`signing setup` without prompts, uploads the set and then builds. A development -or ad-hoc profile needs at least one registered device; with none, the build -stops and points at `builder signing setup --distribution development ---devices-from-mobai`. Without an Apple key the build stops before anything is -pushed and names both ways out: `builder auth apple`, or `builder signing setup ---certificate ... --profile ...`. `--unsigned` skips all of this, and -Codemagic/Bitrise builds skip the check (no secrets API): their runner -reports a missing set itself. - -### Legacy: `ios.signing` without profiles - -A project set up before build profiles has `ios.signing: true` and the -unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and -`IOS_PROVISIONING_PROFILE` secrets. Builds that select no profile still sign -with those, whatever the profile type, exactly as before; `setup` never -touches them. Profiles ignore `ios.signing` and read their own set. - -### Manual path through the Apple Developer portal - -The `.p12` certificate is normally created through Keychain Access, but Builder -does the same thing itself: it generates the private key and certificate -signing request, and assembles the `.p12` from the certificate Apple issues. - -#### 1. Create a certificate signing request - -```bash -builder signing csr -``` - -This asks for your name and email and writes two files to the current -directory: `ios-signing.key` (your private key) and `ios-signing.csr`. Keep -the key wherever suits you — just don't commit it (add it to `.gitignore`; -gitignored files are also excluded from build snapshots). - -#### 2. Create the certificate - -1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal -2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc). TestFlight and App Store uploads need **Apple Distribution** together with an App Store profile in step 4 -3. Upload `ios-signing.csr` and download the resulting `.cer` file - -#### 3. Assemble the .p12 - -```bash -builder signing p12 --certificate development.cer --key ios-signing.key -``` - -This combines the key and certificate into `ios-signing.p12` (`--out` to name -it), protected by a password you choose — byte-for-byte the same kind of file -Keychain Access exports, and usable anywhere one is: `builder signing setup`, -Sideloadly, AltStore, or importing it on a Mac. Keep it, and don't commit it. - -#### 4. Create a provisioning profile - -On the portal: - -1. **Identifiers** → register an App ID matching your app's bundle identifier -2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page) -3. **Profiles** → create an **iOS App Development** (or Ad Hoc, App Store) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file - -The profile type decides the distribution the set is written to and the method -the IPA is exported with: development, ad-hoc, enterprise or store. - -#### 5. Upload the signing secrets - -```bash -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision -``` - -You can also skip step 3 and hand `setup` the `.cer` together with the key — -`builder signing setup --certificate development.cer --key ios-signing.key ---profile MyApp.mobileprovision` — and it assembles the `.p12` on the way, -saving it as `ios-signing-.p12`. Then `builder ios build ---profile `; `--unsigned` skips signing for one build. - -## TestFlight and App Store - -Builder uploads builds to App Store Connect and submits them to TestFlight or -App Review through the App Store Connect API, from Windows, Linux or macOS. No -Transporter, `altool` or Xcode is involved, and the API key never leaves your -machine: the CI runner only builds and signs, the upload happens locally from -the IPA in `./dist/`. - -You need: - -- A paid [Apple Developer Program](https://developer.apple.com/programs/) - membership and an app record in App Store Connect (My Apps → +) with your - bundle ID -- An IPA signed with an **Apple Distribution** certificate and an **App Store** - provisioning profile: `builder signing setup --distribution store` creates - both, stores them as the `STORE` signing set and writes a `store` build - profile, or pick those types on the portal in the manual path. An IPA signed - for development is rejected at upload. -- `builder ios build --profile store` (see [Build Profiles](#build-profiles)): - a `store` profile builds `Release` and signs with that set; a plain `ios - build` is Debug and unsigned, which is what the dev commands expect, not - what you want to ship. -- An App Store Connect API key: App Store Connect → Users and Access → - Integrations → App Store Connect API → Team Keys. Give it the **App Manager** - role, note the **Issuer ID** and **Key ID**, and download the - `AuthKey_.p8` file (Apple offers the download once). - -### 1. Save the API key - -```bash -builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key AuthKey_ABC123DEFG.p8 -``` - -Flags you leave out are prompted for. Builder verifies the key against App -Store Connect and stores it like the other logins (keychain, or a `0600` file on -Linux/WSL); `builder auth status` shows it and `builder auth logout apple` -removes it. In CI or for a coding agent, set `ASC_ISSUER_ID`, `ASC_KEY_ID` and -either `ASC_PRIVATE_KEY` (the .p8 contents; literal `\n` is fine) or -`ASC_KEY_PATH` instead — they take precedence over the saved login. - -### 2. Upload the build - -```bash -builder ios build # produces a signed dist/*.ipa -builder ios upload --wait -``` - -`upload` reads the bundle ID, version and build number from the newest IPA in -`./dist/` (or `--ipa `), finds the app, uploads the archive in chunks -and, with `--wait`, follows App Store Connect until the build has finished -processing and prints its build ID and TestFlight link. Without `--wait` it -returns as soon as Apple has the file. - -Two things Apple checks on every upload: - -- **Build numbers must increase.** A second upload with the same - `CFBundleVersion` for the same version is rejected (`ITMS-90189`), so bump - it before rebuilding. -- **Export compliance.** A build shows as *Missing Compliance* in TestFlight - until you say whether it uses non-exempt encryption. If your Info.plist sets - `ITSAppUsesNonExemptEncryption` to `false`, `upload --wait` answers that - automatically; otherwise pass `--no-encryption` (here or to `submit`) when - your app only uses standard iOS encryption. - -### 3. Distribute to TestFlight - -```bash -builder ios submit --testflight --group "Beta Testers" --notes "New login flow" -``` - -This takes the newest processed build (or `--build-number N`), sets the *What -to Test* notes and adds it to the named groups (`--group` repeats). Internal -groups get the build immediately; the first external group triggers Apple's -beta review, which Builder submits for you (`--wait` follows the decision). Run -it without `--group` to see the build and the groups the app has. - -### 4. Submit to the App Store - -```bash -builder ios submit --app-store --release after-approval -``` - -Builder finds or creates the App Store version matching the IPA's marketing -version (or `--version X.Y.Z`), attaches the build, sets the release type -(`manual` or `after-approval`) and submits it for review. The version's -metadata — description, screenshots, age rating, pricing, privacy — must -already be complete: App Store Connect refuses the submission otherwise and -Builder prints Apple's reasons verbatim. Builder does not manage metadata, -screenshots or in-app purchases; fill them in App Store Connect, or on a Mac -with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of -the `buildUploads` API also proved that the Mac-free upload path works and -served as the reference for Builder's implementation. - -## Installing the IPA - -Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account). - -## Development on Windows/Linux - -Builder supports hot reload for Flutter and React Native on Windows/Linux using [MobAI](https://mobai.run) for iOS device control. This allows you to develop iOS apps without a Mac. - -## Flutter Development - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` - This creates an IPA in `./dist/` -3. Start development with hot reload: - ```bash - builder dev flutter - ``` - Builder installs the IPA through MobAI and asks whether to re-sign it. Re-signing requires an iCloud account - we highly recommend creating a new one at [icloud.com](https://icloud.com) instead of using your primary account. A re-signed app gets a new bundle ID with a team ID suffix (e.g., `com.example.myapp.TEAMID`); Builder prefills it in the prompt that follows. - -### Subsequent Runs - -Once the app is installed, skip the install step: -```bash -builder dev flutter --skip-install --bundle-id com.example.myapp.TEAMID -``` - -### File Watching - -By default, `builder dev flutter` watches for Dart file changes and automatically triggers hot reload. When flutter attach connects, it also sends an initial hot restart to ensure your latest code is running. - -- **Automatic hot reload**: Edit a `.dart` file and save - hot reload triggers automatically -- **Generated files ignored**: Files like `.g.dart` and `.freezed.dart` are ignored by default -- **Configurable**: Customize watched directories, patterns, and debounce via `builder.json` - -To disable file watching: -```bash -builder dev flutter --no-watch -``` - -To print the `flutter attach` command instead of running it (useful for IDE integration): -```bash -builder dev flutter --no-attach -``` - -### When to Rebuild - -- **Native code changes** (Swift, Objective-C, Podfile, native dependencies): Run `builder ios build` and reinstall -- **Dart code changes only**: No rebuild needed - file watcher triggers hot reload automatically - -If you don't see your recent Dart changes after launching, press `R` in the terminal to perform a hot restart. - -### Troubleshooting - -**App won't launch / connection error** -- Close the app on your device before running `builder dev flutter` -- Reconnect the device (unplug/replug USB) -- Restart MobAI -- Run `builder mobai ping` to verify connection - -**"No devices found" error** -- Ensure MobAI is running and device is connected -- Only physical iOS devices are supported (no simulators) - -**Hot reload not working** -- Make sure you're using the correct bundle ID (with team ID suffix) -- Try hot restart with `R` key -- Check that MobAI shows the device as connected - -**File watcher not triggering** -- Ensure you're editing files in watched directories (default: `lib/`) -- Check if the file matches watch patterns (default: `.dart`) -- Generated files (`.g.dart`, `.freezed.dart`) are ignored by default -- Try running without `--no-watch` flag - -## React Native Development - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` -3. Start development with hot reload: - ```bash - builder dev rn - ``` - This will: - - Start Metro bundler if not running - - Install the IPA on your device (with optional re-signing) - - Launch the app with Metro URL configured automatically - -### Subsequent Runs - -Once the app is installed: -```bash -builder dev rn --skip-install --bundle-id com.example.myapp.TEAMID -``` - -### Custom Metro Port - -If port 8081 is in use: -```bash -builder dev rn --metro-port 8082 -``` - -### When to Rebuild - -- **Native code changes** (Swift, Objective-C, Podfile, native modules): Run `builder ios build` and reinstall -- **JavaScript changes only**: No rebuild needed - Metro handles it automatically - -### Troubleshooting - -**Metro not starting** -- Ensure Node.js and React Native CLI are installed -- Try starting Metro manually: `npx react-native start` - -**App not connecting to Metro** -- Device must be on the same WiFi network as the computer running Metro -- Check that Metro is running and accessible -- Verify the Metro port is correct (default: 8081) -- On WSL with mirrored networking, the Hyper-V firewall blocks the phone from reaching Metro by default; see [Using Builder from WSL](docs/wsl.md#react-native) for the firewall rule - -**Hot reload not working** -- Shake device or press `d` in Metro terminal to open dev menu -- Enable "Fast Refresh" in dev menu -- Try reloading with `r` in Metro terminal - -## Kotlin Multiplatform Development - -KMP iOS apps build and run on a device like any other project, with one -difference: **there is no hot reload.** Shared Kotlin is compiled into a native -framework at build time, so there is no runtime to swap code into — every code -change needs a rebuild. - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` -3. Install and launch it on the device: - ```bash - builder dev kmp - ``` - -`builder init` detects Kotlin Multiplatform projects by looking for the -multiplatform Gradle plugin in the root and module build files, and asks which -JDK the CI build should use (default 17): - -```json -{ - "kmp": { "jdkVersion": "17" } -} -``` - -On CI, the iOS app is built with `xcodebuild`, whose run script phase (or -CocoaPods) invokes Gradle to compile the shared framework — which is why the -JDK version matters. Gradle output is cached between builds. - -### When to Rebuild - -Every change to Kotlin or Swift code needs `builder ios build` followed by -`builder dev kmp` again. Use `--skip-install --bundle-id ` to relaunch an -app that is already installed. - -### Troubleshooting - -**Build fails with "Unsupported class file major version" or a Gradle JDK error** -- The project needs a different JDK than the default: set `kmp.jdkVersion` in `builder.json` to match what the project uses locally - -**Build fails with "SDK does not contain 'libarclite'"** -- An old Kotlin/Native version against a newer Xcode; upgrade the Kotlin plugin in Gradle - -**App launches then immediately exits** -- Launch with `builder dev kmp --logs` to see the device output - -## Build Limits - -Free allowances belong to each provider account and depend on the plan and -machine. As published in September 2026: Codemagic personal accounts include -500 macOS M2 minutes per month; Bitrise Hobby includes 300 credits. GitHub has -separate allowances for private repositories and free standard runners for -public repositories. Providers change these, so check the -[current allowance links and switching guidance](docs/providers.md#free-allowances). - -## License - -[MIT License](LICENSE) +# Builder + +Build and develop iOS apps from Windows, Linux, or any platform. + +Builder is a CLI tool for iOS development without a Mac. It uses GitHub Actions (default), Codemagic, or Bitrise for remote builds and [MobAI](https://mobai.run) for on-device development. + +![Builder Demo](assets/ios-builder-demo.gif) + +## Features + +- **Build from anywhere**: Build iOS apps via GitHub Actions, Codemagic, or Bitrise +- **Independent provider logins**: Stay signed in to all three and choose where each build runs +- **Try it on a simulator**: Use your build on an iOS simulator from Windows or Linux +- **Flutter & React Native dev tools**: Hot reload on real iOS devices from Windows/Linux +- **Simple setup**: One command to add the workflow to your repo +- **Code signing**: Optional signing with your certificate and provisioning profile +- **TestFlight and App Store**: Upload builds and submit them for review through the App Store Connect API, from any platform +- **Device integration**: Install and run apps via MobAI + +## How It Works + +``` +Your Repository GitHub Actions (macOS) + └─ .github/workflows/ └─ ios-build.yml + └─ ios-build.yml ├─ Check out the snapshot + ├─ Build with Xcode +builder ios build ───────────────────► Upload IPA artifact + │ pushes a snapshot of + │ your working tree + └─ Downloads IPA ◄─────────────── artifact: ipa +``` + +`builder ios build` builds what is on disk, not your last commit: uncommitted +and untracked files are included, so you can try a change without committing +it. The snapshot is a throwaway commit pushed to a hidden ref that is deleted +when the build finishes; no branch is created and nothing is committed on your +behalf. `.gitignore` still applies, so ignored files such as `.env` or +`GoogleService-Info.plist` are absent from the build. + +## Quick Start + +### 1. Authenticate with GitHub + +```bash +builder auth github +``` + +### 2. Initialize (in your project directory) + +```bash +cd your-ios-project +builder init +``` + +This detects your GitHub repo, creates the workflow files, and offers to commit, push, and trigger your first build - all interactively. + +### 3. Build + +```bash +builder ios build +``` + +The CLI triggers the workflow and downloads the IPA to `./dist/`. + +### 4. Try it on a simulator (optional) + +```bash +builder ios share +``` + +Builds the working tree for the iOS simulator and makes that simulator usable +from the [MobAI](https://mobai.run) app, so you can tap through a build without +a Mac. It shows up under CI Devices, stays available while you are using it, and +closes when you release it there or leave it unused (30 minutes by default, use +`--duration` to change). A coding agent connected to MobAI (Claude Code, Codex, +Cursor) can drive the simulator the same way. + +Free with any MobAI account, on [MobAI 3.0 or later](https://mobai.run). Needs +a `MOBAI_API_KEY` repository secret: create the key in the MobAI app under +Account → API Keys, then: + +```bash +gh secret set MOBAI_API_KEY +``` + +### Triggering from git only + +Where the GitHub API is not reachable, both workflows can also be started by +pushing a tag. Commit the tree you want built, then: + +```bash +git tag ios-build/my-build && git push origin ios-build/my-build # IPA build +git tag ios-share/my-build && git push origin ios-share/my-build # simulator +``` + +The run is named after the tag. Build settings come from `builder.json` in the +tagged commit (`ios.path`, `ios.scheme`, `ios.signing`, `ios.configuration`, +`flutter.version`, `kmp.jdkVersion`), the simulator stays available for the +default 30 minutes, and the tag is deleted when the run ends. The IPA is +attached to the run as an artifact. A tag carries no flags, so a tag build +cannot pick a [profile](#build-profiles) per run; it applies the profile named +by `defaultProfile`, if there is one. + +## Additional macOS Providers + +GitHub Actions remains the default, so existing commands continue to work. Add +Codemagic and Bitrise without logging out of GitHub. First follow the +[app creation and repository connection guide](docs/provider-setup.md) to create +each provider app, authorize GitHub access, and find its app ID: + +```bash +builder auth codemagic +builder auth bitrise +builder auth status +builder init --provider codemagic --app-id YOUR_APP_ID --branch main +builder init --provider bitrise --app-id YOUR_APP_SLUG --branch main +builder ios build --provider codemagic +builder ios build --provider bitrise +``` + +`init` writes `codemagic.yaml` or `bitrise.yml` at the repo root plus the shared +runner script `.builder/ci/runner.sh`. Commit them to the configured branch +and connect the same repository to each provider before building. See +[provider setup, signing, simulator sessions, and free allowances](docs/providers.md). + +## Supported Frameworks + +| Framework | iOS Path | Auto-detected | +|-----------|----------|---------------| +| Native iOS/Swift | `.` (root) | Yes | +| React Native | `ios/` | Yes | +| Expo (ejected) | `ios/` | Yes | +| Flutter | `ios/` | Yes | +| Kotlin Multiplatform | `iosApp/` | Yes | +| Cordova/Ionic | `platforms/ios/` | Yes | + +## Installation + +### Windows + +Download `builder-windows-amd64.exe` from [Releases](https://github.com/MobAI-App/ios-builder/releases), rename it to `builder.exe`, and add it to PATH. + +### Homebrew (macOS/Linux) + +```bash +brew install mobai-app/tap/ios-builder +``` + +The formula is named `ios-builder`; the command it installs is `builder`. + +### macOS/Linux/WSL + +```bash +curl -sSL https://raw.githubusercontent.com/MobAI-App/ios-builder/main/install.sh | bash +``` + +### From Source + +```bash +git clone https://github.com/MobAI-App/ios-builder.git +cd ios-builder +go build -o builder ./cmd/builder +``` + +## Commands + +```bash +# Setup +builder auth github # Authenticate with GitHub +builder auth codemagic # Authenticate with Codemagic (also: bitrise) +builder auth apple # Save an App Store Connect API key +builder auth status # Show which providers you are signed in to +builder auth logout [name] # Remove stored credentials (github, codemagic, bitrise, apple) +builder init # Set up workflows in current repo +builder update # Update builder to the latest release + +# Building (builds the working tree, including uncommitted changes) +builder ios build # Trigger build and download IPA to ./dist/ +builder ios build --unsigned # Build without code signing (if signing is configured) +builder ios build --provider codemagic # Build on another provider (also: bitrise) +builder ios build --profile production # Build with a profile from builder.json + +# Simulator (free, needs a MOBAI_API_KEY secret) +builder ios share # Try the build on a simulator in the MobAI app +builder ios share --duration 1h # Keep it available longer while unused + +# Development (requires MobAI) +builder dev flutter # Flutter hot reload with file watching +builder dev flutter --no-watch # Disable automatic file watching +builder dev flutter --no-attach # Print flutter attach command instead of running it +builder dev rn # React Native hot reload (short for: dev react-native) +builder dev kmp # Kotlin Multiplatform install + launch (alias: kotlin) +builder dev kmp --logs # Also stream the app's output +builder dev flutter --skip-install --bundle-id # Use already installed app +builder dev rn --metro-port 8082 # Use custom Metro port + +# MobAI (used by the dev commands; handy for troubleshooting) +builder mobai ping # Check MobAI connectivity +builder mobai install # Install an IPA on the device +builder mobai run-debug # Launch an app with the debugger attached +builder mobai forward # Forward a device port + +# Code signing (automatic mode needs builder auth apple) +builder signing setup --devices-from-mobai # development: certificate, devices, profile, GitHub secrets, no portal +builder signing setup --distribution store # Apple Distribution certificate + App Store profile +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision # Upload your own files +builder ios build --profile store # Signs with the set; provisions it first when missing +builder signing csr # Manual path: create a private key + certificate signing request +builder signing p12 # Manual path: assemble a .p12 from the key and Apple's certificate + +# TestFlight and App Store (needs builder auth apple) +builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing +builder ios submit --testflight --group "Beta Testers" --notes "What to test" +builder ios submit --app-store --release after-approval # Submit the version for App Review +``` + +Every `upload`/`submit` command takes `--json` for machine-readable output and +never prompts, so agents and CI jobs can drive them. + +## Configuration + +`builder.json`: + +```json +{ + "project": "MyApp", + "platform": "ios", + "github": { + "owner": "username", + "repo": "my-ios-app" + }, + "ios": { + "path": "ios", + "scheme": "", + "bundleId": "com.example.app", + "configuration": "Debug" + }, + "profiles": { + "development": { "distribution": "development" }, + "store": { "distribution": "store" } + }, + "mobai": { + "url": "http://localhost:8686", + "device_id": "" + }, + "flutter": { + "watch": { + "dirs": ["lib"], + "patterns": [".dart"], + "ignore": [".g.dart", ".freezed.dart"], + "debounce": 100 + } + } +} +``` + +### iOS Build Configuration + +| Field | Description | Default | +|-------|-------------|---------| +| `ios.path` | Path to the Xcode project relative to the repo root | detected by `init` | +| `ios.scheme` | Xcode scheme to build | auto-detected | +| `ios.bundleId` | App bundle identifier, used by `signing setup` | detected by `init` when the project has one app target; else saved by `signing setup` | +| `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` | +| `ios.signing` | Legacy: sign builds that select no profile, with the unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and `IOS_PROVISIONING_PROFILE` secrets. Profiles ignore it; use `distribution` there | `false` | + +### Build Profiles + +Profiles are named sets of build settings, in the spirit of `eas.json`, selected +with `--profile` on `ios build` and `ios share`: + +```json +{ + "ios": { "path": "ios", "bundleId": "com.example.app" }, + "defaultProfile": "development", + "profiles": { + "development": { "distribution": "development" }, + "preview": { "distribution": "internal", + "env": { "API_URL": "https://staging.example.com" } }, + "production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic" } + } +} +``` + +```bash +builder ios build --profile preview +builder ios share --profile preview +``` + +| Field | Description | +|-------|-------------| +| `distribution` | The only signing setting: `development`, `ad-hoc` (or `internal`, the same thing), `store` or `enterprise`. The build signs with that distribution's [signing set](#code-signing) and its provisioning profile must be of that type; the IPA is exported with the matching method. Omitted means an unsigned build | +| `configuration` | Overrides the derived configuration: `Debug` for `development`, `Release` for every other distribution, `ios.configuration` for unsigned profiles | +| `scheme` | Overrides `ios.scheme` | +| `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) | +| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them | + +How a build's settings are resolved: + +- Without `--profile`, the profile named by `defaultProfile` applies. With + neither, the top-level `ios.*` and `provider` settings are used exactly as + before, so existing projects are unaffected. +- A profile only overrides the fields it sets; everything else comes from the + top level. An unknown profile name is an error that lists the available ones. +- `--unsigned` and `--provider` on the command line override the profile. +- The resolved settings (profile, configuration, scheme, signing set, provider, + env names) are printed before anything is dispatched. +- `ios share` only takes the profile's scheme, provider and env: simulator + builds are always Debug and unsigned. + +**`env` values are build-time configuration, not secrets.** They are stored in +`builder.json`, sent to the CI provider as plain workflow inputs, and visible in +the run's inputs and logs. Keep tokens and passwords in the provider's secrets +instead (`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets +guide](docs/provider-secrets.md)); the build reads those as environment +variables too. Names the runner owns are rejected: its own parameters +(`SCHEME`, `CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing +secrets, `PATH`, `HOME`, `DEVELOPER_DIR`, and anything starting with `GITHUB_`, +`RUNNER_`, `CM_`, `BITRISE_` or `BUILDER_`. + +Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow +files from this version of Builder, which declare a `profile` input; an older +committed workflow rejects the dispatch. Run `builder init` again to refresh +`.github/workflows/ios-build.yml` and `ios-share.yml` (or `builder init +--provider ...` for `runner.sh`) in a project set up earlier, then commit and +push them to the default branch. + +### MobAI Configuration + +| Field | Description | Default | +|-------|-------------|---------| +| `mobai.url` | MobAI API URL | `http://localhost:8686` | +| `mobai.device_id` | Preferred device ID (uses first available if empty) | `""` | + +**WSL users**: MobAI runs on Windows, and WSL has its own network by default. On +Windows 11, turn on +[mirrored networking](https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking) +and builder reaches MobAI on the default `http://localhost:8686`. See +[Using Builder from WSL](docs/wsl.md) for the steps, and for the setup without +mirrored networking. + +### Flutter File Watcher + +| Field | Description | Default | +|-------|-------------|---------| +| `flutter.watch.dirs` | Directories to watch | `["lib"]` | +| `flutter.watch.patterns` | File patterns to match | `[".dart"]` | +| `flutter.watch.ignore` | Patterns to ignore | `[".g.dart", ".freezed.dart"]` | +| `flutter.watch.debounce` | Debounce delay in ms | `100` | + +## Code Signing + +By default, builds are unsigned. A signed build needs a certificate and a +provisioning profile — and despite what many guides claim, **you do not need a +Mac to create either one**, nor a tour of the Apple Developer portal. Signing +is configured per [build profile](#build-profiles) with one field, +`distribution`, and `builder signing setup` produces the material for it +through the App Store Connect API (or takes your own files). + +You need a paid [Apple Developer Program](https://developer.apple.com/programs/) +membership — Apple only issues certificates to paid accounts. (Without one, +build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free +Apple ID.) + +### Profiles and signing sets + +Each distribution has its own set of three GitHub secrets, so a development set +for your devices and a store set for TestFlight live side by side: + +| `distribution` | Certificate, profile | Secrets | +|----------------|----------------------|---------| +| `development` | Apple Development, iOS App Development (devices required) | `IOS_CERTIFICATE_DEVELOPMENT`, `IOS_CERTIFICATE_PASSWORD_DEVELOPMENT`, `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | +| `ad-hoc` or `internal` | Apple Distribution, Ad Hoc (devices required) | `IOS_CERTIFICATE_AD_HOC`, `IOS_CERTIFICATE_PASSWORD_AD_HOC`, `IOS_PROVISIONING_PROFILE_AD_HOC` | +| `store` | Apple Distribution, App Store | `IOS_CERTIFICATE_STORE`, `IOS_CERTIFICATE_PASSWORD_STORE`, `IOS_PROVISIONING_PROFILE_STORE` | +| `enterprise` | In-house (portal only) | `IOS_CERTIFICATE_ENTERPRISE`, `IOS_CERTIFICATE_PASSWORD_ENTERPRISE`, `IOS_PROVISIONING_PROFILE_ENTERPRISE` | + +A build with `--profile ` signs with the set of that profile's +`distribution`; `configuration` follows it (`Debug` for `development`, +`Release` otherwise) unless the profile sets one. The runner checks that the +profile in the set is of the requested type and fails by name before compiling +anything, and a distribution profile refuses a `Debug` configuration. On +Codemagic and Bitrise the same names are variables you add in the dashboard, +see the [secrets guide](docs/provider-secrets.md). + +### `builder signing setup` + +```bash +builder auth apple # once: save the App Store Connect API key +builder signing setup --devices-from-mobai # development set for the devices MobAI sees +builder signing setup --distribution store # store set for TestFlight / App Store +``` + +Without files, `setup` works through the App Store Connect API for the given +`--distribution` (default `development`; `--name ` reads it from an +existing profile). The key needs the **Admin** role (or App Manager plus +*Access to Certificates, Identifiers & Profiles*): Developer-role keys cannot +create certificates. It then: + +1. Registers the **App ID** if the bundle identifier is not on the account yet. + The bundle ID comes from `--bundle-id`, `ios.bundleId` in `builder.json` + (which `init` fills when the Xcode project has a single app target), or the + newest IPA in `./dist/`; in a terminal it asks as a last resort. +2. Issues a **certificate** — Apple Development for `development`, Apple + Distribution for `ad-hoc` and `store` — for a private key generated on your + machine (`ios-signing-.key`, or `--key` to reuse one from + `signing csr`; a `ios-signing.key` from an earlier version is picked up + too). A valid certificate on the account is reused only when its private + key is here, because that is the only way to build the `.p12`; otherwise a + new one is issued. Nothing is ever revoked: when Apple's limit (2 + Development, 3 Distribution) is hit, the error names it and points at the + portal. +3. Registers **devices** from `--device ` (repeatable) and + `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has + connected; simulators and cloud farm devices are skipped). Development and + ad-hoc profiles cover every enabled iOS device on the account, so with none + given and none registered the command stops and says so. Store profiles + take no devices. Apple allows 100 devices per membership year and never + frees a slot; that error is passed through too. +4. Creates the **profile** `Builder `. An existing + one is reused while it is `ACTIVE`, unexpired and still lists exactly this + certificate and these devices; otherwise it is deleted and recreated, and + the summary says why (`invalid`, `expired`, `certificate changed`, `devices + changed`, `forced`). +5. Writes `ios-signing-.key` (when generated), + `ios-signing-.p12` and `Builder--.mobileprovision` to `--out-dir` (default `.`), uploads the three secrets + of the set to GitHub, and writes the build profile in `builder.json`: + `--name` (default: the distribution name) with `"distribution": + ""`. Other fields of an existing profile are kept; a + different `distribution` in it is replaced, and the command says so. + `defaultProfile` is not touched: point it at the profile for a plain + `ios build` to use it, or pass `--profile`. For Codemagic and Bitrise it + prints the three secret names and file paths to paste instead, following + the [secrets guide](docs/provider-secrets.md). + +The command shows its plan and asks once before creating anything; `--yes` +skips that (required without a terminal), and then the `.p12` password is +generated and printed once unless `--password` is given. `--json` prints the +result as JSON with progress on stderr. Keep the written files out of git. +Run it again whenever you like: it reports what it found and recreates only +what is missing, expired, invalid or changed — add a device, re-run, rebuild. +`--force` issues a fresh certificate and profile regardless. + +With `--certificate` and `--profile`, `setup` takes your own files instead — a +`.p12` (from Keychain Access, or [assembled here](#manual-path-through-the-apple-developer-portal)) +and a `.mobileprovision` — reads the distribution out of the profile +(development, ad-hoc, store or enterprise; this is the only way in for +enterprise), uploads that set and writes the build profile the same way: + +```bash +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision +``` + +### Provisioning from `ios build` + +`builder ios build --profile ` checks, before dispatching to GitHub, +that the repository holds all three secrets of the profile's set. When any is +missing and an App Store Connect key is saved, it runs the same provisioning as +`signing setup` without prompts, uploads the set and then builds. A development +or ad-hoc profile needs at least one registered device; with none, the build +stops and points at `builder signing setup --distribution development +--devices-from-mobai`. Without an Apple key the build stops before anything is +pushed and names both ways out: `builder auth apple`, or `builder signing setup +--certificate ... --profile ...`. `--unsigned` skips all of this, and +Codemagic/Bitrise builds skip the check (no secrets API): their runner +reports a missing set itself. + +### Legacy: `ios.signing` without profiles + +A project set up before build profiles has `ios.signing: true` and the +unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and +`IOS_PROVISIONING_PROFILE` secrets. Builds that select no profile still sign +with those, whatever the profile type, exactly as before; `setup` never +touches them. Profiles ignore `ios.signing` and read their own set. + +### Manual path through the Apple Developer portal + +The `.p12` certificate is normally created through Keychain Access, but Builder +does the same thing itself: it generates the private key and certificate +signing request, and assembles the `.p12` from the certificate Apple issues. + +#### 1. Create a certificate signing request + +```bash +builder signing csr +``` + +This asks for your name and email and writes two files to the current +directory: `ios-signing.key` (your private key) and `ios-signing.csr`. Keep +the key wherever suits you — just don't commit it (add it to `.gitignore`; +gitignored files are also excluded from build snapshots). + +#### 2. Create the certificate + +1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal +2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc). TestFlight and App Store uploads need **Apple Distribution** together with an App Store profile in step 4 +3. Upload `ios-signing.csr` and download the resulting `.cer` file + +#### 3. Assemble the .p12 + +```bash +builder signing p12 --certificate development.cer --key ios-signing.key +``` + +This combines the key and certificate into `ios-signing.p12` (`--out` to name +it), protected by a password you choose — byte-for-byte the same kind of file +Keychain Access exports, and usable anywhere one is: `builder signing setup`, +Sideloadly, AltStore, or importing it on a Mac. Keep it, and don't commit it. + +#### 4. Create a provisioning profile + +On the portal: + +1. **Identifiers** → register an App ID matching your app's bundle identifier +2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page) +3. **Profiles** → create an **iOS App Development** (or Ad Hoc, App Store) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file + +The profile type decides the distribution the set is written to and the method +the IPA is exported with: development, ad-hoc, enterprise or store. + +#### 5. Upload the signing secrets + +```bash +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision +``` + +You can also skip step 3 and hand `setup` the `.cer` together with the key — +`builder signing setup --certificate development.cer --key ios-signing.key +--profile MyApp.mobileprovision` — and it assembles the `.p12` on the way, +saving it as `ios-signing-.p12`. Then `builder ios build +--profile `; `--unsigned` skips signing for one build. + +## TestFlight and App Store + +Builder uploads builds to App Store Connect and submits them to TestFlight or +App Review through the App Store Connect API, from Windows, Linux or macOS. No +Transporter, `altool` or Xcode is involved, and the API key never leaves your +machine: the CI runner only builds and signs, the upload happens locally from +the IPA in `./dist/`. + +You need: + +- A paid [Apple Developer Program](https://developer.apple.com/programs/) + membership and an app record in App Store Connect (My Apps → +) with your + bundle ID +- An IPA signed with an **Apple Distribution** certificate and an **App Store** + provisioning profile: `builder signing setup --distribution store` creates + both, stores them as the `STORE` signing set and writes a `store` build + profile, or pick those types on the portal in the manual path. An IPA signed + for development is rejected at upload. +- `builder ios build --profile store` (see [Build Profiles](#build-profiles)): + a `store` profile builds `Release` and signs with that set; a plain `ios + build` is Debug and unsigned, which is what the dev commands expect, not + what you want to ship. +- An App Store Connect API key: App Store Connect → Users and Access → + Integrations → App Store Connect API → Team Keys. Give it the **App Manager** + role, note the **Issuer ID** and **Key ID**, and download the + `AuthKey_.p8` file (Apple offers the download once). + +### 1. Save the API key + +```bash +builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key AuthKey_ABC123DEFG.p8 +``` + +Flags you leave out are prompted for. Builder verifies the key against App +Store Connect and stores it like the other logins (keychain, or a `0600` file on +Linux/WSL); `builder auth status` shows it and `builder auth logout apple` +removes it. In CI or for a coding agent, set `ASC_ISSUER_ID`, `ASC_KEY_ID` and +either `ASC_PRIVATE_KEY` (the .p8 contents; literal `\n` is fine) or +`ASC_KEY_PATH` instead — they take precedence over the saved login. + +### 2. Upload the build + +```bash +builder ios build # produces a signed dist/*.ipa +builder ios upload --wait +``` + +`upload` reads the bundle ID, version and build number from the newest IPA in +`./dist/` (or `--ipa `), finds the app, uploads the archive in chunks +and, with `--wait`, follows App Store Connect until the build has finished +processing and prints its build ID and TestFlight link. Without `--wait` it +returns as soon as Apple has the file. + +Two things Apple checks on every upload: + +- **Build numbers must increase.** A second upload with the same + `CFBundleVersion` for the same version is rejected (`ITMS-90189`), so bump + it before rebuilding. +- **Export compliance.** A build shows as *Missing Compliance* in TestFlight + until you say whether it uses non-exempt encryption. If your Info.plist sets + `ITSAppUsesNonExemptEncryption` to `false`, `upload --wait` answers that + automatically; otherwise pass `--no-encryption` (here or to `submit`) when + your app only uses standard iOS encryption. + +### 3. Distribute to TestFlight + +```bash +builder ios submit --testflight --group "Beta Testers" --notes "New login flow" +``` + +This takes the newest processed build (or `--build-number N`), sets the *What +to Test* notes and adds it to the named groups (`--group` repeats). Internal +groups get the build immediately; the first external group triggers Apple's +beta review, which Builder submits for you (`--wait` follows the decision). Run +it without `--group` to see the build and the groups the app has. + +### 4. Submit to the App Store + +```bash +builder ios submit --app-store --release after-approval +``` + +Builder finds or creates the App Store version matching the IPA's marketing +version (or `--version X.Y.Z`), attaches the build, sets the release type +(`manual` or `after-approval`) and submits it for review. The version's +metadata — description, screenshots, age rating, pricing, privacy — must +already be complete: App Store Connect refuses the submission otherwise and +Builder prints Apple's reasons verbatim. Builder does not manage metadata, +screenshots or in-app purchases; fill them in App Store Connect, or on a Mac +with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of +the `buildUploads` API also proved that the Mac-free upload path works and +served as the reference for Builder's implementation. + +## Installing the IPA + +Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account). + +## Development on Windows/Linux + +Builder supports hot reload for Flutter and React Native on Windows/Linux using [MobAI](https://mobai.run) for iOS device control. This allows you to develop iOS apps without a Mac. + +## Flutter Development + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` + This creates an IPA in `./dist/` +3. Start development with hot reload: + ```bash + builder dev flutter + ``` + Builder installs the IPA through MobAI and asks whether to re-sign it. Re-signing requires an iCloud account - we highly recommend creating a new one at [icloud.com](https://icloud.com) instead of using your primary account. A re-signed app gets a new bundle ID with a team ID suffix (e.g., `com.example.myapp.TEAMID`); Builder prefills it in the prompt that follows. + +### Subsequent Runs + +Once the app is installed, skip the install step: +```bash +builder dev flutter --skip-install --bundle-id com.example.myapp.TEAMID +``` + +### File Watching + +By default, `builder dev flutter` watches for Dart file changes and automatically triggers hot reload. When flutter attach connects, it also sends an initial hot restart to ensure your latest code is running. + +- **Automatic hot reload**: Edit a `.dart` file and save - hot reload triggers automatically +- **Generated files ignored**: Files like `.g.dart` and `.freezed.dart` are ignored by default +- **Configurable**: Customize watched directories, patterns, and debounce via `builder.json` + +To disable file watching: +```bash +builder dev flutter --no-watch +``` + +To print the `flutter attach` command instead of running it (useful for IDE integration): +```bash +builder dev flutter --no-attach +``` + +### When to Rebuild + +- **Native code changes** (Swift, Objective-C, Podfile, native dependencies): Run `builder ios build` and reinstall +- **Dart code changes only**: No rebuild needed - file watcher triggers hot reload automatically + +If you don't see your recent Dart changes after launching, press `R` in the terminal to perform a hot restart. + +### Troubleshooting + +**App won't launch / connection error** +- Close the app on your device before running `builder dev flutter` +- Reconnect the device (unplug/replug USB) +- Restart MobAI +- Run `builder mobai ping` to verify connection + +**"No devices found" error** +- Ensure MobAI is running and device is connected +- Only physical iOS devices are supported (no simulators) + +**Hot reload not working** +- Make sure you're using the correct bundle ID (with team ID suffix) +- Try hot restart with `R` key +- Check that MobAI shows the device as connected + +**File watcher not triggering** +- Ensure you're editing files in watched directories (default: `lib/`) +- Check if the file matches watch patterns (default: `.dart`) +- Generated files (`.g.dart`, `.freezed.dart`) are ignored by default +- Try running without `--no-watch` flag + +## React Native Development + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` +3. Start development with hot reload: + ```bash + builder dev rn + ``` + This will: + - Start Metro bundler if not running + - Install the IPA on your device (with optional re-signing) + - Launch the app with Metro URL configured automatically + +### Subsequent Runs + +Once the app is installed: +```bash +builder dev rn --skip-install --bundle-id com.example.myapp.TEAMID +``` + +### Custom Metro Port + +If port 8081 is in use: +```bash +builder dev rn --metro-port 8082 +``` + +### When to Rebuild + +- **Native code changes** (Swift, Objective-C, Podfile, native modules): Run `builder ios build` and reinstall +- **JavaScript changes only**: No rebuild needed - Metro handles it automatically + +### Troubleshooting + +**Metro not starting** +- Ensure Node.js and React Native CLI are installed +- Try starting Metro manually: `npx react-native start` + +**App not connecting to Metro** +- Device must be on the same WiFi network as the computer running Metro +- Check that Metro is running and accessible +- Verify the Metro port is correct (default: 8081) +- On WSL with mirrored networking, the Hyper-V firewall blocks the phone from reaching Metro by default; see [Using Builder from WSL](docs/wsl.md#react-native) for the firewall rule + +**Hot reload not working** +- Shake device or press `d` in Metro terminal to open dev menu +- Enable "Fast Refresh" in dev menu +- Try reloading with `r` in Metro terminal + +## Kotlin Multiplatform Development + +KMP iOS apps build and run on a device like any other project, with one +difference: **there is no hot reload.** Shared Kotlin is compiled into a native +framework at build time, so there is no runtime to swap code into — every code +change needs a rebuild. + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` +3. Install and launch it on the device: + ```bash + builder dev kmp + ``` + +`builder init` detects Kotlin Multiplatform projects by looking for the +multiplatform Gradle plugin in the root and module build files, and asks which +JDK the CI build should use (default 17): + +```json +{ + "kmp": { "jdkVersion": "17" } +} +``` + +On CI, the iOS app is built with `xcodebuild`, whose run script phase (or +CocoaPods) invokes Gradle to compile the shared framework — which is why the +JDK version matters. Gradle output is cached between builds. + +### When to Rebuild + +Every change to Kotlin or Swift code needs `builder ios build` followed by +`builder dev kmp` again. Use `--skip-install --bundle-id ` to relaunch an +app that is already installed. + +### Troubleshooting + +**Build fails with "Unsupported class file major version" or a Gradle JDK error** +- The project needs a different JDK than the default: set `kmp.jdkVersion` in `builder.json` to match what the project uses locally + +**Build fails with "SDK does not contain 'libarclite'"** +- An old Kotlin/Native version against a newer Xcode; upgrade the Kotlin plugin in Gradle + +**App launches then immediately exits** +- Launch with `builder dev kmp --logs` to see the device output + +## Build Limits + +Free allowances belong to each provider account and depend on the plan and +machine. As published in September 2026: Codemagic personal accounts include +500 macOS M2 minutes per month; Bitrise Hobby includes 300 credits. GitHub has +separate allowances for private repositories and free standard runners for +public repositories. Providers change these, so check the +[current allowance links and switching guidance](docs/providers.md#free-allowances). + +## License + +[MIT License](LICENSE) From afd4ad23b6b7547098b088b6d3b8925747eb7ad4 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 17:39:18 +0200 Subject: [PATCH 50/75] signing: --provider on setup and a hint when a build's secrets cannot be checked --- README.md | 9 ++++++--- cmd/builder/signing.go | 4 +++- cmd/builder/signing_auto.go | 12 ++++++++++-- cmd/builder/signing_sets_test.go | 6 +++++- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0636148..a5ea4d2 100644 --- a/README.md +++ b/README.md @@ -429,9 +429,12 @@ create certificates. It then: ""`. Other fields of an existing profile are kept; a different `distribution` in it is replaced, and the command says so. `defaultProfile` is not touched: point it at the profile for a plain - `ios build` to use it, or pass `--profile`. For Codemagic and Bitrise it - prints the three secret names and file paths to paste instead, following - the [secrets guide](docs/provider-secrets.md). + `ios build` to use it, or pass `--profile`. For Codemagic and Bitrise + (`provider` in `builder.json`, or `--provider codemagic|bitrise`) it prints + the three secret names and file paths to paste instead, following the + [secrets guide](docs/provider-secrets.md). Builder cannot check those + providers' secrets before a build, so `ios build` only reminds you of this + command when the profile signs there. The command shows its plan and asks once before creating anything; `--yes` skips that (required without a terminal), and then the `.p12` password is diff --git a/cmd/builder/signing.go b/cmd/builder/signing.go index d3d2672..bfdab46 100644 --- a/cmd/builder/signing.go +++ b/cmd/builder/signing.go @@ -99,6 +99,7 @@ func init() { signingSetupCmd.Flags().Bool("force", false, "Issue a new certificate and profile even when valid ones exist") signingSetupCmd.Flags().BoolP("yes", "y", false, "Skip confirmations") signingSetupCmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr)") + signingSetupCmd.Flags().String("provider", "", "CI provider the secrets are for: github, codemagic or bitrise (default: provider in builder.json, else github)") signingCSRCmd.Flags().String("name", "", "Your name (certificate common name)") signingCSRCmd.Flags().String("email", "", "Email address of your Apple Developer account") @@ -268,7 +269,8 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { if err != nil { return err } - provider, err := cfg.ProviderName("") + providerFlag, _ := cmd.Flags().GetString("provider") + provider, err := cfg.ProviderName(providerFlag) if err != nil { return err } diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index 5a9d457..f8855d7 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -73,7 +73,8 @@ func runSigningAuto(cmd *cobra.Command) error { if err != nil { return err } - provider, err := cfg.ProviderName("") + providerFlag, _ := cmd.Flags().GetString("provider") + provider, err := cfg.ProviderName(providerFlag) if err != nil { return err } @@ -444,7 +445,14 @@ func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretS if err != nil { return err } - if name != "github" || s.Distribution == "" { + if s.Distribution == "" { + return nil + } + if name != "github" { + // Codemagic and Bitrise have no secrets API, so the set cannot be + // checked or provisioned from here; the runner fails by name if it + // is missing. + fmt.Fprintf(log, "Profile %q signs with set %s. Builder cannot check %s secrets; if the build fails on signing, run: builder signing setup --distribution %s --provider %s\n", s.Profile, s.SigningSet(), name, s.Distribution, name) return nil } typ, set := signing.Type(s.Distribution), s.SigningSet() diff --git a/cmd/builder/signing_sets_test.go b/cmd/builder/signing_sets_test.go index c2c346b..6980613 100644 --- a/cmd/builder/signing_sets_test.go +++ b/cmd/builder/signing_sets_test.go @@ -322,9 +322,13 @@ func TestEnsureSigningSecretsChecksTheSet(t *testing.T) { store.listErr = nil store.listed = 0 cfg.Profiles["cm"] = config.Profile{Distribution: "store", Provider: "codemagic"} - if err := ensureSigningSecrets(ctx, cfg, store, noASC, "cm", "", io.Discard); err != nil || store.listed != 0 { + var warn strings.Builder + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "cm", "", &warn); err != nil || store.listed != 0 { t.Fatalf("codemagic profile: %v, listed %d", err, store.listed) } + if !strings.Contains(warn.String(), "builder signing setup --distribution store --provider codemagic") { + t.Fatalf("no hint for the unchecked provider: %q", warn.String()) + } if err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", "bitrise", io.Discard); err != nil || store.listed != 0 { t.Fatalf("--provider bitrise: %v, listed %d", err, store.listed) } From 13715af15ce0f4401acf4e79c7f1847dfdcf98cf Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 18:00:31 +0200 Subject: [PATCH 51/75] 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. --- CLAUDE.md | 17 ++- README.md | 22 ++-- cmd/builder/signing.go | 113 ++++++++++---------- cmd/builder/signing_auto.go | 171 +++++++++++++++++++------------ cmd/builder/signing_sets_test.go | 131 ++++++++++++++++++++++- docs/provider-secrets.md | 19 ++-- docs/providers.md | 5 +- 7 files changed, 331 insertions(+), 147 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6d667eb..32a33ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,8 +122,10 @@ builder signing setup ───► Bundle ID: --bundle-id → ios.bundleId → d │ ▼ Writes key/.p12/.mobileprovision (named by distribution), uploads - the three IOS_*_ secrets of the distribution's set (GitHub) - or prints them (Codemagic/Bitrise), writes profiles..distribution + the three IOS_*_ secrets of the distribution's set to GitHub + (a failed upload is printed, not fatal; non-zero exit at the end), + always prints their names and values (Codemagic/Bitrise paste them), + writes profiles..distribution builder ios build --profile X ─► ResolveProfile: distribution → set, signing, configuration │ @@ -241,7 +243,12 @@ internal/ `--distribution` is an error) and never touches other sets or the legacy names, then writes `profiles.<--name or distribution>.distribution` (`writeSigningProfile`: other fields kept, an equal distribution keeps the user's spelling, a different one is replaced and the old value - printed; `defaultProfile` is never set) and never `ios.signing`. Files are `ios-signing-.key/.p12`, so two coexist in + printed; `defaultProfile` is never set) and never `ios.signing`. Both modes always upload to the + `github` repository in builder.json (no `--provider`, no `provider` field) and always print the + three names with where their values come from, for Codemagic, Bitrise or a repository the token + cannot write to; a failed upload (or a GitHub client that cannot be built) is an `Error:` line on + stderr, everything else is still written and printed, and only the exit code is non-zero + (`github_upload` in `--json`: `ok` or the error). Files are `ios-signing-.key/.p12`, so two coexist in one `--out-dir`; the key lookup is `--key`, then the distribution's file, then the legacy `ios-signing.key`. `Progress.Settings` prints `signed (set X)` / `signed (unsuffixed IOS_* secrets)`. Enterprise is a valid set and profile type but `Auto` refuses it (no ASC endpoint @@ -253,8 +260,8 @@ internal/ (`GET /repos/{o}/{r}/actions/secrets`, paginated; 403/404 are reported as a token without the `repo` scope or admin access, never as "no secrets") is checked for the three names; all present → dispatch. Otherwise, with an ASC key (`getASCClient` passed as a - factory so tests inject the `signingtest` portal), `provisionSigning` (= `signing.Auto` + upload, - shared with `signing setup`) runs with no prompts: bundle ID from `ios.bundleId` or `dist/*.ipa`, + factory so tests inject the `signingtest` portal), `signing.Auto` plus `uploadSigningSet` (shared + with `signing setup`, but fatal here) runs with no prompts: bundle ID from `ios.bundleId` or `dist/*.ipa`, key from `.`, generated password (printed once), no devices given (Auto covers the enabled ones and fails naming `signing setup --distribution development --devices-from-mobai` when there are none). Without an ASC key the error names `builder auth apple` and `signing setup --certificate diff --git a/README.md b/README.md index a5ea4d2..ad43558 100644 --- a/README.md +++ b/README.md @@ -429,12 +429,19 @@ create certificates. It then: ""`. Other fields of an existing profile are kept; a different `distribution` in it is replaced, and the command says so. `defaultProfile` is not touched: point it at the profile for a plain - `ios build` to use it, or pass `--profile`. For Codemagic and Bitrise - (`provider` in `builder.json`, or `--provider codemagic|bitrise`) it prints - the three secret names and file paths to paste instead, following the - [secrets guide](docs/provider-secrets.md). Builder cannot check those - providers' secrets before a build, so `ios build` only reminds you of this - command when the profile signs there. + `ios build` to use it, or pass `--profile`. +6. Prints the three secret names and where their values come from — the + `.p12` base64-encoded, the password, the `.mobileprovision` base64-encoded + — every time, so the same set can be pasted into Codemagic or Bitrise, + following the [secrets guide](docs/provider-secrets.md). Builder cannot + check those providers' secrets before a build, so `ios build` only reminds + you of this command when the profile signs there. + +The upload goes to the repository in `builder.json`, always. When it fails (no +GitHub login, or a token that cannot write secrets) the error is printed and +the command carries on: files, values and the build profile are written and +shown anyway, and it exits non-zero at the end so a script notices. `--json` +reports the same in `github_upload` (`ok` or the error). The command shows its plan and asks once before creating anything; `--yes` skips that (required without a terminal), and then the `.p12` password is @@ -448,7 +455,8 @@ With `--certificate` and `--profile`, `setup` takes your own files instead — a `.p12` (from Keychain Access, or [assembled here](#manual-path-through-the-apple-developer-portal)) and a `.mobileprovision` — reads the distribution out of the profile (development, ad-hoc, store or enterprise; this is the only way in for -enterprise), uploads that set and writes the build profile the same way: +enterprise), uploads that set, prints its names and values, and writes the +build profile the same way: ```bash builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision diff --git a/cmd/builder/signing.go b/cmd/builder/signing.go index bfdab46..34c937e 100644 --- a/cmd/builder/signing.go +++ b/cmd/builder/signing.go @@ -51,8 +51,11 @@ IOS_PROVISIONING_PROFILE_, with SET one of DEVELOPMENT, AD_HOC, STORE, ENTERPRISE — and writes a profile in builder.json (--name, default the distribution name) with that distribution. 'builder ios build --profile ' then signs with the set, and provisions it the same way when it is missing. -For Codemagic and Bitrise the command writes the files and prints the secret -names to add in the dashboard instead.`, + +The three names and the values to put in them are always printed too, for +Codemagic, Bitrise or a repository this login cannot write to. A failed upload +is reported and the command carries on — the files and the build profile are +written regardless — and it exits non-zero at the end.`, RunE: runSigningSetup, } @@ -86,20 +89,7 @@ func init() { signingCmd.AddCommand(signingCSRCmd) signingCmd.AddCommand(signingP12Cmd) - signingSetupCmd.Flags().StringP("certificate", "c", "", "Path to certificate file (.p12, or .cer from the Apple Developer portal)") - signingSetupCmd.Flags().StringP("profile", "p", "", "Path to .mobileprovision file") - signingSetupCmd.Flags().StringP("key", "k", "", "Path to the private key from 'builder signing csr' (required with a .cer; automatic mode reuses it and its certificate)") - signingSetupCmd.Flags().String("bundle-id", "", "App bundle ID (default: ios.bundleId in builder.json, else the newest IPA in ./dist)") - signingSetupCmd.Flags().String("distribution", "", "Distribution to sign for: development, ad-hoc (internal), store or enterprise (default: the --name profile's, else development; with --profile: read from the file)") - signingSetupCmd.Flags().String("name", "", "builder.json profile to write the distribution to (default: the distribution name; an existing profile keeps its other fields, a different distribution in it is replaced)") - signingSetupCmd.Flags().StringArray("device", nil, "Device UDID to register (repeatable)") - signingSetupCmd.Flags().Bool("devices-from-mobai", false, "Register the physical iOS devices connected to MobAI") - signingSetupCmd.Flags().String("out-dir", ".", "Directory for the private key, .p12 and .mobileprovision") - signingSetupCmd.Flags().String("password", "", "Password to protect the .p12 (prompted; generated with --yes)") - signingSetupCmd.Flags().Bool("force", false, "Issue a new certificate and profile even when valid ones exist") - signingSetupCmd.Flags().BoolP("yes", "y", false, "Skip confirmations") - signingSetupCmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr)") - signingSetupCmd.Flags().String("provider", "", "CI provider the secrets are for: github, codemagic or bitrise (default: provider in builder.json, else github)") + addSigningSetupFlags(signingSetupCmd) signingCSRCmd.Flags().String("name", "", "Your name (certificate common name)") signingCSRCmd.Flags().String("email", "", "Email address of your Apple Developer account") @@ -110,6 +100,24 @@ func init() { signingP12Cmd.Flags().String("password", "", "Password to protect the .p12 (prompted if omitted)") } +// addSigningSetupFlags registers the flags of `signing setup`; tests build +// their own command with them. +func addSigningSetupFlags(cmd *cobra.Command) { + cmd.Flags().StringP("certificate", "c", "", "Path to certificate file (.p12, or .cer from the Apple Developer portal)") + cmd.Flags().StringP("profile", "p", "", "Path to .mobileprovision file") + cmd.Flags().StringP("key", "k", "", "Path to the private key from 'builder signing csr' (required with a .cer; automatic mode reuses it and its certificate)") + cmd.Flags().String("bundle-id", "", "App bundle ID (default: ios.bundleId in builder.json, else the newest IPA in ./dist)") + cmd.Flags().String("distribution", "", "Distribution to sign for: development, ad-hoc (internal), store or enterprise (default: the --name profile's, else development; with --profile: read from the file)") + cmd.Flags().String("name", "", "builder.json profile to write the distribution to (default: the distribution name; an existing profile keeps its other fields, a different distribution in it is replaced)") + cmd.Flags().StringArray("device", nil, "Device UDID to register (repeatable)") + cmd.Flags().Bool("devices-from-mobai", false, "Register the physical iOS devices connected to MobAI") + cmd.Flags().String("out-dir", ".", "Directory for the private key, .p12 and .mobileprovision") + cmd.Flags().String("password", "", "Password to protect the .p12 (prompted; generated with --yes)") + cmd.Flags().Bool("force", false, "Issue a new certificate and profile even when valid ones exist") + cmd.Flags().BoolP("yes", "y", false, "Skip confirmations") + cmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr)") +} + func runSigningCSR(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") email, _ := cmd.Flags().GetString("email") @@ -269,19 +277,10 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { if err != nil { return err } - providerFlag, _ := cmd.Flags().GetString("provider") - provider, err := cfg.ProviderName(providerFlag) - if err != nil { - return err - } - var store secretStore - if provider == "github" { - ghClient, err := getGitHubClient() - if err != nil { - return err - } - store = ghClient - } + // A GitHub client that cannot be built is reported with the upload, after + // the files are read: the values are printed either way. + store, storeErr := signingSecretStore() + out := cmd.OutOrStdout() // Get certificate path certPath, _ := cmd.Flags().GetString("certificate") @@ -298,7 +297,7 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to read certificate %s: %w", certPath, err) } - fmt.Printf("Certificate: %s (%.1f KB)\n", certPath, float64(len(certData))/1024) + fmt.Fprintf(out, "Certificate: %s (%.1f KB)\n", certPath, float64(len(certData))/1024) // Get provisioning profile path profilePath, _ := cmd.Flags().GetString("profile") @@ -315,7 +314,7 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to read provisioning profile %s: %w", profilePath, err) } - fmt.Printf("Profile: %s (%.1f KB)\n", profilePath, float64(len(profileData))/1024) + fmt.Fprintf(out, "Profile: %s (%.1f KB)\n", profilePath, float64(len(profileData))/1024) distributionFlag, _ := cmd.Flags().GetString("distribution") typ, err := manualSigningType(profileData, distributionFlag) @@ -330,9 +329,9 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { if profileName == "" { profileName = string(typ) } - fmt.Printf("Distribution: %s (read from the profile), signing set %s, build profile %q\n", typ, set, profileName) + fmt.Fprintf(out, "Distribution: %s (read from the profile), signing set %s, build profile %q\n", typ, set, profileName) - var password string + password, _ := cmd.Flags().GetString("password") p12Path := certPath if isPortalCertificate(certPath) { // A .cer from the Apple Developer portal: assemble the .p12 locally @@ -348,9 +347,10 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to read private key %s: %w", keyPath, err) } - password, err = promptPassword("Password to protect the .p12") - if err != nil { - return err + if password == "" { + if password, err = promptPassword("Password to protect the .p12"); err != nil { + return err + } } certData, err = signing.BuildP12(keyPEM, certData, password) if err != nil { @@ -362,10 +362,9 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { if err := os.WriteFile(p12Path, certData, 0600); err != nil { return fmt.Errorf("failed to write .p12: %w", err) } - fmt.Printf("Assembled .p12: %s (do not commit it)\n", p12Path) - } else { - password, err = promptPassword("Certificate password") - if err != nil { + fmt.Fprintf(out, "Assembled .p12: %s (do not commit it)\n", p12Path) + } else if password == "" { + if password, err = promptPassword("Certificate password"); err != nil { return err } } @@ -374,29 +373,33 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { if ctx == nil { ctx = context.Background() } - fmt.Println() - if store != nil { - fmt.Printf("Uploading secrets to %s/%s...\n", cfg.GitHub.Owner, cfg.GitHub.Repo) - if err := uploadSigningSecrets(ctx, store, cfg, os.Stdout, set, certData, password, profileData); err != nil { - return err - } - } else { - printProviderSecrets(provider, config.SigningSecretNames(set), p12Path, profilePath) + fmt.Fprintln(out) + uploadErr := uploadSigningSet(ctx, store, storeErr, cfg, out, set, certData, password, profileData) + if uploadErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", uploadErr) } + // The profile is written whatever the upload did: the files exist and the + // build that uses them is the same either way. replaced := writeSigningProfile(cfg, profileName, typ) if err := config.NewManager().Save(cfg); err != nil { return fmt.Errorf("failed to update config: %w", err) } - fmt.Println(profileWritten(profileName, typ, replaced)) + fmt.Fprintln(out, profileWritten(profileName, typ, replaced)) - fmt.Println() - fmt.Println("Code signing configured successfully!") - fmt.Println() - printSigningNext(profileName, typ) - fmt.Println("To build unsigned, use:") - fmt.Printf(" builder ios build --profile %s --unsigned\n", profileName) + names := config.SigningSecretNames(set) + fmt.Fprintln(out) + fmt.Fprintln(out, signingUploadLine(cfg, names, uploadErr)) + fmt.Fprintln(out) + printSigningSecretValues(out, names, p12Path, profilePath) + fmt.Fprintln(out) + printSigningNext(out, profileName, typ) + fmt.Fprintln(out, "To build unsigned, use:") + fmt.Fprintf(out, " builder ios build --profile %s --unsigned\n", profileName) + if uploadErr != nil { + return signingUploadFailed(cfg) + } return nil } diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index f8855d7..e308422 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -30,11 +30,13 @@ const providerSecretsDoc = "https://github.com/MobAI-App/ios-builder/blob/main/d // signingAutoResult is the JSON output of the automatic `signing setup`. type signingAutoResult struct { *signing.AutoResult - Provider string `json:"provider"` // SigningSet is the suffix of the secrets written (DEVELOPMENT, AD_HOC, // STORE), which builds select by their profile's distribution. SigningSet string `json:"signing_set"` SecretsUploaded bool `json:"secrets_uploaded"` + // GitHubUpload is "ok" or why the upload failed; the values are printed + // either way, so a failure is reported, not fatal. + GitHubUpload string `json:"github_upload"` // BuildProfile is the builder.json profile written with the distribution. BuildProfile string `json:"build_profile"` // GeneratedPassword is set when no password was given: it is printed @@ -69,23 +71,13 @@ func runSigningAuto(cmd *cobra.Command) error { if err != nil { return err } - client, err := getASCClient() + client, err := signingASCClient() if err != nil { return err } - providerFlag, _ := cmd.Flags().GetString("provider") - provider, err := cfg.ProviderName(providerFlag) - if err != nil { - return err - } - var store secretStore - if provider == "github" { - ghClient, err := getGitHubClient() - if err != nil { - return err - } - store = ghClient - } + // A GitHub client that cannot be built is reported with the upload, after + // the material exists: the values are printed either way. + store, storeErr := signingSecretStore() out := newOutput(cmd) yes, _ := cmd.Flags().GetBool("yes") force, _ := cmd.Flags().GetBool("force") @@ -120,7 +112,7 @@ func runSigningAuto(cmd *cobra.Command) error { } else { fmt.Fprintf(out.log, "Key: new, written to %s\n", filepath.Join(outDir, signing.KeyFileName(typ))) } - fmt.Fprintf(out.log, "Provider: %s\n", provider) + fmt.Fprintf(out.log, "Secrets: %s/%s\n", cfg.GitHub.Owner, cfg.GitHub.Repo) if force { fmt.Fprintln(out.log, "Force: a new certificate and profile will be issued") } @@ -149,15 +141,25 @@ func runSigningAuto(cmd *cobra.Command) error { } } - res := &signingAutoResult{Provider: provider, SigningSet: set, BuildProfile: profileName, GeneratedPassword: generated} - res.AutoResult, err = provisionSigning(ctx, client, store, cfg, out.log, &signing.AutoOptions{ + res := &signingAutoResult{SigningSet: set, BuildProfile: profileName, GeneratedPassword: generated} + res.AutoResult, err = signing.Auto(ctx, client, &signing.AutoOptions{ BundleID: bundleID, Type: typ, Devices: devices, KeyPEM: keyPEM, CommonName: cfg.Project, Password: password, Force: force, OutDir: outDir, Log: out.log, }) if err != nil { return finish(out, cmd, res, err, nil) } - res.SecretsUploaded = store != nil + fmt.Fprintln(out.log) + uploadErr := uploadSigningSet(ctx, store, storeErr, cfg, out.log, set, res.P12, password, res.ProfileContent) + res.SecretsUploaded = uploadErr == nil + res.GitHubUpload = "ok" + if uploadErr != nil { + res.GitHubUpload = uploadErr.Error() + fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", uploadErr) + } + + // The profile is written whatever the upload did: the material exists and + // the build that uses it is the same either way. replaced := writeSigningProfile(cfg, profileName, typ) if cfg.IOS.BundleID == "" { cfg.IOS.BundleID = bundleID @@ -167,7 +169,15 @@ func runSigningAuto(cmd *cobra.Command) error { } fmt.Fprintln(out.log, profileWritten(profileName, typ, replaced)) - return finish(out, cmd, res, nil, func() { printSigningSummary(cfg, res) }) + // Everything is printed before the exit code, so finish's success-only + // hook is not used. + if !out.json { + printSigningSummary(out.log, cfg, res, uploadErr) + } + if uploadErr != nil { + return finish(out, cmd, res, signingUploadFailed(cfg), nil) + } + return finish(out, cmd, res, nil, nil) } // setupDistribution is --distribution, else the distribution of the @@ -182,27 +192,23 @@ func setupDistribution(cfg *config.Config, profileName, flag string) (signing.Ty return signing.TypeDevelopment, nil } -// provisionSigning issues (or reuses) the certificate and profile of a -// distribution through App Store Connect and, when store is a GitHub -// repository, uploads them as the distribution's signing set. `signing setup` -// runs it, and so does `ios build` when a profile's set is missing. -func provisionSigning(ctx context.Context, client *asc.Client, store secretStore, cfg *config.Config, log io.Writer, opts *signing.AutoOptions) (*signing.AutoResult, error) { - res, err := signing.Auto(ctx, client, opts) - if err != nil { - return res, err - } - if store == nil { - return res, nil - } - set, err := config.SigningSet(string(opts.Type)) - if err != nil { - return res, err - } - fmt.Fprintf(log, "\nUploading secrets to %s/%s...\n", cfg.GitHub.Owner, cfg.GitHub.Repo) - if err := uploadSigningSecrets(ctx, store, cfg, log, set, res.P12, opts.Password, res.ProfileContent); err != nil { - return res, err +// uploadSigningSet writes the three secrets of a set to the GitHub repository +// in builder.json. storeErr is a client that could not be built at all (no +// login), reported the same way as a failed upload: `signing setup` prints the +// values afterwards, so neither is the end of the road. +func uploadSigningSet(ctx context.Context, store secretStore, storeErr error, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte) error { + if storeErr != nil { + return storeErr } - return res, nil + fmt.Fprintf(log, "Uploading secrets to %s/%s...\n", cfg.GitHub.Owner, cfg.GitHub.Repo) + return uploadSigningSecrets(ctx, store, cfg, log, set, p12, password, profile) +} + +// signingUploadFailed is what `signing setup` ends with when the set did not +// reach the repository: everything is printed by then, so this only carries +// the exit code and says what is left to do. +func signingUploadFailed(cfg *config.Config) error { + return fmt.Errorf("the signing set was not uploaded to %s/%s; add the three secrets above by hand, or fix the access and run builder signing setup again", cfg.GitHub.Owner, cfg.GitHub.Repo) } // writeSigningProfile creates or updates the builder.json profile that builds @@ -374,6 +380,20 @@ func fileExists(path string) bool { return err == nil } +// signingSecretStore is the GitHub secrets API `signing setup` uploads +// through, and signingASCClient the App Store Connect client it provisions +// with. Both are vars so tests can replace them. +var ( + signingSecretStore = func() (secretStore, error) { + gh, err := getGitHubClient() + if err != nil { + return nil, err + } + return gh, nil + } + signingASCClient = getASCClient +) + // secretStore is the part of the GitHub client that signing writes through // and reads the secret names back from. type secretStore interface { @@ -452,7 +472,7 @@ func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretS // Codemagic and Bitrise have no secrets API, so the set cannot be // checked or provisioned from here; the runner fails by name if it // is missing. - fmt.Fprintf(log, "Profile %q signs with set %s. Builder cannot check %s secrets; if the build fails on signing, run: builder signing setup --distribution %s --provider %s\n", s.Profile, s.SigningSet(), name, s.Distribution, name) + fmt.Fprintf(log, "Profile %q signs with set %s. Builder cannot check %s secrets; if the build fails on signing, run: builder signing setup --distribution %s\n", s.Profile, s.SigningSet(), name, s.Distribution) return nil } typ, set := signing.Type(s.Distribution), s.SigningSet() @@ -485,12 +505,18 @@ func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretS return err } fmt.Fprintf(log, "Provisioning %s signing for %s through App Store Connect...\n", typ, bundleID) - res, err := provisionSigning(ctx, client, store, cfg, log, &signing.AutoOptions{ + res, err := signing.Auto(ctx, client, &signing.AutoOptions{ BundleID: bundleID, Type: typ, KeyPEM: keyPEM, CommonName: cfg.Project, Password: password, OutDir: ".", Log: log, }) if err != nil { return err } + // A build cannot go on without the set in the repository, so here the + // upload is fatal. + fmt.Fprintln(log) + if err := uploadSigningSet(ctx, store, nil, cfg, log, set, res.P12, password, res.ProfileContent); err != nil { + return err + } fmt.Fprintln(log) printSigningFiles(log, res, password) if cfg.IOS.BundleID == "" { @@ -517,7 +543,7 @@ func printSigningFiles(w io.Writer, res *signing.AutoResult, generatedPassword s fmt.Fprintln(w, "Keep these out of git (add them to .gitignore); gitignored files are also left out of build snapshots.") } -func printSigningSummary(cfg *config.Config, res *signingAutoResult) { +func printSigningSummary(w io.Writer, cfg *config.Config, res *signingAutoResult, uploadErr error) { state := func(created bool, reason string) string { if !created { return "reused" @@ -527,40 +553,49 @@ func printSigningSummary(cfg *config.Config, res *signingAutoResult) { } return "new" } - fmt.Println() - fmt.Printf("Bundle ID: %s (%s)\n", res.BundleID.Identifier, state(res.BundleID.Created, "")) - fmt.Printf("Certificate: %s (%s, expires %s)\n", res.Certificate.Name, state(res.Certificate.Created, ""), res.Certificate.ExpirationDate.Format("2006-01-02")) + fmt.Fprintln(w) + fmt.Fprintf(w, "Bundle ID: %s (%s)\n", res.BundleID.Identifier, state(res.BundleID.Created, "")) + fmt.Fprintf(w, "Certificate: %s (%s, expires %s)\n", res.Certificate.Name, state(res.Certificate.Created, ""), res.Certificate.ExpirationDate.Format("2006-01-02")) if res.Type.NeedsDevices() { - fmt.Printf("Devices: %d in the profile, %d registered now\n", res.Devices.InProfile, len(res.Devices.Registered)) + fmt.Fprintf(w, "Devices: %d in the profile, %d registered now\n", res.Devices.InProfile, len(res.Devices.Registered)) } - fmt.Printf("Profile: %s (%s, %s, expires %s)\n", res.Profile.Name, state(res.Profile.Created, res.Profile.Reason), strings.ToLower(res.Profile.State), res.Profile.ExpirationDate.Format("2006-01-02")) - fmt.Println() - printSigningFiles(os.Stdout, res.AutoResult, res.GeneratedPassword) - fmt.Println() + fmt.Fprintf(w, "Profile: %s (%s, %s, expires %s)\n", res.Profile.Name, state(res.Profile.Created, res.Profile.Reason), strings.ToLower(res.Profile.State), res.Profile.ExpirationDate.Format("2006-01-02")) + fmt.Fprintln(w) + printSigningFiles(w, res.AutoResult, res.GeneratedPassword) + fmt.Fprintln(w) names := config.SigningSecretNames(res.SigningSet) - if res.SecretsUploaded { - fmt.Printf("Secrets %s, %s and %s uploaded to %s/%s.\n", names.Certificate, names.Password, names.Profile, cfg.GitHub.Owner, cfg.GitHub.Repo) - } else { - printProviderSecrets(res.Provider, names, res.Files.P12, res.Files.Profile) + fmt.Fprintln(w, signingUploadLine(cfg, names, uploadErr)) + fmt.Fprintln(w) + printSigningSecretValues(w, names, res.Files.P12, res.Files.Profile) + fmt.Fprintln(w) + printSigningNext(w, res.BuildProfile, res.Type) + fmt.Fprintln(w, "Run builder signing setup again any time: it reuses what is valid and renews only what expired or changed.") +} + +// signingUploadLine says whether the set reached the GitHub repository. +func signingUploadLine(cfg *config.Config, names config.SigningSecrets, uploadErr error) string { + if uploadErr != nil { + return fmt.Sprintf("Secrets were NOT uploaded to %s/%s: %v", cfg.GitHub.Owner, cfg.GitHub.Repo, uploadErr) } - printSigningNext(res.BuildProfile, res.Type) - fmt.Println("Run builder signing setup again any time: it reuses what is valid and renews only what expired or changed.") + return fmt.Sprintf("Secrets %s, %s and %s uploaded to %s/%s.", names.Certificate, names.Password, names.Profile, cfg.GitHub.Owner, cfg.GitHub.Repo) } -// printProviderSecrets tells Codemagic and Bitrise users what to paste into -// the dashboard, since Builder cannot write secrets there. -func printProviderSecrets(provider string, names config.SigningSecrets, p12Path, profilePath string) { - fmt.Printf("%s secrets are set in its dashboard, not by Builder. Add:\n", provider) - fmt.Printf(" %-*s base64 of %s\n", len(names.Password), names.Certificate, p12Path) - fmt.Printf(" %s the .p12 password\n", names.Password) - fmt.Printf(" %-*s base64 of %s\n", len(names.Password), names.Profile, profilePath) - fmt.Printf("Steps: %s\n", providerSecretsDoc) +// printSigningSecretValues names the three secrets of the set and where their +// values come from. It is printed whether or not the upload worked: Codemagic +// and Bitrise are set in their own dashboards, and so is a GitHub repository +// this token cannot write to. +func printSigningSecretValues(w io.Writer, names config.SigningSecrets, p12Path, profilePath string) { + fmt.Fprintln(w, "Set them by hand wherever Builder cannot (Codemagic, Bitrise, a repository this login cannot write to):") + fmt.Fprintf(w, " %-*s base64 of %s\n", len(names.Password), names.Certificate, p12Path) + fmt.Fprintf(w, " %s the .p12 password\n", names.Password) + fmt.Fprintf(w, " %-*s base64 of %s\n", len(names.Password), names.Profile, profilePath) + fmt.Fprintf(w, "Steps: %s\n", providerSecretsDoc) } // printSigningNext names the build that reads the set just written. -func printSigningNext(buildProfile string, typ signing.Type) { - fmt.Printf("Next: builder ios build --profile %s\n", buildProfile) +func printSigningNext(w io.Writer, buildProfile string, typ signing.Type) { + fmt.Fprintf(w, "Next: builder ios build --profile %s\n", buildProfile) if typ == signing.TypeStore { - fmt.Println("then builder ios upload --wait.") + fmt.Fprintln(w, "then builder ios upload --wait.") } } diff --git a/cmd/builder/signing_sets_test.go b/cmd/builder/signing_sets_test.go index 6980613..56fa4f2 100644 --- a/cmd/builder/signing_sets_test.go +++ b/cmd/builder/signing_sets_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "crypto/rand" "encoding/base64" @@ -18,6 +19,7 @@ import ( "github.com/MobAI-App/ios-builder/internal/github" "github.com/MobAI-App/ios-builder/internal/signing" "github.com/MobAI-App/ios-builder/internal/signing/signingtest" + "github.com/spf13/cobra" "golang.org/x/crypto/nacl/box" ) @@ -29,7 +31,9 @@ type fakeSecrets struct { stored map[string]string names []string listErr error - listed int + // writeErr is a repository the login cannot write to. + writeErr error + listed int } func newFakeSecrets(t *testing.T) *fakeSecrets { @@ -46,6 +50,9 @@ func (f *fakeSecrets) GetPublicKey(context.Context, string, string) (*github.Pub } func (f *fakeSecrets) CreateOrUpdateSecret(_ context.Context, _, _, name, encryptedValue, keyID string) error { + if f.writeErr != nil { + return f.writeErr + } if keyID != "key-1" { return os.ErrInvalid } @@ -326,7 +333,7 @@ func TestEnsureSigningSecretsChecksTheSet(t *testing.T) { if err := ensureSigningSecrets(ctx, cfg, store, noASC, "cm", "", &warn); err != nil || store.listed != 0 { t.Fatalf("codemagic profile: %v, listed %d", err, store.listed) } - if !strings.Contains(warn.String(), "builder signing setup --distribution store --provider codemagic") { + if !strings.Contains(warn.String(), "builder signing setup --distribution store") { t.Fatalf("no hint for the unchecked provider: %q", warn.String()) } if err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", "bitrise", io.Discard); err != nil || store.listed != 0 { @@ -407,3 +414,123 @@ func TestEnsureSigningSecretsProvisionsOnDemand(t *testing.T) { t.Fatalf("no bundle ID: %v, calls %v", err, portal.Calls()) } } + +// signingSetupCommand is `signing setup` with its own flags and buffers, and +// the fake secrets API in place of the GitHub client. +func signingSetupCommand(t *testing.T, store secretStore, storeErr error, args ...string) (cmd *cobra.Command, stdout, stderr *bytes.Buffer) { + t.Helper() + prev := signingSecretStore + signingSecretStore = func() (secretStore, error) { return store, storeErr } + t.Cleanup(func() { signingSecretStore = prev }) + + cmd = &cobra.Command{Use: "setup", RunE: runSigningSetup, SilenceErrors: true, SilenceUsage: true} + addSigningSetupFlags(cmd) + stdout, stderr = &bytes.Buffer{}, &bytes.Buffer{} + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.SetArgs(args) + return cmd, stdout, stderr +} + +// TestSigningSetupManualReportsAFailedUpload: a repository Builder cannot +// write to is a message, not a dead end. The values are printed, the build +// profile is written, and only the exit code says it failed. +func TestSigningSetupManualReportsAFailedUpload(t *testing.T) { + t.Chdir(t.TempDir()) + cfg := &config.Config{Project: "App", Platform: "ios", GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}} + if err := config.NewManager().Save(cfg); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("ios-signing.p12", []byte("p12 bytes"), 0600); err != nil { + t.Fatal(err) + } + profile := profileBytes("Entitlementsget-task-allow") + if err := os.WriteFile("App.mobileprovision", profile, 0600); err != nil { + t.Fatal(err) + } + store := newFakeSecrets(t) + store.writeErr = errors.New("403 Resource not accessible by integration") + + cmd, stdout, stderr := signingSetupCommand(t, store, nil, + "--certificate", "ios-signing.p12", "--profile", "App.mobileprovision", "--password", "pw") + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "o/r") { + t.Fatalf("a failed upload must set the exit code: %v", err) + } + if !strings.Contains(stderr.String(), "Error: failed to upload IOS_CERTIFICATE_STORE") || !strings.Contains(stderr.String(), "403") { + t.Errorf("the failure is not reported on stderr:\n%s", stderr.String()) + } + for _, want := range []string{"NOT uploaded to o/r", "IOS_CERTIFICATE_STORE", "IOS_CERTIFICATE_PASSWORD_STORE", + "IOS_PROVISIONING_PROFILE_STORE", "base64 of ios-signing.p12", "base64 of App.mobileprovision", "the .p12 password"} { + if !strings.Contains(stdout.String(), want) { + t.Errorf("%q not printed:\n%s", want, stdout.String()) + } + } + saved, err := config.NewManager().Load() + if err != nil { + t.Fatal(err) + } + if saved.Profiles["store"].Distribution != "store" { + t.Errorf("build profile not written: %+v", saved.Profiles) + } +} + +// TestSigningSetupAutoReportsAFailedUpload is the same for the App Store +// Connect mode: everything Apple issued is kept and printed. +func TestSigningSetupAutoReportsAFailedUpload(t *testing.T) { + t.Chdir(t.TempDir()) + cfg := &config.Config{Project: "App", Platform: "ios", GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}, + IOS: config.IOSConfig{BundleID: "com.example.app"}} + if err := config.NewManager().Save(cfg); err != nil { + t.Fatal(err) + } + portal := signingtest.New(t) + prev := signingASCClient + signingASCClient = func() (*asc.Client, error) { return portal.Client(t), nil } + t.Cleanup(func() { signingASCClient = prev }) + store := newFakeSecrets(t) + store.writeErr = errors.New("403 Resource not accessible by integration") + + cmd, stdout, stderr := signingSetupCommand(t, store, nil, "--distribution", "store", "--yes") + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "o/r") { + t.Fatalf("a failed upload must set the exit code: %v", err) + } + if !strings.Contains(stderr.String(), "Error: failed to upload IOS_CERTIFICATE_STORE") { + t.Errorf("the failure is not reported on stderr:\n%s", stderr.String()) + } + for _, want := range []string{"NOT uploaded to o/r", "IOS_PROVISIONING_PROFILE_STORE", + "base64 of ios-signing-store.p12", "Next: builder ios build --profile store"} { + if !strings.Contains(stdout.String(), want) { + t.Errorf("%q not printed:\n%s", want, stdout.String()) + } + } + for _, f := range []string{"ios-signing-store.key", "ios-signing-store.p12", "Builder-store-com.example.app.mobileprovision"} { + if _, err := os.Stat(f); err != nil { + t.Errorf("%s not written: %v", f, err) + } + } + saved, err := config.NewManager().Load() + if err != nil { + t.Fatal(err) + } + if saved.Profiles["store"].Distribution != "store" { + t.Errorf("build profile not written: %+v", saved.Profiles) + } + + // --json says the same in github_upload, and still exits non-zero. + cmd, jsonOut, _ := signingSetupCommand(t, store, nil, "--distribution", "store", "--yes", "--json") + if err := cmd.Execute(); err == nil { + t.Fatal("--json run: a failed upload must set the exit code") + } + var res struct { + SigningSet string `json:"signing_set"` + SecretsUploaded bool `json:"secrets_uploaded"` + GitHubUpload string `json:"github_upload"` + } + if err := json.Unmarshal(jsonOut.Bytes(), &res); err != nil { + t.Fatalf("%v:\n%s", err, jsonOut.String()) + } + if res.SigningSet != "STORE" || res.SecretsUploaded || !strings.Contains(res.GitHubUpload, "403") { + t.Errorf("result: %+v", res) + } +} diff --git a/docs/provider-secrets.md b/docs/provider-secrets.md index 6536e12..9a2599f 100644 --- a/docs/provider-secrets.md +++ b/docs/provider-secrets.md @@ -11,9 +11,11 @@ API login, the provider's GitHub connection, and build secrets are separate: | Legacy signed build without a profile (`ios.signing: true`) | The unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD`, `IOS_PROVISIONING_PROFILE` | | Shared simulator (`ios share`) | `MOBAI_API_KEY`; no Apple signing files needed | -`builder signing setup` uploads secrets to **GitHub Actions only**. For the two -new providers, use the steps below even if GitHub signing/sharing already works. -Existing GitHub secret values cannot be downloaded for copying to another service. +`builder signing setup` uploads secrets to **GitHub Actions only**, but it always +prints the three names and the values to paste, so a run of it is also the source +for the two new providers; use the steps below even if GitHub signing/sharing +already works. Existing GitHub secret values cannot be downloaded for copying to +another service. ## 1. Prepare your signing files @@ -48,11 +50,12 @@ directory outside your source checkout: builder signing setup --devices-from-mobai --out-dir ~/signing ``` -With `provider` set to Codemagic or Bitrise in `builder.json`, this creates the -certificate, devices and profile through the API, writes -`ios-signing-development.p12` and the `.mobileprovision` to `~/signing`, prints -the three secret names and file paths to paste below instead of uploading them, -and writes the `development` build profile. Run it again with `--distribution +This creates the certificate, devices and profile through the API, writes +`ios-signing-development.p12` and the `.mobileprovision` to `~/signing`, tries to +upload the set to the GitHub repository in `builder.json` (a failure is printed +and the run continues, ending with a non-zero exit code), prints the three secret +names and file paths to paste below either way, and writes the `development` +build profile. Run it again with `--distribution store` for a second, App Store set: the files are named by distribution, so nothing is overwritten. Alternatively follow the [manual certificate steps](../README.md#1-create-a-certificate-signing-request): diff --git a/docs/providers.md b/docs/providers.md index 89698e4..9e8d7f7 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -149,8 +149,9 @@ same build may consume different minutes on each provider. See [step-by-step signing and MobAI secret setup](provider-secrets.md). -`builder signing setup` uploads signing secrets to **GitHub** only. For -Codemagic/Bitrise, configure these secrets on that provider yourself, one set +`builder signing setup` uploads signing secrets to **GitHub** only, and prints +the three names and the values to paste on every run. For Codemagic/Bitrise, +take them from that output and set these secrets there yourself, one set per distribution (`` is `DEVELOPMENT`, `AD_HOC`, `STORE` or `ENTERPRISE`; a build reads the set its profile's `distribution` names; a build without a profile and with `ios.signing: true` reads the unsuffixed legacy names): From 98cbfadefdc810bc2083240bd82a414223958f7b Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 18:29:33 +0200 Subject: [PATCH 52/75] 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. --- CLAUDE.md | 8 ++++++-- internal/asc/apps.go | 6 ------ internal/asc/certificates.go | 10 ++++++++++ internal/asc/certificates_test.go | 30 ++++++++++++++++++++++++++++++ internal/asc/client.go | 2 +- internal/asc/client_test.go | 14 ++++++++++++++ internal/asc/jwt.go | 4 ++-- internal/asc/profiles.go | 10 ++++++++-- internal/asc/profiles_test.go | 19 +++++++++++++++++++ 9 files changed, 90 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 32a33ee..0221709 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -313,7 +313,10 @@ internal/ - **ASC Credentials**: one JSON secret (`apple-asc-key`) in the keyring/file store, via the shared `readSecret`/`writeSecret`/`deleteSecret` helpers the CI tokens use. `ASC_ISSUER_ID`, `ASC_KEY_ID` + `ASC_PRIVATE_KEY`|`ASC_KEY_PATH` take precedence; a partially set environment is - an error, not a fallback. Only `auth apple` prompts; `upload`/`submit` never do. + an error, not a fallback. Only `auth apple` prompts; `upload`/`submit` never do. `auth apple` + verifies the key with `GET /v1/certificates?limit=1` (as MobAI does): `apps?limit=1` answers 200 + for a key of any role, `certificates` needs the Certificates, Identifiers & Profiles access that + signing needs and every role that can upload builds has. - **Build Upload**: `buildUploads` → `buildUploadFiles` (returns `uploadOperations`) → PUT each byte range with its `requestHeaders`, no bearer token → PATCH `uploaded=true` → poll the upload `state` (COMPLETE/FAILED with `errors[]`) → poll `builds` filtered by app, marketing version and @@ -333,7 +336,8 @@ internal/ A certificate is reused only when its private key is local (`--key`, or the `ios-signing-.key` / legacy `ios-signing.key` a previous run left in `--out-dir`), since a .p12 needs the key; otherwise a new one is issued and Apple's quota error - (2 Development / 3 Distribution) gets a hint. Dev/ad-hoc profiles cover every ENABLED iOS + (2 Development / 3 Distribution) gets a hint. Keys are written as PKCS#8 (`PRIVATE KEY`, as + MobAI's signer writes them); the PKCS#1 `RSA PRIVATE KEY` files of earlier runs are still read. Dev/ad-hoc profiles cover every ENABLED iOS device on the account, not just the ones passed; App Store profiles send no `devices` relationship at all (an empty one is rejected). Profile membership is read from `/v1/profiles/{id}/relationships/{certificates,devices}` (paginated), not `include=`, which diff --git a/internal/asc/apps.go b/internal/asc/apps.go index bb7f264..7d73901 100644 --- a/internal/asc/apps.go +++ b/internal/asc/apps.go @@ -41,9 +41,3 @@ func (c *Client) AppByBundleID(ctx context.Context, bundleID string) (*App, erro } return nil, fmt.Errorf("no App Store Connect app has bundle ID %s; create the app record in App Store Connect (My Apps → +) with that bundle ID first, and check the API key can see it", bundleID) } - -// CheckAccess makes the cheapest authenticated call to verify the key works. -func (c *Client) CheckAccess(ctx context.Context) error { - _, err := getPage[appAttributes](ctx, c, "/v1/apps", url.Values{"limit": {"1"}}) - return err -} diff --git a/internal/asc/certificates.go b/internal/asc/certificates.go index e4acc56..f3bb902 100644 --- a/internal/asc/certificates.go +++ b/internal/asc/certificates.go @@ -62,6 +62,16 @@ func toCertificate(r Resource[certificateAttributes]) (Certificate, error) { return c, nil } +// CheckAccess verifies the key with one cheap read-only call. It lists one +// certificate rather than one app: apps?limit=1 answers 200 with an empty +// page for a key of any role, while certificates demands the Certificates, +// Identifiers & Profiles access that signing needs (and every role that can +// upload builds has). +func (c *Client) CheckAccess(ctx context.Context) error { + _, err := getPage[certificateAttributes](ctx, c, "/v1/certificates", url.Values{"limit": {"1"}}) + return err +} + // ListCertificates lists the team's certificates of one type // (CertificateTypeDevelopment or CertificateTypeDistribution). func (c *Client) ListCertificates(ctx context.Context, certificateType string) ([]Certificate, error) { diff --git a/internal/asc/certificates_test.go b/internal/asc/certificates_test.go index 3fbb9c0..91b75f7 100644 --- a/internal/asc/certificates_test.go +++ b/internal/asc/certificates_test.go @@ -44,6 +44,36 @@ func TestListCertificatesDecodesContent(t *testing.T) { } } +func TestCheckAccessListsOneCertificate(t *testing.T) { + var path, limit string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path, limit = r.URL.Path, r.URL.Query().Get("limit") + if r.Header.Get("Authorization") == "" { + writeJSON(w, 401, map[string]any{"errors": []map[string]any{{"code": "NOT_AUTHORIZED", "title": "Authentication credentials are missing or invalid."}}}) + return + } + writeJSON(w, 200, map[string]any{"data": []any{}}) + })) + defer srv.Close() + if err := newTestClient(t, srv).CheckAccess(context.Background()); err != nil { + t.Fatal(err) + } + if path != "/v1/certificates" || limit != "1" { + t.Errorf("request = %s?limit=%s, want /v1/certificates?limit=1", path, limit) + } +} + +func TestCheckAccessSurfacesForbidden(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 403, map[string]any{"errors": []map[string]any{{"status": "403", "code": "FORBIDDEN_ERROR", "title": "This request is forbidden for security reasons", "detail": "The API key in use does not allow this request"}}}) + })) + defer srv.Close() + err := newTestClient(t, srv).CheckAccess(context.Background()) + if !IsStatus(err, 403) || !strings.Contains(err.Error(), "does not allow this request") { + t.Errorf("err = %v", err) + } +} + func TestListCertificatesRejectsBadContent(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"data": []map[string]any{{"type": "certificates", "id": "cert-1", "attributes": map[string]any{"certificateContent": "not base64!"}}}}) diff --git a/internal/asc/client.go b/internal/asc/client.go index 24c5cdb..cb1501a 100644 --- a/internal/asc/client.go +++ b/internal/asc/client.go @@ -243,7 +243,7 @@ func (c *Client) once(ctx context.Context, method, path string, query url.Values if err != nil { return fmt.Errorf("read response: %w", err) } - if resp.StatusCode >= 400 { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { return decodeError(method, path, resp, data) } if out != nil && len(bytes.TrimSpace(data)) > 0 { diff --git a/internal/asc/client_test.go b/internal/asc/client_test.go index e893021..7cecf37 100644 --- a/internal/asc/client_test.go +++ b/internal/asc/client_test.go @@ -146,6 +146,20 @@ func TestNonJSONErrorBody(t *testing.T) { } } +// Anything outside 2xx is an error, as in MobAI's client: a 3xx that the +// HTTP client did not follow carries no document to decode. +func TestNon2xxIsAnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(304) + })) + defer srv.Close() + var out map[string]any + err := newTestClient(t, srv).Get(context.Background(), "/v1/apps", nil, &out) + if !IsStatus(err, 304) { + t.Errorf("err = %v, want an App Store Connect error with status 304", err) + } +} + func TestPaginationFollowsNextLink(t *testing.T) { var srv *httptest.Server srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/asc/jwt.go b/internal/asc/jwt.go index 19138a2..c06df68 100644 --- a/internal/asc/jwt.go +++ b/internal/asc/jwt.go @@ -43,7 +43,7 @@ func (c Credentials) Validate() error { func ParsePrivateKey(pemKey string) (*ecdsa.PrivateKey, error) { block, _ := pem.Decode([]byte(strings.TrimSpace(pemKey))) if block == nil { - return nil, errors.New("private key is not PEM encoded (expected the .p8 file contents)") + return nil, errors.New("key is not valid PEM (expected the AuthKey_*.p8 contents)") } var key any var err error @@ -60,7 +60,7 @@ func ParsePrivateKey(pemKey string) (*ecdsa.PrivateKey, error) { } ecKey, ok := key.(*ecdsa.PrivateKey) if !ok { - return nil, errors.New("private key is not an EC key; App Store Connect API keys are P-256") + return nil, fmt.Errorf("key is not an EC key (got %T); App Store Connect API keys are P-256", key) } if ecKey.Curve != elliptic.P256() { return nil, errors.New("private key is not on the P-256 curve") diff --git a/internal/asc/profiles.go b/internal/asc/profiles.go index f5d5561..efe396c 100644 --- a/internal/asc/profiles.go +++ b/internal/asc/profiles.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "fmt" + "net/http" "net/url" "time" ) @@ -138,7 +139,12 @@ func (c *Client) CreateProfile(ctx context.Context, name, profileType, bundleIDR return &p, nil } -// DeleteProfile removes a profile. Certificates and devices are untouched. +// DeleteProfile removes a profile; one that is already gone is not an error. +// Certificates and devices are untouched. func (c *Client) DeleteProfile(ctx context.Context, profileID string) error { - return c.Delete(ctx, "/v1/profiles/"+profileID, nil) + err := c.Delete(ctx, "/v1/profiles/"+profileID, nil) + if IsStatus(err, http.StatusNotFound) { + return nil + } + return err } diff --git a/internal/asc/profiles_test.go b/internal/asc/profiles_test.go index ad45f77..d2ecc30 100644 --- a/internal/asc/profiles_test.go +++ b/internal/asc/profiles_test.go @@ -125,3 +125,22 @@ func TestCreateAndDeleteProfile(t *testing.T) { t.Errorf("delete: err = %v, deleted = %q", err, deleted) } } + +func TestDeleteProfileToleratesGone(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/profiles/gone": + writeJSON(w, 404, map[string]any{"errors": []map[string]any{{"status": "404", "code": "NOT_FOUND", "title": "The specified resource does not exist"}}}) + default: + writeJSON(w, 409, map[string]any{"errors": []map[string]any{{"status": "409", "code": "STATE_ERROR", "title": "in use"}}}) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + if err := c.DeleteProfile(context.Background(), "gone"); err != nil { + t.Errorf("a profile that is already gone must not fail the delete: %v", err) + } + if err := c.DeleteProfile(context.Background(), "busy"); !IsStatus(err, 409) { + t.Errorf("other errors must surface: %v", err) + } +} From 4f769e4422a19434976df63dbd964701c50d78c6 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 18:29:33 +0200 Subject: [PATCH 53/75] 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. --- internal/signing/signing.go | 28 +++++++++++++++++----- internal/signing/signing_test.go | 41 +++++++++++++++++++++++--------- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/internal/signing/signing.go b/internal/signing/signing.go index 14fccbd..e56e844 100644 --- a/internal/signing/signing.go +++ b/internal/signing/signing.go @@ -18,16 +18,19 @@ import ( // GenerateKeyAndCSR creates an RSA-2048 private key and a certificate signing // request for the Apple Developer portal, both PEM-encoded. Apple requires // RSA 2048 for signing certificates; the subject mirrors what Keychain Access -// puts in its CSRs (email address and common name). +// puts in its CSRs (email address and common name). The key is written in +// PKCS#8 ("PRIVATE KEY"), the form openssl and zsign read without a legacy +// flag. func GenerateKeyAndCSR(commonName, email string) (keyPEM, csrPEM []byte, err error) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, nil, fmt.Errorf("failed to generate private key: %w", err) } - keyPEM = pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(key), - }) + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, nil, fmt.Errorf("failed to encode private key: %w", err) + } + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) csrPEM, err = CreateCSR(keyPEM, commonName, email) if err != nil { return nil, nil, err @@ -67,15 +70,28 @@ func CreateCSR(keyPEM []byte, commonName, email string) ([]byte, error) { }), nil } +// parseKey reads a PEM RSA key in PKCS#8 ("PRIVATE KEY", as written now) or +// PKCS#1 ("RSA PRIVATE KEY", as earlier Builder versions wrote it). func parseKey(keyPEM []byte) (*rsa.PrivateKey, error) { block, _ := pem.Decode(keyPEM) if block == nil { return nil, fmt.Errorf("invalid private key: not PEM encoded") } - key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if block.Type == "RSA PRIVATE KEY" { + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + return key, nil + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, fmt.Errorf("failed to parse private key: %w", err) } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("private key is %T, Apple signing certificates need an RSA key", parsed) + } return key, nil } diff --git a/internal/signing/signing_test.go b/internal/signing/signing_test.go index 8d14a73..93ef4ea 100644 --- a/internal/signing/signing_test.go +++ b/internal/signing/signing_test.go @@ -20,15 +20,16 @@ func TestGenerateKeyAndCSR(t *testing.T) { } keyBlock, _ := pem.Decode(keyPEM) - if keyBlock == nil || keyBlock.Type != "RSA PRIVATE KEY" { - t.Fatalf("key is not a PEM RSA PRIVATE KEY block") + if keyBlock == nil || keyBlock.Type != "PRIVATE KEY" { + t.Fatalf("key is not a PEM PKCS#8 PRIVATE KEY block") } - key, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + parsed, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes) if err != nil { - t.Fatalf("ParsePKCS1PrivateKey: %v", err) + t.Fatalf("ParsePKCS8PrivateKey: %v", err) } - if key.N.BitLen() != 2048 { - t.Errorf("key size = %d, want 2048", key.N.BitLen()) + key, ok := parsed.(*rsa.PrivateKey) + if !ok || key.N.BitLen() != 2048 { + t.Errorf("key = %T, want an RSA key of 2048 bits", parsed) } csrBlock, _ := pem.Decode(csrPEM) @@ -73,10 +74,9 @@ func TestBuildP12Roundtrip(t *testing.T) { if err != nil { t.Fatalf("GenerateKeyAndCSR: %v", err) } - keyBlock, _ := pem.Decode(keyPEM) - key, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + key, err := parseKey(keyPEM) if err != nil { - t.Fatalf("ParsePKCS1PrivateKey: %v", err) + t.Fatalf("parseKey: %v", err) } certDER := issueCert(t, &key.PublicKey, key) @@ -103,8 +103,7 @@ func TestBuildP12AcceptsPEMCertificate(t *testing.T) { if err != nil { t.Fatalf("GenerateKeyAndCSR: %v", err) } - keyBlock, _ := pem.Decode(keyPEM) - key, _ := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + key, _ := parseKey(keyPEM) certPEM := pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE", Bytes: issueCert(t, &key.PublicKey, key), @@ -115,6 +114,26 @@ func TestBuildP12AcceptsPEMCertificate(t *testing.T) { } } +// Keys written by earlier versions are PKCS#1 "RSA PRIVATE KEY" blocks; they +// still open, so a --key from before this change keeps its certificate. +func TestParseKeyAcceptsPKCS1(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + pkcs1 := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + got, err := parseKey(pkcs1) + if err != nil || !got.Equal(key) { + t.Fatalf("parseKey(PKCS#1) = %v, err = %v", got, err) + } + if _, err := CreateCSR(pkcs1, "Jane", ""); err != nil { + t.Errorf("CreateCSR with a PKCS#1 key: %v", err) + } + if !KeyMatchesCertificate(pkcs1, issueCert(t, &key.PublicKey, key)) { + t.Error("KeyMatchesCertificate with a PKCS#1 key") + } +} + func TestBuildP12RejectsMismatchedCertificate(t *testing.T) { keyPEM, _, err := GenerateKeyAndCSR("Jane Developer", "jane@example.com") if err != nil { From cdb8bb84c69aa439961f1a440018c83232fd9860 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 10:18:52 +0200 Subject: [PATCH 54/75] docs: walk through creating the App Store Connect API key; drop the certificate count from the quota hint --- README.md | 49 ++++++++++++++++++++++++++++------------ internal/signing/auto.go | 2 +- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ad43558..14dd65a 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,35 @@ anything, and a distribution profile refuses a `Debug` configuration. On Codemagic and Bitrise the same names are variables you add in the dashboard, see the [secrets guide](docs/provider-secrets.md). +### Create an App Store Connect API key + +Automatic signing, `ios upload`, `ios submit` and `ios release` all use one +App Store Connect API key. You create it once, in the browser: + +1. Sign in to [App Store Connect](https://appstoreconnect.apple.com) as the + Account Holder or an Admin (only they can create team keys). +2. Go to **Users and Access → Integrations → App Store Connect API**, tab + **Team Keys**, and press **+** (or **Generate API Key**). +3. Name it (for example `Builder`) and choose the role **Admin**. **App + Manager** works too if you also tick *Access to Certificates, Identifiers & + Profiles*; a **Developer** key can upload builds but cannot create + certificates. +4. Press **Generate**, then **Download API Key**. The `AuthKey_.p8` + file downloads once; keep it somewhere private, never in the repo. +5. Note the **Issuer ID** at the top of the page and the **Key ID** in the + row of your key. + +Then save it in your keychain: + +```bash +builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key ~/Downloads/AuthKey_ABC123DEFG.p8 +``` + +`builder auth status` shows it, `builder auth logout apple` removes it. On a +machine without a keychain (CI, a coding agent), set `ASC_ISSUER_ID`, +`ASC_KEY_ID` and `ASC_KEY_PATH` (or `ASC_PRIVATE_KEY` with the file's contents) +instead. + ### `builder signing setup` ```bash @@ -563,23 +592,15 @@ You need: a `store` profile builds `Release` and signs with that set; a plain `ios build` is Debug and unsigned, which is what the dev commands expect, not what you want to ship. -- An App Store Connect API key: App Store Connect → Users and Access → - Integrations → App Store Connect API → Team Keys. Give it the **App Manager** - role, note the **Issuer ID** and **Key ID**, and download the - `AuthKey_.p8` file (Apple offers the download once). +- An App Store Connect API key saved with `builder auth apple`, see + [Create an App Store Connect API key](#create-an-app-store-connect-api-key). ### 1. Save the API key -```bash -builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key AuthKey_ABC123DEFG.p8 -``` - -Flags you leave out are prompted for. Builder verifies the key against App -Store Connect and stores it like the other logins (keychain, or a `0600` file on -Linux/WSL); `builder auth status` shows it and `builder auth logout apple` -removes it. In CI or for a coding agent, set `ASC_ISSUER_ID`, `ASC_KEY_ID` and -either `ASC_PRIVATE_KEY` (the .p8 contents; literal `\n` is fine) or -`ASC_KEY_PATH` instead — they take precedence over the saved login. +`builder auth apple` as described in +[Create an App Store Connect API key](#create-an-app-store-connect-api-key). +Flags you leave out are prompted for; Builder verifies the key against App +Store Connect before storing it. ### 2. Upload the build diff --git a/internal/signing/auto.go b/internal/signing/auto.go index 9d82919..b1018a3 100644 --- a/internal/signing/auto.go +++ b/internal/signing/auto.go @@ -334,7 +334,7 @@ func ensureCertificate(ctx context.Context, client *asc.Client, opts *AutoOption logf(opts.Log, "Requesting a new %s certificate...", certType) cert, err := client.CreateCertificate(ctx, certType, csr) if err != nil { - return nil, withLimitHint(err, "Apple limits a team to 2 Apple Development and 3 Apple Distribution certificates. Revoke one you no longer use at https://developer.apple.com/account/resources/certificates/list (Builder never revokes anything), or pass --key with the private key of an existing certificate to reuse it.") + return nil, withLimitHint(err, "Apple caps how many certificates of each type a team can hold. Revoke one you no longer use at https://developer.apple.com/account/resources/certificates/list (Builder never revokes anything), or pass --key with the private key of an existing certificate to reuse it.") } if !KeyMatchesCertificate(keyPEM, cert.Content) { return nil, fmt.Errorf("certificate %s from App Store Connect was not issued for the private key", cert.ID) From c44fa525e5e5800515af43d4295bb17c6cc16a75 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 11:39:44 +0200 Subject: [PATCH 55/75] auth: say what auth apple saved --- cmd/builder/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/builder/auth.go b/cmd/builder/auth.go index 011c04e..9de60b9 100644 --- a/cmd/builder/auth.go +++ b/cmd/builder/auth.go @@ -181,7 +181,7 @@ func runAuthApple(cmd *cobra.Command, _ []string) error { if err := auth.StoreAppleCredentials(creds); err != nil { return err } - fmt.Printf("Saved Apple login (key %s). Other provider logins are unchanged.\n", creds.KeyID) + fmt.Printf("App Store Connect API key %s verified and saved to the keychain.\n", creds.KeyID) if os.Getenv("ASC_ISSUER_ID") != "" { fmt.Println("ASC_* environment variables are set and take precedence over this saved login.") } From 2e9b6af2938f9981af8555b196aad5b37bdbb1ff Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 12:51:27 +0200 Subject: [PATCH 56/75] 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. --- CLAUDE.md | 6 ++ internal/config/profile.go | 2 +- internal/workflow/providers_test.go | 83 +++++++++++++++++++++++ internal/workflow/templates/ios-build.yml | 26 +++++++ internal/workflow/templates/runner.sh | 25 ++++++- 5 files changed, 140 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0221709..cb769df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -351,6 +351,12 @@ internal/ refuses the export. `detect_export_method` in `ios-build.yml` and `runner.sh` reads it from the profile of the selected signing set, and `check_signing_set` confirms it is the type the build profile's `distribution` asked for (`app-store` is the `store` distribution). +- **Signing Identity Follows The Profile Type**: `signing_identity` (verbatim in both templates, + `development` → `Apple Development`, everything else → `Apple Distribution`) turns the export + method into `CODE_SIGN_IDENTITY`, which every manually signed archive command passes; without it + Xcode keeps the project's default identity and refuses a distribution profile ("No signing + certificate iOS Development found"). `security find-identity` right after `security import` fails + the job by name when the set's certificate is not that kind. - **Extension Points**: a future `ios release` (upload + TestFlight, automatic build numbers) composes `distribute.Upload` and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` for the latest build number; the `pkg/` wrappers do not expose `asc` yet. diff --git a/internal/config/profile.go b/internal/config/profile.go index 79b6e6e..4e8e898 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -34,7 +34,7 @@ var reservedEnv = []string{ "BUILD_ID", "SNAPSHOT_REF", "SNAPSHOT_SHA", "IOS_PATH", "SCHEME", "CONFIGURATION", "USE_SIGNING", "FLUTTER_VERSION", "JDK_VERSION", "BUILD_ENV", "DISTRIBUTION", "SIGNING_SET", "SIGNING_SET_USED", "DURATION", "PROJECT_TYPE", "EXPORT_METHOD", - "MOBAI_API_KEY", + "CODE_SIGN_IDENTITY", "MOBAI_API_KEY", "PATH", "HOME", "USER", "SHELL", "TMPDIR", "DEVELOPER_DIR", "NODE_OPTIONS", } diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index 3b090fa..790a60b 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -327,6 +327,89 @@ func TestExportMethodFollowsProfile(t *testing.T) { } } +// TestSigningIdentityFollowsProfileType holds both templates to the identity +// the profile's type needs. An archive without an explicit CODE_SIGN_IDENTITY +// keeps the project's default ("Apple Development"), which Xcode refuses to +// pair with a distribution profile: "No signing certificate iOS Development +// found". +func TestSigningIdentityFollowsProfileType(t *testing.T) { + workflowTemplate, err := GetWorkflowTemplate() + if err != nil { + t.Fatal(err) + } + runner, err := GetTemplate("runner.sh") + if err != nil { + t.Fatal(err) + } + fromWorkflow := shellFunc(t, string(workflowTemplate), "signing_identity") + fromRunner := shellFunc(t, string(runner), "signing_identity") + if fromWorkflow != fromRunner { + t.Fatalf("templates disagree on the signing identity:\n%s\n---\n%s", fromWorkflow, fromRunner) + } + + wiring := map[string][]string{ + "ios-build.yml": { + `CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD")`, + `echo "CODE_SIGN_IDENTITY=$CODE_SIGN_IDENTITY" >> $GITHUB_ENV`, + }, + "runner.sh": { + `CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD")`, + "export CODE_SIGN_IDENTITY", + }, + } + for name, data := range map[string]string{"ios-build.yml": string(workflowTemplate), "runner.sh": string(runner)} { + data = strings.ReplaceAll(data, "\r\n", "\n") // Windows checkouts + for _, want := range wiring[name] { + if !strings.Contains(data, want) { + t.Errorf("%s: the identity is not derived from the profile, missing %q", name, want) + } + } + // Every manually signed command must name the identity: one that sets + // CODE_SIGN_STYLE=Manual without it signs with the project's default. + manual := strings.Count(data, "CODE_SIGN_STYLE=Manual") + identity := strings.Count(data, `CODE_SIGN_IDENTITY='$CODE_SIGN_IDENTITY'`) + strings.Count(data, `CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY"`) + if manual == 0 || manual != identity { + t.Errorf("%s: %d manual signing commands but %d pass CODE_SIGN_IDENTITY", name, manual, identity) + } + // The imported certificate is checked against that identity, after the + // import and before the archive, so a distribution set holding a + // development certificate fails in seconds instead of minutes. + imported, checked := strings.Index(data, "security import "), strings.Index(data, "security find-identity -v -p codesigning") + if imported < 0 || checked < 0 { + t.Errorf("%s: import %d, identity check %d", name, imported, checked) + } else if checked < imported { + t.Errorf("%s: the identity check must run after security import (offsets %d, %d)", name, imported, checked) + } + } + + if runtime.GOOS == "windows" { + t.Skip("shell test") + } + for _, tc := range []struct{ method, want string }{ + {"development", "Apple Development"}, + {"ad-hoc", "Apple Distribution"}, + {"app-store", "Apple Distribution"}, + {"enterprise", "Apple Distribution"}, + {"nonsense", ""}, // an unknown method must fail, never sign with a guess + } { + t.Run(tc.method, func(t *testing.T) { + out, err := exec.Command("bash", "-c", fromRunner+"\nsigning_identity \"$1\"", "bash", tc.method).CombinedOutput() + if tc.want == "" { + if err == nil { + t.Fatalf("accepted %q: %s", tc.method, out) + } + return + } + if err != nil { + t.Fatalf("%s %v", out, err) + } + if got := strings.TrimSpace(string(out)); got != tc.want { + t.Fatalf("identity = %q, want %q", got, tc.want) + } + }) + } +} + // TestSigningSetSelection runs the set selection and profile check the way // the signing step does, with stub secrets, on the function bodies both // templates carry. diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index 815f08d..db1c892 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -442,6 +442,20 @@ jobs: fi } + # The certificate kind the profile's type needs. Without an explicit + # CODE_SIGN_IDENTITY the archive keeps the project's default (usually + # Apple Development / iPhone Developer), and Xcode refuses to pair a + # development identity with a distribution profile: "No signing + # certificate iOS Development found". Duplicated verbatim in + # ios-build.yml and runner.sh. + signing_identity() { + case "$1" in + development) echo "Apple Development" ;; + ad-hoc|app-store|enterprise) echo "Apple Distribution" ;; + *) return 1 ;; + esac + } + select_signing_set # Read the profile first: its type is checked against the build @@ -462,6 +476,7 @@ jobs: EXPORT_METHOD=$(detect_export_method "$PROFILE_PLIST") check_signing_set "$EXPORT_METHOD" + CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD") || fail "No signing identity for export method $EXPORT_METHOD." # A Debug archive carries get-task-allow=true, which no distribution # profile grants: the export fails, or an IPA that App Store Connect @@ -486,6 +501,12 @@ jobs: security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security list-keychain -d user -s "$KEYCHAIN_PATH" + # The certificate has to be the kind the profile asks for. Say so here + # instead of letting xcodebuild discover it after the whole archive. + IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH") + echo "$IDENTITIES" + echo "$IDENTITIES" | grep -qF "$CODE_SIGN_IDENTITY" || fail "IOS_CERTIFICATE${SIGNING_SET:+_$SIGNING_SET} holds no \"$CODE_SIGN_IDENTITY\" certificate, which an $EXPORT_METHOD profile must be signed with. Run builder signing setup --distribution ${DISTRIBUTION:-} to issue the right one." + # Install provisioning profile mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/"$PROFILE_UUID".mobileprovision @@ -494,6 +515,7 @@ jobs: echo "DEVELOPMENT_TEAM=$TEAM_ID" >> $GITHUB_ENV echo "PROVISIONING_PROFILE_NAME=$PROFILE_NAME" >> $GITHUB_ENV echo "EXPORT_METHOD=$EXPORT_METHOD" >> $GITHUB_ENV + echo "CODE_SIGN_IDENTITY=$CODE_SIGN_IDENTITY" >> $GITHUB_ENV echo "SIGNING_SET_USED=$SIGNING_SET_USED" >> $GITHUB_ENV echo "Certificate and provisioning profile installed" echo " team: $TEAM_ID" @@ -501,6 +523,7 @@ jobs: echo " app id: $PROFILE_BUNDLE_ID" echo " set: $SIGNING_SET_USED" echo " export: $EXPORT_METHOD" + echo " identity: $CODE_SIGN_IDENTITY" echo "If the build fails on a provisioning mismatch, the app's PRODUCT_BUNDLE_IDENTIFIER must match the app id above." - name: Build IPA @@ -609,6 +632,7 @@ jobs: ARCHIVE_CMD="$ARCHIVE_CMD COMPILER_INDEX_STORE_ENABLE=NO" ARCHIVE_CMD="$ARCHIVE_CMD DEVELOPMENT_TEAM='$DEVELOPMENT_TEAM'" ARCHIVE_CMD="$ARCHIVE_CMD CODE_SIGN_STYLE=Manual" + ARCHIVE_CMD="$ARCHIVE_CMD CODE_SIGN_IDENTITY='$CODE_SIGN_IDENTITY'" ARCHIVE_CMD="$ARCHIVE_CMD PROVISIONING_PROFILE_SPECIFIER='$PROVISIONING_PROFILE_NAME'" ARCHIVE_CMD="$ARCHIVE_CMD -quiet" ARCHIVE_CMD="$ARCHIVE_CMD -archivePath '$GITHUB_WORKSPACE/build/App.xcarchive' archive" @@ -657,6 +681,7 @@ jobs: # build/App.xcarchive whenever signing is on. BUILD_CMD="$BUILD_CMD DEVELOPMENT_TEAM='$DEVELOPMENT_TEAM'" BUILD_CMD="$BUILD_CMD CODE_SIGN_STYLE=Manual" + BUILD_CMD="$BUILD_CMD CODE_SIGN_IDENTITY='$CODE_SIGN_IDENTITY'" BUILD_CMD="$BUILD_CMD PROVISIONING_PROFILE_SPECIFIER='$PROVISIONING_PROFILE_NAME'" BUILD_CMD="$BUILD_CMD -archivePath '$GITHUB_WORKSPACE/build/App.xcarchive' archive" else @@ -694,6 +719,7 @@ jobs: # profile installed above. BUILD_CMD="$BUILD_CMD DEVELOPMENT_TEAM='$DEVELOPMENT_TEAM'" BUILD_CMD="$BUILD_CMD CODE_SIGN_STYLE=Manual" + BUILD_CMD="$BUILD_CMD CODE_SIGN_IDENTITY='$CODE_SIGN_IDENTITY'" BUILD_CMD="$BUILD_CMD PROVISIONING_PROFILE_SPECIFIER='$PROVISIONING_PROFILE_NAME'" BUILD_CMD="$BUILD_CMD -archivePath '$GITHUB_WORKSPACE/build/App.xcarchive' archive" else diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index e0429b4..e053eff 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -169,6 +169,20 @@ detect_export_method() { fi } +# The certificate kind the profile's type needs. Without an explicit +# CODE_SIGN_IDENTITY the archive keeps the project's default (usually +# Apple Development / iPhone Developer), and Xcode refuses to pair a +# development identity with a distribution profile: "No signing +# certificate iOS Development found". Duplicated verbatim in +# ios-build.yml and runner.sh. +signing_identity() { + case "$1" in + development) echo "Apple Development" ;; + ad-hoc|app-store|enterprise) echo "Apple Distribution" ;; + *) return 1 ;; + esac +} + # The suffix of the IOS_* secrets a distribution is signed with: its # canonical name upper-cased. No distribution has no set (the legacy # ios.signing path reads the unsuffixed secrets). Same table in ios-build.yml. @@ -241,7 +255,9 @@ install_signing() { export PROVISIONING_PROFILE_NAME="$(plutil -extract Name raw -o - "$signing_dir/profile.plist")" export EXPORT_METHOD="$(detect_export_method "$signing_dir/profile.plist")" check_signing_set "$EXPORT_METHOD" - echo "Signing with '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM, set $SIGNING_SET_USED), export method $EXPORT_METHOD" + CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD") || fail "No signing identity for export method $EXPORT_METHOD." + export CODE_SIGN_IDENTITY + echo "Signing with '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM, set $SIGNING_SET_USED), export method $EXPORT_METHOD, identity $CODE_SIGN_IDENTITY" # A Debug archive carries get-task-allow=true, which no distribution profile # grants: the export fails, or an IPA that App Store Connect rejects comes # out. Say so now instead of after the whole build. @@ -258,6 +274,12 @@ install_signing() { security import "$signing_dir/certificate.p12" -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path" security set-key-partition-list -S apple-tool:,apple: -k "$keychain_password" "$keychain_path" security list-keychains -d user -s "$keychain_path" "$HOME/Library/Keychains/login.keychain-db" + # The certificate has to be the kind the profile asks for. Say so here instead + # of letting xcodebuild discover it after the whole archive. + identities=$(security find-identity -v -p codesigning "$keychain_path") + echo "$identities" + echo "$identities" | grep -qF "$CODE_SIGN_IDENTITY" || + fail "IOS_CERTIFICATE${SIGNING_SET:+_$SIGNING_SET} holds no \"$CODE_SIGN_IDENTITY\" certificate, which an $EXPORT_METHOD profile must be signed with. Run builder signing setup --distribution ${DISTRIBUTION:-} to issue the right one." mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" profile_dest="$HOME/Library/MobileDevice/Provisioning Profiles/$profile_uuid.mobileprovision" cp "$signing_dir/profile.mobileprovision" "$profile_dest" @@ -277,6 +299,7 @@ build_ipa() { mkdir -p "$BUILDER_WORKSPACE/build" if [ "$USE_SIGNING" = true ]; then xcodebuild "${args[@]}" DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY" \ PROVISIONING_PROFILE_SPECIFIER="$PROVISIONING_PROFILE_NAME" -archivePath "$BUILDER_WORKSPACE/build/App.xcarchive" archive export APP_BUNDLE_ID="$(plutil -extract ApplicationProperties.CFBundleIdentifier raw -o - "$BUILDER_WORKSPACE/build/App.xcarchive/Info.plist")" python3 - "$signing_dir/ExportOptions.plist" <<'PY' From be46cb5c0e758fb8617e57605ac55d524562f13f Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 12:55:10 +0200 Subject: [PATCH 57/75] 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. --- CLAUDE.md | 13 +++--- internal/workflow/providers_test.go | 54 ++++++++++++++++------- internal/workflow/templates/ios-build.yml | 40 +++++++++++------ internal/workflow/templates/runner.sh | 47 +++++++++++++------- 4 files changed, 104 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cb769df..f1533e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -351,12 +351,13 @@ internal/ refuses the export. `detect_export_method` in `ios-build.yml` and `runner.sh` reads it from the profile of the selected signing set, and `check_signing_set` confirms it is the type the build profile's `distribution` asked for (`app-store` is the `store` distribution). -- **Signing Identity Follows The Profile Type**: `signing_identity` (verbatim in both templates, - `development` → `Apple Development`, everything else → `Apple Distribution`) turns the export - method into `CODE_SIGN_IDENTITY`, which every manually signed archive command passes; without it - Xcode keeps the project's default identity and refuses a distribution profile ("No signing - certificate iOS Development found"). `security find-identity` right after `security import` fails - the job by name when the set's certificate is not that kind. +- **Signing Identity Follows The Profile Type**: `signing_identities` and `signing_identity` + (verbatim in both templates) pick `CODE_SIGN_IDENTITY` out of `security find-identity`, run right + after `security import`: `Apple Development`, else the pre-2021 `iPhone Developer`, for a + development profile; `Apple Distribution`, else `iPhone Distribution`, for the rest; a named + `::error::` when the set holds neither. Every manually signed archive command passes it, since + without it Xcode keeps the project's default identity and refuses a distribution profile ("No + signing certificate iOS Development found"). - **Extension Points**: a future `ios release` (upload + TestFlight, automatic build numbers) composes `distribute.Upload` and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` for the latest build number; the `pkg/` wrappers do not expose `asc` yet. diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index 790a60b..a13f276 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -1,6 +1,7 @@ package workflow import ( + "fmt" "os" "os/exec" "path/filepath" @@ -341,19 +342,23 @@ func TestSigningIdentityFollowsProfileType(t *testing.T) { if err != nil { t.Fatal(err) } - fromWorkflow := shellFunc(t, string(workflowTemplate), "signing_identity") - fromRunner := shellFunc(t, string(runner), "signing_identity") - if fromWorkflow != fromRunner { - t.Fatalf("templates disagree on the signing identity:\n%s\n---\n%s", fromWorkflow, fromRunner) + var shared string + for _, fn := range []string{"signing_identities", "signing_identity"} { + fromWorkflow := shellFunc(t, string(workflowTemplate), fn) + fromRunner := shellFunc(t, string(runner), fn) + if fromWorkflow != fromRunner { + t.Fatalf("templates disagree on %s:\n%s\n---\n%s", fn, fromWorkflow, fromRunner) + } + shared += fromRunner + "\n" } wiring := map[string][]string{ "ios-build.yml": { - `CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD")`, + `CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD" "$IDENTITIES")`, `echo "CODE_SIGN_IDENTITY=$CODE_SIGN_IDENTITY" >> $GITHUB_ENV`, }, "runner.sh": { - `CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD")`, + `CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD" "$identities")`, "export CODE_SIGN_IDENTITY", }, } @@ -385,18 +390,37 @@ func TestSigningIdentityFollowsProfileType(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell test") } - for _, tc := range []struct{ method, want string }{ - {"development", "Apple Development"}, - {"ad-hoc", "Apple Distribution"}, - {"app-store", "Apple Distribution"}, - {"enterprise", "Apple Distribution"}, - {"nonsense", ""}, // an unknown method must fail, never sign with a guess + // security find-identity prints one line per identity; certificates issued + // before Apple's 2021 rename still say iPhone Developer / iPhone + // Distribution and sign the same profiles, so they must be accepted. + line := func(names ...string) string { + out := "" + for i, n := range names { + out += fmt.Sprintf(" %d) DEADBEEF \"%s: Some One (2638BTZ9X7)\"\n", i+1, n) + } + return out + fmt.Sprintf(" %d valid identities found", len(names)) + } + for _, tc := range []struct{ name, method, identities, want string }{ + {"development", "development", line("Apple Development"), "Apple Development"}, + {"legacy development", "development", line("iPhone Developer"), "iPhone Developer"}, + {"both development names", "development", line("iPhone Developer", "Apple Development"), "Apple Development"}, + {"ad-hoc", "ad-hoc", line("Apple Distribution"), "Apple Distribution"}, + {"app-store", "app-store", line("Apple Distribution"), "Apple Distribution"}, + {"enterprise", "enterprise", line("Apple Distribution"), "Apple Distribution"}, + {"legacy distribution", "app-store", line("iPhone Distribution"), "iPhone Distribution"}, + {"both distribution names", "app-store", line("iPhone Distribution", "Apple Distribution"), "Apple Distribution"}, + // A development certificate cannot sign a distribution profile, and + // an unknown method must never sign with a guess. + {"development certificate in a store set", "app-store", line("Apple Development", "iPhone Developer"), ""}, + {"distribution certificate in a development set", "development", line("Apple Distribution"), ""}, + {"empty keychain", "app-store", line(), ""}, + {"unknown method", "nonsense", line("Apple Distribution"), ""}, } { - t.Run(tc.method, func(t *testing.T) { - out, err := exec.Command("bash", "-c", fromRunner+"\nsigning_identity \"$1\"", "bash", tc.method).CombinedOutput() + t.Run(tc.name, func(t *testing.T) { + out, err := exec.Command("bash", "-c", shared+"\nsigning_identity \"$1\" \"$2\"", "bash", tc.method, tc.identities).CombinedOutput() if tc.want == "" { if err == nil { - t.Fatalf("accepted %q: %s", tc.method, out) + t.Fatalf("accepted %q for %s: %s", tc.identities, tc.method, out) } return } diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index db1c892..39ed39b 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -442,20 +442,34 @@ jobs: fi } - # The certificate kind the profile's type needs. Without an explicit - # CODE_SIGN_IDENTITY the archive keeps the project's default (usually - # Apple Development / iPhone Developer), and Xcode refuses to pair a - # development identity with a distribution profile: "No signing - # certificate iOS Development found". Duplicated verbatim in - # ios-build.yml and runner.sh. - signing_identity() { + # The certificate names that can sign for a profile of this type, the + # current one first. Apple renamed the certificates in 2021; keychains + # still hold iPhone Developer / iPhone Distribution certificates that + # sign exactly the same profiles. Duplicated verbatim in ios-build.yml + # and runner.sh. + signing_identities() { case "$1" in - development) echo "Apple Development" ;; - ad-hoc|app-store|enterprise) echo "Apple Distribution" ;; + development) printf '%s\n' "Apple Development" "iPhone Developer" ;; + ad-hoc|app-store|enterprise) printf '%s\n' "Apple Distribution" "iPhone Distribution" ;; *) return 1 ;; esac } + # The identity to archive with: the first of those names the imported + # certificate actually goes by ($2 is security find-identity output). + # Without an explicit CODE_SIGN_IDENTITY the archive keeps the + # project's default, and Xcode refuses to pair a development identity + # with a distribution profile: "No signing certificate iOS Development + # found". Duplicated verbatim in ios-build.yml and runner.sh. + signing_identity() { + local names name + names=$(signing_identities "$1") || return 1 + while IFS= read -r name; do + case "$2" in *"$name: "*) echo "$name"; return 0 ;; esac + done <<< "$names" + return 2 + } + select_signing_set # Read the profile first: its type is checked against the build @@ -476,7 +490,6 @@ jobs: EXPORT_METHOD=$(detect_export_method "$PROFILE_PLIST") check_signing_set "$EXPORT_METHOD" - CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD") || fail "No signing identity for export method $EXPORT_METHOD." # A Debug archive carries get-task-allow=true, which no distribution # profile grants: the export fails, or an IPA that App Store Connect @@ -501,11 +514,12 @@ jobs: security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security list-keychain -d user -s "$KEYCHAIN_PATH" - # The certificate has to be the kind the profile asks for. Say so here - # instead of letting xcodebuild discover it after the whole archive. + # The certificate has to be the kind the profile asks for, under + # whichever of its names it carries. Say so here instead of letting + # xcodebuild discover it after the whole archive. IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH") echo "$IDENTITIES" - echo "$IDENTITIES" | grep -qF "$CODE_SIGN_IDENTITY" || fail "IOS_CERTIFICATE${SIGNING_SET:+_$SIGNING_SET} holds no \"$CODE_SIGN_IDENTITY\" certificate, which an $EXPORT_METHOD profile must be signed with. Run builder signing setup --distribution ${DISTRIBUTION:-} to issue the right one." + CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD" "$IDENTITIES") || fail "IOS_CERTIFICATE${SIGNING_SET:+_$SIGNING_SET} holds no $(signing_identities "$EXPORT_METHOD" | paste -sd '/' -) certificate, which an $EXPORT_METHOD profile must be signed with. Run builder signing setup --distribution ${DISTRIBUTION:-} to issue the right one." # Install provisioning profile mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index e053eff..8724153 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -169,20 +169,34 @@ detect_export_method() { fi } -# The certificate kind the profile's type needs. Without an explicit -# CODE_SIGN_IDENTITY the archive keeps the project's default (usually -# Apple Development / iPhone Developer), and Xcode refuses to pair a -# development identity with a distribution profile: "No signing -# certificate iOS Development found". Duplicated verbatim in -# ios-build.yml and runner.sh. -signing_identity() { +# The certificate names that can sign for a profile of this type, the +# current one first. Apple renamed the certificates in 2021; keychains +# still hold iPhone Developer / iPhone Distribution certificates that +# sign exactly the same profiles. Duplicated verbatim in ios-build.yml +# and runner.sh. +signing_identities() { case "$1" in - development) echo "Apple Development" ;; - ad-hoc|app-store|enterprise) echo "Apple Distribution" ;; + development) printf '%s\n' "Apple Development" "iPhone Developer" ;; + ad-hoc|app-store|enterprise) printf '%s\n' "Apple Distribution" "iPhone Distribution" ;; *) return 1 ;; esac } +# The identity to archive with: the first of those names the imported +# certificate actually goes by ($2 is security find-identity output). +# Without an explicit CODE_SIGN_IDENTITY the archive keeps the +# project's default, and Xcode refuses to pair a development identity +# with a distribution profile: "No signing certificate iOS Development +# found". Duplicated verbatim in ios-build.yml and runner.sh. +signing_identity() { + local names name + names=$(signing_identities "$1") || return 1 + while IFS= read -r name; do + case "$2" in *"$name: "*) echo "$name"; return 0 ;; esac + done <<< "$names" + return 2 +} + # The suffix of the IOS_* secrets a distribution is signed with: its # canonical name upper-cased. No distribution has no set (the legacy # ios.signing path reads the unsuffixed secrets). Same table in ios-build.yml. @@ -255,9 +269,7 @@ install_signing() { export PROVISIONING_PROFILE_NAME="$(plutil -extract Name raw -o - "$signing_dir/profile.plist")" export EXPORT_METHOD="$(detect_export_method "$signing_dir/profile.plist")" check_signing_set "$EXPORT_METHOD" - CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD") || fail "No signing identity for export method $EXPORT_METHOD." - export CODE_SIGN_IDENTITY - echo "Signing with '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM, set $SIGNING_SET_USED), export method $EXPORT_METHOD, identity $CODE_SIGN_IDENTITY" + echo "Signing with '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM, set $SIGNING_SET_USED), export method $EXPORT_METHOD" # A Debug archive carries get-task-allow=true, which no distribution profile # grants: the export fails, or an IPA that App Store Connect rejects comes # out. Say so now instead of after the whole build. @@ -274,12 +286,15 @@ install_signing() { security import "$signing_dir/certificate.p12" -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path" security set-key-partition-list -S apple-tool:,apple: -k "$keychain_password" "$keychain_path" security list-keychains -d user -s "$keychain_path" "$HOME/Library/Keychains/login.keychain-db" - # The certificate has to be the kind the profile asks for. Say so here instead - # of letting xcodebuild discover it after the whole archive. + # The certificate has to be the kind the profile asks for, under whichever of + # its names it carries. Say so here instead of letting xcodebuild discover it + # after the whole archive. identities=$(security find-identity -v -p codesigning "$keychain_path") echo "$identities" - echo "$identities" | grep -qF "$CODE_SIGN_IDENTITY" || - fail "IOS_CERTIFICATE${SIGNING_SET:+_$SIGNING_SET} holds no \"$CODE_SIGN_IDENTITY\" certificate, which an $EXPORT_METHOD profile must be signed with. Run builder signing setup --distribution ${DISTRIBUTION:-} to issue the right one." + CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD" "$identities") || + fail "IOS_CERTIFICATE${SIGNING_SET:+_$SIGNING_SET} holds no $(signing_identities "$EXPORT_METHOD" | paste -sd '/' -) certificate, which an $EXPORT_METHOD profile must be signed with. Run builder signing setup --distribution ${DISTRIBUTION:-} to issue the right one." + export CODE_SIGN_IDENTITY + echo "Signing identity: $CODE_SIGN_IDENTITY" mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" profile_dest="$HOME/Library/MobileDevice/Provisioning Profiles/$profile_uuid.mobileprovision" cp "$signing_dir/profile.mobileprovision" "$profile_dest" From 152608ab2df401324a635cd3b45b2091b352d600 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 13:02:07 +0200 Subject: [PATCH 58/75] docs: explain creating the App Store Connect app record --- README.md | 1720 +++++++++++++++++++++++++++-------------------------- 1 file changed, 868 insertions(+), 852 deletions(-) diff --git a/README.md b/README.md index 14dd65a..220e64a 100644 --- a/README.md +++ b/README.md @@ -1,852 +1,868 @@ -# Builder - -Build and develop iOS apps from Windows, Linux, or any platform. - -Builder is a CLI tool for iOS development without a Mac. It uses GitHub Actions (default), Codemagic, or Bitrise for remote builds and [MobAI](https://mobai.run) for on-device development. - -![Builder Demo](assets/ios-builder-demo.gif) - -## Features - -- **Build from anywhere**: Build iOS apps via GitHub Actions, Codemagic, or Bitrise -- **Independent provider logins**: Stay signed in to all three and choose where each build runs -- **Try it on a simulator**: Use your build on an iOS simulator from Windows or Linux -- **Flutter & React Native dev tools**: Hot reload on real iOS devices from Windows/Linux -- **Simple setup**: One command to add the workflow to your repo -- **Code signing**: Optional signing with your certificate and provisioning profile -- **TestFlight and App Store**: Upload builds and submit them for review through the App Store Connect API, from any platform -- **Device integration**: Install and run apps via MobAI - -## How It Works - -``` -Your Repository GitHub Actions (macOS) - └─ .github/workflows/ └─ ios-build.yml - └─ ios-build.yml ├─ Check out the snapshot - ├─ Build with Xcode -builder ios build ───────────────────► Upload IPA artifact - │ pushes a snapshot of - │ your working tree - └─ Downloads IPA ◄─────────────── artifact: ipa -``` - -`builder ios build` builds what is on disk, not your last commit: uncommitted -and untracked files are included, so you can try a change without committing -it. The snapshot is a throwaway commit pushed to a hidden ref that is deleted -when the build finishes; no branch is created and nothing is committed on your -behalf. `.gitignore` still applies, so ignored files such as `.env` or -`GoogleService-Info.plist` are absent from the build. - -## Quick Start - -### 1. Authenticate with GitHub - -```bash -builder auth github -``` - -### 2. Initialize (in your project directory) - -```bash -cd your-ios-project -builder init -``` - -This detects your GitHub repo, creates the workflow files, and offers to commit, push, and trigger your first build - all interactively. - -### 3. Build - -```bash -builder ios build -``` - -The CLI triggers the workflow and downloads the IPA to `./dist/`. - -### 4. Try it on a simulator (optional) - -```bash -builder ios share -``` - -Builds the working tree for the iOS simulator and makes that simulator usable -from the [MobAI](https://mobai.run) app, so you can tap through a build without -a Mac. It shows up under CI Devices, stays available while you are using it, and -closes when you release it there or leave it unused (30 minutes by default, use -`--duration` to change). A coding agent connected to MobAI (Claude Code, Codex, -Cursor) can drive the simulator the same way. - -Free with any MobAI account, on [MobAI 3.0 or later](https://mobai.run). Needs -a `MOBAI_API_KEY` repository secret: create the key in the MobAI app under -Account → API Keys, then: - -```bash -gh secret set MOBAI_API_KEY -``` - -### Triggering from git only - -Where the GitHub API is not reachable, both workflows can also be started by -pushing a tag. Commit the tree you want built, then: - -```bash -git tag ios-build/my-build && git push origin ios-build/my-build # IPA build -git tag ios-share/my-build && git push origin ios-share/my-build # simulator -``` - -The run is named after the tag. Build settings come from `builder.json` in the -tagged commit (`ios.path`, `ios.scheme`, `ios.signing`, `ios.configuration`, -`flutter.version`, `kmp.jdkVersion`), the simulator stays available for the -default 30 minutes, and the tag is deleted when the run ends. The IPA is -attached to the run as an artifact. A tag carries no flags, so a tag build -cannot pick a [profile](#build-profiles) per run; it applies the profile named -by `defaultProfile`, if there is one. - -## Additional macOS Providers - -GitHub Actions remains the default, so existing commands continue to work. Add -Codemagic and Bitrise without logging out of GitHub. First follow the -[app creation and repository connection guide](docs/provider-setup.md) to create -each provider app, authorize GitHub access, and find its app ID: - -```bash -builder auth codemagic -builder auth bitrise -builder auth status -builder init --provider codemagic --app-id YOUR_APP_ID --branch main -builder init --provider bitrise --app-id YOUR_APP_SLUG --branch main -builder ios build --provider codemagic -builder ios build --provider bitrise -``` - -`init` writes `codemagic.yaml` or `bitrise.yml` at the repo root plus the shared -runner script `.builder/ci/runner.sh`. Commit them to the configured branch -and connect the same repository to each provider before building. See -[provider setup, signing, simulator sessions, and free allowances](docs/providers.md). - -## Supported Frameworks - -| Framework | iOS Path | Auto-detected | -|-----------|----------|---------------| -| Native iOS/Swift | `.` (root) | Yes | -| React Native | `ios/` | Yes | -| Expo (ejected) | `ios/` | Yes | -| Flutter | `ios/` | Yes | -| Kotlin Multiplatform | `iosApp/` | Yes | -| Cordova/Ionic | `platforms/ios/` | Yes | - -## Installation - -### Windows - -Download `builder-windows-amd64.exe` from [Releases](https://github.com/MobAI-App/ios-builder/releases), rename it to `builder.exe`, and add it to PATH. - -### Homebrew (macOS/Linux) - -```bash -brew install mobai-app/tap/ios-builder -``` - -The formula is named `ios-builder`; the command it installs is `builder`. - -### macOS/Linux/WSL - -```bash -curl -sSL https://raw.githubusercontent.com/MobAI-App/ios-builder/main/install.sh | bash -``` - -### From Source - -```bash -git clone https://github.com/MobAI-App/ios-builder.git -cd ios-builder -go build -o builder ./cmd/builder -``` - -## Commands - -```bash -# Setup -builder auth github # Authenticate with GitHub -builder auth codemagic # Authenticate with Codemagic (also: bitrise) -builder auth apple # Save an App Store Connect API key -builder auth status # Show which providers you are signed in to -builder auth logout [name] # Remove stored credentials (github, codemagic, bitrise, apple) -builder init # Set up workflows in current repo -builder update # Update builder to the latest release - -# Building (builds the working tree, including uncommitted changes) -builder ios build # Trigger build and download IPA to ./dist/ -builder ios build --unsigned # Build without code signing (if signing is configured) -builder ios build --provider codemagic # Build on another provider (also: bitrise) -builder ios build --profile production # Build with a profile from builder.json - -# Simulator (free, needs a MOBAI_API_KEY secret) -builder ios share # Try the build on a simulator in the MobAI app -builder ios share --duration 1h # Keep it available longer while unused - -# Development (requires MobAI) -builder dev flutter # Flutter hot reload with file watching -builder dev flutter --no-watch # Disable automatic file watching -builder dev flutter --no-attach # Print flutter attach command instead of running it -builder dev rn # React Native hot reload (short for: dev react-native) -builder dev kmp # Kotlin Multiplatform install + launch (alias: kotlin) -builder dev kmp --logs # Also stream the app's output -builder dev flutter --skip-install --bundle-id # Use already installed app -builder dev rn --metro-port 8082 # Use custom Metro port - -# MobAI (used by the dev commands; handy for troubleshooting) -builder mobai ping # Check MobAI connectivity -builder mobai install # Install an IPA on the device -builder mobai run-debug # Launch an app with the debugger attached -builder mobai forward # Forward a device port - -# Code signing (automatic mode needs builder auth apple) -builder signing setup --devices-from-mobai # development: certificate, devices, profile, GitHub secrets, no portal -builder signing setup --distribution store # Apple Distribution certificate + App Store profile -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision # Upload your own files -builder ios build --profile store # Signs with the set; provisions it first when missing -builder signing csr # Manual path: create a private key + certificate signing request -builder signing p12 # Manual path: assemble a .p12 from the key and Apple's certificate - -# TestFlight and App Store (needs builder auth apple) -builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing -builder ios submit --testflight --group "Beta Testers" --notes "What to test" -builder ios submit --app-store --release after-approval # Submit the version for App Review -``` - -Every `upload`/`submit` command takes `--json` for machine-readable output and -never prompts, so agents and CI jobs can drive them. - -## Configuration - -`builder.json`: - -```json -{ - "project": "MyApp", - "platform": "ios", - "github": { - "owner": "username", - "repo": "my-ios-app" - }, - "ios": { - "path": "ios", - "scheme": "", - "bundleId": "com.example.app", - "configuration": "Debug" - }, - "profiles": { - "development": { "distribution": "development" }, - "store": { "distribution": "store" } - }, - "mobai": { - "url": "http://localhost:8686", - "device_id": "" - }, - "flutter": { - "watch": { - "dirs": ["lib"], - "patterns": [".dart"], - "ignore": [".g.dart", ".freezed.dart"], - "debounce": 100 - } - } -} -``` - -### iOS Build Configuration - -| Field | Description | Default | -|-------|-------------|---------| -| `ios.path` | Path to the Xcode project relative to the repo root | detected by `init` | -| `ios.scheme` | Xcode scheme to build | auto-detected | -| `ios.bundleId` | App bundle identifier, used by `signing setup` | detected by `init` when the project has one app target; else saved by `signing setup` | -| `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` | -| `ios.signing` | Legacy: sign builds that select no profile, with the unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and `IOS_PROVISIONING_PROFILE` secrets. Profiles ignore it; use `distribution` there | `false` | - -### Build Profiles - -Profiles are named sets of build settings, in the spirit of `eas.json`, selected -with `--profile` on `ios build` and `ios share`: - -```json -{ - "ios": { "path": "ios", "bundleId": "com.example.app" }, - "defaultProfile": "development", - "profiles": { - "development": { "distribution": "development" }, - "preview": { "distribution": "internal", - "env": { "API_URL": "https://staging.example.com" } }, - "production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic" } - } -} -``` - -```bash -builder ios build --profile preview -builder ios share --profile preview -``` - -| Field | Description | -|-------|-------------| -| `distribution` | The only signing setting: `development`, `ad-hoc` (or `internal`, the same thing), `store` or `enterprise`. The build signs with that distribution's [signing set](#code-signing) and its provisioning profile must be of that type; the IPA is exported with the matching method. Omitted means an unsigned build | -| `configuration` | Overrides the derived configuration: `Debug` for `development`, `Release` for every other distribution, `ios.configuration` for unsigned profiles | -| `scheme` | Overrides `ios.scheme` | -| `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) | -| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them | - -How a build's settings are resolved: - -- Without `--profile`, the profile named by `defaultProfile` applies. With - neither, the top-level `ios.*` and `provider` settings are used exactly as - before, so existing projects are unaffected. -- A profile only overrides the fields it sets; everything else comes from the - top level. An unknown profile name is an error that lists the available ones. -- `--unsigned` and `--provider` on the command line override the profile. -- The resolved settings (profile, configuration, scheme, signing set, provider, - env names) are printed before anything is dispatched. -- `ios share` only takes the profile's scheme, provider and env: simulator - builds are always Debug and unsigned. - -**`env` values are build-time configuration, not secrets.** They are stored in -`builder.json`, sent to the CI provider as plain workflow inputs, and visible in -the run's inputs and logs. Keep tokens and passwords in the provider's secrets -instead (`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets -guide](docs/provider-secrets.md)); the build reads those as environment -variables too. Names the runner owns are rejected: its own parameters -(`SCHEME`, `CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing -secrets, `PATH`, `HOME`, `DEVELOPER_DIR`, and anything starting with `GITHUB_`, -`RUNNER_`, `CM_`, `BITRISE_` or `BUILDER_`. - -Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow -files from this version of Builder, which declare a `profile` input; an older -committed workflow rejects the dispatch. Run `builder init` again to refresh -`.github/workflows/ios-build.yml` and `ios-share.yml` (or `builder init ---provider ...` for `runner.sh`) in a project set up earlier, then commit and -push them to the default branch. - -### MobAI Configuration - -| Field | Description | Default | -|-------|-------------|---------| -| `mobai.url` | MobAI API URL | `http://localhost:8686` | -| `mobai.device_id` | Preferred device ID (uses first available if empty) | `""` | - -**WSL users**: MobAI runs on Windows, and WSL has its own network by default. On -Windows 11, turn on -[mirrored networking](https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking) -and builder reaches MobAI on the default `http://localhost:8686`. See -[Using Builder from WSL](docs/wsl.md) for the steps, and for the setup without -mirrored networking. - -### Flutter File Watcher - -| Field | Description | Default | -|-------|-------------|---------| -| `flutter.watch.dirs` | Directories to watch | `["lib"]` | -| `flutter.watch.patterns` | File patterns to match | `[".dart"]` | -| `flutter.watch.ignore` | Patterns to ignore | `[".g.dart", ".freezed.dart"]` | -| `flutter.watch.debounce` | Debounce delay in ms | `100` | - -## Code Signing - -By default, builds are unsigned. A signed build needs a certificate and a -provisioning profile — and despite what many guides claim, **you do not need a -Mac to create either one**, nor a tour of the Apple Developer portal. Signing -is configured per [build profile](#build-profiles) with one field, -`distribution`, and `builder signing setup` produces the material for it -through the App Store Connect API (or takes your own files). - -You need a paid [Apple Developer Program](https://developer.apple.com/programs/) -membership — Apple only issues certificates to paid accounts. (Without one, -build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free -Apple ID.) - -### Profiles and signing sets - -Each distribution has its own set of three GitHub secrets, so a development set -for your devices and a store set for TestFlight live side by side: - -| `distribution` | Certificate, profile | Secrets | -|----------------|----------------------|---------| -| `development` | Apple Development, iOS App Development (devices required) | `IOS_CERTIFICATE_DEVELOPMENT`, `IOS_CERTIFICATE_PASSWORD_DEVELOPMENT`, `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | -| `ad-hoc` or `internal` | Apple Distribution, Ad Hoc (devices required) | `IOS_CERTIFICATE_AD_HOC`, `IOS_CERTIFICATE_PASSWORD_AD_HOC`, `IOS_PROVISIONING_PROFILE_AD_HOC` | -| `store` | Apple Distribution, App Store | `IOS_CERTIFICATE_STORE`, `IOS_CERTIFICATE_PASSWORD_STORE`, `IOS_PROVISIONING_PROFILE_STORE` | -| `enterprise` | In-house (portal only) | `IOS_CERTIFICATE_ENTERPRISE`, `IOS_CERTIFICATE_PASSWORD_ENTERPRISE`, `IOS_PROVISIONING_PROFILE_ENTERPRISE` | - -A build with `--profile ` signs with the set of that profile's -`distribution`; `configuration` follows it (`Debug` for `development`, -`Release` otherwise) unless the profile sets one. The runner checks that the -profile in the set is of the requested type and fails by name before compiling -anything, and a distribution profile refuses a `Debug` configuration. On -Codemagic and Bitrise the same names are variables you add in the dashboard, -see the [secrets guide](docs/provider-secrets.md). - -### Create an App Store Connect API key - -Automatic signing, `ios upload`, `ios submit` and `ios release` all use one -App Store Connect API key. You create it once, in the browser: - -1. Sign in to [App Store Connect](https://appstoreconnect.apple.com) as the - Account Holder or an Admin (only they can create team keys). -2. Go to **Users and Access → Integrations → App Store Connect API**, tab - **Team Keys**, and press **+** (or **Generate API Key**). -3. Name it (for example `Builder`) and choose the role **Admin**. **App - Manager** works too if you also tick *Access to Certificates, Identifiers & - Profiles*; a **Developer** key can upload builds but cannot create - certificates. -4. Press **Generate**, then **Download API Key**. The `AuthKey_.p8` - file downloads once; keep it somewhere private, never in the repo. -5. Note the **Issuer ID** at the top of the page and the **Key ID** in the - row of your key. - -Then save it in your keychain: - -```bash -builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key ~/Downloads/AuthKey_ABC123DEFG.p8 -``` - -`builder auth status` shows it, `builder auth logout apple` removes it. On a -machine without a keychain (CI, a coding agent), set `ASC_ISSUER_ID`, -`ASC_KEY_ID` and `ASC_KEY_PATH` (or `ASC_PRIVATE_KEY` with the file's contents) -instead. - -### `builder signing setup` - -```bash -builder auth apple # once: save the App Store Connect API key -builder signing setup --devices-from-mobai # development set for the devices MobAI sees -builder signing setup --distribution store # store set for TestFlight / App Store -``` - -Without files, `setup` works through the App Store Connect API for the given -`--distribution` (default `development`; `--name ` reads it from an -existing profile). The key needs the **Admin** role (or App Manager plus -*Access to Certificates, Identifiers & Profiles*): Developer-role keys cannot -create certificates. It then: - -1. Registers the **App ID** if the bundle identifier is not on the account yet. - The bundle ID comes from `--bundle-id`, `ios.bundleId` in `builder.json` - (which `init` fills when the Xcode project has a single app target), or the - newest IPA in `./dist/`; in a terminal it asks as a last resort. -2. Issues a **certificate** — Apple Development for `development`, Apple - Distribution for `ad-hoc` and `store` — for a private key generated on your - machine (`ios-signing-.key`, or `--key` to reuse one from - `signing csr`; a `ios-signing.key` from an earlier version is picked up - too). A valid certificate on the account is reused only when its private - key is here, because that is the only way to build the `.p12`; otherwise a - new one is issued. Nothing is ever revoked: when Apple's limit (2 - Development, 3 Distribution) is hit, the error names it and points at the - portal. -3. Registers **devices** from `--device ` (repeatable) and - `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has - connected; simulators and cloud farm devices are skipped). Development and - ad-hoc profiles cover every enabled iOS device on the account, so with none - given and none registered the command stops and says so. Store profiles - take no devices. Apple allows 100 devices per membership year and never - frees a slot; that error is passed through too. -4. Creates the **profile** `Builder `. An existing - one is reused while it is `ACTIVE`, unexpired and still lists exactly this - certificate and these devices; otherwise it is deleted and recreated, and - the summary says why (`invalid`, `expired`, `certificate changed`, `devices - changed`, `forced`). -5. Writes `ios-signing-.key` (when generated), - `ios-signing-.p12` and `Builder--.mobileprovision` to `--out-dir` (default `.`), uploads the three secrets - of the set to GitHub, and writes the build profile in `builder.json`: - `--name` (default: the distribution name) with `"distribution": - ""`. Other fields of an existing profile are kept; a - different `distribution` in it is replaced, and the command says so. - `defaultProfile` is not touched: point it at the profile for a plain - `ios build` to use it, or pass `--profile`. -6. Prints the three secret names and where their values come from — the - `.p12` base64-encoded, the password, the `.mobileprovision` base64-encoded - — every time, so the same set can be pasted into Codemagic or Bitrise, - following the [secrets guide](docs/provider-secrets.md). Builder cannot - check those providers' secrets before a build, so `ios build` only reminds - you of this command when the profile signs there. - -The upload goes to the repository in `builder.json`, always. When it fails (no -GitHub login, or a token that cannot write secrets) the error is printed and -the command carries on: files, values and the build profile are written and -shown anyway, and it exits non-zero at the end so a script notices. `--json` -reports the same in `github_upload` (`ok` or the error). - -The command shows its plan and asks once before creating anything; `--yes` -skips that (required without a terminal), and then the `.p12` password is -generated and printed once unless `--password` is given. `--json` prints the -result as JSON with progress on stderr. Keep the written files out of git. -Run it again whenever you like: it reports what it found and recreates only -what is missing, expired, invalid or changed — add a device, re-run, rebuild. -`--force` issues a fresh certificate and profile regardless. - -With `--certificate` and `--profile`, `setup` takes your own files instead — a -`.p12` (from Keychain Access, or [assembled here](#manual-path-through-the-apple-developer-portal)) -and a `.mobileprovision` — reads the distribution out of the profile -(development, ad-hoc, store or enterprise; this is the only way in for -enterprise), uploads that set, prints its names and values, and writes the -build profile the same way: - -```bash -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision -``` - -### Provisioning from `ios build` - -`builder ios build --profile ` checks, before dispatching to GitHub, -that the repository holds all three secrets of the profile's set. When any is -missing and an App Store Connect key is saved, it runs the same provisioning as -`signing setup` without prompts, uploads the set and then builds. A development -or ad-hoc profile needs at least one registered device; with none, the build -stops and points at `builder signing setup --distribution development ---devices-from-mobai`. Without an Apple key the build stops before anything is -pushed and names both ways out: `builder auth apple`, or `builder signing setup ---certificate ... --profile ...`. `--unsigned` skips all of this, and -Codemagic/Bitrise builds skip the check (no secrets API): their runner -reports a missing set itself. - -### Legacy: `ios.signing` without profiles - -A project set up before build profiles has `ios.signing: true` and the -unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and -`IOS_PROVISIONING_PROFILE` secrets. Builds that select no profile still sign -with those, whatever the profile type, exactly as before; `setup` never -touches them. Profiles ignore `ios.signing` and read their own set. - -### Manual path through the Apple Developer portal - -The `.p12` certificate is normally created through Keychain Access, but Builder -does the same thing itself: it generates the private key and certificate -signing request, and assembles the `.p12` from the certificate Apple issues. - -#### 1. Create a certificate signing request - -```bash -builder signing csr -``` - -This asks for your name and email and writes two files to the current -directory: `ios-signing.key` (your private key) and `ios-signing.csr`. Keep -the key wherever suits you — just don't commit it (add it to `.gitignore`; -gitignored files are also excluded from build snapshots). - -#### 2. Create the certificate - -1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal -2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc). TestFlight and App Store uploads need **Apple Distribution** together with an App Store profile in step 4 -3. Upload `ios-signing.csr` and download the resulting `.cer` file - -#### 3. Assemble the .p12 - -```bash -builder signing p12 --certificate development.cer --key ios-signing.key -``` - -This combines the key and certificate into `ios-signing.p12` (`--out` to name -it), protected by a password you choose — byte-for-byte the same kind of file -Keychain Access exports, and usable anywhere one is: `builder signing setup`, -Sideloadly, AltStore, or importing it on a Mac. Keep it, and don't commit it. - -#### 4. Create a provisioning profile - -On the portal: - -1. **Identifiers** → register an App ID matching your app's bundle identifier -2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page) -3. **Profiles** → create an **iOS App Development** (or Ad Hoc, App Store) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file - -The profile type decides the distribution the set is written to and the method -the IPA is exported with: development, ad-hoc, enterprise or store. - -#### 5. Upload the signing secrets - -```bash -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision -``` - -You can also skip step 3 and hand `setup` the `.cer` together with the key — -`builder signing setup --certificate development.cer --key ios-signing.key ---profile MyApp.mobileprovision` — and it assembles the `.p12` on the way, -saving it as `ios-signing-.p12`. Then `builder ios build ---profile `; `--unsigned` skips signing for one build. - -## TestFlight and App Store - -Builder uploads builds to App Store Connect and submits them to TestFlight or -App Review through the App Store Connect API, from Windows, Linux or macOS. No -Transporter, `altool` or Xcode is involved, and the API key never leaves your -machine: the CI runner only builds and signs, the upload happens locally from -the IPA in `./dist/`. - -You need: - -- A paid [Apple Developer Program](https://developer.apple.com/programs/) - membership and an app record in App Store Connect (My Apps → +) with your - bundle ID -- An IPA signed with an **Apple Distribution** certificate and an **App Store** - provisioning profile: `builder signing setup --distribution store` creates - both, stores them as the `STORE` signing set and writes a `store` build - profile, or pick those types on the portal in the manual path. An IPA signed - for development is rejected at upload. -- `builder ios build --profile store` (see [Build Profiles](#build-profiles)): - a `store` profile builds `Release` and signs with that set; a plain `ios - build` is Debug and unsigned, which is what the dev commands expect, not - what you want to ship. -- An App Store Connect API key saved with `builder auth apple`, see - [Create an App Store Connect API key](#create-an-app-store-connect-api-key). - -### 1. Save the API key - -`builder auth apple` as described in -[Create an App Store Connect API key](#create-an-app-store-connect-api-key). -Flags you leave out are prompted for; Builder verifies the key against App -Store Connect before storing it. - -### 2. Upload the build - -```bash -builder ios build # produces a signed dist/*.ipa -builder ios upload --wait -``` - -`upload` reads the bundle ID, version and build number from the newest IPA in -`./dist/` (or `--ipa `), finds the app, uploads the archive in chunks -and, with `--wait`, follows App Store Connect until the build has finished -processing and prints its build ID and TestFlight link. Without `--wait` it -returns as soon as Apple has the file. - -Two things Apple checks on every upload: - -- **Build numbers must increase.** A second upload with the same - `CFBundleVersion` for the same version is rejected (`ITMS-90189`), so bump - it before rebuilding. -- **Export compliance.** A build shows as *Missing Compliance* in TestFlight - until you say whether it uses non-exempt encryption. If your Info.plist sets - `ITSAppUsesNonExemptEncryption` to `false`, `upload --wait` answers that - automatically; otherwise pass `--no-encryption` (here or to `submit`) when - your app only uses standard iOS encryption. - -### 3. Distribute to TestFlight - -```bash -builder ios submit --testflight --group "Beta Testers" --notes "New login flow" -``` - -This takes the newest processed build (or `--build-number N`), sets the *What -to Test* notes and adds it to the named groups (`--group` repeats). Internal -groups get the build immediately; the first external group triggers Apple's -beta review, which Builder submits for you (`--wait` follows the decision). Run -it without `--group` to see the build and the groups the app has. - -### 4. Submit to the App Store - -```bash -builder ios submit --app-store --release after-approval -``` - -Builder finds or creates the App Store version matching the IPA's marketing -version (or `--version X.Y.Z`), attaches the build, sets the release type -(`manual` or `after-approval`) and submits it for review. The version's -metadata — description, screenshots, age rating, pricing, privacy — must -already be complete: App Store Connect refuses the submission otherwise and -Builder prints Apple's reasons verbatim. Builder does not manage metadata, -screenshots or in-app purchases; fill them in App Store Connect, or on a Mac -with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of -the `buildUploads` API also proved that the Mac-free upload path works and -served as the reference for Builder's implementation. - -## Installing the IPA - -Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account). - -## Development on Windows/Linux - -Builder supports hot reload for Flutter and React Native on Windows/Linux using [MobAI](https://mobai.run) for iOS device control. This allows you to develop iOS apps without a Mac. - -## Flutter Development - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` - This creates an IPA in `./dist/` -3. Start development with hot reload: - ```bash - builder dev flutter - ``` - Builder installs the IPA through MobAI and asks whether to re-sign it. Re-signing requires an iCloud account - we highly recommend creating a new one at [icloud.com](https://icloud.com) instead of using your primary account. A re-signed app gets a new bundle ID with a team ID suffix (e.g., `com.example.myapp.TEAMID`); Builder prefills it in the prompt that follows. - -### Subsequent Runs - -Once the app is installed, skip the install step: -```bash -builder dev flutter --skip-install --bundle-id com.example.myapp.TEAMID -``` - -### File Watching - -By default, `builder dev flutter` watches for Dart file changes and automatically triggers hot reload. When flutter attach connects, it also sends an initial hot restart to ensure your latest code is running. - -- **Automatic hot reload**: Edit a `.dart` file and save - hot reload triggers automatically -- **Generated files ignored**: Files like `.g.dart` and `.freezed.dart` are ignored by default -- **Configurable**: Customize watched directories, patterns, and debounce via `builder.json` - -To disable file watching: -```bash -builder dev flutter --no-watch -``` - -To print the `flutter attach` command instead of running it (useful for IDE integration): -```bash -builder dev flutter --no-attach -``` - -### When to Rebuild - -- **Native code changes** (Swift, Objective-C, Podfile, native dependencies): Run `builder ios build` and reinstall -- **Dart code changes only**: No rebuild needed - file watcher triggers hot reload automatically - -If you don't see your recent Dart changes after launching, press `R` in the terminal to perform a hot restart. - -### Troubleshooting - -**App won't launch / connection error** -- Close the app on your device before running `builder dev flutter` -- Reconnect the device (unplug/replug USB) -- Restart MobAI -- Run `builder mobai ping` to verify connection - -**"No devices found" error** -- Ensure MobAI is running and device is connected -- Only physical iOS devices are supported (no simulators) - -**Hot reload not working** -- Make sure you're using the correct bundle ID (with team ID suffix) -- Try hot restart with `R` key -- Check that MobAI shows the device as connected - -**File watcher not triggering** -- Ensure you're editing files in watched directories (default: `lib/`) -- Check if the file matches watch patterns (default: `.dart`) -- Generated files (`.g.dart`, `.freezed.dart`) are ignored by default -- Try running without `--no-watch` flag - -## React Native Development - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` -3. Start development with hot reload: - ```bash - builder dev rn - ``` - This will: - - Start Metro bundler if not running - - Install the IPA on your device (with optional re-signing) - - Launch the app with Metro URL configured automatically - -### Subsequent Runs - -Once the app is installed: -```bash -builder dev rn --skip-install --bundle-id com.example.myapp.TEAMID -``` - -### Custom Metro Port - -If port 8081 is in use: -```bash -builder dev rn --metro-port 8082 -``` - -### When to Rebuild - -- **Native code changes** (Swift, Objective-C, Podfile, native modules): Run `builder ios build` and reinstall -- **JavaScript changes only**: No rebuild needed - Metro handles it automatically - -### Troubleshooting - -**Metro not starting** -- Ensure Node.js and React Native CLI are installed -- Try starting Metro manually: `npx react-native start` - -**App not connecting to Metro** -- Device must be on the same WiFi network as the computer running Metro -- Check that Metro is running and accessible -- Verify the Metro port is correct (default: 8081) -- On WSL with mirrored networking, the Hyper-V firewall blocks the phone from reaching Metro by default; see [Using Builder from WSL](docs/wsl.md#react-native) for the firewall rule - -**Hot reload not working** -- Shake device or press `d` in Metro terminal to open dev menu -- Enable "Fast Refresh" in dev menu -- Try reloading with `r` in Metro terminal - -## Kotlin Multiplatform Development - -KMP iOS apps build and run on a device like any other project, with one -difference: **there is no hot reload.** Shared Kotlin is compiled into a native -framework at build time, so there is no runtime to swap code into — every code -change needs a rebuild. - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` -3. Install and launch it on the device: - ```bash - builder dev kmp - ``` - -`builder init` detects Kotlin Multiplatform projects by looking for the -multiplatform Gradle plugin in the root and module build files, and asks which -JDK the CI build should use (default 17): - -```json -{ - "kmp": { "jdkVersion": "17" } -} -``` - -On CI, the iOS app is built with `xcodebuild`, whose run script phase (or -CocoaPods) invokes Gradle to compile the shared framework — which is why the -JDK version matters. Gradle output is cached between builds. - -### When to Rebuild - -Every change to Kotlin or Swift code needs `builder ios build` followed by -`builder dev kmp` again. Use `--skip-install --bundle-id ` to relaunch an -app that is already installed. - -### Troubleshooting - -**Build fails with "Unsupported class file major version" or a Gradle JDK error** -- The project needs a different JDK than the default: set `kmp.jdkVersion` in `builder.json` to match what the project uses locally - -**Build fails with "SDK does not contain 'libarclite'"** -- An old Kotlin/Native version against a newer Xcode; upgrade the Kotlin plugin in Gradle - -**App launches then immediately exits** -- Launch with `builder dev kmp --logs` to see the device output - -## Build Limits - -Free allowances belong to each provider account and depend on the plan and -machine. As published in September 2026: Codemagic personal accounts include -500 macOS M2 minutes per month; Bitrise Hobby includes 300 credits. GitHub has -separate allowances for private repositories and free standard runners for -public repositories. Providers change these, so check the -[current allowance links and switching guidance](docs/providers.md#free-allowances). - -## License - -[MIT License](LICENSE) +# Builder + +Build and develop iOS apps from Windows, Linux, or any platform. + +Builder is a CLI tool for iOS development without a Mac. It uses GitHub Actions (default), Codemagic, or Bitrise for remote builds and [MobAI](https://mobai.run) for on-device development. + +![Builder Demo](assets/ios-builder-demo.gif) + +## Features + +- **Build from anywhere**: Build iOS apps via GitHub Actions, Codemagic, or Bitrise +- **Independent provider logins**: Stay signed in to all three and choose where each build runs +- **Try it on a simulator**: Use your build on an iOS simulator from Windows or Linux +- **Flutter & React Native dev tools**: Hot reload on real iOS devices from Windows/Linux +- **Simple setup**: One command to add the workflow to your repo +- **Code signing**: Optional signing with your certificate and provisioning profile +- **TestFlight and App Store**: Upload builds and submit them for review through the App Store Connect API, from any platform +- **Device integration**: Install and run apps via MobAI + +## How It Works + +``` +Your Repository GitHub Actions (macOS) + └─ .github/workflows/ └─ ios-build.yml + └─ ios-build.yml ├─ Check out the snapshot + ├─ Build with Xcode +builder ios build ───────────────────► Upload IPA artifact + │ pushes a snapshot of + │ your working tree + └─ Downloads IPA ◄─────────────── artifact: ipa +``` + +`builder ios build` builds what is on disk, not your last commit: uncommitted +and untracked files are included, so you can try a change without committing +it. The snapshot is a throwaway commit pushed to a hidden ref that is deleted +when the build finishes; no branch is created and nothing is committed on your +behalf. `.gitignore` still applies, so ignored files such as `.env` or +`GoogleService-Info.plist` are absent from the build. + +## Quick Start + +### 1. Authenticate with GitHub + +```bash +builder auth github +``` + +### 2. Initialize (in your project directory) + +```bash +cd your-ios-project +builder init +``` + +This detects your GitHub repo, creates the workflow files, and offers to commit, push, and trigger your first build - all interactively. + +### 3. Build + +```bash +builder ios build +``` + +The CLI triggers the workflow and downloads the IPA to `./dist/`. + +### 4. Try it on a simulator (optional) + +```bash +builder ios share +``` + +Builds the working tree for the iOS simulator and makes that simulator usable +from the [MobAI](https://mobai.run) app, so you can tap through a build without +a Mac. It shows up under CI Devices, stays available while you are using it, and +closes when you release it there or leave it unused (30 minutes by default, use +`--duration` to change). A coding agent connected to MobAI (Claude Code, Codex, +Cursor) can drive the simulator the same way. + +Free with any MobAI account, on [MobAI 3.0 or later](https://mobai.run). Needs +a `MOBAI_API_KEY` repository secret: create the key in the MobAI app under +Account → API Keys, then: + +```bash +gh secret set MOBAI_API_KEY +``` + +### Triggering from git only + +Where the GitHub API is not reachable, both workflows can also be started by +pushing a tag. Commit the tree you want built, then: + +```bash +git tag ios-build/my-build && git push origin ios-build/my-build # IPA build +git tag ios-share/my-build && git push origin ios-share/my-build # simulator +``` + +The run is named after the tag. Build settings come from `builder.json` in the +tagged commit (`ios.path`, `ios.scheme`, `ios.signing`, `ios.configuration`, +`flutter.version`, `kmp.jdkVersion`), the simulator stays available for the +default 30 minutes, and the tag is deleted when the run ends. The IPA is +attached to the run as an artifact. A tag carries no flags, so a tag build +cannot pick a [profile](#build-profiles) per run; it applies the profile named +by `defaultProfile`, if there is one. + +## Additional macOS Providers + +GitHub Actions remains the default, so existing commands continue to work. Add +Codemagic and Bitrise without logging out of GitHub. First follow the +[app creation and repository connection guide](docs/provider-setup.md) to create +each provider app, authorize GitHub access, and find its app ID: + +```bash +builder auth codemagic +builder auth bitrise +builder auth status +builder init --provider codemagic --app-id YOUR_APP_ID --branch main +builder init --provider bitrise --app-id YOUR_APP_SLUG --branch main +builder ios build --provider codemagic +builder ios build --provider bitrise +``` + +`init` writes `codemagic.yaml` or `bitrise.yml` at the repo root plus the shared +runner script `.builder/ci/runner.sh`. Commit them to the configured branch +and connect the same repository to each provider before building. See +[provider setup, signing, simulator sessions, and free allowances](docs/providers.md). + +## Supported Frameworks + +| Framework | iOS Path | Auto-detected | +|-----------|----------|---------------| +| Native iOS/Swift | `.` (root) | Yes | +| React Native | `ios/` | Yes | +| Expo (ejected) | `ios/` | Yes | +| Flutter | `ios/` | Yes | +| Kotlin Multiplatform | `iosApp/` | Yes | +| Cordova/Ionic | `platforms/ios/` | Yes | + +## Installation + +### Windows + +Download `builder-windows-amd64.exe` from [Releases](https://github.com/MobAI-App/ios-builder/releases), rename it to `builder.exe`, and add it to PATH. + +### Homebrew (macOS/Linux) + +```bash +brew install mobai-app/tap/ios-builder +``` + +The formula is named `ios-builder`; the command it installs is `builder`. + +### macOS/Linux/WSL + +```bash +curl -sSL https://raw.githubusercontent.com/MobAI-App/ios-builder/main/install.sh | bash +``` + +### From Source + +```bash +git clone https://github.com/MobAI-App/ios-builder.git +cd ios-builder +go build -o builder ./cmd/builder +``` + +## Commands + +```bash +# Setup +builder auth github # Authenticate with GitHub +builder auth codemagic # Authenticate with Codemagic (also: bitrise) +builder auth apple # Save an App Store Connect API key +builder auth status # Show which providers you are signed in to +builder auth logout [name] # Remove stored credentials (github, codemagic, bitrise, apple) +builder init # Set up workflows in current repo +builder update # Update builder to the latest release + +# Building (builds the working tree, including uncommitted changes) +builder ios build # Trigger build and download IPA to ./dist/ +builder ios build --unsigned # Build without code signing (if signing is configured) +builder ios build --provider codemagic # Build on another provider (also: bitrise) +builder ios build --profile production # Build with a profile from builder.json + +# Simulator (free, needs a MOBAI_API_KEY secret) +builder ios share # Try the build on a simulator in the MobAI app +builder ios share --duration 1h # Keep it available longer while unused + +# Development (requires MobAI) +builder dev flutter # Flutter hot reload with file watching +builder dev flutter --no-watch # Disable automatic file watching +builder dev flutter --no-attach # Print flutter attach command instead of running it +builder dev rn # React Native hot reload (short for: dev react-native) +builder dev kmp # Kotlin Multiplatform install + launch (alias: kotlin) +builder dev kmp --logs # Also stream the app's output +builder dev flutter --skip-install --bundle-id # Use already installed app +builder dev rn --metro-port 8082 # Use custom Metro port + +# MobAI (used by the dev commands; handy for troubleshooting) +builder mobai ping # Check MobAI connectivity +builder mobai install # Install an IPA on the device +builder mobai run-debug # Launch an app with the debugger attached +builder mobai forward # Forward a device port + +# Code signing (automatic mode needs builder auth apple) +builder signing setup --devices-from-mobai # development: certificate, devices, profile, GitHub secrets, no portal +builder signing setup --distribution store # Apple Distribution certificate + App Store profile +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision # Upload your own files +builder ios build --profile store # Signs with the set; provisions it first when missing +builder signing csr # Manual path: create a private key + certificate signing request +builder signing p12 # Manual path: assemble a .p12 from the key and Apple's certificate + +# TestFlight and App Store (needs builder auth apple) +builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing +builder ios submit --testflight --group "Beta Testers" --notes "What to test" +builder ios submit --app-store --release after-approval # Submit the version for App Review +``` + +Every `upload`/`submit` command takes `--json` for machine-readable output and +never prompts, so agents and CI jobs can drive them. + +## Configuration + +`builder.json`: + +```json +{ + "project": "MyApp", + "platform": "ios", + "github": { + "owner": "username", + "repo": "my-ios-app" + }, + "ios": { + "path": "ios", + "scheme": "", + "bundleId": "com.example.app", + "configuration": "Debug" + }, + "profiles": { + "development": { "distribution": "development" }, + "store": { "distribution": "store" } + }, + "mobai": { + "url": "http://localhost:8686", + "device_id": "" + }, + "flutter": { + "watch": { + "dirs": ["lib"], + "patterns": [".dart"], + "ignore": [".g.dart", ".freezed.dart"], + "debounce": 100 + } + } +} +``` + +### iOS Build Configuration + +| Field | Description | Default | +|-------|-------------|---------| +| `ios.path` | Path to the Xcode project relative to the repo root | detected by `init` | +| `ios.scheme` | Xcode scheme to build | auto-detected | +| `ios.bundleId` | App bundle identifier, used by `signing setup` | detected by `init` when the project has one app target; else saved by `signing setup` | +| `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` | +| `ios.signing` | Legacy: sign builds that select no profile, with the unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and `IOS_PROVISIONING_PROFILE` secrets. Profiles ignore it; use `distribution` there | `false` | + +### Build Profiles + +Profiles are named sets of build settings, in the spirit of `eas.json`, selected +with `--profile` on `ios build` and `ios share`: + +```json +{ + "ios": { "path": "ios", "bundleId": "com.example.app" }, + "defaultProfile": "development", + "profiles": { + "development": { "distribution": "development" }, + "preview": { "distribution": "internal", + "env": { "API_URL": "https://staging.example.com" } }, + "production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic" } + } +} +``` + +```bash +builder ios build --profile preview +builder ios share --profile preview +``` + +| Field | Description | +|-------|-------------| +| `distribution` | The only signing setting: `development`, `ad-hoc` (or `internal`, the same thing), `store` or `enterprise`. The build signs with that distribution's [signing set](#code-signing) and its provisioning profile must be of that type; the IPA is exported with the matching method. Omitted means an unsigned build | +| `configuration` | Overrides the derived configuration: `Debug` for `development`, `Release` for every other distribution, `ios.configuration` for unsigned profiles | +| `scheme` | Overrides `ios.scheme` | +| `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) | +| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them | + +How a build's settings are resolved: + +- Without `--profile`, the profile named by `defaultProfile` applies. With + neither, the top-level `ios.*` and `provider` settings are used exactly as + before, so existing projects are unaffected. +- A profile only overrides the fields it sets; everything else comes from the + top level. An unknown profile name is an error that lists the available ones. +- `--unsigned` and `--provider` on the command line override the profile. +- The resolved settings (profile, configuration, scheme, signing set, provider, + env names) are printed before anything is dispatched. +- `ios share` only takes the profile's scheme, provider and env: simulator + builds are always Debug and unsigned. + +**`env` values are build-time configuration, not secrets.** They are stored in +`builder.json`, sent to the CI provider as plain workflow inputs, and visible in +the run's inputs and logs. Keep tokens and passwords in the provider's secrets +instead (`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets +guide](docs/provider-secrets.md)); the build reads those as environment +variables too. Names the runner owns are rejected: its own parameters +(`SCHEME`, `CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing +secrets, `PATH`, `HOME`, `DEVELOPER_DIR`, and anything starting with `GITHUB_`, +`RUNNER_`, `CM_`, `BITRISE_` or `BUILDER_`. + +Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow +files from this version of Builder, which declare a `profile` input; an older +committed workflow rejects the dispatch. Run `builder init` again to refresh +`.github/workflows/ios-build.yml` and `ios-share.yml` (or `builder init +--provider ...` for `runner.sh`) in a project set up earlier, then commit and +push them to the default branch. + +### MobAI Configuration + +| Field | Description | Default | +|-------|-------------|---------| +| `mobai.url` | MobAI API URL | `http://localhost:8686` | +| `mobai.device_id` | Preferred device ID (uses first available if empty) | `""` | + +**WSL users**: MobAI runs on Windows, and WSL has its own network by default. On +Windows 11, turn on +[mirrored networking](https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking) +and builder reaches MobAI on the default `http://localhost:8686`. See +[Using Builder from WSL](docs/wsl.md) for the steps, and for the setup without +mirrored networking. + +### Flutter File Watcher + +| Field | Description | Default | +|-------|-------------|---------| +| `flutter.watch.dirs` | Directories to watch | `["lib"]` | +| `flutter.watch.patterns` | File patterns to match | `[".dart"]` | +| `flutter.watch.ignore` | Patterns to ignore | `[".g.dart", ".freezed.dart"]` | +| `flutter.watch.debounce` | Debounce delay in ms | `100` | + +## Code Signing + +By default, builds are unsigned. A signed build needs a certificate and a +provisioning profile — and despite what many guides claim, **you do not need a +Mac to create either one**, nor a tour of the Apple Developer portal. Signing +is configured per [build profile](#build-profiles) with one field, +`distribution`, and `builder signing setup` produces the material for it +through the App Store Connect API (or takes your own files). + +You need a paid [Apple Developer Program](https://developer.apple.com/programs/) +membership — Apple only issues certificates to paid accounts. (Without one, +build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free +Apple ID.) + +### Profiles and signing sets + +Each distribution has its own set of three GitHub secrets, so a development set +for your devices and a store set for TestFlight live side by side: + +| `distribution` | Certificate, profile | Secrets | +|----------------|----------------------|---------| +| `development` | Apple Development, iOS App Development (devices required) | `IOS_CERTIFICATE_DEVELOPMENT`, `IOS_CERTIFICATE_PASSWORD_DEVELOPMENT`, `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | +| `ad-hoc` or `internal` | Apple Distribution, Ad Hoc (devices required) | `IOS_CERTIFICATE_AD_HOC`, `IOS_CERTIFICATE_PASSWORD_AD_HOC`, `IOS_PROVISIONING_PROFILE_AD_HOC` | +| `store` | Apple Distribution, App Store | `IOS_CERTIFICATE_STORE`, `IOS_CERTIFICATE_PASSWORD_STORE`, `IOS_PROVISIONING_PROFILE_STORE` | +| `enterprise` | In-house (portal only) | `IOS_CERTIFICATE_ENTERPRISE`, `IOS_CERTIFICATE_PASSWORD_ENTERPRISE`, `IOS_PROVISIONING_PROFILE_ENTERPRISE` | + +A build with `--profile ` signs with the set of that profile's +`distribution`; `configuration` follows it (`Debug` for `development`, +`Release` otherwise) unless the profile sets one. The runner checks that the +profile in the set is of the requested type and fails by name before compiling +anything, and a distribution profile refuses a `Debug` configuration. On +Codemagic and Bitrise the same names are variables you add in the dashboard, +see the [secrets guide](docs/provider-secrets.md). + +### Create an App Store Connect API key + +Automatic signing, `ios upload`, `ios submit` and `ios release` all use one +App Store Connect API key. You create it once, in the browser: + +1. Sign in to [App Store Connect](https://appstoreconnect.apple.com) as the + Account Holder or an Admin (only they can create team keys). +2. Go to **Users and Access → Integrations → App Store Connect API**, tab + **Team Keys**, and press **+** (or **Generate API Key**). +3. Name it (for example `Builder`) and choose the role **Admin**. **App + Manager** works too if you also tick *Access to Certificates, Identifiers & + Profiles*; a **Developer** key can upload builds but cannot create + certificates. +4. Press **Generate**, then **Download API Key**. The `AuthKey_.p8` + file downloads once; keep it somewhere private, never in the repo. +5. Note the **Issuer ID** at the top of the page and the **Key ID** in the + row of your key. + +Then save it in your keychain: + +```bash +builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key ~/Downloads/AuthKey_ABC123DEFG.p8 +``` + +`builder auth status` shows it, `builder auth logout apple` removes it. On a +machine without a keychain (CI, a coding agent), set `ASC_ISSUER_ID`, +`ASC_KEY_ID` and `ASC_KEY_PATH` (or `ASC_PRIVATE_KEY` with the file's contents) +instead. + +### `builder signing setup` + +```bash +builder auth apple # once: save the App Store Connect API key +builder signing setup --devices-from-mobai # development set for the devices MobAI sees +builder signing setup --distribution store # store set for TestFlight / App Store +``` + +Without files, `setup` works through the App Store Connect API for the given +`--distribution` (default `development`; `--name ` reads it from an +existing profile). The key needs the **Admin** role (or App Manager plus +*Access to Certificates, Identifiers & Profiles*): Developer-role keys cannot +create certificates. It then: + +1. Registers the **App ID** if the bundle identifier is not on the account yet. + The bundle ID comes from `--bundle-id`, `ios.bundleId` in `builder.json` + (which `init` fills when the Xcode project has a single app target), or the + newest IPA in `./dist/`; in a terminal it asks as a last resort. +2. Issues a **certificate** — Apple Development for `development`, Apple + Distribution for `ad-hoc` and `store` — for a private key generated on your + machine (`ios-signing-.key`, or `--key` to reuse one from + `signing csr`; a `ios-signing.key` from an earlier version is picked up + too). A valid certificate on the account is reused only when its private + key is here, because that is the only way to build the `.p12`; otherwise a + new one is issued. Nothing is ever revoked: when Apple's limit (2 + Development, 3 Distribution) is hit, the error names it and points at the + portal. +3. Registers **devices** from `--device ` (repeatable) and + `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has + connected; simulators and cloud farm devices are skipped). Development and + ad-hoc profiles cover every enabled iOS device on the account, so with none + given and none registered the command stops and says so. Store profiles + take no devices. Apple allows 100 devices per membership year and never + frees a slot; that error is passed through too. +4. Creates the **profile** `Builder `. An existing + one is reused while it is `ACTIVE`, unexpired and still lists exactly this + certificate and these devices; otherwise it is deleted and recreated, and + the summary says why (`invalid`, `expired`, `certificate changed`, `devices + changed`, `forced`). +5. Writes `ios-signing-.key` (when generated), + `ios-signing-.p12` and `Builder--.mobileprovision` to `--out-dir` (default `.`), uploads the three secrets + of the set to GitHub, and writes the build profile in `builder.json`: + `--name` (default: the distribution name) with `"distribution": + ""`. Other fields of an existing profile are kept; a + different `distribution` in it is replaced, and the command says so. + `defaultProfile` is not touched: point it at the profile for a plain + `ios build` to use it, or pass `--profile`. +6. Prints the three secret names and where their values come from — the + `.p12` base64-encoded, the password, the `.mobileprovision` base64-encoded + — every time, so the same set can be pasted into Codemagic or Bitrise, + following the [secrets guide](docs/provider-secrets.md). Builder cannot + check those providers' secrets before a build, so `ios build` only reminds + you of this command when the profile signs there. + +The upload goes to the repository in `builder.json`, always. When it fails (no +GitHub login, or a token that cannot write secrets) the error is printed and +the command carries on: files, values and the build profile are written and +shown anyway, and it exits non-zero at the end so a script notices. `--json` +reports the same in `github_upload` (`ok` or the error). + +The command shows its plan and asks once before creating anything; `--yes` +skips that (required without a terminal), and then the `.p12` password is +generated and printed once unless `--password` is given. `--json` prints the +result as JSON with progress on stderr. Keep the written files out of git. +Run it again whenever you like: it reports what it found and recreates only +what is missing, expired, invalid or changed — add a device, re-run, rebuild. +`--force` issues a fresh certificate and profile regardless. + +With `--certificate` and `--profile`, `setup` takes your own files instead — a +`.p12` (from Keychain Access, or [assembled here](#manual-path-through-the-apple-developer-portal)) +and a `.mobileprovision` — reads the distribution out of the profile +(development, ad-hoc, store or enterprise; this is the only way in for +enterprise), uploads that set, prints its names and values, and writes the +build profile the same way: + +```bash +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision +``` + +### Provisioning from `ios build` + +`builder ios build --profile ` checks, before dispatching to GitHub, +that the repository holds all three secrets of the profile's set. When any is +missing and an App Store Connect key is saved, it runs the same provisioning as +`signing setup` without prompts, uploads the set and then builds. A development +or ad-hoc profile needs at least one registered device; with none, the build +stops and points at `builder signing setup --distribution development +--devices-from-mobai`. Without an Apple key the build stops before anything is +pushed and names both ways out: `builder auth apple`, or `builder signing setup +--certificate ... --profile ...`. `--unsigned` skips all of this, and +Codemagic/Bitrise builds skip the check (no secrets API): their runner +reports a missing set itself. + +### Legacy: `ios.signing` without profiles + +A project set up before build profiles has `ios.signing: true` and the +unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and +`IOS_PROVISIONING_PROFILE` secrets. Builds that select no profile still sign +with those, whatever the profile type, exactly as before; `setup` never +touches them. Profiles ignore `ios.signing` and read their own set. + +### Manual path through the Apple Developer portal + +The `.p12` certificate is normally created through Keychain Access, but Builder +does the same thing itself: it generates the private key and certificate +signing request, and assembles the `.p12` from the certificate Apple issues. + +#### 1. Create a certificate signing request + +```bash +builder signing csr +``` + +This asks for your name and email and writes two files to the current +directory: `ios-signing.key` (your private key) and `ios-signing.csr`. Keep +the key wherever suits you — just don't commit it (add it to `.gitignore`; +gitignored files are also excluded from build snapshots). + +#### 2. Create the certificate + +1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal +2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc). TestFlight and App Store uploads need **Apple Distribution** together with an App Store profile in step 4 +3. Upload `ios-signing.csr` and download the resulting `.cer` file + +#### 3. Assemble the .p12 + +```bash +builder signing p12 --certificate development.cer --key ios-signing.key +``` + +This combines the key and certificate into `ios-signing.p12` (`--out` to name +it), protected by a password you choose — byte-for-byte the same kind of file +Keychain Access exports, and usable anywhere one is: `builder signing setup`, +Sideloadly, AltStore, or importing it on a Mac. Keep it, and don't commit it. + +#### 4. Create a provisioning profile + +On the portal: + +1. **Identifiers** → register an App ID matching your app's bundle identifier +2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page) +3. **Profiles** → create an **iOS App Development** (or Ad Hoc, App Store) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file + +The profile type decides the distribution the set is written to and the method +the IPA is exported with: development, ad-hoc, enterprise or store. + +#### 5. Upload the signing secrets + +```bash +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision +``` + +You can also skip step 3 and hand `setup` the `.cer` together with the key — +`builder signing setup --certificate development.cer --key ios-signing.key +--profile MyApp.mobileprovision` — and it assembles the `.p12` on the way, +saving it as `ios-signing-.p12`. Then `builder ios build +--profile `; `--unsigned` skips signing for one build. + +## TestFlight and App Store + +Builder uploads builds to App Store Connect and submits them to TestFlight or +App Review through the App Store Connect API, from Windows, Linux or macOS. No +Transporter, `altool` or Xcode is involved, and the API key never leaves your +machine: the CI runner only builds and signs, the upload happens locally from +the IPA in `./dist/`. + +You need: + +- A paid [Apple Developer Program](https://developer.apple.com/programs/) + membership and an app record in App Store Connect for your bundle ID (step 2 + below; the API cannot create it) +- An IPA signed with an **Apple Distribution** certificate and an **App Store** + provisioning profile: `builder signing setup --distribution store` creates + both, stores them as the `STORE` signing set and writes a `store` build + profile, or pick those types on the portal in the manual path. An IPA signed + for development is rejected at upload. +- `builder ios build --profile store` (see [Build Profiles](#build-profiles)): + a `store` profile builds `Release` and signs with that set; a plain `ios + build` is Debug and unsigned, which is what the dev commands expect, not + what you want to ship. +- An App Store Connect API key saved with `builder auth apple`, see + [Create an App Store Connect API key](#create-an-app-store-connect-api-key). + +### 1. Save the API key + +`builder auth apple` as described in +[Create an App Store Connect API key](#create-an-app-store-connect-api-key). +Flags you leave out are prompted for; Builder verifies the key against App +Store Connect before storing it. + +### 2. Create the app record + +App Store Connect only accepts uploads for an app it already knows, and the API +cannot create one. Once, in the browser: + +1. Register the bundle ID first: `builder signing setup --distribution store` + does it (or **Certificates, Identifiers & Profiles → Identifiers → +** on + the developer portal). +2. Open [App Store Connect → My Apps](https://appstoreconnect.apple.com/apps), + press **+ → New App**, pick **iOS**, a name, the primary language, your + bundle ID from the list, and any SKU (an internal string, e.g. the bundle + ID). Press **Create**. + +Nothing else on the record is needed for TestFlight. App Store review needs the +rest of the metadata (screenshots, description, privacy policy) filled in there. + +### 3. Upload the build + +```bash +builder ios build --profile store # produces a signed dist/*.ipa +builder ios upload --wait +``` + +`upload` reads the bundle ID, version and build number from the newest IPA in +`./dist/` (or `--ipa `), finds the app, uploads the archive in chunks +and, with `--wait`, follows App Store Connect until the build has finished +processing and prints its build ID and TestFlight link. Without `--wait` it +returns as soon as Apple has the file. + +Two things Apple checks on every upload: + +- **Build numbers must increase.** A second upload with the same + `CFBundleVersion` for the same version is rejected (`ITMS-90189`), so bump + it before rebuilding. +- **Export compliance.** A build shows as *Missing Compliance* in TestFlight + until you say whether it uses non-exempt encryption. If your Info.plist sets + `ITSAppUsesNonExemptEncryption` to `false`, `upload --wait` answers that + automatically; otherwise pass `--no-encryption` (here or to `submit`) when + your app only uses standard iOS encryption. + +### 4. Distribute to TestFlight + +```bash +builder ios submit --testflight --group "Beta Testers" --notes "New login flow" +``` + +This takes the newest processed build (or `--build-number N`), sets the *What +to Test* notes and adds it to the named groups (`--group` repeats). Internal +groups get the build immediately; the first external group triggers Apple's +beta review, which Builder submits for you (`--wait` follows the decision). Run +it without `--group` to see the build and the groups the app has. + +### 5. Submit to the App Store + +```bash +builder ios submit --app-store --release after-approval +``` + +Builder finds or creates the App Store version matching the IPA's marketing +version (or `--version X.Y.Z`), attaches the build, sets the release type +(`manual` or `after-approval`) and submits it for review. The version's +metadata — description, screenshots, age rating, pricing, privacy — must +already be complete: App Store Connect refuses the submission otherwise and +Builder prints Apple's reasons verbatim. Builder does not manage metadata, +screenshots or in-app purchases; fill them in App Store Connect, or on a Mac +with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of +the `buildUploads` API also proved that the Mac-free upload path works and +served as the reference for Builder's implementation. + +## Installing the IPA + +Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account). + +## Development on Windows/Linux + +Builder supports hot reload for Flutter and React Native on Windows/Linux using [MobAI](https://mobai.run) for iOS device control. This allows you to develop iOS apps without a Mac. + +## Flutter Development + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` + This creates an IPA in `./dist/` +3. Start development with hot reload: + ```bash + builder dev flutter + ``` + Builder installs the IPA through MobAI and asks whether to re-sign it. Re-signing requires an iCloud account - we highly recommend creating a new one at [icloud.com](https://icloud.com) instead of using your primary account. A re-signed app gets a new bundle ID with a team ID suffix (e.g., `com.example.myapp.TEAMID`); Builder prefills it in the prompt that follows. + +### Subsequent Runs + +Once the app is installed, skip the install step: +```bash +builder dev flutter --skip-install --bundle-id com.example.myapp.TEAMID +``` + +### File Watching + +By default, `builder dev flutter` watches for Dart file changes and automatically triggers hot reload. When flutter attach connects, it also sends an initial hot restart to ensure your latest code is running. + +- **Automatic hot reload**: Edit a `.dart` file and save - hot reload triggers automatically +- **Generated files ignored**: Files like `.g.dart` and `.freezed.dart` are ignored by default +- **Configurable**: Customize watched directories, patterns, and debounce via `builder.json` + +To disable file watching: +```bash +builder dev flutter --no-watch +``` + +To print the `flutter attach` command instead of running it (useful for IDE integration): +```bash +builder dev flutter --no-attach +``` + +### When to Rebuild + +- **Native code changes** (Swift, Objective-C, Podfile, native dependencies): Run `builder ios build` and reinstall +- **Dart code changes only**: No rebuild needed - file watcher triggers hot reload automatically + +If you don't see your recent Dart changes after launching, press `R` in the terminal to perform a hot restart. + +### Troubleshooting + +**App won't launch / connection error** +- Close the app on your device before running `builder dev flutter` +- Reconnect the device (unplug/replug USB) +- Restart MobAI +- Run `builder mobai ping` to verify connection + +**"No devices found" error** +- Ensure MobAI is running and device is connected +- Only physical iOS devices are supported (no simulators) + +**Hot reload not working** +- Make sure you're using the correct bundle ID (with team ID suffix) +- Try hot restart with `R` key +- Check that MobAI shows the device as connected + +**File watcher not triggering** +- Ensure you're editing files in watched directories (default: `lib/`) +- Check if the file matches watch patterns (default: `.dart`) +- Generated files (`.g.dart`, `.freezed.dart`) are ignored by default +- Try running without `--no-watch` flag + +## React Native Development + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` +3. Start development with hot reload: + ```bash + builder dev rn + ``` + This will: + - Start Metro bundler if not running + - Install the IPA on your device (with optional re-signing) + - Launch the app with Metro URL configured automatically + +### Subsequent Runs + +Once the app is installed: +```bash +builder dev rn --skip-install --bundle-id com.example.myapp.TEAMID +``` + +### Custom Metro Port + +If port 8081 is in use: +```bash +builder dev rn --metro-port 8082 +``` + +### When to Rebuild + +- **Native code changes** (Swift, Objective-C, Podfile, native modules): Run `builder ios build` and reinstall +- **JavaScript changes only**: No rebuild needed - Metro handles it automatically + +### Troubleshooting + +**Metro not starting** +- Ensure Node.js and React Native CLI are installed +- Try starting Metro manually: `npx react-native start` + +**App not connecting to Metro** +- Device must be on the same WiFi network as the computer running Metro +- Check that Metro is running and accessible +- Verify the Metro port is correct (default: 8081) +- On WSL with mirrored networking, the Hyper-V firewall blocks the phone from reaching Metro by default; see [Using Builder from WSL](docs/wsl.md#react-native) for the firewall rule + +**Hot reload not working** +- Shake device or press `d` in Metro terminal to open dev menu +- Enable "Fast Refresh" in dev menu +- Try reloading with `r` in Metro terminal + +## Kotlin Multiplatform Development + +KMP iOS apps build and run on a device like any other project, with one +difference: **there is no hot reload.** Shared Kotlin is compiled into a native +framework at build time, so there is no runtime to swap code into — every code +change needs a rebuild. + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` +3. Install and launch it on the device: + ```bash + builder dev kmp + ``` + +`builder init` detects Kotlin Multiplatform projects by looking for the +multiplatform Gradle plugin in the root and module build files, and asks which +JDK the CI build should use (default 17): + +```json +{ + "kmp": { "jdkVersion": "17" } +} +``` + +On CI, the iOS app is built with `xcodebuild`, whose run script phase (or +CocoaPods) invokes Gradle to compile the shared framework — which is why the +JDK version matters. Gradle output is cached between builds. + +### When to Rebuild + +Every change to Kotlin or Swift code needs `builder ios build` followed by +`builder dev kmp` again. Use `--skip-install --bundle-id ` to relaunch an +app that is already installed. + +### Troubleshooting + +**Build fails with "Unsupported class file major version" or a Gradle JDK error** +- The project needs a different JDK than the default: set `kmp.jdkVersion` in `builder.json` to match what the project uses locally + +**Build fails with "SDK does not contain 'libarclite'"** +- An old Kotlin/Native version against a newer Xcode; upgrade the Kotlin plugin in Gradle + +**App launches then immediately exits** +- Launch with `builder dev kmp --logs` to see the device output + +## Build Limits + +Free allowances belong to each provider account and depend on the plan and +machine. As published in September 2026: Codemagic personal accounts include +500 macOS M2 minutes per month; Bitrise Hobby includes 300 credits. GitHub has +separate allowances for private repositories and free standard runners for +public repositories. Providers change these, so check the +[current allowance links and switching guidance](docs/providers.md#free-allowances). + +## License + +[MIT License](LICENSE) From 87e5daadc1a0788773ec7dd94073d6f568534d4d Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 14:29:58 +0200 Subject: [PATCH 59/75] share: drop --profile; the simulator build takes no profile --- CLAUDE.md | 6 ++- README.md | 23 +++++----- cmd/builder/root.go | 11 ++--- internal/build/coordinator.go | 15 +++--- internal/build/inputs_test.go | 9 ++-- internal/build/remote.go | 14 ++++-- internal/build/share.go | 21 +++++---- internal/workflow/profile_test.go | 40 +++++++++++----- internal/workflow/templates/ios-share.yml | 56 ++--------------------- 9 files changed, 84 insertions(+), 111 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f1533e3..4f051fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,8 +193,10 @@ internal/ - **Run Correlation**: `run-name` carries the build ID so concurrent builds cannot adopt each other's runs - **Build Profiles**: `profiles.` in `builder.json` overrides `ios.configuration`, `ios.scheme` - and `provider`, and adds `env` and `distribution`. `ios build` and `ios share` take `--profile`; - without it `defaultProfile` applies, and without that the top-level settings are used unchanged. + and `provider`, and adds `env` and `distribution`. `ios build` takes `--profile`; without it + `defaultProfile` applies, and without that the top-level settings are used unchanged. `ios share` + takes no profile at all: it builds Debug for the simulator and never signs, so `Share` uses the + top-level settings and `ios-share.yml` declares no `profile` input. `config.ResolveProfile` does the merge, `Coordinator.settings` layers `--unsigned`/`--provider` on top, and `Progress.Settings` prints the result before dispatch. `distribution` is the only signing field of a profile (EAS-style): `development`, `ad-hoc` (alias `internal`, canonical diff --git a/README.md b/README.md index 220e64a..9b1b9d8 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,10 @@ The run is named after the tag. Build settings come from `builder.json` in the tagged commit (`ios.path`, `ios.scheme`, `ios.signing`, `ios.configuration`, `flutter.version`, `kmp.jdkVersion`), the simulator stays available for the default 30 minutes, and the tag is deleted when the run ends. The IPA is -attached to the run as an artifact. A tag carries no flags, so a tag build -cannot pick a [profile](#build-profiles) per run; it applies the profile named -by `defaultProfile`, if there is one. +attached to the run as an artifact. A tag carries no flags, so a tagged IPA +build cannot pick a [profile](#build-profiles) per run; it applies the profile +named by `defaultProfile`, if there is one. The simulator build takes no +profile at all. ## Additional macOS Providers @@ -267,7 +268,7 @@ never prompts, so agents and CI jobs can drive them. ### Build Profiles Profiles are named sets of build settings, in the spirit of `eas.json`, selected -with `--profile` on `ios build` and `ios share`: +with `--profile` on `ios build`: ```json { @@ -284,7 +285,6 @@ with `--profile` on `ios build` and `ios share`: ```bash builder ios build --profile preview -builder ios share --profile preview ``` | Field | Description | @@ -305,8 +305,9 @@ How a build's settings are resolved: - `--unsigned` and `--provider` on the command line override the profile. - The resolved settings (profile, configuration, scheme, signing set, provider, env names) are printed before anything is dispatched. -- `ios share` only takes the profile's scheme, provider and env: simulator - builds are always Debug and unsigned. +- Profiles apply to `ios build` only. `ios share` takes no `--profile`: + simulator builds are always Debug and unsigned, and use `ios.scheme` and the + top-level `provider` (or `--provider`). **`env` values are build-time configuration, not secrets.** They are stored in `builder.json`, sent to the CI provider as plain workflow inputs, and visible in @@ -319,11 +320,11 @@ secrets, `PATH`, `HOME`, `DEVELOPER_DIR`, and anything starting with `GITHUB_`, `RUNNER_`, `CM_`, `BITRISE_` or `BUILDER_`. Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow -files from this version of Builder, which declare a `profile` input; an older +file from this version of Builder, which declares a `profile` input; an older committed workflow rejects the dispatch. Run `builder init` again to refresh -`.github/workflows/ios-build.yml` and `ios-share.yml` (or `builder init ---provider ...` for `runner.sh`) in a project set up earlier, then commit and -push them to the default branch. +`.github/workflows/ios-build.yml` (or `builder init --provider ...` for +`runner.sh`) in a project set up earlier, then commit and push it to the +default branch. ### MobAI Configuration diff --git a/cmd/builder/root.go b/cmd/builder/root.go index e6321ba..27f9b2c 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -600,7 +600,6 @@ func init() { iosShareCmd.Flags().Duration("duration", 30*time.Minute, "How long the simulator stays available while unused") iosShareCmd.Flags().StringP("remote", "r", "origin", "Git remote to push the working-tree snapshot to") iosShareCmd.Flags().String("provider", "", "Override CI provider (default github or builder.json provider)") - iosShareCmd.Flags().String("profile", "", "Build profile from builder.json; its scheme, provider and env apply to the simulator build") iosCmd.AddCommand(iosShareCmd) } @@ -674,8 +673,9 @@ func runIOSShare(cmd *cobra.Command, args []string) error { duration, _ := cmd.Flags().GetDuration("duration") remote, _ := cmd.Flags().GetString("remote") - providerFlag, _ := cmd.Flags().GetString("provider") - profile, _ := cmd.Flags().GetString("profile") + // A simulator build takes no profile, so the provider is the flag, else + // builder.json's. + provider, _ := cmd.Flags().GetString("provider") ctx := cmd.Context() if ctx == nil { @@ -687,17 +687,12 @@ func runIOSShare(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() - provider, err := effectiveProvider(cfg, profile, providerFlag) - if err != nil { - return err - } ghClient, err := clientForProvider(cfg, provider) if err != nil { return err } result, err := build.NewCoordinator(cfg, ghClient).Share(ctx, build.ShareOptions{ Provider: provider, - Profile: profile, Duration: duration, Remote: remote, }) diff --git a/internal/build/coordinator.go b/internal/build/coordinator.go index 24b8040..aefe4d3 100644 --- a/internal/build/coordinator.go +++ b/internal/build/coordinator.go @@ -87,8 +87,7 @@ func (c *Coordinator) settings(profile, provider string, unsigned bool) (*config // workflowInputs maps the settings onto the workflow_dispatch inputs both // GitHub workflows share. Empty values are left out so the declared defaults -// apply, and `profile` is only sent when one is selected: a workflow file from -// before profiles rejects a dispatch carrying an input it does not declare. +// apply. func (c *Coordinator) workflowInputs(buildID, ref string, s *config.BuildSettings) map[string]string { inputs := map[string]string{ "build_id": buildID, @@ -108,14 +107,13 @@ func (c *Coordinator) workflowInputs(buildID, ref string, s *config.BuildSetting if c.config.KMP.JDKVersion != "" { inputs["jdk_version"] = c.config.KMP.JDKVersion } - if p := s.ProfileInput(); p != "" { - inputs["profile"] = p - } return inputs } -// buildInputs are the ios-build.yml inputs: the shared ones plus signing and -// configuration, which the simulator workflow has no use for. +// buildInputs are the ios-build.yml inputs: the shared ones plus signing, +// configuration and the profile, none of which the simulator workflow has a +// use for. `profile` is only sent when one is selected: a workflow file from +// before profiles rejects a dispatch carrying an input it does not declare. func (c *Coordinator) buildInputs(buildID, ref string, s *config.BuildSettings) map[string]string { inputs := c.workflowInputs(buildID, ref, s) if s.Signing { @@ -125,6 +123,9 @@ func (c *Coordinator) buildInputs(buildID, ref string, s *config.BuildSettings) if s.Configuration != "" { inputs["configuration"] = s.Configuration } + if p := s.ProfileInput(); p != "" { + inputs["profile"] = p + } return inputs } diff --git a/internal/build/inputs_test.go b/internal/build/inputs_test.go index c9c13eb..8d99036 100644 --- a/internal/build/inputs_test.go +++ b/internal/build/inputs_test.go @@ -89,21 +89,20 @@ func TestGitHubInputsMapping(t *testing.T) { t.Fatalf("workflow_dispatch allows at most 10 inputs, sending %d", len(got)) } + // The simulator workflow declares none of the build-only inputs, and + // `ios share` takes no profile at all. share := c.workflowInputs("abcdef12", "ref", s) - for _, k := range []string{"use_signing", "configuration"} { + for _, k := range []string{"use_signing", "configuration", "profile"} { if _, ok := share[k]; ok { t.Fatalf("simulator workflow does not declare %s", k) } } - if share["profile"] == "" || share["scheme"] != "AppPreview" { - t.Fatalf("share inputs: %v", share) - } s, _, _ = c.settings("", "", false) share = c.workflowInputs("abcdef12", "ref", s) want = map[string]string{"build_id": "abcdef12", "snapshot_ref": "ref", "ios_path": "ios", "scheme": "App", "flutter_version": "3.24.0"} if !reflect.DeepEqual(share, want) { - t.Fatalf("without a profile the share inputs must be unchanged:\n got %v\nwant %v", share, want) + t.Fatalf("the share inputs must match the pre-profiles set:\n got %v\nwant %v", share, want) } } diff --git a/internal/build/remote.go b/internal/build/remote.go index f41b530..8d654b7 100644 --- a/internal/build/remote.go +++ b/internal/build/remote.go @@ -92,9 +92,10 @@ func (c *Coordinator) inputs(buildID, ref, sha string, s *config.BuildSettings) return v } -func (c *Coordinator) pushSnapshot(ctx context.Context, remote, buildID string, s *config.BuildSettings, provider string) (string, string, error) { - c.progress.Start(buildID) - c.progress.Settings(s, provider) +// pushSnapshot pushes the working tree the run will build. The caller has +// already started the progress report, since only a build has settings to +// print under it. +func (c *Coordinator) pushSnapshot(ctx context.Context, remote, buildID string) (string, string, error) { c.progress.Update(PhaseSnapshot, "Snapshotting working tree...") sha, err := snapshot.Create(ctx, fmt.Sprintf("ios-builder snapshot %s", buildID)) if err != nil { @@ -132,7 +133,9 @@ func (c *Coordinator) buildRemote(ctx context.Context, opts *BuildOptions, s *co defer cancel() started := time.Now() buildID := uuid.New().String()[:8] - ref, sha, err := c.pushSnapshot(ctx, opts.Remote, buildID, s, p.Name()) + c.progress.Start(buildID) + c.progress.Settings(s, p.Name()) + ref, sha, err := c.pushSnapshot(ctx, opts.Remote, buildID) if err != nil { return nil, err } @@ -300,7 +303,8 @@ func (c *Coordinator) shareRemote(ctx context.Context, opts ShareOptions, s *con ctx, cancel := context.WithTimeout(ctx, opts.Timeout) defer cancel() buildID := uuid.New().String()[:8] - ref, sha, err := c.pushSnapshot(ctx, opts.Remote, buildID, s, p.Name()) + c.progress.Start(buildID) + ref, sha, err := c.pushSnapshot(ctx, opts.Remote, buildID) if err != nil { return nil, err } diff --git a/internal/build/share.go b/internal/build/share.go index 662c404..10dae69 100644 --- a/internal/build/share.go +++ b/internal/build/share.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" + "github.com/MobAI-App/ios-builder/internal/config" "github.com/MobAI-App/ios-builder/internal/snapshot" ) @@ -16,8 +17,7 @@ const ShareWorkflowFile = "ios-share.yml" // ShareOptions configures a simulator session. type ShareOptions struct { - Provider string // Override the configured CI provider (and the profile's) - Profile string // builder.json profile; only its scheme, provider and env apply to a simulator build + Provider string // Override the configured CI provider // Duration is how long the simulator stays available while unused. Using // it keeps it open past this. Duration time.Duration @@ -45,14 +45,20 @@ const sharePublishGrace = 30 * time.Second // Share builds the working tree for the simulator and publishes it to the // account's MobAI app, then returns while the job outlives the command. func (c *Coordinator) Share(ctx context.Context, opts ShareOptions) (*ShareResult, error) { - settings, name, err := c.settings(opts.Profile, opts.Provider, true) + // A simulator build takes no build profile: it is always Debug, never + // signed and never exported, so only the top-level settings apply. + settings := &config.BuildSettings{ + Configuration: c.config.IOS.Configuration, + Scheme: c.config.IOS.Scheme, + Provider: c.config.Provider, + } + if opts.Provider != "" { + settings.Provider = opts.Provider + } + name, err := c.config.ProviderName(settings.Provider) if err != nil { return nil, err } - // Simulator builds are always Debug, never signed and never exported, - // whatever the profile says. - settings.Configuration = "Debug" - settings.Distribution = "" if name != "github" || c.provider != nil { return c.shareRemote(ctx, opts, settings) } @@ -70,7 +76,6 @@ func (c *Coordinator) Share(ctx context.Context, opts ShareOptions) (*ShareResul buildID := uuid.New().String()[:8] c.progress.Start(buildID) - c.progress.Settings(settings, name) c.progress.Update(PhaseSnapshot, "Snapshotting working tree...") sha, err := snapshot.Create(ctx, fmt.Sprintf("ios-builder snapshot %s", buildID)) diff --git a/internal/workflow/profile_test.go b/internal/workflow/profile_test.go index 6d0d0f1..979dd08 100644 --- a/internal/workflow/profile_test.go +++ b/internal/workflow/profile_test.go @@ -119,7 +119,6 @@ func TestResolveParametersApplyProfiles(t *testing.T) { } } build := resolveStep(t, "ios-build.yml") - share := resolveStep(t, "ios-share.yml") t.Run("tag build applies defaultProfile", func(t *testing.T) { // A distribution signs the build and derives Release; internal is @@ -192,17 +191,6 @@ func TestResolveParametersApplyProfiles(t *testing.T) { } }) - t.Run("share exports env and profile scheme", func(t *testing.T) { - withScheme := strings.Replace(profiledBuilderJSON, `"preview": {"distribution": "internal",`, `"preview": {"distribution": "internal", "scheme": "Preview",`, 1) - r := runResolve(t, share, withScheme, map[string]string{"GITHUB_EVENT_NAME": "push"}) - if r.err != nil { - t.Fatalf("%v\n%s", r.err, r.log) - } - if r.outputs["scheme"] != "Preview" || r.outputs["profile"] != "preview" || r.outputs["duration"] != "30m" || r.env["API_URL"] == "" { - t.Fatalf("outputs %v env %v\n%s", r.outputs, r.env, r.log) - } - }) - t.Run("bad profiles fail the job", func(t *testing.T) { for name, tt := range map[string]struct { json string @@ -221,3 +209,31 @@ func TestResolveParametersApplyProfiles(t *testing.T) { } }) } + +// The simulator workflow builds Debug and never signs, so it takes no profile +// at all: no `profile` input, and a tag push ignores builder.json's profiles. +func TestShareWorkflowTakesNoProfile(t *testing.T) { + data, err := GetTemplate("ios-share.yml") + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(string(data)), "profile") { + t.Error("ios-share.yml mentions a profile; `ios share` takes none") + } + + if runtime.GOOS == "windows" { + t.Skip("shell test") + } + for _, tool := range []string{"bash", "jq"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s unavailable", tool) + } + } + r := runResolve(t, resolveStep(t, "ios-share.yml"), profiledBuilderJSON, map[string]string{"GITHUB_EVENT_NAME": "push"}) + if r.err != nil { + t.Fatalf("%v\n%s", r.err, r.log) + } + if r.outputs["scheme"] != "Top" || r.outputs["duration"] != "30m" || len(r.env) != 0 { + t.Fatalf("a profile reached the simulator build: outputs %v env %v\n%s", r.outputs, r.env, r.log) + } +} diff --git a/internal/workflow/templates/ios-share.yml b/internal/workflow/templates/ios-share.yml index 636b2de..56f757f 100644 --- a/internal/workflow/templates/ios-share.yml +++ b/internal/workflow/templates/ios-share.yml @@ -53,13 +53,6 @@ on: required: false type: string default: '17' - # Same encoding as ios-build.yml. Only the env applies here: simulator - # builds are always Debug and unsigned, so distribution is ignored. - profile: - description: 'Selected builder.json profile as JSON: {"name": "...", "env": {...}, "distribution": "..."}' - required: false - type: string - default: '{}' jobs: simulator: @@ -88,8 +81,7 @@ jobs: # Dispatch inputs arrive with their declared defaults. A tag push has no # inputs, so the values come from builder.json in the tagged commit and - # the build id is the tag name after the prefix. A tag build cannot pick - # a profile per run; it applies builder.json's defaultProfile, if any. + # the build id is the tag name after the prefix. - name: Resolve parameters id: params env: @@ -99,23 +91,14 @@ jobs: IN_DURATION: ${{ inputs.duration }} IN_FLUTTER_VERSION: ${{ inputs.flutter_version }} IN_JDK_VERSION: ${{ inputs.jdk_version }} - IN_PROFILE: ${{ inputs.profile }} run: | set -e - PROFILE="" - if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] && [ -f builder.json ]; then - PROFILE=$(jq -r '.defaultProfile // empty' builder.json) - if [ -n "$PROFILE" ] && [ "$(jq -r --arg p "$PROFILE" '.profiles[$p] != null' builder.json)" != "true" ]; then - echo "::error::defaultProfile \"$PROFILE\" is not defined under profiles in builder.json" - exit 1 - fi - fi param() { # name dispatch-value jq-path default local v="" if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then v="$2" elif [ -f builder.json ]; then - v=$(jq -r --arg p "$PROFILE" "$3 // empty" builder.json) + v=$(jq -r "$3 // empty" builder.json) fi v="${v:-$4}" echo "$1=$v" >> "$GITHUB_OUTPUT" @@ -123,44 +106,11 @@ jobs: } param build_id "$IN_BUILD_ID" '""' "${GITHUB_REF_NAME##*/}" param ios_path "$IN_IOS_PATH" '.ios.path' '.' - param scheme "$IN_SCHEME" '(.profiles[$p].scheme // .ios.scheme)' '' + param scheme "$IN_SCHEME" '.ios.scheme' '' param duration "$IN_DURATION" '""' '30m' param flutter_version "$IN_FLUTTER_VERSION" '.flutter.version' '' param jdk_version "$IN_JDK_VERSION" '.kmp.jdkVersion' '17' - # The profile's env is exported to every step from here on so the - # dependency installs and the build see it. - if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then - PROFILE_JSON="$IN_PROFILE" - elif [ -f builder.json ]; then - PROFILE_JSON=$(jq -c --arg p "$PROFILE" '{name: $p, env: (.profiles[$p].env // {})}' builder.json) - fi - [ -n "${PROFILE_JSON:-}" ] || PROFILE_JSON='{}' - PROFILE=$(jq -r '.name // ""' <<< "$PROFILE_JSON") - echo "profile=$PROFILE" >> "$GITHUB_OUTPUT" - echo "profile=${PROFILE:-(none)}" - # Values are base64 per entry so newlines and quotes survive; the - # heredoc form of GITHUB_ENV then takes them verbatim, with a random - # delimiter so no value line can end it early. Names are checked so a - # value cannot smuggle in a second variable. - if [ "$(jq -r '.env // {} | type' <<< "$PROFILE_JSON")" != "object" ]; then - echo "::error::the profile's env must be a JSON object of variable names to string values"; exit 1 - fi - while IFS=' ' read -r key encoded; do - name=$(printf '%s' "$key" | base64 --decode) - if ! [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then - echo "::error::env name \"$name\" is not a valid environment variable name"; exit 1 - fi - delim="BUILDER_ENV_${RANDOM}${RANDOM}${RANDOM}" - { - echo "$name<<$delim" - printf '%s' "$encoded" | base64 --decode - echo - echo "$delim" - } >> "$GITHUB_ENV" - echo "env: $name" - done < <(jq -r '.env // {} | to_entries[] | "\(.key | @base64) \(.value | tostring | @base64)"' <<< "$PROFILE_JSON") - # Starts the simulator booting in the background (cached, so later runs # boot fast) while the app builds. - name: Install mobai-ci + boot simulator From 5534520350ad3c28eef803fc1eeaf13fda914f68 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 14:48:10 +0200 Subject: [PATCH 60/75] 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-.key and suggests signing setup --distribution --key or --out-dir. --- CLAUDE.md | 14 +++- README.md | 6 +- cmd/builder/signing_auto.go | 78 +++++++++++++++---- cmd/builder/signing_sets_test.go | 126 +++++++++++++++++++++++++++++-- internal/config/types.go | 11 +++ 5 files changed, 211 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f051fd..356c398 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -264,9 +264,14 @@ internal/ present → dispatch. Otherwise, with an ASC key (`getASCClient` passed as a factory so tests inject the `signingtest` portal), `signing.Auto` plus `uploadSigningSet` (shared with `signing setup`, but fatal here) runs with no prompts: bundle ID from `ios.bundleId` or `dist/*.ipa`, - key from `.`, generated password (printed once), no devices given (Auto covers the enabled ones + key from `signing.dir` in builder.json (the `--out-dir` the last automatic `signing setup` + recorded, tilde kept, unset for `.`; `signingKeyDirs`) then `.`, material written to the first + of those, generated password (printed once), no devices given (Auto covers the enabled ones and fails naming `signing setup --distribution development --devices-from-mobai` when there are - none). Without an ASC key the error names `builder auth apple` and `signing setup --certificate + none). Apple issues one certificate per type, so a 409 from `POST /v1/certificates` + (`certificateRefused`) with no key found is reported with the directories searched for + `ios-signing-.key` and `signing setup --distribution --key ` / + `--out-dir`. Without an ASC key the error names `builder auth apple` and `signing setup --certificate ... --profile ...`, before anything is pushed. Codemagic/Bitrise skip the check (no secrets API). - **Flutter Detection**: Auto-detects Flutter projects, runs `flutter pub get`, uses `Runner` scheme - **DerivedData Caching**: `restore` keys on `github.run_id` and only the prefix in `restore-keys` @@ -384,7 +389,10 @@ internal/ `ios.bundleId` is optional: `init` fills it from `PRODUCT_BUNDLE_IDENTIFIER` when the Xcode project has exactly one app target (test targets and `$(…)` values are skipped), and -`signing setup` saves whatever it resolved. +`signing setup` saves whatever it resolved. `signing.dir` (`"signing": {"dir": "~/signing/app"}`) +is the `--out-dir` of the last automatic `signing setup`, written as given and only when it is +not `.`; on-demand provisioning reads the certificate's key from there before the working +directory. `profiles` and `defaultProfile` are optional. A profile's fields are `distribution` (`development`, `ad-hoc`/`internal`, `store`, `enterprise`; the only signing field: selects the diff --git a/README.md b/README.md index 9b1b9d8..0be4ac9 100644 --- a/README.md +++ b/README.md @@ -459,7 +459,11 @@ create certificates. It then: ""`. Other fields of an existing profile are kept; a different `distribution` in it is replaced, and the command says so. `defaultProfile` is not touched: point it at the profile for a plain - `ios build` to use it, or pass `--profile`. + `ios build` to use it, or pass `--profile`. An `--out-dir` other than `.` + is recorded as `signing.dir` (as typed, `~` included), so a later + `ios build --profile` that has to provision a set finds the certificate's + key there instead of asking Apple for a second certificate, which it + refuses. 6. Prints the three secret names and where their values come from — the `.p12` base64-encoded, the password, the `.mobileprovision` base64-encoded — every time, so the same set can be pasted into Codemagic or Bitrise, diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index e308422..feabd9d 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "net/http" "os" "path/filepath" "regexp" @@ -81,8 +82,8 @@ func runSigningAuto(cmd *cobra.Command) error { out := newOutput(cmd) yes, _ := cmd.Flags().GetBool("yes") force, _ := cmd.Flags().GetBool("force") - outDir, _ := cmd.Flags().GetString("out-dir") - outDir = expandPath(outDir) + outDirFlag, _ := cmd.Flags().GetString("out-dir") + outDir := expandPath(outDirFlag) ctx, cancel := commandContext(cmd, false) defer cancel() @@ -95,7 +96,7 @@ func runSigningAuto(cmd *cobra.Command) error { return err } keyFlag, _ := cmd.Flags().GetString("key") - keyPEM, keyPath, err := signingKey(keyFlag, outDir, typ) + keyPEM, keyPath, err := signingKey(keyFlag, typ, outDir) if err != nil { return err } @@ -161,6 +162,7 @@ func runSigningAuto(cmd *cobra.Command) error { // The profile is written whatever the upload did: the material exists and // the build that uses it is the same either way. replaced := writeSigningProfile(cfg, profileName, typ) + recordSigningDir(cfg, outDirFlag) if cfg.IOS.BundleID == "" { cfg.IOS.BundleID = bundleID } @@ -328,17 +330,12 @@ func mobaiSigningDevices(connected []mobai.Device) []signing.Device { } // signingKey returns the key at keyPath (--key), else the key a previous run -// of this type left in outDir (ios-signing-.key, or the ios-signing.key -// of runs before signing sets), else nil so a key is generated. The returned -// path is "" when generating. -func signingKey(keyPath, outDir string, typ signing.Type) (keyPEM []byte, path string, err error) { +// of this type left in the first of dirs that has one (ios-signing-.key, +// or the ios-signing.key of runs before signing sets), else nil so a key is +// generated. The returned path is "" when generating. +func signingKey(keyPath string, typ signing.Type, dirs ...string) (keyPEM []byte, path string, err error) { if keyPath == "" { - for _, name := range []string{signing.KeyFileName(typ), signing.LegacyKeyFileName} { - if candidate := filepath.Join(outDir, name); fileExists(candidate) { - keyPath = candidate - break - } - } + keyPath = findSigningKey(typ, dirs) if keyPath == "" { return nil, "", nil } @@ -351,6 +348,44 @@ func signingKey(keyPath, outDir string, typ signing.Type) (keyPEM []byte, path s return keyPEM, keyPath, nil } +// findSigningKey is the first key file of the type in dirs, or "". +func findSigningKey(typ signing.Type, dirs []string) string { + for _, dir := range dirs { + for _, name := range []string{signing.KeyFileName(typ), signing.LegacyKeyFileName} { + if candidate := filepath.Join(dir, name); fileExists(candidate) { + return candidate + } + } + } + return "" +} + +// recordSigningDir keeps `signing setup`'s --out-dir in builder.json as it +// was given (a ~ stays a ~, so the file works for every user of the repo), +// where on-demand provisioning looks for the key first. The default working +// directory is not written. +func recordSigningDir(cfg *config.Config, outDir string) { + outDir = strings.TrimSpace(outDir) + if filepath.Clean(outDir) == "." { + cfg.Signing = nil + return + } + cfg.Signing = &config.SigningConfig{Dir: outDir} +} + +// signingKeyDirs is where on-demand provisioning looks for the private key +// and writes the material: the directory `signing setup` recorded, then the +// working directory. +func signingKeyDirs(cfg *config.Config) []string { + if cfg.Signing == nil { + return []string{"."} + } + if dir := expandPath(cfg.Signing.Dir); dir != "" && filepath.Clean(dir) != "." { + return []string{dir, "."} + } + return []string{"."} +} + func describeDevices(devices []signing.Device) string { if len(devices) == 0 { return "none given; the profile covers the devices already on the account" @@ -496,7 +531,8 @@ func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretS if bundleID == "" { return fmt.Errorf("bundle ID unknown: set ios.bundleId in builder.json, or run builder signing setup --distribution %s --bundle-id ", typ) } - keyPEM, _, err := signingKey("", ".", typ) + dirs := signingKeyDirs(cfg) + keyPEM, keyPath, err := signingKey("", typ, dirs...) if err != nil { return err } @@ -506,9 +542,14 @@ func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretS } fmt.Fprintf(log, "Provisioning %s signing for %s through App Store Connect...\n", typ, bundleID) res, err := signing.Auto(ctx, client, &signing.AutoOptions{ - BundleID: bundleID, Type: typ, KeyPEM: keyPEM, CommonName: cfg.Project, Password: password, OutDir: ".", Log: log, + BundleID: bundleID, Type: typ, KeyPEM: keyPEM, CommonName: cfg.Project, Password: password, OutDir: dirs[0], Log: log, }) if err != nil { + if keyPath == "" && certificateRefused(err) { + // Apple has a certificate of this type already, and without its + // key Builder asked for another: say where the key was looked for. + return fmt.Errorf("%w\nNo private key of an existing %s certificate was found: looked for %s in %s. Pass the key of the certificate Apple already issued with builder signing setup --distribution %s --key , or --out-dir with the directory that holds it", err, typ, signing.KeyFileName(typ), strings.Join(dirs, ", "), typ) + } return err } // A build cannot go on without the set in the repository, so here the @@ -529,6 +570,13 @@ func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretS return nil } +// certificateRefused reports App Store Connect's 409 on a certificate request: +// the team already holds one of that type (or is at its quota). +func certificateRefused(err error) bool { + var apiErr *asc.Error + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict && apiErr.Path == "/v1/certificates" +} + // printSigningFiles lists what was written and, when Builder made it up, the // .p12 password: it is printed exactly once. func printSigningFiles(w io.Writer, res *signing.AutoResult, generatedPassword string) { diff --git a/cmd/builder/signing_sets_test.go b/cmd/builder/signing_sets_test.go index 56fa4f2..2a7751f 100644 --- a/cmd/builder/signing_sets_test.go +++ b/cmd/builder/signing_sets_test.go @@ -4,8 +4,11 @@ import ( "bytes" "context" "crypto/rand" + "crypto/rsa" + "crypto/x509" "encoding/base64" "encoding/json" + "encoding/pem" "errors" "io" "os" @@ -177,7 +180,7 @@ func TestSigningKeyPrefersTheTypeThenLegacy(t *testing.T) { dir := t.TempDir() // Nothing on disk: generate. - if pem, path, err := signingKey("", dir, signing.TypeStore); err != nil || pem != nil || path != "" { + if pem, path, err := signingKey("", signing.TypeStore, dir); err != nil || pem != nil || path != "" { t.Fatalf("empty dir: %q %q %v", pem, path, err) } // A key from before signing sets is reused by every type. @@ -185,7 +188,7 @@ func TestSigningKeyPrefersTheTypeThenLegacy(t *testing.T) { if err := os.WriteFile(legacy, []byte("legacy"), 0600); err != nil { t.Fatal(err) } - if pem, path, err := signingKey("", dir, signing.TypeStore); err != nil || string(pem) != "legacy" || path != legacy { + if pem, path, err := signingKey("", signing.TypeStore, dir); err != nil || string(pem) != "legacy" || path != legacy { t.Fatalf("legacy key: %q %q %v", pem, path, err) } // The type's own key wins over it. @@ -193,10 +196,10 @@ func TestSigningKeyPrefersTheTypeThenLegacy(t *testing.T) { if err := os.WriteFile(typed, []byte("typed"), 0600); err != nil { t.Fatal(err) } - if pem, path, err := signingKey("", dir, signing.TypeStore); err != nil || string(pem) != "typed" || path != typed { + if pem, path, err := signingKey("", signing.TypeStore, dir); err != nil || string(pem) != "typed" || path != typed { t.Fatalf("typed key: %q %q %v", pem, path, err) } - if pem, path, err := signingKey("", dir, signing.TypeDevelopment); err != nil || string(pem) != "legacy" || path != legacy { + if pem, path, err := signingKey("", signing.TypeDevelopment, dir); err != nil || string(pem) != "legacy" || path != legacy { t.Fatalf("other type falls back to legacy: %q %q %v", pem, path, err) } // --key beats both. @@ -204,7 +207,7 @@ func TestSigningKeyPrefersTheTypeThenLegacy(t *testing.T) { if err := os.WriteFile(explicit, []byte("mine"), 0600); err != nil { t.Fatal(err) } - if pem, path, err := signingKey(explicit, dir, signing.TypeStore); err != nil || string(pem) != "mine" || path != explicit { + if pem, path, err := signingKey(explicit, signing.TypeStore, dir); err != nil || string(pem) != "mine" || path != explicit { t.Fatalf("--key: %q %q %v", pem, path, err) } } @@ -415,6 +418,119 @@ func TestEnsureSigningSecretsProvisionsOnDemand(t *testing.T) { } } +// writeSigningKey generates a private key, writes it to path and issues a +// certificate of certType for it on the portal, as a `signing setup` run +// with --out-dir filepath.Dir(path) would have. +func writeSigningKey(t *testing.T, portal *signingtest.Portal, path, certType string) { + t.Helper() + keyPEM, _, err := signing.GenerateKeyAndCSR("Jane", "jane@example.com") + if err != nil { + t.Fatal(err) + } + block, _ := pem.Decode(keyPEM) + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + t.Fatal(err) + } + portal.Issue(certType, &key.(*rsa.PrivateKey).PublicKey, signingtest.Now.AddDate(0, 6, 0)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, keyPEM, 0600); err != nil { + t.Fatal(err) + } +} + +// TestEnsureSigningSecretsReusesTheKeyInTheRecordedDir: the key of a +// `signing setup --out-dir ~/signing/app` run is found through signing.dir in +// builder.json, so the certificate is reused instead of requested again (and +// refused by Apple, which allows one per type). +func TestEnsureSigningSecretsReusesTheKeyInTheRecordedDir(t *testing.T) { + t.Chdir(t.TempDir()) + home := t.TempDir() + t.Setenv("HOME", home) + ctx := context.Background() + cfg := signedConfig() + cfg.Signing = &config.SigningConfig{Dir: "~/signing/app"} + store := newFakeSecrets(t) + portal := signingtest.New(t) + withPortal := func() (*asc.Client, error) { return portal.Client(t), nil } + keyDir := filepath.Join(home, "signing", "app") + writeSigningKey(t, portal, filepath.Join(keyDir, "ios-signing-store.key"), asc.CertificateTypeDistribution) + + var log strings.Builder + if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "store", "", &log); err != nil { + t.Fatalf("on-demand provisioning: %v\n%s", err, log.String()) + } + if portal.Count("POST /v1/certificates") != 0 || len(store.names) != 3 { + t.Errorf("the certificate must be reused, not requested: %v, uploads %v", portal.Calls(), store.names) + } + // The material lands next to the key, not in the working directory. + if _, err := os.Stat(filepath.Join(keyDir, "ios-signing-store.p12")); err != nil { + t.Errorf(".p12 not written to the recorded dir: %v", err) + } + if _, err := os.Stat("ios-signing-store.p12"); err == nil { + t.Error(".p12 written to the working directory") + } + + // A recorded dir without the key, and Apple refusing another + // certificate: the error says where the key was looked for and how to + // pass it. + cfg.Signing.Dir = "~/signing/other" + store = newFakeSecrets(t) + portal.RefuseCertificates = true + err := ensureSigningSecrets(ctx, cfg, store, withPortal, "store", "", io.Discard) + if err == nil { + t.Fatal("a refused certificate must fail the build") + } + for _, want := range []string{"ios-signing-store.key", filepath.Join(home, "signing", "other") + ", .", "builder signing setup --distribution store --key ", "--out-dir"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q missing from:\n%v", want, err) + } + } +} + +// TestSigningSetupRecordsTheOutDir: --out-dir goes into builder.json as +// given, tilde included, so the next build finds the key; the default +// working directory is not written. +func TestSigningSetupRecordsTheOutDir(t *testing.T) { + t.Chdir(t.TempDir()) + home := t.TempDir() + t.Setenv("HOME", home) + cfg := &config.Config{Project: "App", Platform: "ios", GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}, + IOS: config.IOSConfig{BundleID: "com.example.app"}} + if err := config.NewManager().Save(cfg); err != nil { + t.Fatal(err) + } + portal := signingtest.New(t) + prev := signingASCClient + signingASCClient = func() (*asc.Client, error) { return portal.Client(t), nil } + t.Cleanup(func() { signingASCClient = prev }) + + cmd, _, stderr := signingSetupCommand(t, newFakeSecrets(t), nil, "--distribution", "store", "--yes", "--out-dir", "~/signing/app") + if err := cmd.Execute(); err != nil { + t.Fatalf("%v\n%s", err, stderr.String()) + } + if _, err := os.Stat(filepath.Join(home, "signing", "app", "ios-signing-store.key")); err != nil { + t.Errorf("key not written under --out-dir: %v", err) + } + saved, err := config.NewManager().Load() + if err != nil { + t.Fatal(err) + } + if saved.Signing == nil || saved.Signing.Dir != "~/signing/app" { + t.Errorf("signing = %+v, want the flag as given", saved.Signing) + } + + cmd, _, stderr = signingSetupCommand(t, newFakeSecrets(t), nil, "--distribution", "store", "--yes") + if err := cmd.Execute(); err != nil { + t.Fatalf("%v\n%s", err, stderr.String()) + } + if saved, err = config.NewManager().Load(); err != nil || saved.Signing != nil { + t.Errorf("signing = %+v after the default --out-dir, %v", saved.Signing, err) + } +} + // signingSetupCommand is `signing setup` with its own flags and buffers, and // the fake secrets API in place of the GitHub client. func signingSetupCommand(t *testing.T, store secretStore, storeErr error, args ...string) (cmd *cobra.Command, stdout, stderr *bytes.Buffer) { diff --git a/internal/config/types.go b/internal/config/types.go index 153a9a8..e939d44 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -18,12 +18,23 @@ type Config struct { ReactNative ReactNativeConfig `json:"reactNative,omitempty"` KMP KMPConfig `json:"kmp,omitempty"` MobAI MobAIConfig `json:"mobai,omitempty"` + // Signing records where `signing setup` put the key, .p12 and profile; + // nil when it was the working directory. + Signing *SigningConfig `json:"signing,omitempty"` // DefaultProfile is used when a command is run without --profile. Tag-triggered // runs have no flags, so it is also the only way they can select a profile. DefaultProfile string `json:"defaultProfile,omitempty"` Profiles map[string]Profile `json:"profiles,omitempty"` } +// SigningConfig is where the signing material lives on this machine. +type SigningConfig struct { + // Dir is the --out-dir of the last automatic `signing setup`, as given + // (a leading ~ is kept); empty means the working directory. On-demand + // provisioning looks there first for the certificate's private key. + Dir string `json:"dir,omitempty"` +} + // Profile is a named set of build settings, selected with --profile. Every // field is optional and overrides the matching top-level setting; unset fields // keep the top-level value. Runner and submit settings are planned here too. From dd662e4e4692b7326d69a6eab557fcafadb87e0f Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 14:50:19 +0200 Subject: [PATCH 61/75] 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. --- cmd/builder/signing_sets_test.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/cmd/builder/signing_sets_test.go b/cmd/builder/signing_sets_test.go index 2a7751f..4064afb 100644 --- a/cmd/builder/signing_sets_test.go +++ b/cmd/builder/signing_sets_test.go @@ -432,7 +432,11 @@ func writeSigningKey(t *testing.T, portal *signingtest.Portal, path, certType st if err != nil { t.Fatal(err) } - portal.Issue(certType, &key.(*rsa.PrivateKey).PublicKey, signingtest.Now.AddDate(0, 6, 0)) + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + t.Fatalf("generated key is %T, want RSA", key) + } + portal.Issue(certType, &rsaKey.PublicKey, signingtest.Now.AddDate(0, 6, 0)) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } @@ -507,7 +511,7 @@ func TestSigningSetupRecordsTheOutDir(t *testing.T) { signingASCClient = func() (*asc.Client, error) { return portal.Client(t), nil } t.Cleanup(func() { signingASCClient = prev }) - cmd, _, stderr := signingSetupCommand(t, newFakeSecrets(t), nil, "--distribution", "store", "--yes", "--out-dir", "~/signing/app") + cmd, _, stderr := signingSetupCommand(t, newFakeSecrets(t), "--distribution", "store", "--yes", "--out-dir", "~/signing/app") if err := cmd.Execute(); err != nil { t.Fatalf("%v\n%s", err, stderr.String()) } @@ -522,7 +526,7 @@ func TestSigningSetupRecordsTheOutDir(t *testing.T) { t.Errorf("signing = %+v, want the flag as given", saved.Signing) } - cmd, _, stderr = signingSetupCommand(t, newFakeSecrets(t), nil, "--distribution", "store", "--yes") + cmd, _, stderr = signingSetupCommand(t, newFakeSecrets(t), "--distribution", "store", "--yes") if err := cmd.Execute(); err != nil { t.Fatalf("%v\n%s", err, stderr.String()) } @@ -533,10 +537,10 @@ func TestSigningSetupRecordsTheOutDir(t *testing.T) { // signingSetupCommand is `signing setup` with its own flags and buffers, and // the fake secrets API in place of the GitHub client. -func signingSetupCommand(t *testing.T, store secretStore, storeErr error, args ...string) (cmd *cobra.Command, stdout, stderr *bytes.Buffer) { +func signingSetupCommand(t *testing.T, store secretStore, args ...string) (cmd *cobra.Command, stdout, stderr *bytes.Buffer) { t.Helper() prev := signingSecretStore - signingSecretStore = func() (secretStore, error) { return store, storeErr } + signingSecretStore = func() (secretStore, error) { return store, nil } t.Cleanup(func() { signingSecretStore = prev }) cmd = &cobra.Command{Use: "setup", RunE: runSigningSetup, SilenceErrors: true, SilenceUsage: true} @@ -567,7 +571,7 @@ func TestSigningSetupManualReportsAFailedUpload(t *testing.T) { store := newFakeSecrets(t) store.writeErr = errors.New("403 Resource not accessible by integration") - cmd, stdout, stderr := signingSetupCommand(t, store, nil, + cmd, stdout, stderr := signingSetupCommand(t, store, "--certificate", "ios-signing.p12", "--profile", "App.mobileprovision", "--password", "pw") err := cmd.Execute() if err == nil || !strings.Contains(err.Error(), "o/r") { @@ -607,7 +611,7 @@ func TestSigningSetupAutoReportsAFailedUpload(t *testing.T) { store := newFakeSecrets(t) store.writeErr = errors.New("403 Resource not accessible by integration") - cmd, stdout, stderr := signingSetupCommand(t, store, nil, "--distribution", "store", "--yes") + cmd, stdout, stderr := signingSetupCommand(t, store, "--distribution", "store", "--yes") if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "o/r") { t.Fatalf("a failed upload must set the exit code: %v", err) } @@ -634,7 +638,7 @@ func TestSigningSetupAutoReportsAFailedUpload(t *testing.T) { } // --json says the same in github_upload, and still exits non-zero. - cmd, jsonOut, _ := signingSetupCommand(t, store, nil, "--distribution", "store", "--yes", "--json") + cmd, jsonOut, _ := signingSetupCommand(t, store, "--distribution", "store", "--yes", "--json") if err := cmd.Execute(); err == nil { t.Fatal("--json run: a failed upload must set the exit code") } From 6919ef9660b1f4e5477f3315a69ad149ffd17de2 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 14:50:19 +0200 Subject: [PATCH 62/75] 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. --- CLAUDE.md | 6 +++ internal/github/types.go | 19 +++++-- internal/github/workflow.go | 87 +++++++++++++++++++++++++++++++- internal/github/workflow_test.go | 77 ++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 internal/github/workflow_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 356c398..9ed582f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,6 +192,12 @@ internal/ submodule commit that only exists locally fails checkout on the runner. - **Run Correlation**: `run-name` carries the build ID so concurrent builds cannot adopt each other's runs +- **Run Failures**: when the run completes without success while `PollForArtifact` waits, the + error is a `github.RunFailedError`: the conclusion, the first failed job and step + (`ListRunJobs`) and that job's `failure`-level annotations (`GET + /repos/{o}/{r}/check-runs/{job_id}/annotations`; a job ID is its check run ID), which are the + runner's `::error::` lines. Reading the details is best-effort, so the conclusion is reported + even when the annotations endpoint fails - **Build Profiles**: `profiles.` in `builder.json` overrides `ios.configuration`, `ios.scheme` and `provider`, and adds `env` and `distribution`. `ios build` takes `--profile`; without it `defaultProfile` applies, and without that the top-level settings are used unchanged. `ios share` diff --git a/internal/github/types.go b/internal/github/types.go index d9a4b98..b112334 100644 --- a/internal/github/types.go +++ b/internal/github/types.go @@ -38,10 +38,11 @@ type WorkflowRunsResponse struct { // Job represents a job within a workflow run type Job struct { - ID int64 `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - Steps []JobStep `json:"steps"` + ID int64 `json:"id"` // also the job's check run ID + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` // success, failure, cancelled, skipped + Steps []JobStep `json:"steps"` } // JobStep represents a single step within a job @@ -59,6 +60,16 @@ type JobsResponse struct { Jobs []Job `json:"jobs"` } +// Annotation is a check-run annotation. The runner turns a job's ::error:: +// and ::warning:: lines into failure- and warning-level ones. +type Annotation struct { + Path string `json:"path"` + StartLine int `json:"start_line"` + Level string `json:"annotation_level"` // notice, warning, failure + Title string `json:"title"` + Message string `json:"message"` +} + // WorkflowDispatchRequest is the request body for triggering a workflow type WorkflowDispatchRequest struct { Ref string `json:"ref"` diff --git a/internal/github/workflow.go b/internal/github/workflow.go index 70dce2e..97c4f74 100644 --- a/internal/github/workflow.go +++ b/internal/github/workflow.go @@ -163,6 +163,91 @@ func (c *Client) RunningStep(ctx context.Context, owner, repo string, runID int6 return nil, 0, nil } +// ListCheckRunAnnotations lists the annotations of a check run. A job's ID is +// its check run ID. +func (c *Client) ListCheckRunAnnotations(ctx context.Context, owner, repo string, checkRunID int64) ([]Annotation, error) { + path := fmt.Sprintf("/repos/%s/%s/check-runs/%d/annotations?per_page=100", owner, repo, checkRunID) + + var annotations []Annotation + if err := c.do(ctx, path, &annotations); err != nil { + return nil, fmt.Errorf("failed to list annotations: %w", err) + } + + return annotations, nil +} + +// RunFailure is why a run failed: its first failed job and step, and the +// failure-level annotations of that job (the runner's ::error:: lines). +type RunFailure struct { + Job string + Step string + Messages []string +} + +// RunFailure reads the failed job, its failed step and its error annotations. +// It returns nil when no job failed. +func (c *Client) RunFailure(ctx context.Context, owner, repo string, runID int64) (*RunFailure, error) { + jobs, err := c.ListRunJobs(ctx, owner, repo, runID) + if err != nil { + return nil, err + } + for _, job := range jobs { + var step string + for _, s := range job.Steps { + if s.Conclusion == "failure" { + step = s.Name + break + } + } + if step == "" && job.Conclusion != "failure" { + continue + } + failure := &RunFailure{Job: job.Name, Step: step} + annotations, err := c.ListCheckRunAnnotations(ctx, owner, repo, job.ID) + if err != nil { + return failure, err + } + for _, a := range annotations { + if a.Level == "failure" && strings.TrimSpace(a.Message) != "" { + failure.Messages = append(failure.Messages, strings.TrimSpace(a.Message)) + } + } + return failure, nil + } + return nil, nil +} + +// RunFailedError is a run that completed without success, with what the +// failed job reported when it could be read. +type RunFailedError struct { + Conclusion string + Failure *RunFailure +} + +func (e *RunFailedError) Error() string { + var b strings.Builder + fmt.Fprintf(&b, "workflow failed with conclusion: %s", e.Conclusion) + if e.Failure == nil { + return b.String() + } + if e.Failure.Step != "" { + fmt.Fprintf(&b, "\n Failed step: %s (job %s)", e.Failure.Step, e.Failure.Job) + } else { + fmt.Fprintf(&b, "\n Failed job: %s", e.Failure.Job) + } + for _, m := range e.Failure.Messages { + fmt.Fprintf(&b, "\n %s", strings.ReplaceAll(m, "\n", "\n ")) + } + return b.String() +} + +// runFailed builds the error for a run that ended without success. Reading +// the failure details is best-effort: the conclusion is reported either way. +func (c *Client) runFailed(ctx context.Context, owner, repo string, runID int64, conclusion string) error { + failure, _ := c.RunFailure(ctx, owner, repo, runID) + return &RunFailedError{Conclusion: conclusion, Failure: failure} +} + // ListRunArtifacts lists all artifacts for a workflow run func (c *Client) ListRunArtifacts(ctx context.Context, owner, repo string, runID int64) ([]Artifact, error) { path := fmt.Sprintf("/repos/%s/%s/actions/runs/%d/artifacts", owner, repo, runID) @@ -285,7 +370,7 @@ func (c *Client) PollForArtifact(ctx context.Context, owner, repo string, runID return nil, fmt.Errorf("failed to check workflow status: %w", err) } if run.Status == "completed" && run.Conclusion != "success" { - return nil, fmt.Errorf("workflow failed with conclusion: %s", run.Conclusion) + return nil, c.runFailed(ctx, owner, repo, runID, run.Conclusion) } if onPoll != nil { diff --git a/internal/github/workflow_test.go b/internal/github/workflow_test.go new file mode 100644 index 0000000..470a227 --- /dev/null +++ b/internal/github/workflow_test.go @@ -0,0 +1,77 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// failedRunServer is a run that completed with conclusion failure, whose +// build job failed in "Build IPA" and left one error and one warning +// annotation; annotationsStatus is what the annotations endpoint answers. +func failedRunServer(t *testing.T, annotationsStatus int) *Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/repos/o/r/actions/runs/7/artifacts": + fmt.Fprint(w, `{"total_count":0,"artifacts":[]}`) + case "/repos/o/r/actions/runs/7": + fmt.Fprint(w, `{"id":7,"status":"completed","conclusion":"failure","html_url":"https://github.com/o/r/actions/runs/7"}`) + case "/repos/o/r/actions/runs/7/jobs": + fmt.Fprint(w, `{"total_count":1,"jobs":[{"id":99,"name":"build","status":"completed","conclusion":"failure","steps":[ + {"name":"Checkout","status":"completed","conclusion":"success","number":1}, + {"name":"Build IPA","status":"completed","conclusion":"failure","number":2}, + {"name":"Upload IPA","status":"completed","conclusion":"skipped","number":3}]}]}`) + case "/repos/o/r/check-runs/99/annotations": + if annotationsStatus != http.StatusOK { + w.WriteHeader(annotationsStatus) + fmt.Fprint(w, `{"message":"Not Found"}`) + return + } + fmt.Fprint(w, `[ + {"path":".github","start_line":1,"annotation_level":"warning","title":"","message":"Node.js 16 actions are deprecated"}, + {"path":".github","start_line":1,"annotation_level":"failure","title":"","message":"No profile for team 'ABC' matching 'Builder store com.example.app' found"}, + {"path":".github","start_line":1,"annotation_level":"failure","title":"","message":"Process completed with exit code 65."}]`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + c := NewClient("tok") + c.baseURL = srv.URL + return c +} + +func TestPollForArtifactReportsTheFailedStepAndErrors(t *testing.T) { + c := failedRunServer(t, http.StatusOK) + _, err := c.PollForArtifact(context.Background(), "o", "r", 7, "ipa", time.Minute, nil) + if err == nil { + t.Fatal("a failed run must end the wait") + } + want := "workflow failed with conclusion: failure\n" + + " Failed step: Build IPA (job build)\n" + + " No profile for team 'ABC' matching 'Builder store com.example.app' found\n" + + " Process completed with exit code 65." + if err.Error() != want { + t.Errorf("error:\n%v\nwant:\n%s", err, want) + } + if strings.Contains(err.Error(), "deprecated") { + t.Errorf("warnings must not be listed: %v", err) + } +} + +func TestPollForArtifactWithoutAnnotations(t *testing.T) { + // The annotations endpoint failing (a token without checks:read, an old + // GHES) still leaves the conclusion and the step. + c := failedRunServer(t, http.StatusNotFound) + _, err := c.PollForArtifact(context.Background(), "o", "r", 7, "ipa", time.Minute, nil) + if err == nil || err.Error() != "workflow failed with conclusion: failure\n Failed step: Build IPA (job build)" { + t.Errorf("error: %v", err) + } +} From ac9aef5c18a964bc60e445b854eaa462729c1247 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 16:55:54 +0200 Subject: [PATCH 63/75] workflow: set manual signing on the app target only, not on every Pods target --- CLAUDE.md | 25 +- internal/config/profile.go | 3 +- internal/workflow/providers_test.go | 309 +++++++++++++++++++++- internal/workflow/templates/ios-build.yml | 90 +++++-- internal/workflow/templates/runner.sh | 73 ++++- 5 files changed, 470 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ed582f..bd750e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -368,9 +368,28 @@ internal/ (verbatim in both templates) pick `CODE_SIGN_IDENTITY` out of `security find-identity`, run right after `security import`: `Apple Development`, else the pre-2021 `iPhone Developer`, for a development profile; `Apple Distribution`, else `iPhone Distribution`, for the rest; a named - `::error::` when the set holds neither. Every manually signed archive command passes it, since - without it Xcode keeps the project's default identity and refuses a distribution profile ("No - signing certificate iOS Development found"). + `::error::` when the set holds neither. `apply_signing_to_app_target` writes it into the app + target with the other manual settings, since without it Xcode keeps the project's default + identity and refuses a distribution profile ("No signing certificate iOS Development found"). +- **Signing Settings Live In The pbxproj**: `CODE_SIGN_STYLE=Manual`, `DEVELOPMENT_TEAM`, + `PROVISIONING_PROFILE_SPECIFIER` and `CODE_SIGN_IDENTITY` are never passed to `xcodebuild`: a + command-line setting applies to every target in the workspace, and CocoaPods framework + targets refuse a profile ("FirebaseCore does not support provisioning profiles, but + provisioning profile … has been manually specified"), so only pod-free projects passed. + `apply_signing_to_app_target` (verbatim in `ios-build.yml` and `runner.sh`; run in the iOS + directory right before each signed archive, after `pod install` / `expo prebuild` / + `flutter build ios` have generated the projects) converts each top-level `*.xcodeproj`'s + `project.pbxproj` to JSON with `plutil`, sets the four settings on every configuration of + the `PBXNativeTarget`s whose `productType` is an application (dropping conditional + `NAME[sdk=…]` variants that would override them), and writes the file back as an XML plist, + which Xcode reads. `Pods/Pods.xcodeproj` is a level down and never a candidate; extension and + framework targets are never touched. With one app target it is signed whatever its bundle id + (the export reports a mismatch); with several, the ones whose `PRODUCT_BUNDLE_IDENTIFIER` + the profile's app id covers (`PROFILE_BUNDLE_ID`: `application-identifier` minus the team + prefix; `*` and `com.example.*` are wildcards), else a `::error::` naming the bundle ids + found. `ExportOptions.plist` keeps its `provisioningProfiles` map as before. + `TestSigningSettingsOnAppTargetOnly` compares the two bodies, asserts no archive command + passes the settings, and (darwin) runs the function on generated pbxproj fixtures. - **Extension Points**: a future `ios release` (upload + TestFlight, automatic build numbers) composes `distribute.Upload` and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` for the latest build number; the `pkg/` wrappers do not expose `asc` yet. diff --git a/internal/config/profile.go b/internal/config/profile.go index 4e8e898..839e539 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -34,7 +34,8 @@ var reservedEnv = []string{ "BUILD_ID", "SNAPSHOT_REF", "SNAPSHOT_SHA", "IOS_PATH", "SCHEME", "CONFIGURATION", "USE_SIGNING", "FLUTTER_VERSION", "JDK_VERSION", "BUILD_ENV", "DISTRIBUTION", "SIGNING_SET", "SIGNING_SET_USED", "DURATION", "PROJECT_TYPE", "EXPORT_METHOD", - "CODE_SIGN_IDENTITY", "MOBAI_API_KEY", + "CODE_SIGN_IDENTITY", "DEVELOPMENT_TEAM", "PROVISIONING_PROFILE_NAME", "PROFILE_BUNDLE_ID", + "MOBAI_API_KEY", "PATH", "HOME", "USER", "SHELL", "TMPDIR", "DEVELOPER_DIR", "NODE_OPTIONS", } diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index a13f276..d2879e2 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -1,6 +1,8 @@ package workflow import ( + "bytes" + "encoding/json" "fmt" "os" "os/exec" @@ -369,12 +371,13 @@ func TestSigningIdentityFollowsProfileType(t *testing.T) { t.Errorf("%s: the identity is not derived from the profile, missing %q", name, want) } } - // Every manually signed command must name the identity: one that sets - // CODE_SIGN_STYLE=Manual without it signs with the project's default. - manual := strings.Count(data, "CODE_SIGN_STYLE=Manual") - identity := strings.Count(data, `CODE_SIGN_IDENTITY='$CODE_SIGN_IDENTITY'`) + strings.Count(data, `CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY"`) - if manual == 0 || manual != identity { - t.Errorf("%s: %d manual signing commands but %d pass CODE_SIGN_IDENTITY", name, manual, identity) + // The identity reaches the app target through apply_signing_to_app_target + // (TestSigningSettingsOnAppTargetOnly), which reads it from the + // environment together with CODE_SIGN_STYLE=Manual; a command line that + // set the style without it would sign with the project's default. + fn := shellFunc(t, data, "apply_signing_to_app_target") + if !strings.Contains(fn, "'CODE_SIGN_IDENTITY': os.environ['CODE_SIGN_IDENTITY']") || !strings.Contains(fn, "'CODE_SIGN_STYLE': 'Manual'") { + t.Errorf("%s: apply_signing_to_app_target does not set the identity with the manual style", name) } // The imported certificate is checked against that identity, after the // import and before the archive, so a distribution set holding a @@ -434,6 +437,300 @@ func TestSigningIdentityFollowsProfileType(t *testing.T) { } } +// pbxTarget describes one native target of a test project. +type pbxTarget struct { + name, productType, bundleID string + extra map[string]string // more build settings on both configurations +} + +// pbxproj writes a minimal OpenStep-format project.pbxproj with Debug and +// Release configurations for each target, the way Xcode lays one out. +func pbxproj(targets ...pbxTarget) string { + var b strings.Builder + b.WriteString("// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 56;\n\tobjects = {\n") + b.WriteString("\t\tP0 = {\n\t\t\tisa = PBXProject;\n\t\t\tbuildConfigurationList = L0;\n\t\t\tcompatibilityVersion = \"Xcode 14.0\";\n\t\t\tmainGroup = G0;\n\t\t\tproductRefGroup = G0;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n") + for i := range targets { + fmt.Fprintf(&b, "\t\t\t\tT%d,\n", i) + } + b.WriteString("\t\t\t);\n\t\t};\n\t\tG0 = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t);\n\t\t\tsourceTree = \"\";\n\t\t};\n") + configList := func(id string, settings map[string]string) { + fmt.Fprintf(&b, "\t\t%s = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t%sD,\n\t\t\t\t%sR,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n", id, id, id) + for _, c := range []struct{ suffix, name string }{{"D", "Debug"}, {"R", "Release"}} { + fmt.Fprintf(&b, "\t\t%s%s = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n", id, c.suffix) + for k, v := range settings { + fmt.Fprintf(&b, "\t\t\t\t%q = %q;\n", k, v) + } + fmt.Fprintf(&b, "\t\t\t};\n\t\t\tname = %s;\n\t\t};\n", c.name) + } + } + configList("L0", map[string]string{"SDKROOT": "iphoneos"}) + for i, tg := range targets { + fmt.Fprintf(&b, "\t\tT%d = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = L%d;\n\t\t\tbuildPhases = (\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = %s;\n\t\t\tproductName = %s;\n\t\t\tproductReference = F%d;\n\t\t\tproductType = %q;\n\t\t};\n", i, i+1, tg.name, tg.name, i, tg.productType) + fmt.Fprintf(&b, "\t\tF%d = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = %s.app; sourceTree = BUILT_PRODUCTS_DIR; };\n", i, tg.name) + settings := map[string]string{"CODE_SIGN_STYLE": "Automatic", "PRODUCT_BUNDLE_IDENTIFIER": tg.bundleID, "PRODUCT_NAME": "$(TARGET_NAME)"} + for k, v := range tg.extra { + settings[k] = v + } + configList(fmt.Sprintf("L%d", i+1), settings) + } + b.WriteString("\t};\n\trootObject = P0;\n}\n") + return b.String() +} + +// pbxSettings reads the build settings of every configuration of every native +// target of a project, as target → configuration → settings. +func pbxSettings(t *testing.T, project string) map[string]map[string]map[string]string { + t.Helper() + out, err := exec.Command("plutil", "-convert", "json", "-o", "-", filepath.Join(project, "project.pbxproj")).CombinedOutput() + if err != nil { + t.Fatalf("plutil: %s %v", out, err) + } + var parsed struct { + Objects map[string]struct { + Isa string `json:"isa"` + Name string `json:"name"` + BuildConfigurationList string `json:"buildConfigurationList"` + BuildConfigurations []string `json:"buildConfigurations"` + BuildSettings map[string]string `json:"buildSettings"` + } `json:"objects"` + } + if err := json.Unmarshal(out, &parsed); err != nil { + t.Fatal(err) + } + result := map[string]map[string]map[string]string{} + for _, o := range parsed.Objects { + if o.Isa != "PBXNativeTarget" { + continue + } + result[o.Name] = map[string]map[string]string{} + for _, id := range parsed.Objects[o.BuildConfigurationList].BuildConfigurations { + c := parsed.Objects[id] + result[o.Name][c.Name] = c.BuildSettings + } + } + return result +} + +// TestSigningSettingsOnAppTargetOnly holds both templates to writing the +// manual signing settings into the app target's build configurations rather +// than passing them to xcodebuild, where every target in the workspace — the +// CocoaPods framework targets included — would inherit them: "FirebaseCore +// does not support provisioning profiles, but provisioning profile ... has +// been manually specified". +func TestSigningSettingsOnAppTargetOnly(t *testing.T) { + workflowTemplate, err := GetWorkflowTemplate() + if err != nil { + t.Fatal(err) + } + runner, err := GetTemplate("runner.sh") + if err != nil { + t.Fatal(err) + } + fromWorkflow := shellFunc(t, string(workflowTemplate), "apply_signing_to_app_target") + fromRunner := shellFunc(t, string(runner), "apply_signing_to_app_target") + if fromWorkflow != fromRunner { + t.Fatalf("templates disagree on apply_signing_to_app_target:\n%s\n---\n%s", fromWorkflow, fromRunner) + } + if n := strings.Count(fromRunner, "\n"); n > 70 { + t.Errorf("apply_signing_to_app_target has grown to %d lines", n) + } + + call := `apply_signing_to_app_target "$PROFILE_BUNDLE_ID"` + wiring := map[string][]string{ + "ios-build.yml": {`echo "PROFILE_BUNDLE_ID=$PROFILE_BUNDLE_ID" >> $GITHUB_ENV`, `PROFILE_BUNDLE_ID=${APP_ID#"$TEAM_ID".}`}, + "runner.sh": {`export PROFILE_BUNDLE_ID="${app_id#"$DEVELOPMENT_TEAM".}"`}, + } + for name, data := range map[string]string{"ios-build.yml": string(workflowTemplate), "runner.sh": string(runner)} { + data = strings.ReplaceAll(data, "\r\n", "\n") // Windows checkouts + for _, want := range wiring[name] { + if !strings.Contains(data, want) { + t.Errorf("%s: the profile's app id does not reach the build, missing %q", name, want) + } + } + // No signed archive passes the settings on the command line any more. + for _, arg := range []string{"PROVISIONING_PROFILE_SPECIFIER=", "CODE_SIGN_STYLE=", `DEVELOPMENT_TEAM='$DEVELOPMENT_TEAM'`, `DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM"`, `CODE_SIGN_IDENTITY='$CODE_SIGN_IDENTITY'`, `CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY"`} { + if strings.Contains(data, arg) { + t.Errorf("%s: still passes %s to xcodebuild, which applies it to every Pods target too", name, arg) + } + } + // Every archive is preceded by its own call, after the generated + // projects exist: pod install and flutter build ios come first (the + // runner's prepare, with expo prebuild, precedes build_ipa; on GitHub + // the Build IPA step follows every setup step). + archives, calls := strings.Count(data, `.xcarchive' archive`)+strings.Count(data, `.xcarchive" archive`), strings.Count(data, call) + if archives == 0 || archives != calls { + t.Errorf("%s: %d signed archives but %d calls of apply_signing_to_app_target", name, archives, calls) + } + steps := []string{"pod install\n", "flutter build ios --release --no-codesign"} + if name == "runner.sh" { + steps = append(steps, "expo prebuild") + } + for _, step := range steps { + if first, applied := strings.Index(data, step), strings.Index(data, call); first < 0 || applied < first { + t.Errorf("%s: apply_signing_to_app_target (offset %d) must run after %q (offset %d)", name, applied, step, first) + } + } + } + + if runtime.GOOS != "darwin" { + t.Skip("plutil is macOS only") + } + script := "set -euo pipefail\nfail() { echo \"$*\" >&2; exit 1; }\n" + fromRunner + "\ncd \"$1\"\napply_signing_to_app_target \"$2\"\n" + want := map[string]string{"CODE_SIGN_STYLE": "Manual", "DEVELOPMENT_TEAM": "ABCDE12345", "PROVISIONING_PROFILE_SPECIFIER": "Builder store run.mobai.flicker", "CODE_SIGN_IDENTITY": "Apple Distribution"} + run := func(t *testing.T, dir, appID string) (string, error) { + t.Helper() + cmd := exec.Command("bash", "-c", script, "bash", dir, appID) + cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + os.Getenv("HOME"), "DEVELOPMENT_TEAM=" + want["DEVELOPMENT_TEAM"], "PROVISIONING_PROFILE_NAME=" + want["PROVISIONING_PROFILE_SPECIFIER"], "CODE_SIGN_IDENTITY=" + want["CODE_SIGN_IDENTITY"]} + out, err := cmd.CombinedOutput() + return string(out), err + } + write := func(t *testing.T, dir, project string, targets ...pbxTarget) string { + t.Helper() + path := filepath.Join(dir, project) + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "project.pbxproj"), []byte(pbxproj(targets...)), 0644); err != nil { + t.Fatal(err) + } + return path + } + // signed asserts the four settings on both configurations of a target and + // that nothing conditional is left to override them. + signed := func(t *testing.T, settings map[string]map[string]string, target string) { + t.Helper() + for _, config := range []string{"Debug", "Release"} { + for k, v := range want { + if got := settings[config][k]; got != v { + t.Errorf("%s %s: %s = %q, want %q", target, config, k, got, v) + } + } + for k := range settings[config] { + if strings.Contains(k, "[") { + t.Errorf("%s %s: conditional %s left in place", target, config, k) + } + } + } + } + untouched := func(t *testing.T, settings map[string]map[string]string, target string) { + t.Helper() + for _, config := range []string{"Debug", "Release"} { + if s := settings[config]; s["CODE_SIGN_STYLE"] != "Automatic" || s["PROVISIONING_PROFILE_SPECIFIER"] != "" || s["DEVELOPMENT_TEAM"] != "" { + t.Errorf("%s %s was signed: %v", target, config, s) + } + } + } + app := pbxTarget{"App", "com.apple.product-type.application", "run.mobai.flicker", map[string]string{"CODE_SIGN_IDENTITY[sdk=iphoneos*]": "iPhone Developer"}} + other := pbxTarget{"Other", "com.apple.product-type.application", "run.mobai.other", nil} + kit := pbxTarget{"Kit", "com.apple.product-type.framework", "run.mobai.Kit", nil} + widget := pbxTarget{"Widget", "com.apple.product-type.app-extension", "run.mobai.flicker.widget", nil} + + t.Run("app target only", func(t *testing.T) { + dir := t.TempDir() + project := write(t, dir, "App.xcodeproj", app, kit, widget) + // The pods project sits a level down and is never a candidate. + pods := write(t, filepath.Join(dir, "Pods"), "Pods.xcodeproj", pbxTarget{"FirebaseCore", "com.apple.product-type.framework", "org.cocoapods.FirebaseCore", nil}) + before, _ := os.ReadFile(filepath.Join(pods, "project.pbxproj")) + out, err := run(t, dir, "run.mobai.flicker") + if err != nil { + t.Fatalf("%s %v", out, err) + } + if !strings.Contains(out, "target App in App.xcodeproj: Debug, Release") { + t.Errorf("log does not say what changed: %s", out) + } + settings := pbxSettings(t, project) + signed(t, settings["App"], "App") + untouched(t, settings["Kit"], "Kit") + untouched(t, settings["Widget"], "Widget") + after, _ := os.ReadFile(filepath.Join(pods, "project.pbxproj")) + if !bytes.Equal(before, after) { + t.Error("Pods.xcodeproj was rewritten") + } + data, err := os.ReadFile(filepath.Join(project, "project.pbxproj")) + if err != nil || !strings.HasPrefix(string(data), "> $GITHUB_ENV echo "DEVELOPMENT_TEAM=$TEAM_ID" >> $GITHUB_ENV echo "PROVISIONING_PROFILE_NAME=$PROFILE_NAME" >> $GITHUB_ENV + echo "PROFILE_BUNDLE_ID=$PROFILE_BUNDLE_ID" >> $GITHUB_ENV echo "EXPORT_METHOD=$EXPORT_METHOD" >> $GITHUB_ENV echo "CODE_SIGN_IDENTITY=$CODE_SIGN_IDENTITY" >> $GITHUB_ENV echo "SIGNING_SET_USED=$SIGNING_SET_USED" >> $GITHUB_ENV @@ -538,7 +539,7 @@ jobs: echo " set: $SIGNING_SET_USED" echo " export: $EXPORT_METHOD" echo " identity: $CODE_SIGN_IDENTITY" - echo "If the build fails on a provisioning mismatch, the app's PRODUCT_BUNDLE_IDENTIFIER must match the app id above." + echo "The build writes these into the app target's build settings; its PRODUCT_BUNDLE_IDENTIFIER must match the app id above." - name: Build IPA env: @@ -550,6 +551,67 @@ jobs: CONFIGURATION: ${{ steps.params.outputs.configuration }} run: | set -e + fail() { echo "::error::$*"; exit 1; } + + # Manual signing goes into the app target's build configurations, not on + # the xcodebuild command line: a command-line setting applies to every + # target in the workspace, and a CocoaPods framework target refuses a + # provisioning profile ("FirebaseCore does not support provisioning + # profiles"). Edits the application targets of the .xcodeproj files in the + # current directory (Pods/Pods.xcodeproj is a level down): the only one, or + # with several the ones whose PRODUCT_BUNDLE_IDENTIFIER the profile's app id + # ($1, "*" or "com.example.*" for a wildcard) covers. DEVELOPMENT_TEAM, + # PROVISIONING_PROFILE_NAME and CODE_SIGN_IDENTITY come from the + # environment. The pbxproj is written back as an XML plist, which Xcode + # reads like the OpenStep form. Duplicated verbatim in ios-build.yml and + # runner.sh. + apply_signing_to_app_target() { + local projects=(*.xcodeproj) out + [ -d "${projects[0]}" ] || fail "No .xcodeproj in $PWD to apply the signing settings to" + out=$(python3 - "$1" "${projects[@]}" 2>&1 <<'PY' + import json, os, subprocess, sys + app_id, projects = sys.argv[1], sys.argv[2:] + settings = {'CODE_SIGN_STYLE': 'Manual', 'DEVELOPMENT_TEAM': os.environ['DEVELOPMENT_TEAM'], + 'PROVISIONING_PROFILE_SPECIFIER': os.environ['PROVISIONING_PROFILE_NAME'], + 'CODE_SIGN_IDENTITY': os.environ['CODE_SIGN_IDENTITY']} + + def covers(bundle_id): + if app_id.endswith('*'): + return bundle_id.startswith(app_id[:-1]) + return bundle_id == app_id + + apps, plists = [], {} + for project in projects: + path = os.path.join(project, 'project.pbxproj') + plists[project] = json.loads(subprocess.check_output(['plutil', '-convert', 'json', '-o', '-', path])) + objects = plists[project]['objects'] + for target in objects.values(): + if target.get('isa') != 'PBXNativeTarget' or target.get('productType') != 'com.apple.product-type.application': + continue + configs = [objects[c] for c in objects[target['buildConfigurationList']]['buildConfigurations']] + ids = sorted({c.setdefault('buildSettings', {}).get('PRODUCT_BUNDLE_IDENTIFIER', '') for c in configs}) + apps.append((project, target['name'], configs, ids)) + if not apps: + sys.exit('No application target in %s to apply the signing settings to' % ', '.join(projects)) + chosen = apps if len(apps) == 1 else [a for a in apps if any(covers(i) for i in a[3])] + if not chosen: + found = '; '.join('%s in %s (%s)' % (name, project, ', '.join(i or '?' for i in ids)) for project, name, _, ids in apps) + sys.exit('No application target has the PRODUCT_BUNDLE_IDENTIFIER the provisioning profile covers (%s): %s' % (app_id, found)) + for project, name, configs, _ in chosen: + for config in configs: + build = config['buildSettings'] + # A conditional setting (CODE_SIGN_IDENTITY[sdk=iphoneos*]) would win over the plain one. + for key in [k for k in build if k.split('[')[0] in settings]: + del build[key] + build.update(settings) + print('Signing settings applied to target %s in %s: %s' % (name, project, ', '.join(c['name'] for c in configs))) + for project in sorted({a[0] for a in chosen}): + subprocess.run(['plutil', '-convert', 'xml1', '-o', os.path.join(project, 'project.pbxproj'), '-'], + input=json.dumps(plists[project]).encode(), check=True) + PY + ) || fail "$out" + echo "$out" + } # Navigate to iOS project cd "$IOS_PATH" @@ -644,13 +706,12 @@ jobs: ARCHIVE_CMD="$ARCHIVE_CMD -destination 'generic/platform=iOS'" ARCHIVE_CMD="$ARCHIVE_CMD -derivedDataPath '$DERIVED_DATA_PATH'" ARCHIVE_CMD="$ARCHIVE_CMD COMPILER_INDEX_STORE_ENABLE=NO" - ARCHIVE_CMD="$ARCHIVE_CMD DEVELOPMENT_TEAM='$DEVELOPMENT_TEAM'" - ARCHIVE_CMD="$ARCHIVE_CMD CODE_SIGN_STYLE=Manual" - ARCHIVE_CMD="$ARCHIVE_CMD CODE_SIGN_IDENTITY='$CODE_SIGN_IDENTITY'" - ARCHIVE_CMD="$ARCHIVE_CMD PROVISIONING_PROFILE_SPECIFIER='$PROVISIONING_PROFILE_NAME'" ARCHIVE_CMD="$ARCHIVE_CMD -quiet" ARCHIVE_CMD="$ARCHIVE_CMD -archivePath '$GITHUB_WORKSPACE/build/App.xcarchive' archive" + # The manual signing settings go on the Runner target, after + # flutter build ios has regenerated the project. + apply_signing_to_app_target "$PROFILE_BUNDLE_ID" echo "Running: $ARCHIVE_CMD" eval $ARCHIVE_CMD fi @@ -692,11 +753,11 @@ jobs: if [ "$USE_SIGNING" = "true" ]; then # Must archive, not build: the IPA step below exports - # build/App.xcarchive whenever signing is on. - BUILD_CMD="$BUILD_CMD DEVELOPMENT_TEAM='$DEVELOPMENT_TEAM'" - BUILD_CMD="$BUILD_CMD CODE_SIGN_STYLE=Manual" - BUILD_CMD="$BUILD_CMD CODE_SIGN_IDENTITY='$CODE_SIGN_IDENTITY'" - BUILD_CMD="$BUILD_CMD PROVISIONING_PROFILE_SPECIFIER='$PROVISIONING_PROFILE_NAME'" + # build/App.xcarchive whenever signing is on. The manual signing + # settings go on the app target, now that the project is + # generated and pod install has run, never on the command line, + # where every pod would inherit the profile. + apply_signing_to_app_target "$PROFILE_BUNDLE_ID" BUILD_CMD="$BUILD_CMD -archivePath '$GITHUB_WORKSPACE/build/App.xcarchive' archive" else BUILD_CMD="$BUILD_CMD CODE_SIGNING_ALLOWED=NO build" @@ -729,12 +790,9 @@ jobs: if [ "$USE_SIGNING" = "true" ]; then # For signed builds, use archive. Automatic signing cannot resolve a - # team on a runner (no Xcode account), so sign manually against the - # profile installed above. - BUILD_CMD="$BUILD_CMD DEVELOPMENT_TEAM='$DEVELOPMENT_TEAM'" - BUILD_CMD="$BUILD_CMD CODE_SIGN_STYLE=Manual" - BUILD_CMD="$BUILD_CMD CODE_SIGN_IDENTITY='$CODE_SIGN_IDENTITY'" - BUILD_CMD="$BUILD_CMD PROVISIONING_PROFILE_SPECIFIER='$PROVISIONING_PROFILE_NAME'" + # team on a runner (no Xcode account), so sign the app target + # manually against the profile installed above. + apply_signing_to_app_target "$PROFILE_BUNDLE_ID" BUILD_CMD="$BUILD_CMD -archivePath '$GITHUB_WORKSPACE/build/App.xcarchive' archive" else # For unsigned builds, use faster 'build' action diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 8724153..e896cef 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -255,6 +255,66 @@ check_signing_set() { fi } +# Manual signing goes into the app target's build configurations, not on +# the xcodebuild command line: a command-line setting applies to every +# target in the workspace, and a CocoaPods framework target refuses a +# provisioning profile ("FirebaseCore does not support provisioning +# profiles"). Edits the application targets of the .xcodeproj files in the +# current directory (Pods/Pods.xcodeproj is a level down): the only one, or +# with several the ones whose PRODUCT_BUNDLE_IDENTIFIER the profile's app id +# ($1, "*" or "com.example.*" for a wildcard) covers. DEVELOPMENT_TEAM, +# PROVISIONING_PROFILE_NAME and CODE_SIGN_IDENTITY come from the +# environment. The pbxproj is written back as an XML plist, which Xcode +# reads like the OpenStep form. Duplicated verbatim in ios-build.yml and +# runner.sh. +apply_signing_to_app_target() { + local projects=(*.xcodeproj) out + [ -d "${projects[0]}" ] || fail "No .xcodeproj in $PWD to apply the signing settings to" + out=$(python3 - "$1" "${projects[@]}" 2>&1 <<'PY' +import json, os, subprocess, sys +app_id, projects = sys.argv[1], sys.argv[2:] +settings = {'CODE_SIGN_STYLE': 'Manual', 'DEVELOPMENT_TEAM': os.environ['DEVELOPMENT_TEAM'], + 'PROVISIONING_PROFILE_SPECIFIER': os.environ['PROVISIONING_PROFILE_NAME'], + 'CODE_SIGN_IDENTITY': os.environ['CODE_SIGN_IDENTITY']} + +def covers(bundle_id): + if app_id.endswith('*'): + return bundle_id.startswith(app_id[:-1]) + return bundle_id == app_id + +apps, plists = [], {} +for project in projects: + path = os.path.join(project, 'project.pbxproj') + plists[project] = json.loads(subprocess.check_output(['plutil', '-convert', 'json', '-o', '-', path])) + objects = plists[project]['objects'] + for target in objects.values(): + if target.get('isa') != 'PBXNativeTarget' or target.get('productType') != 'com.apple.product-type.application': + continue + configs = [objects[c] for c in objects[target['buildConfigurationList']]['buildConfigurations']] + ids = sorted({c.setdefault('buildSettings', {}).get('PRODUCT_BUNDLE_IDENTIFIER', '') for c in configs}) + apps.append((project, target['name'], configs, ids)) +if not apps: + sys.exit('No application target in %s to apply the signing settings to' % ', '.join(projects)) +chosen = apps if len(apps) == 1 else [a for a in apps if any(covers(i) for i in a[3])] +if not chosen: + found = '; '.join('%s in %s (%s)' % (name, project, ', '.join(i or '?' for i in ids)) for project, name, _, ids in apps) + sys.exit('No application target has the PRODUCT_BUNDLE_IDENTIFIER the provisioning profile covers (%s): %s' % (app_id, found)) +for project, name, configs, _ in chosen: + for config in configs: + build = config['buildSettings'] + # A conditional setting (CODE_SIGN_IDENTITY[sdk=iphoneos*]) would win over the plain one. + for key in [k for k in build if k.split('[')[0] in settings]: + del build[key] + build.update(settings) + print('Signing settings applied to target %s in %s: %s' % (name, project, ', '.join(c['name'] for c in configs))) +for project in sorted({a[0] for a in chosen}): + subprocess.run(['plutil', '-convert', 'xml1', '-o', os.path.join(project, 'project.pbxproj'), '-'], + input=json.dumps(plists[project]).encode(), check=True) +PY + ) || fail "$out" + echo "$out" +} + install_signing() { SIGNING_SET=$(signing_set "$DISTRIBUTION") || fail "DISTRIBUTION \"$DISTRIBUTION\" must be development, ad-hoc (or internal), store or enterprise" select_signing_set @@ -267,9 +327,13 @@ install_signing() { profile_uuid=$(plutil -extract UUID raw -o - "$signing_dir/profile.plist") export DEVELOPMENT_TEAM="$(plutil -extract TeamIdentifier.0 raw -o - "$signing_dir/profile.plist")" export PROVISIONING_PROFILE_NAME="$(plutil -extract Name raw -o - "$signing_dir/profile.plist")" + # The app id the profile covers, without the team prefix: the target to + # sign is the one whose bundle id it matches. + app_id=$(plutil -extract Entitlements.application-identifier raw -o - "$signing_dir/profile.plist") + export PROFILE_BUNDLE_ID="${app_id#"$DEVELOPMENT_TEAM".}" export EXPORT_METHOD="$(detect_export_method "$signing_dir/profile.plist")" check_signing_set "$EXPORT_METHOD" - echo "Signing with '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM, set $SIGNING_SET_USED), export method $EXPORT_METHOD" + echo "Signing with '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM, app id $PROFILE_BUNDLE_ID, set $SIGNING_SET_USED), export method $EXPORT_METHOD" # A Debug archive carries get-task-allow=true, which no distribution profile # grants: the export fails, or an IPA that App Store Connect rejects comes # out. Say so now instead of after the whole build. @@ -313,9 +377,10 @@ build_ipa() { -derivedDataPath "$BUILDER_WORKSPACE/DerivedData" COMPILER_INDEX_STORE_ENABLE=NO) mkdir -p "$BUILDER_WORKSPACE/build" if [ "$USE_SIGNING" = true ]; then - xcodebuild "${args[@]}" DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" CODE_SIGN_STYLE=Manual \ - CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY" \ - PROVISIONING_PROFILE_SPECIFIER="$PROVISIONING_PROFILE_NAME" -archivePath "$BUILDER_WORKSPACE/build/App.xcarchive" archive + # After pod install / expo prebuild / flutter build ios, so the project + # the archive reads exists; select_project left us in the iOS directory. + apply_signing_to_app_target "$PROFILE_BUNDLE_ID" + xcodebuild "${args[@]}" -archivePath "$BUILDER_WORKSPACE/build/App.xcarchive" archive export APP_BUNDLE_ID="$(plutil -extract ApplicationProperties.CFBundleIdentifier raw -o - "$BUILDER_WORKSPACE/build/App.xcarchive/Info.plist")" python3 - "$signing_dir/ExportOptions.plist" <<'PY' import os, plistlib, sys From 896f7eb066d44b4541e9c2c9946ba7a4f334c42a Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 16:57:57 +0200 Subject: [PATCH 64/75] workflow: match the Flutter build line by prefix in the signing test --- internal/workflow/providers_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index d2879e2..b675d08 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -561,7 +561,7 @@ func TestSigningSettingsOnAppTargetOnly(t *testing.T) { if archives == 0 || archives != calls { t.Errorf("%s: %d signed archives but %d calls of apply_signing_to_app_target", name, archives, calls) } - steps := []string{"pod install\n", "flutter build ios --release --no-codesign"} + steps := []string{"pod install\n", "flutter build ios"} if name == "runner.sh" { steps = append(steps, "expo prebuild") } From e921ce7bc3809ab3e5d8eb39e532f04728e975a8 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 17:18:50 +0200 Subject: [PATCH 65/75] ci: say why Codemagic or Bitrise rejected a request --- internal/ci/http.go | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/internal/ci/http.go b/internal/ci/http.go index 254a722..bec9881 100644 --- a/internal/ci/http.go +++ b/internal/ci/http.go @@ -125,7 +125,7 @@ func (a apiClient) requestOnce(ctx context.Context, method, endpoint string, bod } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return &APIError{StatusCode: resp.StatusCode, retryAfter: retryAfter(resp.Header.Get("Retry-After"))} + return &APIError{StatusCode: resp.StatusCode, retryAfter: retryAfter(resp.Header.Get("Retry-After")), message: providerMessage(resp)} } if dest == nil { return nil @@ -146,6 +146,29 @@ func (a apiClient) requestOnce(ctx context.Context, method, endpoint string, bod return nil } +// providerMessage is the reason Codemagic or Bitrise put in an error response +// ("message", "error" or "error_msg"), so a rejected dispatch says why. Only +// that one field is kept, capped, never the whole body. +func providerMessage(resp *http.Response) string { + data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) + if err != nil || !json.Valid(data) { + return "" + } + var body map[string]any + if json.Unmarshal(data, &body) != nil { + return "" + } + for _, key := range []string{"message", "error", "error_msg"} { + if s, ok := body[key].(string); ok && s != "" { + if len(s) > 300 { + s = s[:300] + "…" + } + return fmt.Sprintf("CI API returned HTTP %d: %s", resp.StatusCode, s) + } + } + return "" +} + // downloadURL uses an unauthenticated client, including every redirect hop. // Production artifact URLs must use HTTPS; tests use an injected transport. func downloadURL(ctx context.Context, endpoint string, w io.Writer, transport http.RoundTripper) (int64, error) { From 7092e0ec7e7d638f38df465fc6ad91d49a2e6667 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 18:35:46 +0200 Subject: [PATCH 66/75] 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. --- cmd/builder/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/builder/auth.go b/cmd/builder/auth.go index 9de60b9..b63eb87 100644 --- a/cmd/builder/auth.go +++ b/cmd/builder/auth.go @@ -181,7 +181,7 @@ func runAuthApple(cmd *cobra.Command, _ []string) error { if err := auth.StoreAppleCredentials(creds); err != nil { return err } - fmt.Printf("App Store Connect API key %s verified and saved to the keychain.\n", creds.KeyID) + fmt.Printf("Verified and saved App Store Connect API key %s. Other provider logins are unchanged.\n", creds.KeyID) if os.Getenv("ASC_ISSUER_ID") != "" { fmt.Println("ASC_* environment variables are set and take precedence over this saved login.") } From 1f66ee8ce030b52b8cb9e6aa7f01928292591e5f Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 18:35:46 +0200 Subject: [PATCH 67/75] 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. --- CLAUDE.md | 45 ++++++++++++------------------- cmd/builder/upload.go | 3 --- internal/asc/client.go | 1 - internal/asc/jsonapi.go | 3 --- internal/asc/jwt.go | 5 +--- internal/asc/uploads.go | 1 - internal/distribute/distribute.go | 3 --- 7 files changed, 18 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d36f8d5..7a448ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,35 +198,24 @@ internal/ CLI calls KMP but the runner does not gets no JDK, and vice versa. - **KMP Has No Hot Reload**: shared Kotlin compiles to a native framework at build time, so `dev kmp` only installs, launches and streams output; code changes need `ios build` -- **ASC Client** (`internal/asc`): runs locally, never on the runner. Auth is an ES256 JWT - (15 min, cached, refreshed a minute early) signed with the `.p8` key. JSON:API plumbing is - generic (`Document`/`Resource[A]`, `getOne`/`getAll`/`post`/`patch`); typed helpers exist only - for what the commands use, so the signing resources (bundle IDs, certificates, profiles, - devices) add files in the same package without restructuring. `getAll` follows `links.next`; - 429 retries on every method, 5xx only on idempotent ones (a failed POST may have created the - resource). All waits go through `Client.sleep`, which tests replace, so retry and poll tests - run instantly; status polls (`poller`) grow 1.5× per round up to 4× the base interval. - `*asc.Error` carries the ASC `errors[]` and renders on one line. -- **ASC Credentials**: one JSON secret (`apple-asc-key`) in the keyring/file store, via the - shared `readSecret`/`writeSecret`/`deleteSecret` helpers the CI tokens use. `ASC_ISSUER_ID`, - `ASC_KEY_ID` + `ASC_PRIVATE_KEY`|`ASC_KEY_PATH` take precedence; a partially set environment is - an error, not a fallback. Only `auth apple` prompts; `upload`/`submit` never do. -- **Build Upload**: `buildUploads` → `buildUploadFiles` (returns `uploadOperations`) → PUT each - byte range with its `requestHeaders`, no bearer token → PATCH `uploaded=true` → poll the upload - `state` (COMPLETE/FAILED with `errors[]`) → poll `builds` filtered by app, marketing version and - build number until VALID. No checksum is sent (asc-cli found ASC rejects some encodings). The IPA - must be App Store signed and each upload needs a higher `CFBundleVersion`. +- **ASC Client** (`internal/asc`): runs locally, never on the runner; ES256 JWT (15 min, cached) + from the `.p8`, generic JSON:API plumbing (`getOne`/`getAll`/`post`/`patch`, `getAll` follows + `links.next`). 429 retries on any method, 5xx only off POST; every wait goes through `Client.sleep`. +- **ASC Credentials**: one JSON secret (`apple-asc-key`) in the keyring/file store, via the shared + `readSecret`/`writeSecret`/`deleteSecret` helpers. `ASC_ISSUER_ID`, `ASC_KEY_ID` + + `ASC_PRIVATE_KEY`|`ASC_KEY_PATH` win; a partial environment is an error. Only `auth apple` prompts. +- **Build Upload**: `buildUploads` → `buildUploadFiles` (returns `uploadOperations`) → PUT each byte + range with its `requestHeaders`, no bearer token → PATCH `uploaded=true` → poll the upload `state`, + then `builds` until VALID. The IPA must be App Store signed with an ever-higher `CFBundleVersion`. - **Export Compliance**: a build sits in "Missing Compliance" until `usesNonExemptEncryption` is - answered. `upload --wait` PATCHes it to false when Info.plist says `ITSAppUsesNonExemptEncryption` - false or `--no-encryption` is given; the build must exist first, so without `--wait` it is left - for `submit --no-encryption`. `submit --testflight` refuses to add an unanswered build to groups. -- **Submit Order**: TestFlight is compliance → notes → `betaAppReviewSubmissions` (only when a - chosen group is external and none exists) → add groups. App Store reuses an open - `reviewSubmission` (READY_FOR_REVIEW/UNRESOLVED_ISSUES), skips the item when the version is - already in it, and rewrites ASC 409/422 with a "complete the metadata" hint. -- **Extension Points**: a future `ios release` (upload + TestFlight, automatic build numbers) - composes `distribute.Upload` and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` - for the latest build number; the `pkg/` wrappers do not expose `asc` yet. + answered; `upload --wait` PATCHes it from the plist or `--no-encryption`. The build must exist + first, so without `--wait` it falls to `submit`, which refuses unanswered builds for TestFlight. +- **Submit Order**: TestFlight is compliance → notes → `betaAppReviewSubmissions` (only for a new + external group) → add groups. App Store reuses an open `reviewSubmission`, skips an item the + version is already in, and rewrites ASC 409/422 with a "complete the metadata" hint. +- **Extension Points**: a future `ios release` composes `distribute.Upload` and + `distribute.SubmitTestFlight`, reading `asc.Client.ListBuilds` for the latest build number; the + `pkg/` wrappers do not expose `asc` yet. ## Configuration diff --git a/cmd/builder/upload.go b/cmd/builder/upload.go index 88220f9..606c211 100644 --- a/cmd/builder/upload.go +++ b/cmd/builder/upload.go @@ -45,8 +45,6 @@ func init() { iosCmd.AddCommand(iosUploadCmd) } -// getASCClient builds an App Store Connect client from the saved Apple login -// or the ASC_* environment variables. func getASCClient() (*asc.Client, error) { creds, _, err := auth.GetAppleCredentials() if err != nil { @@ -58,7 +56,6 @@ func getASCClient() (*asc.Client, error) { return asc.NewClient(asc.Credentials{IssuerID: creds.IssuerID, KeyID: creds.KeyID, PrivateKey: creds.PrivateKey}) } -// resolveIPA returns the given path, or the newest IPA in ./dist. func resolveIPA(path string) (string, error) { if path != "" { return path, nil diff --git a/internal/asc/client.go b/internal/asc/client.go index 24c5cdb..6607d37 100644 --- a/internal/asc/client.go +++ b/internal/asc/client.go @@ -184,7 +184,6 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, } } -// sleep waits for d or until ctx is done. func sleep(ctx context.Context, d time.Duration) error { timer := time.NewTimer(d) defer timer.Stop() diff --git a/internal/asc/jsonapi.go b/internal/asc/jsonapi.go index 5dfc56a..fb6d202 100644 --- a/internal/asc/jsonapi.go +++ b/internal/asc/jsonapi.go @@ -87,7 +87,6 @@ func (r Relationships) One(name string) (Linkage, bool) { // pageLimit is the largest page App Store Connect serves. const pageLimit = 200 -// getOne fetches a single resource. func getOne[A any](ctx context.Context, c *Client, path string, query url.Values) (*Resource[A], error) { var doc Document[Resource[A]] if err := c.Get(ctx, path, query, &doc); err != nil { @@ -129,7 +128,6 @@ func getPage[A any](ctx context.Context, c *Client, path string, query url.Value return doc.Data, nil } -// post creates a resource and decodes the created one. func post[Req, Resp any](ctx context.Context, c *Client, path string, req Resource[Req]) (*Resource[Resp], error) { var doc Document[Resource[Resp]] if err := c.Post(ctx, path, Document[Resource[Req]]{Data: req}, &doc); err != nil { @@ -138,7 +136,6 @@ func post[Req, Resp any](ctx context.Context, c *Client, path string, req Resour return &doc.Data, nil } -// patch updates a resource and decodes the updated one. func patch[Req, Resp any](ctx context.Context, c *Client, path string, req Resource[Req]) (*Resource[Resp], error) { var doc Document[Resource[Resp]] if err := c.Patch(ctx, path, Document[Resource[Req]]{Data: req}, &doc); err != nil { diff --git a/internal/asc/jwt.go b/internal/asc/jwt.go index 19138a2..f952ad7 100644 --- a/internal/asc/jwt.go +++ b/internal/asc/jwt.go @@ -2,10 +2,7 @@ // // It runs on the developer's machine (or a CI agent) rather than on the macOS // runner, authenticating with an App Store Connect API key: no Mac, altool or -// Transporter is involved. The client covers the JSON:API plumbing (auth, -// errors, pagination, retries) generically and adds typed helpers for the -// resources Builder needs: apps, builds, build uploads, TestFlight groups and -// App Store review submissions. +// Transporter is involved. package asc import ( diff --git a/internal/asc/uploads.go b/internal/asc/uploads.go index 8c8bc14..b2fe948 100644 --- a/internal/asc/uploads.go +++ b/internal/asc/uploads.go @@ -153,7 +153,6 @@ func toBuildUploadFile(r Resource[buildUploadFileAttributes]) BuildUploadFile { } } -// utiFor maps the archive extension to Apple's uniform type identifier. func utiFor(fileName string) string { if strings.EqualFold(filepath.Ext(fileName), ".pkg") { return "com.apple.pkg" diff --git a/internal/distribute/distribute.go b/internal/distribute/distribute.go index 7da0911..bfffd15 100644 --- a/internal/distribute/distribute.go +++ b/internal/distribute/distribute.go @@ -58,14 +58,12 @@ func distributionLink(appID string) string { return "https://appstoreconnect.apple.com/apps/" + appID + "/distribution" } -// logf writes progress when w is set. func logf(w io.Writer, format string, args ...any) { if w != nil { fmt.Fprintf(w, format+"\n", args...) } } -// pollInterval applies the default when opts leave it zero. func pollInterval(d time.Duration) time.Duration { if d <= 0 { return 15 * time.Second @@ -108,7 +106,6 @@ func pickBuild(ctx context.Context, client *asc.Client, appID, version, buildNum // setCompliance answers the export compliance question with "no non-exempt // encryption" when the caller asked for it and the build is still unanswered. -// It returns what happened for the result. func setCompliance(ctx context.Context, client *asc.Client, log io.Writer, build *asc.Build, exempt bool) (string, error) { switch { case build.UsesNonExemptEncryption != nil: From 9cd22f3eadf52508c8099c57bb5ab438cd19da0e Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 18:36:33 +0200 Subject: [PATCH 68/75] auth: drop the unrelated tail from the Apple key message --- cmd/builder/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/builder/auth.go b/cmd/builder/auth.go index b63eb87..b7be08a 100644 --- a/cmd/builder/auth.go +++ b/cmd/builder/auth.go @@ -181,7 +181,7 @@ func runAuthApple(cmd *cobra.Command, _ []string) error { if err := auth.StoreAppleCredentials(creds); err != nil { return err } - fmt.Printf("Verified and saved App Store Connect API key %s. Other provider logins are unchanged.\n", creds.KeyID) + fmt.Printf("Verified and saved App Store Connect API key %s.\n", creds.KeyID) if os.Getenv("ASC_ISSUER_ID") != "" { fmt.Println("ASC_* environment variables are set and take precedence over this saved login.") } From adf365505eaf8b3e44b272a31fcb012a6ac39759 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 18:40:13 +0200 Subject: [PATCH 69/75] 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. --- README.md | 1746 ++++++++++++++++++++++++++--------------------------- 1 file changed, 873 insertions(+), 873 deletions(-) diff --git a/README.md b/README.md index 0be4ac9..dda4cda 100644 --- a/README.md +++ b/README.md @@ -1,873 +1,873 @@ -# Builder - -Build and develop iOS apps from Windows, Linux, or any platform. - -Builder is a CLI tool for iOS development without a Mac. It uses GitHub Actions (default), Codemagic, or Bitrise for remote builds and [MobAI](https://mobai.run) for on-device development. - -![Builder Demo](assets/ios-builder-demo.gif) - -## Features - -- **Build from anywhere**: Build iOS apps via GitHub Actions, Codemagic, or Bitrise -- **Independent provider logins**: Stay signed in to all three and choose where each build runs -- **Try it on a simulator**: Use your build on an iOS simulator from Windows or Linux -- **Flutter & React Native dev tools**: Hot reload on real iOS devices from Windows/Linux -- **Simple setup**: One command to add the workflow to your repo -- **Code signing**: Optional signing with your certificate and provisioning profile -- **TestFlight and App Store**: Upload builds and submit them for review through the App Store Connect API, from any platform -- **Device integration**: Install and run apps via MobAI - -## How It Works - -``` -Your Repository GitHub Actions (macOS) - └─ .github/workflows/ └─ ios-build.yml - └─ ios-build.yml ├─ Check out the snapshot - ├─ Build with Xcode -builder ios build ───────────────────► Upload IPA artifact - │ pushes a snapshot of - │ your working tree - └─ Downloads IPA ◄─────────────── artifact: ipa -``` - -`builder ios build` builds what is on disk, not your last commit: uncommitted -and untracked files are included, so you can try a change without committing -it. The snapshot is a throwaway commit pushed to a hidden ref that is deleted -when the build finishes; no branch is created and nothing is committed on your -behalf. `.gitignore` still applies, so ignored files such as `.env` or -`GoogleService-Info.plist` are absent from the build. - -## Quick Start - -### 1. Authenticate with GitHub - -```bash -builder auth github -``` - -### 2. Initialize (in your project directory) - -```bash -cd your-ios-project -builder init -``` - -This detects your GitHub repo, creates the workflow files, and offers to commit, push, and trigger your first build - all interactively. - -### 3. Build - -```bash -builder ios build -``` - -The CLI triggers the workflow and downloads the IPA to `./dist/`. - -### 4. Try it on a simulator (optional) - -```bash -builder ios share -``` - -Builds the working tree for the iOS simulator and makes that simulator usable -from the [MobAI](https://mobai.run) app, so you can tap through a build without -a Mac. It shows up under CI Devices, stays available while you are using it, and -closes when you release it there or leave it unused (30 minutes by default, use -`--duration` to change). A coding agent connected to MobAI (Claude Code, Codex, -Cursor) can drive the simulator the same way. - -Free with any MobAI account, on [MobAI 3.0 or later](https://mobai.run). Needs -a `MOBAI_API_KEY` repository secret: create the key in the MobAI app under -Account → API Keys, then: - -```bash -gh secret set MOBAI_API_KEY -``` - -### Triggering from git only - -Where the GitHub API is not reachable, both workflows can also be started by -pushing a tag. Commit the tree you want built, then: - -```bash -git tag ios-build/my-build && git push origin ios-build/my-build # IPA build -git tag ios-share/my-build && git push origin ios-share/my-build # simulator -``` - -The run is named after the tag. Build settings come from `builder.json` in the -tagged commit (`ios.path`, `ios.scheme`, `ios.signing`, `ios.configuration`, -`flutter.version`, `kmp.jdkVersion`), the simulator stays available for the -default 30 minutes, and the tag is deleted when the run ends. The IPA is -attached to the run as an artifact. A tag carries no flags, so a tagged IPA -build cannot pick a [profile](#build-profiles) per run; it applies the profile -named by `defaultProfile`, if there is one. The simulator build takes no -profile at all. - -## Additional macOS Providers - -GitHub Actions remains the default, so existing commands continue to work. Add -Codemagic and Bitrise without logging out of GitHub. First follow the -[app creation and repository connection guide](docs/provider-setup.md) to create -each provider app, authorize GitHub access, and find its app ID: - -```bash -builder auth codemagic -builder auth bitrise -builder auth status -builder init --provider codemagic --app-id YOUR_APP_ID --branch main -builder init --provider bitrise --app-id YOUR_APP_SLUG --branch main -builder ios build --provider codemagic -builder ios build --provider bitrise -``` - -`init` writes `codemagic.yaml` or `bitrise.yml` at the repo root plus the shared -runner script `.builder/ci/runner.sh`. Commit them to the configured branch -and connect the same repository to each provider before building. See -[provider setup, signing, simulator sessions, and free allowances](docs/providers.md). - -## Supported Frameworks - -| Framework | iOS Path | Auto-detected | -|-----------|----------|---------------| -| Native iOS/Swift | `.` (root) | Yes | -| React Native | `ios/` | Yes | -| Expo (ejected) | `ios/` | Yes | -| Flutter | `ios/` | Yes | -| Kotlin Multiplatform | `iosApp/` | Yes | -| Cordova/Ionic | `platforms/ios/` | Yes | - -## Installation - -### Windows - -Download `builder-windows-amd64.exe` from [Releases](https://github.com/MobAI-App/ios-builder/releases), rename it to `builder.exe`, and add it to PATH. - -### Homebrew (macOS/Linux) - -```bash -brew install mobai-app/tap/ios-builder -``` - -The formula is named `ios-builder`; the command it installs is `builder`. - -### macOS/Linux/WSL - -```bash -curl -sSL https://raw.githubusercontent.com/MobAI-App/ios-builder/main/install.sh | bash -``` - -### From Source - -```bash -git clone https://github.com/MobAI-App/ios-builder.git -cd ios-builder -go build -o builder ./cmd/builder -``` - -## Commands - -```bash -# Setup -builder auth github # Authenticate with GitHub -builder auth codemagic # Authenticate with Codemagic (also: bitrise) -builder auth apple # Save an App Store Connect API key -builder auth status # Show which providers you are signed in to -builder auth logout [name] # Remove stored credentials (github, codemagic, bitrise, apple) -builder init # Set up workflows in current repo -builder update # Update builder to the latest release - -# Building (builds the working tree, including uncommitted changes) -builder ios build # Trigger build and download IPA to ./dist/ -builder ios build --unsigned # Build without code signing (if signing is configured) -builder ios build --provider codemagic # Build on another provider (also: bitrise) -builder ios build --profile production # Build with a profile from builder.json - -# Simulator (free, needs a MOBAI_API_KEY secret) -builder ios share # Try the build on a simulator in the MobAI app -builder ios share --duration 1h # Keep it available longer while unused - -# Development (requires MobAI) -builder dev flutter # Flutter hot reload with file watching -builder dev flutter --no-watch # Disable automatic file watching -builder dev flutter --no-attach # Print flutter attach command instead of running it -builder dev rn # React Native hot reload (short for: dev react-native) -builder dev kmp # Kotlin Multiplatform install + launch (alias: kotlin) -builder dev kmp --logs # Also stream the app's output -builder dev flutter --skip-install --bundle-id # Use already installed app -builder dev rn --metro-port 8082 # Use custom Metro port - -# MobAI (used by the dev commands; handy for troubleshooting) -builder mobai ping # Check MobAI connectivity -builder mobai install # Install an IPA on the device -builder mobai run-debug # Launch an app with the debugger attached -builder mobai forward # Forward a device port - -# Code signing (automatic mode needs builder auth apple) -builder signing setup --devices-from-mobai # development: certificate, devices, profile, GitHub secrets, no portal -builder signing setup --distribution store # Apple Distribution certificate + App Store profile -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision # Upload your own files -builder ios build --profile store # Signs with the set; provisions it first when missing -builder signing csr # Manual path: create a private key + certificate signing request -builder signing p12 # Manual path: assemble a .p12 from the key and Apple's certificate - -# TestFlight and App Store (needs builder auth apple) -builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing -builder ios submit --testflight --group "Beta Testers" --notes "What to test" -builder ios submit --app-store --release after-approval # Submit the version for App Review -``` - -Every `upload`/`submit` command takes `--json` for machine-readable output and -never prompts, so agents and CI jobs can drive them. - -## Configuration - -`builder.json`: - -```json -{ - "project": "MyApp", - "platform": "ios", - "github": { - "owner": "username", - "repo": "my-ios-app" - }, - "ios": { - "path": "ios", - "scheme": "", - "bundleId": "com.example.app", - "configuration": "Debug" - }, - "profiles": { - "development": { "distribution": "development" }, - "store": { "distribution": "store" } - }, - "mobai": { - "url": "http://localhost:8686", - "device_id": "" - }, - "flutter": { - "watch": { - "dirs": ["lib"], - "patterns": [".dart"], - "ignore": [".g.dart", ".freezed.dart"], - "debounce": 100 - } - } -} -``` - -### iOS Build Configuration - -| Field | Description | Default | -|-------|-------------|---------| -| `ios.path` | Path to the Xcode project relative to the repo root | detected by `init` | -| `ios.scheme` | Xcode scheme to build | auto-detected | -| `ios.bundleId` | App bundle identifier, used by `signing setup` | detected by `init` when the project has one app target; else saved by `signing setup` | -| `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` | -| `ios.signing` | Legacy: sign builds that select no profile, with the unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and `IOS_PROVISIONING_PROFILE` secrets. Profiles ignore it; use `distribution` there | `false` | - -### Build Profiles - -Profiles are named sets of build settings, in the spirit of `eas.json`, selected -with `--profile` on `ios build`: - -```json -{ - "ios": { "path": "ios", "bundleId": "com.example.app" }, - "defaultProfile": "development", - "profiles": { - "development": { "distribution": "development" }, - "preview": { "distribution": "internal", - "env": { "API_URL": "https://staging.example.com" } }, - "production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic" } - } -} -``` - -```bash -builder ios build --profile preview -``` - -| Field | Description | -|-------|-------------| -| `distribution` | The only signing setting: `development`, `ad-hoc` (or `internal`, the same thing), `store` or `enterprise`. The build signs with that distribution's [signing set](#code-signing) and its provisioning profile must be of that type; the IPA is exported with the matching method. Omitted means an unsigned build | -| `configuration` | Overrides the derived configuration: `Debug` for `development`, `Release` for every other distribution, `ios.configuration` for unsigned profiles | -| `scheme` | Overrides `ios.scheme` | -| `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) | -| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them | - -How a build's settings are resolved: - -- Without `--profile`, the profile named by `defaultProfile` applies. With - neither, the top-level `ios.*` and `provider` settings are used exactly as - before, so existing projects are unaffected. -- A profile only overrides the fields it sets; everything else comes from the - top level. An unknown profile name is an error that lists the available ones. -- `--unsigned` and `--provider` on the command line override the profile. -- The resolved settings (profile, configuration, scheme, signing set, provider, - env names) are printed before anything is dispatched. -- Profiles apply to `ios build` only. `ios share` takes no `--profile`: - simulator builds are always Debug and unsigned, and use `ios.scheme` and the - top-level `provider` (or `--provider`). - -**`env` values are build-time configuration, not secrets.** They are stored in -`builder.json`, sent to the CI provider as plain workflow inputs, and visible in -the run's inputs and logs. Keep tokens and passwords in the provider's secrets -instead (`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets -guide](docs/provider-secrets.md)); the build reads those as environment -variables too. Names the runner owns are rejected: its own parameters -(`SCHEME`, `CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing -secrets, `PATH`, `HOME`, `DEVELOPER_DIR`, and anything starting with `GITHUB_`, -`RUNNER_`, `CM_`, `BITRISE_` or `BUILDER_`. - -Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow -file from this version of Builder, which declares a `profile` input; an older -committed workflow rejects the dispatch. Run `builder init` again to refresh -`.github/workflows/ios-build.yml` (or `builder init --provider ...` for -`runner.sh`) in a project set up earlier, then commit and push it to the -default branch. - -### MobAI Configuration - -| Field | Description | Default | -|-------|-------------|---------| -| `mobai.url` | MobAI API URL | `http://localhost:8686` | -| `mobai.device_id` | Preferred device ID (uses first available if empty) | `""` | - -**WSL users**: MobAI runs on Windows, and WSL has its own network by default. On -Windows 11, turn on -[mirrored networking](https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking) -and builder reaches MobAI on the default `http://localhost:8686`. See -[Using Builder from WSL](docs/wsl.md) for the steps, and for the setup without -mirrored networking. - -### Flutter File Watcher - -| Field | Description | Default | -|-------|-------------|---------| -| `flutter.watch.dirs` | Directories to watch | `["lib"]` | -| `flutter.watch.patterns` | File patterns to match | `[".dart"]` | -| `flutter.watch.ignore` | Patterns to ignore | `[".g.dart", ".freezed.dart"]` | -| `flutter.watch.debounce` | Debounce delay in ms | `100` | - -## Code Signing - -By default, builds are unsigned. A signed build needs a certificate and a -provisioning profile — and despite what many guides claim, **you do not need a -Mac to create either one**, nor a tour of the Apple Developer portal. Signing -is configured per [build profile](#build-profiles) with one field, -`distribution`, and `builder signing setup` produces the material for it -through the App Store Connect API (or takes your own files). - -You need a paid [Apple Developer Program](https://developer.apple.com/programs/) -membership — Apple only issues certificates to paid accounts. (Without one, -build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free -Apple ID.) - -### Profiles and signing sets - -Each distribution has its own set of three GitHub secrets, so a development set -for your devices and a store set for TestFlight live side by side: - -| `distribution` | Certificate, profile | Secrets | -|----------------|----------------------|---------| -| `development` | Apple Development, iOS App Development (devices required) | `IOS_CERTIFICATE_DEVELOPMENT`, `IOS_CERTIFICATE_PASSWORD_DEVELOPMENT`, `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | -| `ad-hoc` or `internal` | Apple Distribution, Ad Hoc (devices required) | `IOS_CERTIFICATE_AD_HOC`, `IOS_CERTIFICATE_PASSWORD_AD_HOC`, `IOS_PROVISIONING_PROFILE_AD_HOC` | -| `store` | Apple Distribution, App Store | `IOS_CERTIFICATE_STORE`, `IOS_CERTIFICATE_PASSWORD_STORE`, `IOS_PROVISIONING_PROFILE_STORE` | -| `enterprise` | In-house (portal only) | `IOS_CERTIFICATE_ENTERPRISE`, `IOS_CERTIFICATE_PASSWORD_ENTERPRISE`, `IOS_PROVISIONING_PROFILE_ENTERPRISE` | - -A build with `--profile ` signs with the set of that profile's -`distribution`; `configuration` follows it (`Debug` for `development`, -`Release` otherwise) unless the profile sets one. The runner checks that the -profile in the set is of the requested type and fails by name before compiling -anything, and a distribution profile refuses a `Debug` configuration. On -Codemagic and Bitrise the same names are variables you add in the dashboard, -see the [secrets guide](docs/provider-secrets.md). - -### Create an App Store Connect API key - -Automatic signing, `ios upload`, `ios submit` and `ios release` all use one -App Store Connect API key. You create it once, in the browser: - -1. Sign in to [App Store Connect](https://appstoreconnect.apple.com) as the - Account Holder or an Admin (only they can create team keys). -2. Go to **Users and Access → Integrations → App Store Connect API**, tab - **Team Keys**, and press **+** (or **Generate API Key**). -3. Name it (for example `Builder`) and choose the role **Admin**. **App - Manager** works too if you also tick *Access to Certificates, Identifiers & - Profiles*; a **Developer** key can upload builds but cannot create - certificates. -4. Press **Generate**, then **Download API Key**. The `AuthKey_.p8` - file downloads once; keep it somewhere private, never in the repo. -5. Note the **Issuer ID** at the top of the page and the **Key ID** in the - row of your key. - -Then save it in your keychain: - -```bash -builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key ~/Downloads/AuthKey_ABC123DEFG.p8 -``` - -`builder auth status` shows it, `builder auth logout apple` removes it. On a -machine without a keychain (CI, a coding agent), set `ASC_ISSUER_ID`, -`ASC_KEY_ID` and `ASC_KEY_PATH` (or `ASC_PRIVATE_KEY` with the file's contents) -instead. - -### `builder signing setup` - -```bash -builder auth apple # once: save the App Store Connect API key -builder signing setup --devices-from-mobai # development set for the devices MobAI sees -builder signing setup --distribution store # store set for TestFlight / App Store -``` - -Without files, `setup` works through the App Store Connect API for the given -`--distribution` (default `development`; `--name ` reads it from an -existing profile). The key needs the **Admin** role (or App Manager plus -*Access to Certificates, Identifiers & Profiles*): Developer-role keys cannot -create certificates. It then: - -1. Registers the **App ID** if the bundle identifier is not on the account yet. - The bundle ID comes from `--bundle-id`, `ios.bundleId` in `builder.json` - (which `init` fills when the Xcode project has a single app target), or the - newest IPA in `./dist/`; in a terminal it asks as a last resort. -2. Issues a **certificate** — Apple Development for `development`, Apple - Distribution for `ad-hoc` and `store` — for a private key generated on your - machine (`ios-signing-.key`, or `--key` to reuse one from - `signing csr`; a `ios-signing.key` from an earlier version is picked up - too). A valid certificate on the account is reused only when its private - key is here, because that is the only way to build the `.p12`; otherwise a - new one is issued. Nothing is ever revoked: when Apple's limit (2 - Development, 3 Distribution) is hit, the error names it and points at the - portal. -3. Registers **devices** from `--device ` (repeatable) and - `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has - connected; simulators and cloud farm devices are skipped). Development and - ad-hoc profiles cover every enabled iOS device on the account, so with none - given and none registered the command stops and says so. Store profiles - take no devices. Apple allows 100 devices per membership year and never - frees a slot; that error is passed through too. -4. Creates the **profile** `Builder `. An existing - one is reused while it is `ACTIVE`, unexpired and still lists exactly this - certificate and these devices; otherwise it is deleted and recreated, and - the summary says why (`invalid`, `expired`, `certificate changed`, `devices - changed`, `forced`). -5. Writes `ios-signing-.key` (when generated), - `ios-signing-.p12` and `Builder--.mobileprovision` to `--out-dir` (default `.`), uploads the three secrets - of the set to GitHub, and writes the build profile in `builder.json`: - `--name` (default: the distribution name) with `"distribution": - ""`. Other fields of an existing profile are kept; a - different `distribution` in it is replaced, and the command says so. - `defaultProfile` is not touched: point it at the profile for a plain - `ios build` to use it, or pass `--profile`. An `--out-dir` other than `.` - is recorded as `signing.dir` (as typed, `~` included), so a later - `ios build --profile` that has to provision a set finds the certificate's - key there instead of asking Apple for a second certificate, which it - refuses. -6. Prints the three secret names and where their values come from — the - `.p12` base64-encoded, the password, the `.mobileprovision` base64-encoded - — every time, so the same set can be pasted into Codemagic or Bitrise, - following the [secrets guide](docs/provider-secrets.md). Builder cannot - check those providers' secrets before a build, so `ios build` only reminds - you of this command when the profile signs there. - -The upload goes to the repository in `builder.json`, always. When it fails (no -GitHub login, or a token that cannot write secrets) the error is printed and -the command carries on: files, values and the build profile are written and -shown anyway, and it exits non-zero at the end so a script notices. `--json` -reports the same in `github_upload` (`ok` or the error). - -The command shows its plan and asks once before creating anything; `--yes` -skips that (required without a terminal), and then the `.p12` password is -generated and printed once unless `--password` is given. `--json` prints the -result as JSON with progress on stderr. Keep the written files out of git. -Run it again whenever you like: it reports what it found and recreates only -what is missing, expired, invalid or changed — add a device, re-run, rebuild. -`--force` issues a fresh certificate and profile regardless. - -With `--certificate` and `--profile`, `setup` takes your own files instead — a -`.p12` (from Keychain Access, or [assembled here](#manual-path-through-the-apple-developer-portal)) -and a `.mobileprovision` — reads the distribution out of the profile -(development, ad-hoc, store or enterprise; this is the only way in for -enterprise), uploads that set, prints its names and values, and writes the -build profile the same way: - -```bash -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision -``` - -### Provisioning from `ios build` - -`builder ios build --profile ` checks, before dispatching to GitHub, -that the repository holds all three secrets of the profile's set. When any is -missing and an App Store Connect key is saved, it runs the same provisioning as -`signing setup` without prompts, uploads the set and then builds. A development -or ad-hoc profile needs at least one registered device; with none, the build -stops and points at `builder signing setup --distribution development ---devices-from-mobai`. Without an Apple key the build stops before anything is -pushed and names both ways out: `builder auth apple`, or `builder signing setup ---certificate ... --profile ...`. `--unsigned` skips all of this, and -Codemagic/Bitrise builds skip the check (no secrets API): their runner -reports a missing set itself. - -### Legacy: `ios.signing` without profiles - -A project set up before build profiles has `ios.signing: true` and the -unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and -`IOS_PROVISIONING_PROFILE` secrets. Builds that select no profile still sign -with those, whatever the profile type, exactly as before; `setup` never -touches them. Profiles ignore `ios.signing` and read their own set. - -### Manual path through the Apple Developer portal - -The `.p12` certificate is normally created through Keychain Access, but Builder -does the same thing itself: it generates the private key and certificate -signing request, and assembles the `.p12` from the certificate Apple issues. - -#### 1. Create a certificate signing request - -```bash -builder signing csr -``` - -This asks for your name and email and writes two files to the current -directory: `ios-signing.key` (your private key) and `ios-signing.csr`. Keep -the key wherever suits you — just don't commit it (add it to `.gitignore`; -gitignored files are also excluded from build snapshots). - -#### 2. Create the certificate - -1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal -2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc). TestFlight and App Store uploads need **Apple Distribution** together with an App Store profile in step 4 -3. Upload `ios-signing.csr` and download the resulting `.cer` file - -#### 3. Assemble the .p12 - -```bash -builder signing p12 --certificate development.cer --key ios-signing.key -``` - -This combines the key and certificate into `ios-signing.p12` (`--out` to name -it), protected by a password you choose — byte-for-byte the same kind of file -Keychain Access exports, and usable anywhere one is: `builder signing setup`, -Sideloadly, AltStore, or importing it on a Mac. Keep it, and don't commit it. - -#### 4. Create a provisioning profile - -On the portal: - -1. **Identifiers** → register an App ID matching your app's bundle identifier -2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page) -3. **Profiles** → create an **iOS App Development** (or Ad Hoc, App Store) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file - -The profile type decides the distribution the set is written to and the method -the IPA is exported with: development, ad-hoc, enterprise or store. - -#### 5. Upload the signing secrets - -```bash -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision -``` - -You can also skip step 3 and hand `setup` the `.cer` together with the key — -`builder signing setup --certificate development.cer --key ios-signing.key ---profile MyApp.mobileprovision` — and it assembles the `.p12` on the way, -saving it as `ios-signing-.p12`. Then `builder ios build ---profile `; `--unsigned` skips signing for one build. - -## TestFlight and App Store - -Builder uploads builds to App Store Connect and submits them to TestFlight or -App Review through the App Store Connect API, from Windows, Linux or macOS. No -Transporter, `altool` or Xcode is involved, and the API key never leaves your -machine: the CI runner only builds and signs, the upload happens locally from -the IPA in `./dist/`. - -You need: - -- A paid [Apple Developer Program](https://developer.apple.com/programs/) - membership and an app record in App Store Connect for your bundle ID (step 2 - below; the API cannot create it) -- An IPA signed with an **Apple Distribution** certificate and an **App Store** - provisioning profile: `builder signing setup --distribution store` creates - both, stores them as the `STORE` signing set and writes a `store` build - profile, or pick those types on the portal in the manual path. An IPA signed - for development is rejected at upload. -- `builder ios build --profile store` (see [Build Profiles](#build-profiles)): - a `store` profile builds `Release` and signs with that set; a plain `ios - build` is Debug and unsigned, which is what the dev commands expect, not - what you want to ship. -- An App Store Connect API key saved with `builder auth apple`, see - [Create an App Store Connect API key](#create-an-app-store-connect-api-key). - -### 1. Save the API key - -`builder auth apple` as described in -[Create an App Store Connect API key](#create-an-app-store-connect-api-key). -Flags you leave out are prompted for; Builder verifies the key against App -Store Connect before storing it. - -### 2. Create the app record - -App Store Connect only accepts uploads for an app it already knows, and the API -cannot create one. Once, in the browser: - -1. Register the bundle ID first: `builder signing setup --distribution store` - does it (or **Certificates, Identifiers & Profiles → Identifiers → +** on - the developer portal). -2. Open [App Store Connect → My Apps](https://appstoreconnect.apple.com/apps), - press **+ → New App**, pick **iOS**, a name, the primary language, your - bundle ID from the list, and any SKU (an internal string, e.g. the bundle - ID). Press **Create**. - -Nothing else on the record is needed for TestFlight. App Store review needs the -rest of the metadata (screenshots, description, privacy policy) filled in there. - -### 3. Upload the build - -```bash -builder ios build --profile store # produces a signed dist/*.ipa -builder ios upload --wait -``` - -`upload` reads the bundle ID, version and build number from the newest IPA in -`./dist/` (or `--ipa `), finds the app, uploads the archive in chunks -and, with `--wait`, follows App Store Connect until the build has finished -processing and prints its build ID and TestFlight link. Without `--wait` it -returns as soon as Apple has the file. - -Two things Apple checks on every upload: - -- **Build numbers must increase.** A second upload with the same - `CFBundleVersion` for the same version is rejected (`ITMS-90189`), so bump - it before rebuilding. -- **Export compliance.** A build shows as *Missing Compliance* in TestFlight - until you say whether it uses non-exempt encryption. If your Info.plist sets - `ITSAppUsesNonExemptEncryption` to `false`, `upload --wait` answers that - automatically; otherwise pass `--no-encryption` (here or to `submit`) when - your app only uses standard iOS encryption. - -### 4. Distribute to TestFlight - -```bash -builder ios submit --testflight --group "Beta Testers" --notes "New login flow" -``` - -This takes the newest processed build (or `--build-number N`), sets the *What -to Test* notes and adds it to the named groups (`--group` repeats). Internal -groups get the build immediately; the first external group triggers Apple's -beta review, which Builder submits for you (`--wait` follows the decision). Run -it without `--group` to see the build and the groups the app has. - -### 5. Submit to the App Store - -```bash -builder ios submit --app-store --release after-approval -``` - -Builder finds or creates the App Store version matching the IPA's marketing -version (or `--version X.Y.Z`), attaches the build, sets the release type -(`manual` or `after-approval`) and submits it for review. The version's -metadata — description, screenshots, age rating, pricing, privacy — must -already be complete: App Store Connect refuses the submission otherwise and -Builder prints Apple's reasons verbatim. Builder does not manage metadata, -screenshots or in-app purchases; fill them in App Store Connect, or on a Mac -with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of -the `buildUploads` API also proved that the Mac-free upload path works and -served as the reference for Builder's implementation. - -## Installing the IPA - -Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account). - -## Development on Windows/Linux - -Builder supports hot reload for Flutter and React Native on Windows/Linux using [MobAI](https://mobai.run) for iOS device control. This allows you to develop iOS apps without a Mac. - -## Flutter Development - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` - This creates an IPA in `./dist/` -3. Start development with hot reload: - ```bash - builder dev flutter - ``` - Builder installs the IPA through MobAI and asks whether to re-sign it. Re-signing requires an iCloud account - we highly recommend creating a new one at [icloud.com](https://icloud.com) instead of using your primary account. A re-signed app gets a new bundle ID with a team ID suffix (e.g., `com.example.myapp.TEAMID`); Builder prefills it in the prompt that follows. - -### Subsequent Runs - -Once the app is installed, skip the install step: -```bash -builder dev flutter --skip-install --bundle-id com.example.myapp.TEAMID -``` - -### File Watching - -By default, `builder dev flutter` watches for Dart file changes and automatically triggers hot reload. When flutter attach connects, it also sends an initial hot restart to ensure your latest code is running. - -- **Automatic hot reload**: Edit a `.dart` file and save - hot reload triggers automatically -- **Generated files ignored**: Files like `.g.dart` and `.freezed.dart` are ignored by default -- **Configurable**: Customize watched directories, patterns, and debounce via `builder.json` - -To disable file watching: -```bash -builder dev flutter --no-watch -``` - -To print the `flutter attach` command instead of running it (useful for IDE integration): -```bash -builder dev flutter --no-attach -``` - -### When to Rebuild - -- **Native code changes** (Swift, Objective-C, Podfile, native dependencies): Run `builder ios build` and reinstall -- **Dart code changes only**: No rebuild needed - file watcher triggers hot reload automatically - -If you don't see your recent Dart changes after launching, press `R` in the terminal to perform a hot restart. - -### Troubleshooting - -**App won't launch / connection error** -- Close the app on your device before running `builder dev flutter` -- Reconnect the device (unplug/replug USB) -- Restart MobAI -- Run `builder mobai ping` to verify connection - -**"No devices found" error** -- Ensure MobAI is running and device is connected -- Only physical iOS devices are supported (no simulators) - -**Hot reload not working** -- Make sure you're using the correct bundle ID (with team ID suffix) -- Try hot restart with `R` key -- Check that MobAI shows the device as connected - -**File watcher not triggering** -- Ensure you're editing files in watched directories (default: `lib/`) -- Check if the file matches watch patterns (default: `.dart`) -- Generated files (`.g.dart`, `.freezed.dart`) are ignored by default -- Try running without `--no-watch` flag - -## React Native Development - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` -3. Start development with hot reload: - ```bash - builder dev rn - ``` - This will: - - Start Metro bundler if not running - - Install the IPA on your device (with optional re-signing) - - Launch the app with Metro URL configured automatically - -### Subsequent Runs - -Once the app is installed: -```bash -builder dev rn --skip-install --bundle-id com.example.myapp.TEAMID -``` - -### Custom Metro Port - -If port 8081 is in use: -```bash -builder dev rn --metro-port 8082 -``` - -### When to Rebuild - -- **Native code changes** (Swift, Objective-C, Podfile, native modules): Run `builder ios build` and reinstall -- **JavaScript changes only**: No rebuild needed - Metro handles it automatically - -### Troubleshooting - -**Metro not starting** -- Ensure Node.js and React Native CLI are installed -- Try starting Metro manually: `npx react-native start` - -**App not connecting to Metro** -- Device must be on the same WiFi network as the computer running Metro -- Check that Metro is running and accessible -- Verify the Metro port is correct (default: 8081) -- On WSL with mirrored networking, the Hyper-V firewall blocks the phone from reaching Metro by default; see [Using Builder from WSL](docs/wsl.md#react-native) for the firewall rule - -**Hot reload not working** -- Shake device or press `d` in Metro terminal to open dev menu -- Enable "Fast Refresh" in dev menu -- Try reloading with `r` in Metro terminal - -## Kotlin Multiplatform Development - -KMP iOS apps build and run on a device like any other project, with one -difference: **there is no hot reload.** Shared Kotlin is compiled into a native -framework at build time, so there is no runtime to swap code into — every code -change needs a rebuild. - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` -3. Install and launch it on the device: - ```bash - builder dev kmp - ``` - -`builder init` detects Kotlin Multiplatform projects by looking for the -multiplatform Gradle plugin in the root and module build files, and asks which -JDK the CI build should use (default 17): - -```json -{ - "kmp": { "jdkVersion": "17" } -} -``` - -On CI, the iOS app is built with `xcodebuild`, whose run script phase (or -CocoaPods) invokes Gradle to compile the shared framework — which is why the -JDK version matters. Gradle output is cached between builds. - -### When to Rebuild - -Every change to Kotlin or Swift code needs `builder ios build` followed by -`builder dev kmp` again. Use `--skip-install --bundle-id ` to relaunch an -app that is already installed. - -### Troubleshooting - -**Build fails with "Unsupported class file major version" or a Gradle JDK error** -- The project needs a different JDK than the default: set `kmp.jdkVersion` in `builder.json` to match what the project uses locally - -**Build fails with "SDK does not contain 'libarclite'"** -- An old Kotlin/Native version against a newer Xcode; upgrade the Kotlin plugin in Gradle - -**App launches then immediately exits** -- Launch with `builder dev kmp --logs` to see the device output - -## Build Limits - -Free allowances belong to each provider account and depend on the plan and -machine. As published in September 2026: Codemagic personal accounts include -500 macOS M2 minutes per month; Bitrise Hobby includes 300 credits. GitHub has -separate allowances for private repositories and free standard runners for -public repositories. Providers change these, so check the -[current allowance links and switching guidance](docs/providers.md#free-allowances). - -## License - -[MIT License](LICENSE) +# Builder + +Build and develop iOS apps from Windows, Linux, or any platform. + +Builder is a CLI tool for iOS development without a Mac. It uses GitHub Actions (default), Codemagic, or Bitrise for remote builds and [MobAI](https://mobai.run) for on-device development. + +![Builder Demo](assets/ios-builder-demo.gif) + +## Features + +- **Build from anywhere**: Build iOS apps via GitHub Actions, Codemagic, or Bitrise +- **Independent provider logins**: Stay signed in to all three and choose where each build runs +- **Try it on a simulator**: Use your build on an iOS simulator from Windows or Linux +- **Flutter & React Native dev tools**: Hot reload on real iOS devices from Windows/Linux +- **Simple setup**: One command to add the workflow to your repo +- **Code signing**: Optional signing with your certificate and provisioning profile +- **TestFlight and App Store**: Upload builds and submit them for review through the App Store Connect API, from any platform +- **Device integration**: Install and run apps via MobAI + +## How It Works + +``` +Your Repository GitHub Actions (macOS) + └─ .github/workflows/ └─ ios-build.yml + └─ ios-build.yml ├─ Check out the snapshot + ├─ Build with Xcode +builder ios build ───────────────────► Upload IPA artifact + │ pushes a snapshot of + │ your working tree + └─ Downloads IPA ◄─────────────── artifact: ipa +``` + +`builder ios build` builds what is on disk, not your last commit: uncommitted +and untracked files are included, so you can try a change without committing +it. The snapshot is a throwaway commit pushed to a hidden ref that is deleted +when the build finishes; no branch is created and nothing is committed on your +behalf. `.gitignore` still applies, so ignored files such as `.env` or +`GoogleService-Info.plist` are absent from the build. + +## Quick Start + +### 1. Authenticate with GitHub + +```bash +builder auth github +``` + +### 2. Initialize (in your project directory) + +```bash +cd your-ios-project +builder init +``` + +This detects your GitHub repo, creates the workflow files, and offers to commit, push, and trigger your first build - all interactively. + +### 3. Build + +```bash +builder ios build +``` + +The CLI triggers the workflow and downloads the IPA to `./dist/`. + +### 4. Try it on a simulator (optional) + +```bash +builder ios share +``` + +Builds the working tree for the iOS simulator and makes that simulator usable +from the [MobAI](https://mobai.run) app, so you can tap through a build without +a Mac. It shows up under CI Devices, stays available while you are using it, and +closes when you release it there or leave it unused (30 minutes by default, use +`--duration` to change). A coding agent connected to MobAI (Claude Code, Codex, +Cursor) can drive the simulator the same way. + +Free with any MobAI account, on [MobAI 3.0 or later](https://mobai.run). Needs +a `MOBAI_API_KEY` repository secret: create the key in the MobAI app under +Account → API Keys, then: + +```bash +gh secret set MOBAI_API_KEY +``` + +### Triggering from git only + +Where the GitHub API is not reachable, both workflows can also be started by +pushing a tag. Commit the tree you want built, then: + +```bash +git tag ios-build/my-build && git push origin ios-build/my-build # IPA build +git tag ios-share/my-build && git push origin ios-share/my-build # simulator +``` + +The run is named after the tag. Build settings come from `builder.json` in the +tagged commit (`ios.path`, `ios.scheme`, `ios.signing`, `ios.configuration`, +`flutter.version`, `kmp.jdkVersion`), the simulator stays available for the +default 30 minutes, and the tag is deleted when the run ends. The IPA is +attached to the run as an artifact. A tag carries no flags, so a tagged IPA +build cannot pick a [profile](#build-profiles) per run; it applies the profile +named by `defaultProfile`, if there is one. The simulator build takes no +profile at all. + +## Additional macOS Providers + +GitHub Actions remains the default, so existing commands continue to work. Add +Codemagic and Bitrise without logging out of GitHub. First follow the +[app creation and repository connection guide](docs/provider-setup.md) to create +each provider app, authorize GitHub access, and find its app ID: + +```bash +builder auth codemagic +builder auth bitrise +builder auth status +builder init --provider codemagic --app-id YOUR_APP_ID --branch main +builder init --provider bitrise --app-id YOUR_APP_SLUG --branch main +builder ios build --provider codemagic +builder ios build --provider bitrise +``` + +`init` writes `codemagic.yaml` or `bitrise.yml` at the repo root plus the shared +runner script `.builder/ci/runner.sh`. Commit them to the configured branch +and connect the same repository to each provider before building. See +[provider setup, signing, simulator sessions, and free allowances](docs/providers.md). + +## Supported Frameworks + +| Framework | iOS Path | Auto-detected | +|-----------|----------|---------------| +| Native iOS/Swift | `.` (root) | Yes | +| React Native | `ios/` | Yes | +| Expo (ejected) | `ios/` | Yes | +| Flutter | `ios/` | Yes | +| Kotlin Multiplatform | `iosApp/` | Yes | +| Cordova/Ionic | `platforms/ios/` | Yes | + +## Installation + +### Windows + +Download `builder-windows-amd64.exe` from [Releases](https://github.com/MobAI-App/ios-builder/releases), rename it to `builder.exe`, and add it to PATH. + +### Homebrew (macOS/Linux) + +```bash +brew install mobai-app/tap/ios-builder +``` + +The formula is named `ios-builder`; the command it installs is `builder`. + +### macOS/Linux/WSL + +```bash +curl -sSL https://raw.githubusercontent.com/MobAI-App/ios-builder/main/install.sh | bash +``` + +### From Source + +```bash +git clone https://github.com/MobAI-App/ios-builder.git +cd ios-builder +go build -o builder ./cmd/builder +``` + +## Commands + +```bash +# Setup +builder auth github # Authenticate with GitHub +builder auth codemagic # Authenticate with Codemagic (also: bitrise) +builder auth apple # Save an App Store Connect API key +builder auth status # Show which providers you are signed in to +builder auth logout [name] # Remove stored credentials (github, codemagic, bitrise, apple) +builder init # Set up workflows in current repo +builder update # Update builder to the latest release + +# Building (builds the working tree, including uncommitted changes) +builder ios build # Trigger build and download IPA to ./dist/ +builder ios build --unsigned # Build without code signing (if signing is configured) +builder ios build --provider codemagic # Build on another provider (also: bitrise) +builder ios build --profile production # Build with a profile from builder.json + +# Simulator (free, needs a MOBAI_API_KEY secret) +builder ios share # Try the build on a simulator in the MobAI app +builder ios share --duration 1h # Keep it available longer while unused + +# Development (requires MobAI) +builder dev flutter # Flutter hot reload with file watching +builder dev flutter --no-watch # Disable automatic file watching +builder dev flutter --no-attach # Print flutter attach command instead of running it +builder dev rn # React Native hot reload (short for: dev react-native) +builder dev kmp # Kotlin Multiplatform install + launch (alias: kotlin) +builder dev kmp --logs # Also stream the app's output +builder dev flutter --skip-install --bundle-id # Use already installed app +builder dev rn --metro-port 8082 # Use custom Metro port + +# MobAI (used by the dev commands; handy for troubleshooting) +builder mobai ping # Check MobAI connectivity +builder mobai install # Install an IPA on the device +builder mobai run-debug # Launch an app with the debugger attached +builder mobai forward # Forward a device port + +# Code signing (automatic mode needs builder auth apple) +builder signing setup --devices-from-mobai # development: certificate, devices, profile, GitHub secrets, no portal +builder signing setup --distribution store # Apple Distribution certificate + App Store profile +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision # Upload your own files +builder ios build --profile store # Signs with the set; provisions it first when missing +builder signing csr # Manual path: create a private key + certificate signing request +builder signing p12 # Manual path: assemble a .p12 from the key and Apple's certificate + +# TestFlight and App Store (needs builder auth apple) +builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing +builder ios submit --testflight --group "Beta Testers" --notes "What to test" +builder ios submit --app-store --release after-approval # Submit the version for App Review +``` + +Every `upload`/`submit` command takes `--json` for machine-readable output and +never prompts, so agents and CI jobs can drive them. + +## Configuration + +`builder.json`: + +```json +{ + "project": "MyApp", + "platform": "ios", + "github": { + "owner": "username", + "repo": "my-ios-app" + }, + "ios": { + "path": "ios", + "scheme": "", + "bundleId": "com.example.app", + "configuration": "Debug" + }, + "profiles": { + "development": { "distribution": "development" }, + "store": { "distribution": "store" } + }, + "mobai": { + "url": "http://localhost:8686", + "device_id": "" + }, + "flutter": { + "watch": { + "dirs": ["lib"], + "patterns": [".dart"], + "ignore": [".g.dart", ".freezed.dart"], + "debounce": 100 + } + } +} +``` + +### iOS Build Configuration + +| Field | Description | Default | +|-------|-------------|---------| +| `ios.path` | Path to the Xcode project relative to the repo root | detected by `init` | +| `ios.scheme` | Xcode scheme to build | auto-detected | +| `ios.bundleId` | App bundle identifier, used by `signing setup` | detected by `init` when the project has one app target; else saved by `signing setup` | +| `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` | +| `ios.signing` | Legacy: sign builds that select no profile, with the unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and `IOS_PROVISIONING_PROFILE` secrets. Profiles ignore it; use `distribution` there | `false` | + +### Build Profiles + +Profiles are named sets of build settings, in the spirit of `eas.json`, selected +with `--profile` on `ios build`: + +```json +{ + "ios": { "path": "ios", "bundleId": "com.example.app" }, + "defaultProfile": "development", + "profiles": { + "development": { "distribution": "development" }, + "preview": { "distribution": "internal", + "env": { "API_URL": "https://staging.example.com" } }, + "production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic" } + } +} +``` + +```bash +builder ios build --profile preview +``` + +| Field | Description | +|-------|-------------| +| `distribution` | The only signing setting: `development`, `ad-hoc` (or `internal`, the same thing), `store` or `enterprise`. The build signs with that distribution's [signing set](#code-signing) and its provisioning profile must be of that type; the IPA is exported with the matching method. Omitted means an unsigned build | +| `configuration` | Overrides the derived configuration: `Debug` for `development`, `Release` for every other distribution, `ios.configuration` for unsigned profiles | +| `scheme` | Overrides `ios.scheme` | +| `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) | +| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them | + +How a build's settings are resolved: + +- Without `--profile`, the profile named by `defaultProfile` applies. With + neither, the top-level `ios.*` and `provider` settings are used exactly as + before, so existing projects are unaffected. +- A profile only overrides the fields it sets; everything else comes from the + top level. An unknown profile name is an error that lists the available ones. +- `--unsigned` and `--provider` on the command line override the profile. +- The resolved settings (profile, configuration, scheme, signing set, provider, + env names) are printed before anything is dispatched. +- Profiles apply to `ios build` only. `ios share` takes no `--profile`: + simulator builds are always Debug and unsigned, and use `ios.scheme` and the + top-level `provider` (or `--provider`). + +**`env` values are build-time configuration, not secrets.** They are stored in +`builder.json`, sent to the CI provider as plain workflow inputs, and visible in +the run's inputs and logs. Keep tokens and passwords in the provider's secrets +instead (`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets +guide](docs/provider-secrets.md)); the build reads those as environment +variables too. Names the runner owns are rejected: its own parameters +(`SCHEME`, `CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing +secrets, `PATH`, `HOME`, `DEVELOPER_DIR`, and anything starting with `GITHUB_`, +`RUNNER_`, `CM_`, `BITRISE_` or `BUILDER_`. + +Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow +file from this version of Builder, which declares a `profile` input; an older +committed workflow rejects the dispatch. Run `builder init` again to refresh +`.github/workflows/ios-build.yml` (or `builder init --provider ...` for +`runner.sh`) in a project set up earlier, then commit and push it to the +default branch. + +### MobAI Configuration + +| Field | Description | Default | +|-------|-------------|---------| +| `mobai.url` | MobAI API URL | `http://localhost:8686` | +| `mobai.device_id` | Preferred device ID (uses first available if empty) | `""` | + +**WSL users**: MobAI runs on Windows, and WSL has its own network by default. On +Windows 11, turn on +[mirrored networking](https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking) +and builder reaches MobAI on the default `http://localhost:8686`. See +[Using Builder from WSL](docs/wsl.md) for the steps, and for the setup without +mirrored networking. + +### Flutter File Watcher + +| Field | Description | Default | +|-------|-------------|---------| +| `flutter.watch.dirs` | Directories to watch | `["lib"]` | +| `flutter.watch.patterns` | File patterns to match | `[".dart"]` | +| `flutter.watch.ignore` | Patterns to ignore | `[".g.dart", ".freezed.dart"]` | +| `flutter.watch.debounce` | Debounce delay in ms | `100` | + +## Code Signing + +By default, builds are unsigned. A signed build needs a certificate and a +provisioning profile — and despite what many guides claim, **you do not need a +Mac to create either one**, nor a tour of the Apple Developer portal. Signing +is configured per [build profile](#build-profiles) with one field, +`distribution`, and `builder signing setup` produces the material for it +through the App Store Connect API (or takes your own files). + +You need a paid [Apple Developer Program](https://developer.apple.com/programs/) +membership — Apple only issues certificates to paid accounts. (Without one, +build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free +Apple ID.) + +### Profiles and signing sets + +Each distribution has its own set of three GitHub secrets, so a development set +for your devices and a store set for TestFlight live side by side: + +| `distribution` | Certificate, profile | Secrets | +|----------------|----------------------|---------| +| `development` | Apple Development, iOS App Development (devices required) | `IOS_CERTIFICATE_DEVELOPMENT`, `IOS_CERTIFICATE_PASSWORD_DEVELOPMENT`, `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | +| `ad-hoc` or `internal` | Apple Distribution, Ad Hoc (devices required) | `IOS_CERTIFICATE_AD_HOC`, `IOS_CERTIFICATE_PASSWORD_AD_HOC`, `IOS_PROVISIONING_PROFILE_AD_HOC` | +| `store` | Apple Distribution, App Store | `IOS_CERTIFICATE_STORE`, `IOS_CERTIFICATE_PASSWORD_STORE`, `IOS_PROVISIONING_PROFILE_STORE` | +| `enterprise` | In-house (portal only) | `IOS_CERTIFICATE_ENTERPRISE`, `IOS_CERTIFICATE_PASSWORD_ENTERPRISE`, `IOS_PROVISIONING_PROFILE_ENTERPRISE` | + +A build with `--profile ` signs with the set of that profile's +`distribution`; `configuration` follows it (`Debug` for `development`, +`Release` otherwise) unless the profile sets one. The runner checks that the +profile in the set is of the requested type and fails by name before compiling +anything, and a distribution profile refuses a `Debug` configuration. On +Codemagic and Bitrise the same names are variables you add in the dashboard, +see the [secrets guide](docs/provider-secrets.md). + +### Create an App Store Connect API key + +Automatic signing, `ios upload`, `ios submit` and `ios release` all use one +App Store Connect API key. You create it once, in the browser: + +1. Sign in to [App Store Connect](https://appstoreconnect.apple.com) as the + Account Holder or an Admin (only they can create team keys). +2. Go to **Users and Access → Integrations → App Store Connect API**, tab + **Team Keys**, and press **+** (or **Generate API Key**). +3. Name it (for example `Builder`) and choose the role **Admin**. **App + Manager** works too if you also tick *Access to Certificates, Identifiers & + Profiles*; a **Developer** key can upload builds but cannot create + certificates. +4. Press **Generate**, then **Download API Key**. The `AuthKey_.p8` + file downloads once; keep it somewhere private, never in the repo. +5. Note the **Issuer ID** at the top of the page and the **Key ID** in the + row of your key. + +Then save it in your keychain: + +```bash +builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key ~/Downloads/AuthKey_ABC123DEFG.p8 +``` + +`builder auth status` shows it, `builder auth logout apple` removes it. On a +machine without a keychain (CI, a coding agent), set `ASC_ISSUER_ID`, +`ASC_KEY_ID` and `ASC_KEY_PATH` (or `ASC_PRIVATE_KEY` with the file's contents) +instead. + +### `builder signing setup` + +```bash +builder auth apple # once: save the App Store Connect API key +builder signing setup --devices-from-mobai # development set for the devices MobAI sees +builder signing setup --distribution store # store set for TestFlight / App Store +``` + +Without files, `setup` works through the App Store Connect API for the given +`--distribution` (default `development`; `--name ` reads it from an +existing profile). The key needs the **Admin** role (or App Manager plus +*Access to Certificates, Identifiers & Profiles*): Developer-role keys cannot +create certificates. It then: + +1. Registers the **App ID** if the bundle identifier is not on the account yet. + The bundle ID comes from `--bundle-id`, `ios.bundleId` in `builder.json` + (which `init` fills when the Xcode project has a single app target), or the + newest IPA in `./dist/`; in a terminal it asks as a last resort. +2. Issues a **certificate** — Apple Development for `development`, Apple + Distribution for `ad-hoc` and `store` — for a private key generated on your + machine (`ios-signing-.key`, or `--key` to reuse one from + `signing csr`; a `ios-signing.key` from an earlier version is picked up + too). A valid certificate on the account is reused only when its private + key is here, because that is the only way to build the `.p12`; otherwise a + new one is issued. Nothing is ever revoked: when Apple's limit (2 + Development, 3 Distribution) is hit, the error names it and points at the + portal. +3. Registers **devices** from `--device ` (repeatable) and + `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has + connected; simulators and cloud farm devices are skipped). Development and + ad-hoc profiles cover every enabled iOS device on the account, so with none + given and none registered the command stops and says so. Store profiles + take no devices. Apple allows 100 devices per membership year and never + frees a slot; that error is passed through too. +4. Creates the **profile** `Builder `. An existing + one is reused while it is `ACTIVE`, unexpired and still lists exactly this + certificate and these devices; otherwise it is deleted and recreated, and + the summary says why (`invalid`, `expired`, `certificate changed`, `devices + changed`, `forced`). +5. Writes `ios-signing-.key` (when generated), + `ios-signing-.p12` and `Builder--.mobileprovision` to `--out-dir` (default `.`), uploads the three secrets + of the set to GitHub, and writes the build profile in `builder.json`: + `--name` (default: the distribution name) with `"distribution": + ""`. Other fields of an existing profile are kept; a + different `distribution` in it is replaced, and the command says so. + `defaultProfile` is not touched: point it at the profile for a plain + `ios build` to use it, or pass `--profile`. An `--out-dir` other than `.` + is recorded as `signing.dir` (as typed, `~` included), so a later + `ios build --profile` that has to provision a set finds the certificate's + key there instead of asking Apple for a second certificate, which it + refuses. +6. Prints the three secret names and where their values come from — the + `.p12` base64-encoded, the password, the `.mobileprovision` base64-encoded + — every time, so the same set can be pasted into Codemagic or Bitrise, + following the [secrets guide](docs/provider-secrets.md). Builder cannot + check those providers' secrets before a build, so `ios build` only reminds + you of this command when the profile signs there. + +The upload goes to the repository in `builder.json`, always. When it fails (no +GitHub login, or a token that cannot write secrets) the error is printed and +the command carries on: files, values and the build profile are written and +shown anyway, and it exits non-zero at the end so a script notices. `--json` +reports the same in `github_upload` (`ok` or the error). + +The command shows its plan and asks once before creating anything; `--yes` +skips that (required without a terminal), and then the `.p12` password is +generated and printed once unless `--password` is given. `--json` prints the +result as JSON with progress on stderr. Keep the written files out of git. +Run it again whenever you like: it reports what it found and recreates only +what is missing, expired, invalid or changed — add a device, re-run, rebuild. +`--force` issues a fresh certificate and profile regardless. + +With `--certificate` and `--profile`, `setup` takes your own files instead — a +`.p12` (from Keychain Access, or [assembled here](#manual-path-through-the-apple-developer-portal)) +and a `.mobileprovision` — reads the distribution out of the profile +(development, ad-hoc, store or enterprise; this is the only way in for +enterprise), uploads that set, prints its names and values, and writes the +build profile the same way: + +```bash +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision +``` + +### Provisioning from `ios build` + +`builder ios build --profile ` checks, before dispatching to GitHub, +that the repository holds all three secrets of the profile's set. When any is +missing and an App Store Connect key is saved, it runs the same provisioning as +`signing setup` without prompts, uploads the set and then builds. A development +or ad-hoc profile needs at least one registered device; with none, the build +stops and points at `builder signing setup --distribution development +--devices-from-mobai`. Without an Apple key the build stops before anything is +pushed and names both ways out: `builder auth apple`, or `builder signing setup +--certificate ... --profile ...`. `--unsigned` skips all of this, and +Codemagic/Bitrise builds skip the check (no secrets API): their runner +reports a missing set itself. + +### Legacy: `ios.signing` without profiles + +A project set up before build profiles has `ios.signing: true` and the +unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and +`IOS_PROVISIONING_PROFILE` secrets. Builds that select no profile still sign +with those, whatever the profile type, exactly as before; `setup` never +touches them. Profiles ignore `ios.signing` and read their own set. + +### Manual path through the Apple Developer portal + +The `.p12` certificate is normally created through Keychain Access, but Builder +does the same thing itself: it generates the private key and certificate +signing request, and assembles the `.p12` from the certificate Apple issues. + +#### 1. Create a certificate signing request + +```bash +builder signing csr +``` + +This asks for your name and email and writes two files to the current +directory: `ios-signing.key` (your private key) and `ios-signing.csr`. Keep +the key wherever suits you — just don't commit it (add it to `.gitignore`; +gitignored files are also excluded from build snapshots). + +#### 2. Create the certificate + +1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal +2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc). TestFlight and App Store uploads need **Apple Distribution** together with an App Store profile in step 4 +3. Upload `ios-signing.csr` and download the resulting `.cer` file + +#### 3. Assemble the .p12 + +```bash +builder signing p12 --certificate development.cer --key ios-signing.key +``` + +This combines the key and certificate into `ios-signing.p12` (`--out` to name +it), protected by a password you choose — byte-for-byte the same kind of file +Keychain Access exports, and usable anywhere one is: `builder signing setup`, +Sideloadly, AltStore, or importing it on a Mac. Keep it, and don't commit it. + +#### 4. Create a provisioning profile + +On the portal: + +1. **Identifiers** → register an App ID matching your app's bundle identifier +2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page) +3. **Profiles** → create an **iOS App Development** (or Ad Hoc, App Store) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file + +The profile type decides the distribution the set is written to and the method +the IPA is exported with: development, ad-hoc, enterprise or store. + +#### 5. Upload the signing secrets + +```bash +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision +``` + +You can also skip step 3 and hand `setup` the `.cer` together with the key — +`builder signing setup --certificate development.cer --key ios-signing.key +--profile MyApp.mobileprovision` — and it assembles the `.p12` on the way, +saving it as `ios-signing-.p12`. Then `builder ios build +--profile `; `--unsigned` skips signing for one build. + +## TestFlight and App Store + +Builder uploads builds to App Store Connect and submits them to TestFlight or +App Review through the App Store Connect API, from Windows, Linux or macOS. No +Transporter, `altool` or Xcode is involved, and the API key never leaves your +machine: the CI runner only builds and signs, the upload happens locally from +the IPA in `./dist/`. + +You need: + +- A paid [Apple Developer Program](https://developer.apple.com/programs/) + membership and an app record in App Store Connect for your bundle ID (step 2 + below; the API cannot create it) +- An IPA signed with an **Apple Distribution** certificate and an **App Store** + provisioning profile: `builder signing setup --distribution store` creates + both, stores them as the `STORE` signing set and writes a `store` build + profile, or pick those types on the portal in the manual path. An IPA signed + for development is rejected at upload. +- `builder ios build --profile store` (see [Build Profiles](#build-profiles)): + a `store` profile builds `Release` and signs with that set; a plain `ios + build` is Debug and unsigned, which is what the dev commands expect, not + what you want to ship. +- An App Store Connect API key saved with `builder auth apple`, see + [Create an App Store Connect API key](#create-an-app-store-connect-api-key). + +### 1. Save the API key + +`builder auth apple` as described in +[Create an App Store Connect API key](#create-an-app-store-connect-api-key). +Flags you leave out are prompted for; Builder verifies the key against App +Store Connect before storing it. + +### 2. Create the app record + +App Store Connect only accepts uploads for an app it already knows, and the API +cannot create one. Once, in the browser: + +1. Register the bundle ID first: `builder signing setup --distribution store` + does it (or **Certificates, Identifiers & Profiles → Identifiers → +** on + the developer portal). +2. Open [App Store Connect → My Apps](https://appstoreconnect.apple.com/apps), + press **+ → New App**, pick **iOS**, a name, the primary language, your + bundle ID from the list, and any SKU (an internal string, e.g. the bundle + ID). Press **Create**. + +Nothing else on the record is needed for TestFlight. App Store review needs the +rest of the metadata (screenshots, description, privacy policy) filled in there. + +### 3. Upload the build + +```bash +builder ios build --profile store # produces a signed dist/*.ipa +builder ios upload --wait +``` + +`upload` reads the bundle ID, version and build number from the newest IPA in +`./dist/` (or `--ipa `), finds the app, uploads the archive in chunks +and, with `--wait`, follows App Store Connect until the build has finished +processing and prints its build ID and TestFlight link. Without `--wait` it +returns as soon as Apple has the file. + +Two things Apple checks on every upload: + +- **Build numbers must increase.** A second upload with the same + `CFBundleVersion` for the same version is rejected (`ITMS-90189`), so bump + it before rebuilding. +- **Export compliance.** A build shows as *Missing Compliance* in TestFlight + until you say whether it uses non-exempt encryption. If your Info.plist sets + `ITSAppUsesNonExemptEncryption` to `false`, `upload --wait` answers that + automatically; otherwise pass `--no-encryption` (here or to `submit`) when + your app only uses standard iOS encryption. + +### 4. Distribute to TestFlight + +```bash +builder ios submit --testflight --group "Beta Testers" --notes "New login flow" +``` + +This takes the newest processed build (or `--build-number N`), sets the *What +to Test* notes and adds it to the named groups (`--group` repeats). Internal +groups get the build immediately; the first external group triggers Apple's +beta review, which Builder submits for you (`--wait` follows the decision). Run +it without `--group` to see the build and the groups the app has. + +### 5. Submit to the App Store + +```bash +builder ios submit --app-store --release after-approval +``` + +Builder finds or creates the App Store version matching the IPA's marketing +version (or `--version X.Y.Z`), attaches the build, sets the release type +(`manual` or `after-approval`) and submits it for review. The version's +metadata — description, screenshots, age rating, pricing, privacy — must +already be complete: App Store Connect refuses the submission otherwise and +Builder prints Apple's reasons verbatim. Builder does not manage metadata, +screenshots or in-app purchases; fill them in App Store Connect, or on a Mac +with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of +the `buildUploads` API also proved that the Mac-free upload path works and +served as the reference for Builder's implementation. + +## Installing the IPA + +Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account). + +## Development on Windows/Linux + +Builder supports hot reload for Flutter and React Native on Windows/Linux using [MobAI](https://mobai.run) for iOS device control. This allows you to develop iOS apps without a Mac. + +## Flutter Development + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` + This creates an IPA in `./dist/` +3. Start development with hot reload: + ```bash + builder dev flutter + ``` + Builder installs the IPA through MobAI and asks whether to re-sign it. Re-signing requires an iCloud account - we highly recommend creating a new one at [icloud.com](https://icloud.com) instead of using your primary account. A re-signed app gets a new bundle ID with a team ID suffix (e.g., `com.example.myapp.TEAMID`); Builder prefills it in the prompt that follows. + +### Subsequent Runs + +Once the app is installed, skip the install step: +```bash +builder dev flutter --skip-install --bundle-id com.example.myapp.TEAMID +``` + +### File Watching + +By default, `builder dev flutter` watches for Dart file changes and automatically triggers hot reload. When flutter attach connects, it also sends an initial hot restart to ensure your latest code is running. + +- **Automatic hot reload**: Edit a `.dart` file and save - hot reload triggers automatically +- **Generated files ignored**: Files like `.g.dart` and `.freezed.dart` are ignored by default +- **Configurable**: Customize watched directories, patterns, and debounce via `builder.json` + +To disable file watching: +```bash +builder dev flutter --no-watch +``` + +To print the `flutter attach` command instead of running it (useful for IDE integration): +```bash +builder dev flutter --no-attach +``` + +### When to Rebuild + +- **Native code changes** (Swift, Objective-C, Podfile, native dependencies): Run `builder ios build` and reinstall +- **Dart code changes only**: No rebuild needed - file watcher triggers hot reload automatically + +If you don't see your recent Dart changes after launching, press `R` in the terminal to perform a hot restart. + +### Troubleshooting + +**App won't launch / connection error** +- Close the app on your device before running `builder dev flutter` +- Reconnect the device (unplug/replug USB) +- Restart MobAI +- Run `builder mobai ping` to verify connection + +**"No devices found" error** +- Ensure MobAI is running and device is connected +- Only physical iOS devices are supported (no simulators) + +**Hot reload not working** +- Make sure you're using the correct bundle ID (with team ID suffix) +- Try hot restart with `R` key +- Check that MobAI shows the device as connected + +**File watcher not triggering** +- Ensure you're editing files in watched directories (default: `lib/`) +- Check if the file matches watch patterns (default: `.dart`) +- Generated files (`.g.dart`, `.freezed.dart`) are ignored by default +- Try running without `--no-watch` flag + +## React Native Development + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` +3. Start development with hot reload: + ```bash + builder dev rn + ``` + This will: + - Start Metro bundler if not running + - Install the IPA on your device (with optional re-signing) + - Launch the app with Metro URL configured automatically + +### Subsequent Runs + +Once the app is installed: +```bash +builder dev rn --skip-install --bundle-id com.example.myapp.TEAMID +``` + +### Custom Metro Port + +If port 8081 is in use: +```bash +builder dev rn --metro-port 8082 +``` + +### When to Rebuild + +- **Native code changes** (Swift, Objective-C, Podfile, native modules): Run `builder ios build` and reinstall +- **JavaScript changes only**: No rebuild needed - Metro handles it automatically + +### Troubleshooting + +**Metro not starting** +- Ensure Node.js and React Native CLI are installed +- Try starting Metro manually: `npx react-native start` + +**App not connecting to Metro** +- Device must be on the same WiFi network as the computer running Metro +- Check that Metro is running and accessible +- Verify the Metro port is correct (default: 8081) +- On WSL with mirrored networking, the Hyper-V firewall blocks the phone from reaching Metro by default; see [Using Builder from WSL](docs/wsl.md#react-native) for the firewall rule + +**Hot reload not working** +- Shake device or press `d` in Metro terminal to open dev menu +- Enable "Fast Refresh" in dev menu +- Try reloading with `r` in Metro terminal + +## Kotlin Multiplatform Development + +KMP iOS apps build and run on a device like any other project, with one +difference: **there is no hot reload.** Shared Kotlin is compiled into a native +framework at build time, so there is no runtime to swap code into — every code +change needs a rebuild. + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` +3. Install and launch it on the device: + ```bash + builder dev kmp + ``` + +`builder init` detects Kotlin Multiplatform projects by looking for the +multiplatform Gradle plugin in the root and module build files, and asks which +JDK the CI build should use (default 17): + +```json +{ + "kmp": { "jdkVersion": "17" } +} +``` + +On CI, the iOS app is built with `xcodebuild`, whose run script phase (or +CocoaPods) invokes Gradle to compile the shared framework — which is why the +JDK version matters. Gradle output is cached between builds. + +### When to Rebuild + +Every change to Kotlin or Swift code needs `builder ios build` followed by +`builder dev kmp` again. Use `--skip-install --bundle-id ` to relaunch an +app that is already installed. + +### Troubleshooting + +**Build fails with "Unsupported class file major version" or a Gradle JDK error** +- The project needs a different JDK than the default: set `kmp.jdkVersion` in `builder.json` to match what the project uses locally + +**Build fails with "SDK does not contain 'libarclite'"** +- An old Kotlin/Native version against a newer Xcode; upgrade the Kotlin plugin in Gradle + +**App launches then immediately exits** +- Launch with `builder dev kmp --logs` to see the device output + +## Build Limits + +Free allowances belong to each provider account and depend on the plan and +machine. As published in September 2026: Codemagic personal accounts include +500 macOS M2 minutes per month; Bitrise Hobby includes 300 credits. GitHub has +separate allowances for private repositories and free standard runners for +public repositories. Providers change these, so check the +[current allowance links and switching guidance](docs/providers.md#free-allowances). + +## License + +[MIT License](LICENSE) From 90811efa52a2a58e755468d557f553395f19cde1 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 18:40:52 +0200 Subject: [PATCH 70/75] 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. --- CLAUDE.md | 234 ++++++---------------- README.md | 54 +++-- cmd/builder/root.go | 8 +- cmd/builder/signing_auto.go | 40 ++-- internal/asc/certificates.go | 9 +- internal/build/coordinator.go | 8 +- internal/build/remote.go | 5 +- internal/config/profile.go | 32 ++- internal/config/signing.go | 9 +- internal/config/types.go | 10 +- internal/github/repo.go | 7 +- internal/signing/auto.go | 11 +- internal/signing/signing.go | 10 +- internal/workflow/templates/ios-build.yml | 101 ++++------ internal/workflow/templates/runner.sh | 73 +++---- 15 files changed, 210 insertions(+), 401 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bd750e4..3a8bca1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,11 +121,9 @@ builder signing setup ───► Bundle ID: --bundle-id → ios.bundleId → d └─ profiles?filter[name] → reuse / DELETE + POST profiles │ ▼ - Writes key/.p12/.mobileprovision (named by distribution), uploads - the three IOS_*_ secrets of the distribution's set to GitHub - (a failed upload is printed, not fatal; non-zero exit at the end), - always prints their names and values (Codemagic/Bitrise paste them), - writes profiles..distribution + Writes key/.p12/.mobileprovision named by distribution, uploads the + IOS_*_ trio to GitHub (failure printed, non-zero exit at the end), + prints names + values, writes profiles..distribution builder ios build --profile X ─► ResolveProfile: distribution → set, signing, configuration │ @@ -192,93 +190,30 @@ internal/ submodule commit that only exists locally fails checkout on the runner. - **Run Correlation**: `run-name` carries the build ID so concurrent builds cannot adopt each other's runs -- **Run Failures**: when the run completes without success while `PollForArtifact` waits, the - error is a `github.RunFailedError`: the conclusion, the first failed job and step - (`ListRunJobs`) and that job's `failure`-level annotations (`GET - /repos/{o}/{r}/check-runs/{job_id}/annotations`; a job ID is its check run ID), which are the - runner's `::error::` lines. Reading the details is best-effort, so the conclusion is reported - even when the annotations endpoint fails -- **Build Profiles**: `profiles.` in `builder.json` overrides `ios.configuration`, `ios.scheme` - and `provider`, and adds `env` and `distribution`. `ios build` takes `--profile`; without it - `defaultProfile` applies, and without that the top-level settings are used unchanged. `ios share` - takes no profile at all: it builds Debug for the simulator and never signs, so `Share` uses the - top-level settings and `ios-share.yml` declares no `profile` input. - `config.ResolveProfile` does the merge, `Coordinator.settings` layers `--unsigned`/`--provider` on - top, and `Progress.Settings` prints the result before dispatch. `distribution` is the only - signing field of a profile (EAS-style): `development`, `ad-hoc` (alias `internal`, canonical - `ad-hoc`; `config.ParseDistribution`), `store`, `enterprise`. A profile signs iff it has one; - `ios.signing` applies only when no profile is selected (legacy, unsuffixed secrets). Its - configuration is the one it sets, else Debug for `development` and Release for the rest, else - `ios.configuration`. The jq in `Resolve parameters` derives the same for tag builds (`$p != ""` - guards, since `.profiles[""]` is null). The runner receives env as one JSON object: the `profile` dispatch input - (`{"name","env","distribution"}`, one input to stay under the ten-input limit) on GitHub, and - `BUILD_ENV` plus `DISTRIBUTION` variables for `runner.sh`. Each entry is base64-encoded per - key and value on the runner (jq drops NUL bytes, and a key with a space must not split), the - `$GITHUB_ENV` heredoc uses a random delimiter so no value line can end it early, names are - checked against `^[A-Za-z_][A-Za-z0-9_]*$`, and `ResolveProfile` rejects the names the runners - own (`reservedEnv` and `reservedEnvPrefixes` in `internal/config/profile.go`: the runner - parameters, the signing secrets, `PATH`/`HOME`/`DEVELOPER_DIR`, and the `GITHUB_`, `RUNNER_`, - `CM_`, `BITRISE_`, `BUILDER_` namespaces; keep that list in step with what `runner.sh` and the - workflows read). `profile` is only sent when a profile is selected (`--profile` or - `defaultProfile`), because a workflow file from before profiles rejects a dispatch with an input - it does not declare; `triggerError` turns that 422 into a "run `builder init`" message. On - GitHub the profile's env lands in `$GITHUB_ENV`, and step-level `env:` (the signing secrets, the - build parameters) takes precedence over it. `distribution` reaches the runner as the - `steps.params.outputs.distribution` output on GitHub and the `DISTRIBUTION` variable for - `runner.sh`, where it selects the signing set (below). - `env` is build-time configuration, not secrets: it sits in `builder.json` and in the run's inputs -- **Signing Sets**: one trio of secrets per distribution, `IOS_CERTIFICATE_`, - `IOS_CERTIFICATE_PASSWORD_`, `IOS_PROVISIONING_PROFILE_` with SET the canonical - distribution upper-cased, `-` → `_`: DEVELOPMENT, AD_HOC (also for `internal`), STORE, - ENTERPRISE. The unsuffixed names serve only the legacy no-profile path (`ios.signing`); a - profile never falls back to them. The distribution → set table exists twice and must agree: - `config.SigningSet` (Go; `config.SigningSecretNames` builds the names, `""` → legacy) and the - shell function `signing_set` in `ios-build.yml`'s `Resolve parameters` (emits the `signing_set` - output; canonicalizes `internal`) and `runner.sh` (`install_signing` derives it from - `DISTRIBUTION`). The signing step receives every set's secrets as env (GitHub hands a missing - secret over as empty; Codemagic/Bitrise users define the suffixed variables); - `select_signing_set` picks the set by bash indirect expansion, or with an empty set the - unsuffixed names. A suffixed set needs all three secrets, password included (Builder never - writes one without): a partial set fails naming the missing names; only the unsuffixed password - may be empty, as before. `check_signing_set` compares `detect_export_method`'s result with the - requested distribution canonically (`app-store` → `store`, `internal` → `ad-hoc`; the legacy set - is never checked) after the profile is decoded and before any keychain exists or `security - import` runs. `select_signing_set`/`check_signing_set`/`signing_set` are verbatim in both - templates, each with its own `fail` (`::error::` vs stderr); `TestSigningSetSelection` compares - the bodies and runs them with stub secrets. `signing setup` writes only the set of its - distribution (automatic: `--distribution`, else the `--name` profile's, else development; - manual: `signing.ProfileType` reads the plist out of the CMS blob and a disagreeing - `--distribution` is an error) and never touches other sets or the legacy names, then writes - `profiles.<--name or distribution>.distribution` (`writeSigningProfile`: other fields kept, an - equal distribution keeps the user's spelling, a different one is replaced and the old value - printed; `defaultProfile` is never set) and never `ios.signing`. Both modes always upload to the - `github` repository in builder.json (no `--provider`, no `provider` field) and always print the - three names with where their values come from, for Codemagic, Bitrise or a repository the token - cannot write to; a failed upload (or a GitHub client that cannot be built) is an `Error:` line on - stderr, everything else is still written and printed, and only the exit code is non-zero - (`github_upload` in `--json`: `ok` or the error). Files are `ios-signing-.key/.p12`, so two coexist in - one `--out-dir`; the key lookup is `--key`, then the distribution's file, then the legacy - `ios-signing.key`. `Progress.Settings` prints `signed (set X)` / `signed (unsuffixed IOS_* - secrets)`. Enterprise is a valid set and profile type but `Auto` refuses it (no ASC endpoint - for in-house profiles). The suffixed secret names and `SIGNING_SET*` are reserved env names. -- **On-Demand Provisioning** (`ensureSigningSecrets` in `cmd/builder/signing_auto.go`, called by - `runBuild` without `--unsigned`; it returns early unless the provider that will run the job — - `--provider`, else the profile's, else the top level, as `Coordinator.settings` resolves it — is - GitHub): when the selected profile has a distribution, `github.Client.ListSecretNames` - (`GET /repos/{o}/{r}/actions/secrets`, paginated; 403/404 are reported as a token without the - `repo` scope or admin access, never as "no secrets") is checked for the three names; all - present → dispatch. Otherwise, with an ASC key (`getASCClient` passed as a - factory so tests inject the `signingtest` portal), `signing.Auto` plus `uploadSigningSet` (shared - with `signing setup`, but fatal here) runs with no prompts: bundle ID from `ios.bundleId` or `dist/*.ipa`, - key from `signing.dir` in builder.json (the `--out-dir` the last automatic `signing setup` - recorded, tilde kept, unset for `.`; `signingKeyDirs`) then `.`, material written to the first - of those, generated password (printed once), no devices given (Auto covers the enabled ones - and fails naming `signing setup --distribution development --devices-from-mobai` when there are - none). Apple issues one certificate per type, so a 409 from `POST /v1/certificates` - (`certificateRefused`) with no key found is reported with the directories searched for - `ios-signing-.key` and `signing setup --distribution --key ` / - `--out-dir`. Without an ASC key the error names `builder auth apple` and `signing setup --certificate - ... --profile ...`, before anything is pushed. Codemagic/Bitrise skip the check (no secrets API). +- **Run Failures**: a run that completes without success is a `github.RunFailedError`: conclusion, + first failed job/step, and that job's `failure` annotations (`/check-runs/{job_id}/annotations`; a + job ID is its check run ID). The details are best-effort, so the conclusion is always reported +- **Build Profiles**: `profiles.` overrides `ios.configuration`/`ios.scheme`/`provider` and adds + `env` and `distribution` (`config.ResolveProfile`: `--profile`, else `defaultProfile`, else top level + unchanged). A profile signs iff it has a `distribution`; `ios.signing` is only the no-profile path +- **Profile Transport**: the `profile` dispatch input is one JSON object (`{"name","env","distribution"}`) + to stay under the ten-input limit and is sent only when a profile is selected, since an older + workflow rejects unknown inputs (`triggerError`). `runner.sh` reads `BUILD_ENV` and `DISTRIBUTION` +- **Profile Env**: entries are base64 per key/value on the runner and the `$GITHUB_ENV` heredoc uses a + random delimiter; names must match `^[A-Za-z_][A-Za-z0-9_]*$` and not hit `reservedEnv`/ + `reservedEnvPrefixes` (`internal/config/profile.go`), which must track what the runners read +- **Signing Sets**: one trio per distribution, `IOS_{CERTIFICATE,CERTIFICATE_PASSWORD,PROVISIONING_PROFILE}_` + (DEVELOPMENT, AD_HOC, STORE, ENTERPRISE); the unsuffixed names serve only the legacy no-profile path. + The table lives in `config.SigningSet` and the shell `signing_set` (both templates) and must agree +- **Signing Step**: `select_signing_set` (indirect expansion; a suffixed set needs all three, only the + legacy password may be empty) then `check_signing_set` compares `detect_export_method` with the + distribution before any keychain exists. Shared functions are verbatim in both templates; tests diff them +- **Signing Setup**: writes only its distribution's set and `profiles..distribution` (other fields + and an equal spelling kept), never `ios.signing` or `defaultProfile`. Upload always targets the `github` + repo in builder.json; a failure is printed, values still shown, exit non-zero (`github_upload` in `--json`) +- **On-Demand Provisioning**: `ensureSigningSecrets` (GitHub only, before any push) lists secret names + (403/404 = missing scope/admin, never "no secrets") and provisions a missing set via `signing.Auto` with + no prompts, key from `signing.dir` then `.`; a certificate 409 with no local key names the dirs searched - **Flutter Detection**: Auto-detects Flutter projects, runs `flutter pub get`, uses `Runner` scheme - **DerivedData Caching**: `restore` keys on `github.run_id` and only the prefix in `restore-keys` ever hits, so every run must pair with a `cache/save` step or later builds stay cold. `ios-share` @@ -327,9 +262,8 @@ internal/ shared `readSecret`/`writeSecret`/`deleteSecret` helpers the CI tokens use. `ASC_ISSUER_ID`, `ASC_KEY_ID` + `ASC_PRIVATE_KEY`|`ASC_KEY_PATH` take precedence; a partially set environment is an error, not a fallback. Only `auth apple` prompts; `upload`/`submit` never do. `auth apple` - verifies the key with `GET /v1/certificates?limit=1` (as MobAI does): `apps?limit=1` answers 200 - for a key of any role, `certificates` needs the Certificates, Identifiers & Profiles access that - signing needs and every role that can upload builds has. + verifies with `GET /v1/certificates?limit=1`: `apps?limit=1` answers 200 for any role, while + certificates needs the Certificates, Identifiers & Profiles access signing needs. - **Build Upload**: `buildUploads` → `buildUploadFiles` (returns `uploadOperations`) → PUT each byte range with its `requestHeaders`, no bearer token → PATCH `uploaded=true` → poll the upload `state` (COMPLETE/FAILED with `errors[]`) → poll `builds` filtered by app, marketing version and @@ -343,53 +277,24 @@ internal/ chosen group is external and none exists) → add groups. App Store reuses an open `reviewSubmission` (READY_FOR_REVIEW/UNRESOLVED_ISSUES), skips the item when the version is already in it, and rewrites ASC 409/422 with a "complete the metadata" hint. -- **Automatic Signing** (`signing.Auto`, behind `signing setup` without `--certificate`/ - `--profile` and behind on-demand provisioning): idempotent and never revokes. `signing.Type`'s - values are the canonical distributions (`signing.ParseType` wraps `config.ParseDistribution`). - A certificate is reused only when its private key is local (`--key`, or the - `ios-signing-.key` / legacy `ios-signing.key` a previous run left in - `--out-dir`), since a .p12 needs the key; otherwise a new one is issued and Apple's quota error - (2 Development / 3 Distribution) gets a hint. Keys are written as PKCS#8 (`PRIVATE KEY`, as - MobAI's signer writes them); the PKCS#1 `RSA PRIVATE KEY` files of earlier runs are still read. Dev/ad-hoc profiles cover every ENABLED iOS - device on the account, not just the ones passed; App Store profiles send no `devices` - relationship at all (an empty one is rejected). Profile membership is read from - `/v1/profiles/{id}/relationships/{certificates,devices}` (paginated), not `include=`, which - caps linkage arrays. The profile `Builder ` is recreated when - INVALID, expired, `--force`, or when the certificate/device set differs; same-named duplicates - are deleted with it. `filter[identifier]` on bundleIds is a prefix match, so the exact - identifier is checked client-side. The in-memory portal for tests is - `internal/signing/signingtest` (must not import `signing`: the signing package's own tests use it). -- **Export Method Follows The Profile**: the `method` in ExportOptions.plist must match the - uploaded profile's type (`development`, `ad-hoc`, `app-store`, `enterprise`), or xcodebuild - refuses the export. `detect_export_method` in `ios-build.yml` and `runner.sh` reads it from - the profile of the selected signing set, and `check_signing_set` confirms it is the type the - build profile's `distribution` asked for (`app-store` is the `store` distribution). -- **Signing Identity Follows The Profile Type**: `signing_identities` and `signing_identity` - (verbatim in both templates) pick `CODE_SIGN_IDENTITY` out of `security find-identity`, run right - after `security import`: `Apple Development`, else the pre-2021 `iPhone Developer`, for a - development profile; `Apple Distribution`, else `iPhone Distribution`, for the rest; a named - `::error::` when the set holds neither. `apply_signing_to_app_target` writes it into the app - target with the other manual settings, since without it Xcode keeps the project's default - identity and refuses a distribution profile ("No signing certificate iOS Development found"). -- **Signing Settings Live In The pbxproj**: `CODE_SIGN_STYLE=Manual`, `DEVELOPMENT_TEAM`, - `PROVISIONING_PROFILE_SPECIFIER` and `CODE_SIGN_IDENTITY` are never passed to `xcodebuild`: a - command-line setting applies to every target in the workspace, and CocoaPods framework - targets refuse a profile ("FirebaseCore does not support provisioning profiles, but - provisioning profile … has been manually specified"), so only pod-free projects passed. - `apply_signing_to_app_target` (verbatim in `ios-build.yml` and `runner.sh`; run in the iOS - directory right before each signed archive, after `pod install` / `expo prebuild` / - `flutter build ios` have generated the projects) converts each top-level `*.xcodeproj`'s - `project.pbxproj` to JSON with `plutil`, sets the four settings on every configuration of - the `PBXNativeTarget`s whose `productType` is an application (dropping conditional - `NAME[sdk=…]` variants that would override them), and writes the file back as an XML plist, - which Xcode reads. `Pods/Pods.xcodeproj` is a level down and never a candidate; extension and - framework targets are never touched. With one app target it is signed whatever its bundle id - (the export reports a mismatch); with several, the ones whose `PRODUCT_BUNDLE_IDENTIFIER` - the profile's app id covers (`PROFILE_BUNDLE_ID`: `application-identifier` minus the team - prefix; `*` and `com.example.*` are wildcards), else a `::error::` naming the bundle ids - found. `ExportOptions.plist` keeps its `provisioningProfiles` map as before. - `TestSigningSettingsOnAppTargetOnly` compares the two bodies, asserts no archive command - passes the settings, and (darwin) runs the function on generated pbxproj fixtures. +- **Automatic Signing** (`signing.Auto`): idempotent, never revokes. A certificate is reused only when + its key is local (`--key`, `ios-signing-.key`, legacy `ios-signing.key`; PKCS#8 written, + PKCS#1 still read). Profile `Builder ` is recreated on INVALID/expired/`--force`/changes +- **ASC Signing Gotchas**: `filter[identifier]` on bundleIds is a prefix match (exact checked client-side); + membership comes from `/relationships/{certificates,devices}` (`include=` caps arrays); store profiles + send no `devices` relationship; enterprise is refused; `signingtest` must not import `signing` +- **Export Method Follows The Profile**: `detect_export_method` (both templates) reads the type from the + set's profile into ExportOptions.plist `method` (legacy names: older Xcodes reject the 15.3+ ones); + `check_signing_set` maps `app-store` → `store` when comparing with the distribution +- **Signing Identity Follows The Profile Type**: `signing_identity` picks `CODE_SIGN_IDENTITY` from + `security find-identity` right after import: `Apple Development`/`iPhone Developer` for development, + `Apple Distribution`/`iPhone Distribution` otherwise; without it Xcode keeps the project's default +- **Signing Settings Live In The pbxproj**: `apply_signing_to_app_target` (both templates, right before + each signed archive, after `pod install`/`expo prebuild`/`flutter build ios`) writes the four manual + settings into app targets only via `plutil`; on the command line every Pods target would inherit them +- **App Target Selection**: one app target is signed whatever its bundle id (the export reports a + mismatch); with several, those whose `PRODUCT_BUNDLE_IDENTIFIER` `PROFILE_BUNDLE_ID` covers (`*` + wildcards), else `::error::` naming the ids found; conditional `NAME[sdk=…]` variants are dropped - **Extension Points**: a future `ios release` (upload + TestFlight, automatic build numbers) composes `distribute.Upload` and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` for the latest build number; the `pkg/` wrappers do not expose `asc` yet. @@ -412,28 +317,20 @@ internal/ } ``` -`ios.bundleId` is optional: `init` fills it from `PRODUCT_BUNDLE_IDENTIFIER` when the Xcode -project has exactly one app target (test targets and `$(…)` values are skipped), and -`signing setup` saves whatever it resolved. `signing.dir` (`"signing": {"dir": "~/signing/app"}`) -is the `--out-dir` of the last automatic `signing setup`, written as given and only when it is -not `.`; on-demand provisioning reads the certificate's key from there before the working -directory. - -`profiles` and `defaultProfile` are optional. A profile's fields are `distribution` -(`development`, `ad-hoc`/`internal`, `store`, `enterprise`; the only signing field: selects the -signing set and the profile type the runner expects, and with it the export method; omitted is -unsigned), `configuration` (derived from the distribution when omitted: Debug for development, -Release otherwise), `scheme`, `provider` and `env` (string map). `ios.signing` is the legacy -no-profile path with the unsuffixed secrets. `runner` and `submit` are planned for the same struct -(`config.Profile`) but not read. +`ios.bundleId` is optional: `init` fills it when the project has exactly one app target, `signing +setup` saves what it resolved. `signing.dir` is the last automatic `signing setup`'s `--out-dir` as +given (`~` kept, omitted for `.`); on-demand provisioning looks there for the key first. + +A profile's fields are `distribution` (`development`, `ad-hoc`/`internal`, `store`, `enterprise`; the +only signing field, omitted = unsigned), `configuration` (else Debug for development, Release +otherwise), `scheme`, `provider`, `env`. `runner`/`submit` are planned on `config.Profile`, not read. ## Workflow Features The embedded workflow template (`internal/workflow/templates/ios-build.yml`): -- Triggered via `workflow_dispatch` with `build_id`, `snapshot_ref`, `ios_path`, `scheme`, - `use_signing`, `configuration`, `flutter_version`, `jdk_version` and `profile` (nine of the ten - inputs GitHub allows; the last slot is meant for item 5's `build_number`, so add nothing else - without combining) +- Triggered via `workflow_dispatch` with `build_id`, `snapshot_ref`, `ios_path`, `scheme`, `use_signing`, + `configuration`, `flutter_version`, `jdk_version` and `profile`: nine of the ten inputs GitHub + allows, and the last slot is reserved for `build_number`, so combine before adding one - Dispatch runs the workflow from the **default branch**, so edits to the workflow file itself only take effect once pushed there — unlike app sources, which come from the snapshot ref - Checks out `snapshot_ref` over the default-branch checkout when set @@ -441,9 +338,9 @@ The embedded workflow template (`internal/workflow/templates/ios-build.yml`): workflow) for environments without GitHub API access. Push events run the workflow file from the tagged commit, `inputs` are empty, so a `Resolve parameters` step reads `ios_path`, `scheme`, `use_signing`, `configuration`, `flutter_version` and `jdk_version` from `builder.json` in the - tagged tree, applying the profile named by `defaultProfile` (a tag cannot pick one per run); - every later step reads `steps.params.outputs.*`, never `inputs.*`. The same step exports the - profile's `env` to `$GITHUB_ENV` and outputs `profile`, `distribution` and `signing_set`. The job deletes + tagged tree, applying `defaultProfile` (a tag cannot pick a profile per run); every later step + reads `steps.params.outputs.*`, never `inputs.*`. The same step exports the profile's `env` to + `$GITHUB_ENV` and outputs `profile`, `distribution` and `signing_set`. The job deletes the tag when it ends (`permissions: contents: write`). Any other workflow in the repo with an unfiltered `on: push` also fires on these tags. - Runs on `macos-latest` @@ -453,14 +350,9 @@ The embedded workflow template (`internal/workflow/templates/ios-build.yml`): - Flutter: uses `Runner` scheme, runs `flutter pub get` - Installs CocoaPods if Podfile exists - Builds unsigned IPA with `CODE_SIGNING_ALLOWED=NO` -- **Export Method**: `detect_export_method` reads the profile — `ProvisionsAllDevices` → - `enterprise`, `ProvisionedDevices` with `get-task-allow` → `development`, without → `ad-hoc`, - neither → `app-store` — and that method goes into `ExportOptions.plist` (legacy names, since - older Xcodes reject the 15.3+ ones). Non-development exports add - `manageAppVersionAndBuildNumber = false`, and a distribution profile with configuration `Debug` - fails in the signing step, before the build. The function is duplicated verbatim in - `ios-build.yml` and `runner.sh`; a test compares the two bodies and runs one against - synthetic profile plists +- **Export Method**: `detect_export_method` maps `ProvisionsAllDevices` → `enterprise`, `ProvisionedDevices` + + `get-task-allow` → `development`, without → `ad-hoc`, neither → `app-store`; non-development exports + set `manageAppVersionAndBuildNumber = false`, and a distribution profile with `Debug` fails before the build - Uploads IPA as GitHub artifact with 7-day retention ## Flutter Dev Requirements diff --git a/README.md b/README.md index dda4cda..98e8e81 100644 --- a/README.md +++ b/README.md @@ -312,12 +312,11 @@ How a build's settings are resolved: **`env` values are build-time configuration, not secrets.** They are stored in `builder.json`, sent to the CI provider as plain workflow inputs, and visible in the run's inputs and logs. Keep tokens and passwords in the provider's secrets -instead (`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets +(`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets guide](docs/provider-secrets.md)); the build reads those as environment -variables too. Names the runner owns are rejected: its own parameters -(`SCHEME`, `CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing -secrets, `PATH`, `HOME`, `DEVELOPER_DIR`, and anything starting with `GITHUB_`, -`RUNNER_`, `CM_`, `BITRISE_` or `BUILDER_`. +variables too. Names the runner owns are rejected: its own parameters (`SCHEME`, +`CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing secrets, `PATH`, +`HOME`, `DEVELOPER_DIR`, and the `GITHUB_`, `RUNNER_`, `CM_`, `BITRISE_`, `BUILDER_` prefixes. Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow file from this version of Builder, which declares a `profile` input; an older @@ -432,13 +431,12 @@ create certificates. It then: newest IPA in `./dist/`; in a terminal it asks as a last resort. 2. Issues a **certificate** — Apple Development for `development`, Apple Distribution for `ad-hoc` and `store` — for a private key generated on your - machine (`ios-signing-.key`, or `--key` to reuse one from - `signing csr`; a `ios-signing.key` from an earlier version is picked up + machine (`ios-signing-.key`; `--key` reuses one from + `signing csr`, and an `ios-signing.key` from an earlier version is picked up too). A valid certificate on the account is reused only when its private - key is here, because that is the only way to build the `.p12`; otherwise a - new one is issued. Nothing is ever revoked: when Apple's limit (2 - Development, 3 Distribution) is hit, the error names it and points at the - portal. + key is here, since the `.p12` needs it; otherwise a new one is issued. + Nothing is ever revoked: at Apple's limit (2 Development, 3 Distribution) + the error says so and points at the portal. 3. Registers **devices** from `--device ` (repeatable) and `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has connected; simulators and cloud farm devices are skipped). Development and @@ -453,17 +451,13 @@ create certificates. It then: changed`, `forced`). 5. Writes `ios-signing-.key` (when generated), `ios-signing-.p12` and `Builder--.mobileprovision` to `--out-dir` (default `.`), uploads the three secrets - of the set to GitHub, and writes the build profile in `builder.json`: - `--name` (default: the distribution name) with `"distribution": - ""`. Other fields of an existing profile are kept; a - different `distribution` in it is replaced, and the command says so. - `defaultProfile` is not touched: point it at the profile for a plain - `ios build` to use it, or pass `--profile`. An `--out-dir` other than `.` - is recorded as `signing.dir` (as typed, `~` included), so a later - `ios build --profile` that has to provision a set finds the certificate's - key there instead of asking Apple for a second certificate, which it - refuses. + id>.mobileprovision` to `--out-dir` (default `.`), uploads the set's three + secrets to GitHub, and writes `"distribution": ""` into the + `--name` profile (default: the distribution name) in `builder.json`, keeping + its other fields and reporting a replaced distribution; `defaultProfile` is + left alone. An `--out-dir` other than `.` is recorded as `signing.dir`, so a + later `ios build` that provisions a set reuses the key there instead of + asking Apple for a second certificate, which it refuses. 6. Prints the three secret names and where their values come from — the `.p12` base64-encoded, the password, the `.mobileprovision` base64-encoded — every time, so the same set can be pasted into Codemagic or Bitrise, @@ -500,15 +494,13 @@ builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovis `builder ios build --profile ` checks, before dispatching to GitHub, that the repository holds all three secrets of the profile's set. When any is -missing and an App Store Connect key is saved, it runs the same provisioning as -`signing setup` without prompts, uploads the set and then builds. A development -or ad-hoc profile needs at least one registered device; with none, the build -stops and points at `builder signing setup --distribution development ---devices-from-mobai`. Without an Apple key the build stops before anything is -pushed and names both ways out: `builder auth apple`, or `builder signing setup ---certificate ... --profile ...`. `--unsigned` skips all of this, and -Codemagic/Bitrise builds skip the check (no secrets API): their runner -reports a missing set itself. +missing and an App Store Connect key is saved, it runs the same provisioning +as `signing setup` without prompts, uploads the set and builds; a development +or ad-hoc profile with no registered device stops and points at `builder +signing setup --distribution development --devices-from-mobai`. Without an +Apple key it stops before anything is pushed and names both ways out (`builder +auth apple`, or `signing setup --certificate ... --profile ...`). `--unsigned` +skips the check, and so do Codemagic/Bitrise builds (no secrets API). ### Legacy: `ios.signing` without profiles diff --git a/cmd/builder/root.go b/cmd/builder/root.go index 27f9b2c..90a402e 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -223,9 +223,8 @@ func detectIOSPath() (string, string) { var bundleIDRe = regexp.MustCompile(`PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^";\s]+)"?\s*;`) // detectBundleID reads the app's bundle identifier from the Xcode project -// under iosPath. Test targets (…Tests) and values built from build settings -// ($(…)) are ignored; anything still ambiguous yields "" so init leaves the -// field for `signing setup` to resolve. +// under iosPath, skipping test targets and $(…) values. Anything still +// ambiguous yields "" so init leaves the field for `signing setup` to resolve. func detectBundleID(iosPath string) string { if iosPath == "" { iosPath = "." @@ -723,8 +722,7 @@ func runBuild(ctx context.Context, cfg *config.Config, opts *build.BuildOptions) return err } // A GitHub build with a distribution needs its signing set in the - // repository; Codemagic and Bitrise have no secrets API, so their runner - // reports a missing set itself (the check knows the profile may pick them). + // repository; ensureSigningSecrets leaves Codemagic and Bitrise alone. if ghClient != nil && !opts.Unsigned { if err := ensureSigningSecrets(ctx, cfg, ghClient, getASCClient, opts.Profile, opts.Provider, os.Stdout); err != nil { return err diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index feabd9d..40d1cfc 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -195,9 +195,8 @@ func setupDistribution(cfg *config.Config, profileName, flag string) (signing.Ty } // uploadSigningSet writes the three secrets of a set to the GitHub repository -// in builder.json. storeErr is a client that could not be built at all (no -// login), reported the same way as a failed upload: `signing setup` prints the -// values afterwards, so neither is the end of the road. +// in builder.json. storeErr is a client that could not be built (no login), +// reported like a failed upload since the values are printed afterwards. func uploadSigningSet(ctx context.Context, store secretStore, storeErr error, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte) error { if storeErr != nil { return storeErr @@ -329,10 +328,9 @@ func mobaiSigningDevices(connected []mobai.Device) []signing.Device { return devices } -// signingKey returns the key at keyPath (--key), else the key a previous run -// of this type left in the first of dirs that has one (ios-signing-.key, -// or the ios-signing.key of runs before signing sets), else nil so a key is -// generated. The returned path is "" when generating. +// signingKey returns the key at keyPath (--key), else the first +// ios-signing-.key or legacy ios-signing.key in dirs, else nil so a key +// is generated (path "" then). func signingKey(keyPath string, typ signing.Type, dirs ...string) (keyPEM []byte, path string, err error) { if keyPath == "" { keyPath = findSigningKey(typ, dirs) @@ -360,10 +358,9 @@ func findSigningKey(typ signing.Type, dirs []string) string { return "" } -// recordSigningDir keeps `signing setup`'s --out-dir in builder.json as it -// was given (a ~ stays a ~, so the file works for every user of the repo), -// where on-demand provisioning looks for the key first. The default working -// directory is not written. +// recordSigningDir keeps `signing setup`'s --out-dir in builder.json as given +// (a ~ stays a ~, so the file works for every user of the repo), where +// on-demand provisioning looks for the key first; "." is not written. func recordSigningDir(cfg *config.Config, outDir string) { outDir = strings.TrimSpace(outDir) if filepath.Clean(outDir) == "." { @@ -480,14 +477,10 @@ func missingSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Conf return missing, nil } -// ensureSigningSecrets runs before a build is dispatched to GitHub: when the -// selected profile has a distribution, its signing set must be in the -// repository. A missing or partial set is provisioned through App Store -// Connect the way `signing setup` does, without prompts; without Apple -// credentials the build stops here, before anything is pushed. The provider -// that will run the job is --provider, else the profile's, else the top-level -// one (as the coordinator resolves it); Codemagic and Bitrise have no secrets -// API, so their builds are left to the runner, which reports a missing set. +// ensureSigningSecrets provisions a missing or partial signing set through +// App Store Connect without prompts before a build is dispatched, so a +// distribution build never fails on the runner for want of secrets. It stops +// before anything is pushed when there are no Apple credentials. func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretStore, ascClient func() (*asc.Client, error), profile, provider string, log io.Writer) error { s, err := cfg.ResolveProfile(profile) if err != nil { @@ -504,9 +497,7 @@ func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretS return nil } if name != "github" { - // Codemagic and Bitrise have no secrets API, so the set cannot be - // checked or provisioned from here; the runner fails by name if it - // is missing. + // Codemagic and Bitrise have no secrets API; their runner fails by name. fmt.Fprintf(log, "Profile %q signs with set %s. Builder cannot check %s secrets; if the build fails on signing, run: builder signing setup --distribution %s\n", s.Profile, s.SigningSet(), name, s.Distribution) return nil } @@ -629,9 +620,8 @@ func signingUploadLine(cfg *config.Config, names config.SigningSecrets, uploadEr } // printSigningSecretValues names the three secrets of the set and where their -// values come from. It is printed whether or not the upload worked: Codemagic -// and Bitrise are set in their own dashboards, and so is a GitHub repository -// this token cannot write to. +// values come from, whether or not the upload worked: Codemagic, Bitrise and a +// repository this token cannot write to are set by hand. func printSigningSecretValues(w io.Writer, names config.SigningSecrets, p12Path, profilePath string) { fmt.Fprintln(w, "Set them by hand wherever Builder cannot (Codemagic, Bitrise, a repository this login cannot write to):") fmt.Fprintf(w, " %-*s base64 of %s\n", len(names.Password), names.Certificate, p12Path) diff --git a/internal/asc/certificates.go b/internal/asc/certificates.go index f3bb902..1c17a1b 100644 --- a/internal/asc/certificates.go +++ b/internal/asc/certificates.go @@ -62,11 +62,10 @@ func toCertificate(r Resource[certificateAttributes]) (Certificate, error) { return c, nil } -// CheckAccess verifies the key with one cheap read-only call. It lists one -// certificate rather than one app: apps?limit=1 answers 200 with an empty -// page for a key of any role, while certificates demands the Certificates, -// Identifiers & Profiles access that signing needs (and every role that can -// upload builds has). +// CheckAccess verifies the key with one cheap read-only call. It lists +// certificates rather than apps because apps?limit=1 answers 200 for a key of +// any role, while certificates demands the Certificates, Identifiers & +// Profiles access that signing needs. func (c *Client) CheckAccess(ctx context.Context) error { _, err := getPage[certificateAttributes](ctx, c, "/v1/certificates", url.Values{"limit": {"1"}}) return err diff --git a/internal/build/coordinator.go b/internal/build/coordinator.go index aefe4d3..fb811c5 100644 --- a/internal/build/coordinator.go +++ b/internal/build/coordinator.go @@ -99,11 +99,10 @@ func (c *Coordinator) workflowInputs(buildID, ref string, s *config.BuildSetting if s.Scheme != "" { inputs["scheme"] = s.Scheme } - // Pass Flutter version if configured (ensures SDK version match for hot reload) + // The Flutter SDK version must match the local one for hot reload. if c.config.Flutter.Version != "" { inputs["flutter_version"] = c.config.Flutter.Version } - // Pass JDK version for Kotlin Multiplatform Gradle builds if c.config.KMP.JDKVersion != "" { inputs["jdk_version"] = c.config.KMP.JDKVersion } @@ -111,15 +110,14 @@ func (c *Coordinator) workflowInputs(buildID, ref string, s *config.BuildSetting } // buildInputs are the ios-build.yml inputs: the shared ones plus signing, -// configuration and the profile, none of which the simulator workflow has a -// use for. `profile` is only sent when one is selected: a workflow file from +// configuration and the profile, which the simulator workflow does not declare. +// `profile` is only sent when one is selected, since a workflow file from // before profiles rejects a dispatch carrying an input it does not declare. func (c *Coordinator) buildInputs(buildID, ref string, s *config.BuildSettings) map[string]string { inputs := c.workflowInputs(buildID, ref, s) if s.Signing { inputs["use_signing"] = "true" } - // Pass build configuration (Debug is faster, Release for production) if s.Configuration != "" { inputs["configuration"] = s.Configuration } diff --git a/internal/build/remote.go b/internal/build/remote.go index 8d654b7..2cb6d92 100644 --- a/internal/build/remote.go +++ b/internal/build/remote.go @@ -62,9 +62,8 @@ func (c *Coordinator) remote(override string) (ci.Provider, config.CIConfig, err } // inputs are the variables runner.sh reads on Codemagic and Bitrise. The -// profile's env travels as one JSON object in BUILD_ENV, which the runner -// exports before installing dependencies; DISTRIBUTION is passed through for -// the export step. Both are only set when the profile provides them. +// profile's env travels as one JSON object in BUILD_ENV, and DISTRIBUTION +// selects the signing set; both are only set when the profile provides them. func (c *Coordinator) inputs(buildID, ref, sha string, s *config.BuildSettings) map[string]string { v := map[string]string{"BUILD_ID": buildID, "SNAPSHOT_REF": ref, "SNAPSHOT_SHA": sha, "IOS_PATH": c.config.IOS.Path, "SCHEME": s.Scheme, diff --git a/internal/config/profile.go b/internal/config/profile.go index 839e539..11f4155 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -26,10 +26,9 @@ type BuildSettings struct { Distribution string } -// reservedEnv names the variables the runners read their parameters and -// secrets from, and the ones the shell and the CI services own. A profile that -// set one of these would silently change the build, or on runner.sh replace a -// provider secret, since the env is exported before the signing step reads it. +// reservedEnv names the variables the runners, the shell and the CI services +// own. A profile that set one would silently change the build or, on +// runner.sh, replace a provider secret, since the env is exported first. var reservedEnv = []string{ "BUILD_ID", "SNAPSHOT_REF", "SNAPSHOT_SHA", "IOS_PATH", "SCHEME", "CONFIGURATION", "USE_SIGNING", "FLUTTER_VERSION", "JDK_VERSION", "BUILD_ENV", "DISTRIBUTION", @@ -72,13 +71,10 @@ func (c *Config) ProfileNames() []string { } // ResolveProfile applies the named profile, or defaultProfile when name is -// empty, over the top-level ios.* and provider settings. With neither, the -// result is the top-level settings unchanged, so projects without profiles -// build exactly as before. -// -// A profile signs exactly when it has a distribution; ios.signing does not -// apply to it. Its configuration is the one it sets, else Debug for -// development and Release for every other distribution, else ios.configuration. +// empty, over the top-level ios.* and provider settings; with neither the +// top-level settings come back unchanged. A profile signs exactly when it has a +// distribution (ios.signing does not apply to it), and its configuration +// defaults to Debug for development and Release for every other distribution. func (c *Config) ResolveProfile(name string) (BuildSettings, error) { s := BuildSettings{ Configuration: c.IOS.Configuration, @@ -135,9 +131,9 @@ func (c *Config) ResolveProfile(name string) (BuildSettings, error) { return s, nil } -// EnvJSON encodes the profile's environment as a JSON object, which is how it -// travels to the runner: workflow inputs and CI variables are strings, and JSON -// survives values with spaces, quotes and newlines. Empty when there is none. +// EnvJSON encodes the profile's environment as a JSON object (empty when there +// is none), because workflow inputs and CI variables are strings and JSON +// survives values with spaces, quotes and newlines. func (s *BuildSettings) EnvJSON() string { if len(s.Env) == 0 { return "" @@ -146,11 +142,9 @@ func (s *BuildSettings) EnvJSON() string { return string(data) } -// ProfileInput encodes the parts of the profile that are not workflow inputs of -// their own (name, env, distribution) as the single `profile` dispatch input, -// keeping the workflow under GitHub's limit of ten inputs. Empty when no -// profile is selected, so older workflow files keep receiving the inputs they -// declare. +// ProfileInput encodes name, env and distribution as the single `profile` +// dispatch input, keeping the workflow under GitHub's limit of ten inputs. It +// is empty when no profile is selected, so older workflow files still work. func (s *BuildSettings) ProfileInput() string { if s.Profile == "" { return "" diff --git a/internal/config/signing.go b/internal/config/signing.go index f153b61..148dcea 100644 --- a/internal/config/signing.go +++ b/internal/config/signing.go @@ -42,11 +42,10 @@ func ParseDistribution(s string) (string, error) { return "", fmt.Errorf("distribution %q must be one of %s (internal is ad-hoc)", s, strings.Join(Distributions, ", ")) } -// SigningSet returns the suffix of the secrets a distribution is signed with: -// the canonical name upper-cased with - as _ (DEVELOPMENT, AD_HOC, STORE, -// ENTERPRISE). No distribution has no set: that is the legacy ios.signing -// path, which reads the unsuffixed secrets. The shell function signing_set in -// ios-build.yml and runner.sh is the same table. +// SigningSet returns the suffix of the secrets a distribution is signed with +// (DEVELOPMENT, AD_HOC, STORE, ENTERPRISE; "" for the legacy ios.signing path, +// which reads the unsuffixed secrets). The shell function signing_set in +// ios-build.yml and runner.sh is the same table and must agree. func SigningSet(distribution string) (string, error) { d, err := ParseDistribution(distribution) if err != nil { diff --git a/internal/config/types.go b/internal/config/types.go index e939d44..da21f64 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -36,17 +36,15 @@ type SigningConfig struct { } // Profile is a named set of build settings, selected with --profile. Every -// field is optional and overrides the matching top-level setting; unset fields -// keep the top-level value. Runner and submit settings are planned here too. +// field is optional and overrides the matching top-level setting. type Profile struct { Configuration string `json:"configuration,omitempty"` // overrides ios.configuration; derived from distribution when empty Scheme string `json:"scheme,omitempty"` // overrides ios.scheme Provider string `json:"provider,omitempty"` // overrides provider Env map[string]string `json:"env,omitempty"` // exported on the runner before dependencies and the build - // Distribution is the only signing setting of a profile: development, - // ad-hoc (or internal), store or enterprise. It selects the signing set the - // runner reads (IOS_*_ secrets, see SigningSet) and the type the - // provisioning profile in it must have. Empty means an unsigned build. + // Distribution is the only signing setting of a profile (development, + // ad-hoc or internal, store, enterprise; empty is unsigned): it selects the + // signing set and the type the provisioning profile in it must have. Distribution string `json:"distribution,omitempty"` } diff --git a/internal/github/repo.go b/internal/github/repo.go index 2c68c4e..b88e277 100644 --- a/internal/github/repo.go +++ b/internal/github/repo.go @@ -32,10 +32,9 @@ func (c *Client) GetPublicKey(ctx context.Context, owner, repo string) (*PublicK } // ListSecretNames returns the names of the repository's Actions secrets -// (values are never readable). It follows the pages GitHub returns. GitHub -// answers 404 (token without the repo scope) or 403 (no admin access) rather -// than an empty list, so those name the cause instead of reading as "no -// secrets". +// (values are never readable), following every page. GitHub answers 404 +// (token without the repo scope) or 403 (no admin access) rather than an +// empty list, so those name the cause instead of reading as "no secrets". func (c *Client) ListSecretNames(ctx context.Context, owner, repo string) ([]string, error) { var names []string for page := 1; ; page++ { diff --git a/internal/signing/auto.go b/internal/signing/auto.go index b1018a3..dfe7307 100644 --- a/internal/signing/auto.go +++ b/internal/signing/auto.go @@ -172,9 +172,9 @@ type AutoResult struct { // Auto provisions everything an iOS build needs to sign through the App Store // Connect API: the App ID, a certificate whose private key is on this -// machine, the devices, and a profile tying them together. It is idempotent: -// a second run reuses what is valid and recreates only what is missing, -// expired, invalid or no longer matches. Nothing is ever revoked. +// machine, the devices, and a profile tying them together. It is idempotent +// (a second run recreates only what is missing, expired, invalid or changed) +// and never revokes anything. func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResult, error) { if opts.BundleID == "" { return nil, errors.New("bundle ID is required") @@ -223,9 +223,8 @@ func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResu } } - // 3. Certificate. A generated key is on disk before the CSR goes to - // Apple: a certificate whose key is lost cannot be revoked by Builder and - // occupies one of the team's slots for a year. + // 3. Certificate, with a generated key on disk before the CSR goes to + // Apple: a certificate whose key is lost occupies a team slot for a year. if err := os.MkdirAll(opts.OutDir, 0755); err != nil { return res, fmt.Errorf("create %s: %w", opts.OutDir, err) } diff --git a/internal/signing/signing.go b/internal/signing/signing.go index e56e844..b0e140f 100644 --- a/internal/signing/signing.go +++ b/internal/signing/signing.go @@ -15,12 +15,10 @@ import ( pkcs12 "software.sslmate.com/src/go-pkcs12" ) -// GenerateKeyAndCSR creates an RSA-2048 private key and a certificate signing -// request for the Apple Developer portal, both PEM-encoded. Apple requires -// RSA 2048 for signing certificates; the subject mirrors what Keychain Access -// puts in its CSRs (email address and common name). The key is written in -// PKCS#8 ("PRIVATE KEY"), the form openssl and zsign read without a legacy -// flag. +// GenerateKeyAndCSR creates an RSA-2048 private key (Apple's requirement) and +// a certificate signing request whose subject mirrors Keychain Access's, both +// PEM-encoded. The key is PKCS#8 ("PRIVATE KEY"), the form openssl and zsign +// read without a legacy flag. func GenerateKeyAndCSR(commonName, email string) (keyPEM, csrPEM []byte, err error) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index c708e9a..7cfd70d 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -81,10 +81,9 @@ jobs: git checkout --force snapshot echo "Building $(git rev-parse --short HEAD)" - # Dispatch inputs arrive with their declared defaults. A tag push has no - # inputs, so the values come from builder.json in the tagged commit and - # the build id is the tag name after the prefix. A tag build cannot pick - # a profile per run; it applies builder.json's defaultProfile, if any. + # Dispatch inputs arrive with their declared defaults; a tag push has none, + # so the values come from builder.json in the tagged commit (defaultProfile + # included) and the build id is the tag name after the prefix. - name: Resolve parameters id: params env: @@ -129,10 +128,8 @@ jobs: param flutter_version "$IN_FLUTTER_VERSION" '.flutter.version' '' param jdk_version "$IN_JDK_VERSION" '.kmp.jdkVersion' '17' - # The rest of the profile: name (for the summary), distribution - # (which signing set the signing step reads and which profile type - # it expects) and env, exported to every step from here on so - # dependency installs and the build see it. + # The rest of the profile: name, distribution (selects the signing + # set) and env, exported from here on so dependency installs see it. if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then PROFILE_JSON="$IN_PROFILE" elif [ -f builder.json ]; then @@ -141,12 +138,10 @@ jobs: [ -n "${PROFILE_JSON:-}" ] || PROFILE_JSON='{}' PROFILE=$(jq -r '.name // ""' <<< "$PROFILE_JSON") DISTRIBUTION=$(jq -r '.distribution // ""' <<< "$PROFILE_JSON") - # internal is an alias of ad-hoc; the CLI sends the canonical name, - # a tag build reads whatever builder.json says. + # The CLI sends the canonical name; a tag build reads whatever builder.json says. case "$DISTRIBUTION" in internal) DISTRIBUTION=ad-hoc ;; esac - # The suffix of the IOS_* secrets a distribution is signed with: its - # canonical name upper-cased. No distribution has no set (the legacy - # ios.signing path reads the unsuffixed secrets). Same table in runner.sh. + # The suffix of the IOS_* secrets a distribution is signed with; no + # distribution means the legacy unsuffixed secrets. Same table in runner.sh. signing_set() { case "$1" in '') echo '' ;; @@ -166,10 +161,9 @@ jobs: echo "profile=${PROFILE:-(none)}" echo "distribution=$DISTRIBUTION" echo "signing_set=$SIGNING_SET" - # Values are base64 per entry so newlines and quotes survive; the - # heredoc form of GITHUB_ENV then takes them verbatim, with a random - # delimiter so no value line can end it early. Names are checked so a - # value cannot smuggle in a second variable. + # Values are base64 per entry so newlines and quotes survive, and the + # GITHUB_ENV heredoc gets a random delimiter so no value line can end + # it early. Names are checked so a value cannot smuggle in a second variable. if [ "$(jq -r '.env // {} | type' <<< "$PROFILE_JSON")" != "object" ]; then echo "::error::the profile's env must be a JSON object of variable names to string values"; exit 1 fi @@ -349,10 +343,8 @@ jobs: restore-keys: | pods-${{ runner.os }}- - # One set of secrets per distribution, IOS_*_, selected by the - # build profile's distribution; the unsuffixed names serve builds - # without a profile (ios.signing). A secret that does not exist arrives - # empty. + # One set of secrets per distribution, IOS_*_; the unsuffixed names + # serve builds without a profile. A secret that does not exist arrives empty. - name: Install certificate and provisioning profile if: steps.params.outputs.use_signing == 'true' env: @@ -378,13 +370,10 @@ jobs: set -e fail() { echo "::error::$*"; exit 1; } - # Picks the secrets of the set the build profile's distribution names - # (IOS_CERTIFICATE_ and friends) into IOS_CERTIFICATE, - # IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE. A set needs - # all three (builder signing setup always writes a password). With - # no distribution — ios.signing without a profile — the unsuffixed - # secrets are used as they are, password optional. SIGNING_SET_USED - # says which it was. Same function in runner.sh. + # Picks the set's IOS_*_ secrets into the unsuffixed names, or with + # no distribution takes the unsuffixed ones as they are (password + # optional). A suffixed set needs all three, since builder signing setup + # always writes a password. select_signing_set() { if [ -z "$SIGNING_SET" ]; then if [ -z "${IOS_CERTIFICATE:-}" ] || [ -z "${IOS_PROVISIONING_PROFILE:-}" ]; then @@ -407,12 +396,10 @@ jobs: echo "Signing set: $SIGNING_SET_USED" } - # The profile in the set must be the type the build profile asked - # for, or the export method, and the IPA, would not be what the - # profile promised. Names are compared canonically: the export - # method calls the store distribution app-store, and a tag build may - # say internal for ad-hoc. The unsuffixed secrets (no distribution) - # are taken as they are. + # The profile in the set must be the type the build profile asked for, + # or the IPA would not be what the profile promised. Compared + # canonically: the export method says app-store for store, and a tag + # build may say internal for ad-hoc. check_signing_set() { [ "$SIGNING_SET_USED" != legacy ] || return 0 local have="$1" want="$DISTRIBUTION" @@ -424,10 +411,9 @@ jobs: } # The export method has to match the profile, or -exportArchive fails - # and App Store Connect rejects the IPA. Xcode 15.3+ also accepts - # debugging/release-testing/app-store-connect, but these legacy names - # still work in Xcode 16 and are the only ones older Xcodes (pinned or - # self-hosted runners) understand, so both templates use them. + # and App Store Connect rejects the IPA. These legacy names are the only + # ones older Xcodes (pinned or self-hosted runners) understand, and + # Xcode 16 still takes them. detect_export_method() { if [ "$(plutil -extract ProvisionsAllDevices raw -o - "$1" 2>/dev/null)" = "true" ]; then echo enterprise @@ -443,10 +429,8 @@ jobs: } # The certificate names that can sign for a profile of this type, the - # current one first. Apple renamed the certificates in 2021; keychains - # still hold iPhone Developer / iPhone Distribution certificates that - # sign exactly the same profiles. Duplicated verbatim in ios-build.yml - # and runner.sh. + # current one first: keychains still hold pre-2021 iPhone Developer / + # iPhone Distribution certificates that sign the same profiles. signing_identities() { case "$1" in development) printf '%s\n' "Apple Development" "iPhone Developer" ;; @@ -455,12 +439,10 @@ jobs: esac } - # The identity to archive with: the first of those names the imported - # certificate actually goes by ($2 is security find-identity output). - # Without an explicit CODE_SIGN_IDENTITY the archive keeps the - # project's default, and Xcode refuses to pair a development identity - # with a distribution profile: "No signing certificate iOS Development - # found". Duplicated verbatim in ios-build.yml and runner.sh. + # The identity to archive with: the first of those names in security + # find-identity's output ($2). Without an explicit CODE_SIGN_IDENTITY the + # archive keeps the project's default, which Xcode refuses to pair with a + # distribution profile ("No signing certificate iOS Development found"). signing_identity() { local names name names=$(signing_identities "$1") || return 1 @@ -553,18 +535,11 @@ jobs: set -e fail() { echo "::error::$*"; exit 1; } - # Manual signing goes into the app target's build configurations, not on - # the xcodebuild command line: a command-line setting applies to every - # target in the workspace, and a CocoaPods framework target refuses a - # provisioning profile ("FirebaseCore does not support provisioning - # profiles"). Edits the application targets of the .xcodeproj files in the - # current directory (Pods/Pods.xcodeproj is a level down): the only one, or - # with several the ones whose PRODUCT_BUNDLE_IDENTIFIER the profile's app id - # ($1, "*" or "com.example.*" for a wildcard) covers. DEVELOPMENT_TEAM, - # PROVISIONING_PROFILE_NAME and CODE_SIGN_IDENTITY come from the - # environment. The pbxproj is written back as an XML plist, which Xcode - # reads like the OpenStep form. Duplicated verbatim in ios-build.yml and - # runner.sh. + # Writes the manual signing settings (team, profile and identity from the + # environment) into the application targets of ./*.xcodeproj that the + # profile's app id ($1, "*" wildcards) covers, as an XML plist Xcode reads. + # On the xcodebuild command line they would apply to every target, and a + # CocoaPods framework target refuses a provisioning profile. apply_signing_to_app_target() { local projects=(*.xcodeproj) out [ -d "${projects[0]}" ] || fail "No .xcodeproj in $PWD to apply the signing settings to" @@ -753,10 +728,8 @@ jobs: if [ "$USE_SIGNING" = "true" ]; then # Must archive, not build: the IPA step below exports - # build/App.xcarchive whenever signing is on. The manual signing - # settings go on the app target, now that the project is - # generated and pod install has run, never on the command line, - # where every pod would inherit the profile. + # build/App.xcarchive whenever signing is on. The signing settings + # go on the app target now that pod install has generated the project. apply_signing_to_app_target "$PROFILE_BUNDLE_ID" BUILD_CMD="$BUILD_CMD -archivePath '$GITHUB_WORKSPACE/build/App.xcarchive' archive" else diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index e896cef..0049e2c 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -7,10 +7,8 @@ mkdir -p "$ci_dir" mode="${1:-build}" export IOS_PATH="${IOS_PATH:-.}" SCHEME="${SCHEME:-}" CONFIGURATION="${CONFIGURATION:-Debug}" export USE_SIGNING="${USE_SIGNING:-false}" JDK_VERSION="${JDK_VERSION:-17}" -# From the selected builder.json profile: DISTRIBUTION (canonical: internal -# arrives as ad-hoc) picks the signing set (IOS_*_ secrets) and the -# profile type install_signing expects; BUILD_ENV is a JSON object exported by -# prepare(). +# From the selected builder.json profile: DISTRIBUTION (canonical, so internal +# arrives as ad-hoc) picks the signing set, BUILD_ENV is a JSON object prepare() exports. export DISTRIBUTION="${DISTRIBUTION:-}" BUILD_ENV="${BUILD_ENV:-}" fail() { echo "$*" >&2; exit 1; } @@ -150,11 +148,10 @@ cleanup_signing() { if [ -n "${signing_dir:-}" ]; then rm -rf "$signing_dir"; fi } -# The export method has to match the profile, or -exportArchive fails and App -# Store Connect rejects the IPA. Xcode 15.3+ also accepts -# debugging/release-testing/app-store-connect, but these legacy names still work -# in Xcode 16 and are the only ones older Xcodes (pinned or self-hosted runners) -# understand, so both templates use them. +# The export method has to match the profile, or -exportArchive fails +# and App Store Connect rejects the IPA. These legacy names are the only +# ones older Xcodes (pinned or self-hosted runners) understand, and +# Xcode 16 still takes them. detect_export_method() { if [ "$(plutil -extract ProvisionsAllDevices raw -o - "$1" 2>/dev/null)" = "true" ]; then echo enterprise @@ -170,10 +167,8 @@ detect_export_method() { } # The certificate names that can sign for a profile of this type, the -# current one first. Apple renamed the certificates in 2021; keychains -# still hold iPhone Developer / iPhone Distribution certificates that -# sign exactly the same profiles. Duplicated verbatim in ios-build.yml -# and runner.sh. +# current one first: keychains still hold pre-2021 iPhone Developer / +# iPhone Distribution certificates that sign the same profiles. signing_identities() { case "$1" in development) printf '%s\n' "Apple Development" "iPhone Developer" ;; @@ -182,12 +177,10 @@ signing_identities() { esac } -# The identity to archive with: the first of those names the imported -# certificate actually goes by ($2 is security find-identity output). -# Without an explicit CODE_SIGN_IDENTITY the archive keeps the -# project's default, and Xcode refuses to pair a development identity -# with a distribution profile: "No signing certificate iOS Development -# found". Duplicated verbatim in ios-build.yml and runner.sh. +# The identity to archive with: the first of those names in security +# find-identity's output ($2). Without an explicit CODE_SIGN_IDENTITY the +# archive keeps the project's default, which Xcode refuses to pair with a +# distribution profile ("No signing certificate iOS Development found"). signing_identity() { local names name names=$(signing_identities "$1") || return 1 @@ -197,9 +190,8 @@ signing_identity() { return 2 } -# The suffix of the IOS_* secrets a distribution is signed with: its -# canonical name upper-cased. No distribution has no set (the legacy -# ios.signing path reads the unsuffixed secrets). Same table in ios-build.yml. +# The suffix of the IOS_* secrets a distribution is signed with; no +# distribution means the legacy unsuffixed secrets. Same table in ios-build.yml. signing_set() { case "$1" in '') echo '' ;; @@ -211,13 +203,10 @@ signing_set() { esac } -# Picks the secrets of the set the build profile's distribution names -# (IOS_CERTIFICATE_ and friends) into IOS_CERTIFICATE, -# IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE. A set needs all three -# (builder signing setup always writes a password). With no distribution — -# ios.signing without a profile — the unsuffixed secrets are used as they are, -# password optional. SIGNING_SET_USED says which it was. Same function in -# ios-build.yml. +# Picks the set's IOS_*_ secrets into the unsuffixed names, or with +# no distribution takes the unsuffixed ones as they are (password +# optional). A suffixed set needs all three, since builder signing setup +# always writes a password. select_signing_set() { if [ -z "$SIGNING_SET" ]; then if [ -z "${IOS_CERTIFICATE:-}" ] || [ -z "${IOS_PROVISIONING_PROFILE:-}" ]; then @@ -240,11 +229,10 @@ select_signing_set() { echo "Signing set: $SIGNING_SET_USED" } -# The profile in the set must be the type the build profile asked for, or the -# export method, and the IPA, would not be what the profile promised. Names -# are compared canonically: the export method calls the store distribution -# app-store, and a tag build may say internal for ad-hoc. The unsuffixed -# secrets (no distribution) are taken as they are. +# The profile in the set must be the type the build profile asked for, +# or the IPA would not be what the profile promised. Compared +# canonically: the export method says app-store for store, and a tag +# build may say internal for ad-hoc. check_signing_set() { [ "$SIGNING_SET_USED" != legacy ] || return 0 local have="$1" want="$DISTRIBUTION" @@ -255,18 +243,11 @@ check_signing_set() { fi } -# Manual signing goes into the app target's build configurations, not on -# the xcodebuild command line: a command-line setting applies to every -# target in the workspace, and a CocoaPods framework target refuses a -# provisioning profile ("FirebaseCore does not support provisioning -# profiles"). Edits the application targets of the .xcodeproj files in the -# current directory (Pods/Pods.xcodeproj is a level down): the only one, or -# with several the ones whose PRODUCT_BUNDLE_IDENTIFIER the profile's app id -# ($1, "*" or "com.example.*" for a wildcard) covers. DEVELOPMENT_TEAM, -# PROVISIONING_PROFILE_NAME and CODE_SIGN_IDENTITY come from the -# environment. The pbxproj is written back as an XML plist, which Xcode -# reads like the OpenStep form. Duplicated verbatim in ios-build.yml and -# runner.sh. +# Writes the manual signing settings (team, profile and identity from the +# environment) into the application targets of ./*.xcodeproj that the +# profile's app id ($1, "*" wildcards) covers, as an XML plist Xcode reads. +# On the xcodebuild command line they would apply to every target, and a +# CocoaPods framework target refuses a provisioning profile. apply_signing_to_app_target() { local projects=(*.xcodeproj) out [ -d "${projects[0]}" ] || fail "No .xcodeproj in $PWD to apply the signing settings to" From e97f79870f4f6a1ea088642ff2dfc71d40e81eee Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 19:25:59 +0200 Subject: [PATCH 71/75] asc: page through the app lookup instead of trusting a two-item filter --- internal/asc/apps.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/asc/apps.go b/internal/asc/apps.go index bb7f264..d9cf135 100644 --- a/internal/asc/apps.go +++ b/internal/asc/apps.go @@ -28,8 +28,8 @@ func toApp(r Resource[appAttributes]) App { // AppByBundleID finds the app record for a bundle identifier. func (c *Client) AppByBundleID(ctx context.Context, bundleID string) (*App, error) { - q := url.Values{"filter[bundleId]": {bundleID}, "limit": {"2"}} - apps, err := getPage[appAttributes](ctx, c, "/v1/apps", q) + // The filter may match more than the exact ID, so page through and compare. + apps, err := getAll[appAttributes](ctx, c, "/v1/apps", url.Values{"filter[bundleId]": {bundleID}}) if err != nil { return nil, err } From 555822e7b001152d8750bdcd57de077e5ae68a73 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 19:27:14 +0200 Subject: [PATCH 72/75] docs: extension targets are not signed yet --- CLAUDE.md | 4 +++- README.md | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index b7c2eeb..44e0c8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,7 +282,9 @@ internal/ settings into app targets only via `plutil`; on the command line every Pods target would inherit them - **App Target Selection**: one app target is signed whatever its bundle id (the export reports a mismatch); with several, those whose `PRODUCT_BUNDLE_IDENTIFIER` `PROFILE_BUNDLE_ID` covers (`*` - wildcards), else `::error::` naming the ids found; conditional `NAME[sdk=…]` variants are dropped + wildcards), else `::error::` naming the ids found; conditional `NAME[sdk=…]` variants are dropped. + Extension targets (widgets, share/notification extensions) are not signed: they need their own + profiles, which Builder does not create, so such apps still fail at the archive - **Extension Points**: a future `ios release` composes `distribute.Upload` and `distribute.SubmitTestFlight`, reading `asc.Client.ListBuilds` for the latest build number; the `pkg/` wrappers do not expose `asc` yet. diff --git a/README.md b/README.md index 98e8e81..d382dc4 100644 --- a/README.md +++ b/README.md @@ -471,6 +471,10 @@ the command carries on: files, values and the build profile are written and shown anyway, and it exits non-zero at the end so a script notices. `--json` reports the same in `github_upload` (`ok` or the error). +Signed builds cover the app target only. An app with extension targets (a +widget, a share or notification extension) needs a profile per extension, which +Builder does not create yet, so such projects still fail at the archive step. + The command shows its plan and asks once before creating anything; `--yes` skips that (required without a terminal), and then the `.p12` password is generated and printed once unless `--password` is given. `--json` prints the From 3156445229bc412d91cbf996fb5ace9a560fa5bf Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 20:06:44 +0200 Subject: [PATCH 73/75] 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 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_: 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. --- cmd/builder/root.go | 1 + cmd/builder/signing.go | 97 +++++++++++++-- cmd/builder/signing_auto.go | 110 +++++++++++++---- cmd/builder/signing_sets_test.go | 174 +++++++++++++++++++++++++-- internal/config/profile.go | 4 +- internal/config/signing.go | 19 ++- internal/config/signing_test.go | 6 +- internal/config/types.go | 15 ++- internal/signing/auto.go | 82 ++++++++++--- internal/signing/auto_test.go | 57 +++++++++ internal/signing/extensions.go | 76 ++++++++++++ internal/signing/extensions_test.go | 70 +++++++++++ internal/signing/profile.go | 25 ++-- internal/xcodeproj/xcodeproj.go | 85 +++++++++++++ internal/xcodeproj/xcodeproj_test.go | 108 +++++++++++++++++ 15 files changed, 842 insertions(+), 87 deletions(-) create mode 100644 internal/signing/extensions.go create mode 100644 internal/signing/extensions_test.go create mode 100644 internal/xcodeproj/xcodeproj.go create mode 100644 internal/xcodeproj/xcodeproj_test.go diff --git a/cmd/builder/root.go b/cmd/builder/root.go index 90a402e..6529c8e 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -438,6 +438,7 @@ func runInit(cmd *cobra.Command, args []string) error { if cfg.IOS.BundleID == "" { cfg.IOS.BundleID = detectBundleID(iosPath) } + syncExtensions(cfg, os.Stdout) if flutterVersion != "" { cfg.Flutter.Version = flutterVersion } diff --git a/cmd/builder/signing.go b/cmd/builder/signing.go index 34c937e..26e84f1 100644 --- a/cmd/builder/signing.go +++ b/cmd/builder/signing.go @@ -3,8 +3,10 @@ package main import ( "context" "fmt" + "maps" "os" "path/filepath" + "slices" "strings" "github.com/MobAI-App/ios-builder/internal/config" @@ -45,14 +47,21 @@ With --certificate and --profile the files are taken as they are: The distribution is read from the .mobileprovision (development, ad-hoc, store or enterprise). -Either way the command uploads the three GitHub repository secrets of the +Extension targets (widgets, share/notification extensions, watch apps, app +clips) need a profile each. Their bundle IDs are ios.extensions in +builder.json, filled in from the local Xcode project; automatic mode creates +"Builder " for each, manual mode takes one +--extension-profile per extension. + +Either way the command uploads the GitHub repository secrets of the distribution's signing set — IOS_CERTIFICATE_, IOS_CERTIFICATE_PASSWORD_, -IOS_PROVISIONING_PROFILE_, with SET one of DEVELOPMENT, AD_HOC, STORE, -ENTERPRISE — and writes a profile in builder.json (--name, default the -distribution name) with that distribution. 'builder ios build --profile ' -then signs with the set, and provisions it the same way when it is missing. +IOS_PROVISIONING_PROFILE_ and IOS_EXTENSION_PROFILES_, with SET one of +DEVELOPMENT, AD_HOC, STORE, ENTERPRISE — and writes a profile in builder.json +(--name, default the distribution name) with that distribution. 'builder ios +build --profile ' then signs with the set, and provisions it the same +way when it is missing. -The three names and the values to put in them are always printed too, for +The names and the values to put in them are always printed too, for Codemagic, Bitrise or a repository this login cannot write to. A failed upload is reported and the command carries on — the files and the build profile are written regardless — and it exits non-zero at the end.`, @@ -105,6 +114,7 @@ func init() { func addSigningSetupFlags(cmd *cobra.Command) { cmd.Flags().StringP("certificate", "c", "", "Path to certificate file (.p12, or .cer from the Apple Developer portal)") cmd.Flags().StringP("profile", "p", "", "Path to .mobileprovision file") + cmd.Flags().StringArray("extension-profile", nil, "Path to the .mobileprovision of an extension target listed in ios.extensions (repeatable; with --profile)") cmd.Flags().StringP("key", "k", "", "Path to the private key from 'builder signing csr' (required with a .cer; automatic mode reuses it and its certificate)") cmd.Flags().String("bundle-id", "", "App bundle ID (default: ios.bundleId in builder.json, else the newest IPA in ./dist)") cmd.Flags().String("distribution", "", "Distribution to sign for: development, ad-hoc (internal), store or enterprise (default: the --name profile's, else development; with --profile: read from the file)") @@ -331,6 +341,27 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { } fmt.Fprintf(out, "Distribution: %s (read from the profile), signing set %s, build profile %q\n", typ, set, profileName) + syncExtensions(cfg, out) + extensionPaths, _ := cmd.Flags().GetStringArray("extension-profile") + extensionFiles := make(map[string][]byte, len(extensionPaths)) + for _, path := range extensionPaths { + path = expandPath(path) + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read provisioning profile %s: %w", path, err) + } + extensionFiles[path] = data + } + extensionPathByID, err := matchExtensionProfiles(cfg.IOS.Extensions, extensionFiles, typ) + if err != nil { + return err + } + extensionProfiles := make(map[string][]byte, len(extensionPathByID)) + for _, id := range cfg.IOS.Extensions { + extensionProfiles[id] = extensionFiles[extensionPathByID[id]] + fmt.Fprintf(out, "Extension: %s (%s)\n", id, extensionPathByID[id]) + } + password, _ := cmd.Flags().GetString("password") p12Path := certPath if isPortalCertificate(certPath) { @@ -374,7 +405,7 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { ctx = context.Background() } fmt.Fprintln(out) - uploadErr := uploadSigningSet(ctx, store, storeErr, cfg, out, set, certData, password, profileData) + uploadErr := uploadSigningSet(ctx, store, storeErr, cfg, out, set, certData, password, profileData, extensionProfiles) if uploadErr != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", uploadErr) } @@ -391,7 +422,7 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { fmt.Fprintln(out) fmt.Fprintln(out, signingUploadLine(cfg, names, uploadErr)) fmt.Fprintln(out) - printSigningSecretValues(out, names, p12Path, profilePath) + printSigningSecretValues(out, names, p12Path, profilePath, extensionPathByID) fmt.Fprintln(out) printSigningNext(out, profileName, typ) fmt.Fprintln(out, "To build unsigned, use:") @@ -403,6 +434,56 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { return nil } +// matchExtensionProfiles pairs every extension in ios.extensions with the +// path of the --extension-profile (path → contents) whose app id covers it. +// Each must be of the app profile's type; an extension without a profile, or +// a profile for no listed extension, is an error naming it. +func matchExtensionProfiles(extensions []string, files map[string][]byte, typ signing.Type) (map[string]string, error) { + appIDs := make(map[string]string, len(files)) + for _, path := range slices.Sorted(maps.Keys(files)) { + fileType, err := signing.ProfileType(files[path]) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + if fileType != typ { + return nil, fmt.Errorf("%s is a %s profile, but the app profile is %s; every extension profile must be of the same type", path, fileType, typ) + } + if appIDs[path], err = signing.ProfileBundleID(files[path]); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + } + // The longest app id is the most specific, so an exact profile wins over + // a wildcard one covering the same extension. + paths := slices.SortedFunc(maps.Keys(appIDs), func(a, b string) int { + return len(appIDs[b]) - len(appIDs[a]) + }) + profiles := make(map[string]string, len(extensions)) + var problems []string + for _, path := range paths { + covered := false + for _, id := range extensions { + if signing.Covers(appIDs[path], id) { + covered = true + if _, ok := profiles[id]; !ok { + profiles[id] = path + } + } + } + if !covered { + problems = append(problems, fmt.Sprintf("%s covers %s, which is not in ios.extensions", path, appIDs[path])) + } + } + for _, id := range extensions { + if _, ok := profiles[id]; !ok { + problems = append(problems, fmt.Sprintf("extension %s has no profile; pass --extension-profile for it", id)) + } + } + if len(problems) > 0 { + return nil, fmt.Errorf("extension profiles do not match ios.extensions in builder.json:\n %s", strings.Join(problems, "\n ")) + } + return profiles, nil +} + // manualSigningType is what the .mobileprovision says it is. A --distribution // that disagrees is an error, since the runner refuses such a pair. func manualSigningType(profileData []byte, distributionFlag string) (signing.Type, error) { diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index 40d1cfc..8a2fd6a 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "maps" "net/http" "os" "path/filepath" @@ -20,6 +21,7 @@ import ( "github.com/MobAI-App/ios-builder/internal/ipa" "github.com/MobAI-App/ios-builder/internal/mobai" "github.com/MobAI-App/ios-builder/internal/signing" + "github.com/MobAI-App/ios-builder/internal/xcodeproj" "github.com/manifoldco/promptui" "github.com/spf13/cobra" "golang.org/x/term" @@ -91,6 +93,7 @@ func runSigningAuto(cmd *cobra.Command) error { if err != nil { return err } + syncExtensions(cfg, out.log) devices, err := signingDevices(ctx, cmd, cfg, typ) if err != nil { return err @@ -103,6 +106,9 @@ func runSigningAuto(cmd *cobra.Command) error { // The plan, then one confirmation before anything is created. fmt.Fprintf(out.log, "Bundle ID: %s\n", bundleID) + if len(cfg.IOS.Extensions) > 0 { + fmt.Fprintf(out.log, "Extensions: %s\n", strings.Join(cfg.IOS.Extensions, ", ")) + } fmt.Fprintf(out.log, "Distribution: %s (signing set %s)\n", typ, set) fmt.Fprintf(out.log, "Profile: %s (builder.json)\n", profileName) if typ.NeedsDevices() { @@ -144,14 +150,14 @@ func runSigningAuto(cmd *cobra.Command) error { res := &signingAutoResult{SigningSet: set, BuildProfile: profileName, GeneratedPassword: generated} res.AutoResult, err = signing.Auto(ctx, client, &signing.AutoOptions{ - BundleID: bundleID, Type: typ, Devices: devices, KeyPEM: keyPEM, CommonName: cfg.Project, + BundleID: bundleID, Extensions: cfg.IOS.Extensions, Type: typ, Devices: devices, KeyPEM: keyPEM, CommonName: cfg.Project, Password: password, Force: force, OutDir: outDir, Log: out.log, }) if err != nil { return finish(out, cmd, res, err, nil) } fmt.Fprintln(out.log) - uploadErr := uploadSigningSet(ctx, store, storeErr, cfg, out.log, set, res.P12, password, res.ProfileContent) + uploadErr := uploadSigningSet(ctx, store, storeErr, cfg, out.log, set, res.P12, password, res.ProfileContent, res.ExtensionProfiles) res.SecretsUploaded = uploadErr == nil res.GitHubUpload = "ok" if uploadErr != nil { @@ -194,22 +200,42 @@ func setupDistribution(cfg *config.Config, profileName, flag string) (signing.Ty return signing.TypeDevelopment, nil } -// uploadSigningSet writes the three secrets of a set to the GitHub repository +// uploadSigningSet writes the secrets of a set to the GitHub repository // in builder.json. storeErr is a client that could not be built (no login), // reported like a failed upload since the values are printed afterwards. -func uploadSigningSet(ctx context.Context, store secretStore, storeErr error, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte) error { +func uploadSigningSet(ctx context.Context, store secretStore, storeErr error, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte, extensions map[string][]byte) error { if storeErr != nil { return storeErr } fmt.Fprintf(log, "Uploading secrets to %s/%s...\n", cfg.GitHub.Owner, cfg.GitHub.Repo) - return uploadSigningSecrets(ctx, store, cfg, log, set, p12, password, profile) + return uploadSigningSecrets(ctx, store, cfg, log, set, p12, password, profile, extensions) } // signingUploadFailed is what `signing setup` ends with when the set did not // reach the repository: everything is printed by then, so this only carries // the exit code and says what is left to do. func signingUploadFailed(cfg *config.Config) error { - return fmt.Errorf("the signing set was not uploaded to %s/%s; add the three secrets above by hand, or fix the access and run builder signing setup again", cfg.GitHub.Owner, cfg.GitHub.Repo) + return fmt.Errorf("the signing set was not uploaded to %s/%s; add the secrets above by hand, or fix the access and run builder signing setup again", cfg.GitHub.Owner, cfg.GitHub.Repo) +} + +// syncExtensions appends the extension targets of the local Xcode project +// that ios.extensions does not list yet, keeping what was listed by hand (a +// managed Expo project has no project to read until the runner generates it) +// and returning the new ones. +func syncExtensions(cfg *config.Config, log io.Writer) []string { + found, err := xcodeproj.ExtensionBundleIDs(cfg.IOS.Path) + if err != nil { + fmt.Fprintf(log, "Warning: could not read the extension targets of the Xcode project: %v. List their bundle IDs in ios.extensions in builder.json.\n", err) + return nil + } + var added []string + for _, id := range found { + if !slices.Contains(cfg.IOS.Extensions, id) { + cfg.IOS.Extensions = append(cfg.IOS.Extensions, id) + added = append(added, id) + } + } + return added } // writeSigningProfile creates or updates the builder.json profile that builds @@ -434,10 +460,12 @@ type secretStore interface { ListSecretNames(ctx context.Context, owner, repo string) ([]string, error) } -// uploadSigningSecrets encrypts and stores the three signing secrets of a set +// uploadSigningSecrets encrypts and stores the signing secrets of a set // (IOS_CERTIFICATE_, ...). Other sets, and the unsuffixed secrets of -// repositories set up before signing sets, are left alone. -func uploadSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte) error { +// repositories set up before signing sets, are left alone. The extension +// profiles are written even when empty, so a removed extension's profile +// does not linger in the repository. +func uploadSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte, extensions map[string][]byte) error { publicKey, err := gh.GetPublicKey(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo) if err != nil { return fmt.Errorf("failed to get repository public key: %w", err) @@ -447,6 +475,7 @@ func uploadSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Confi {names.Certificate, base64.StdEncoding.EncodeToString(p12)}, {names.Password, password}, {names.Profile, base64.StdEncoding.EncodeToString(profile)}, + {names.Extensions, signing.EncodeExtensionProfiles(extensions)}, } for _, s := range secrets { encrypted, err := github.EncryptSecret(publicKey.Key, s.value) @@ -462,14 +491,18 @@ func uploadSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Confi } // missingSigningSecrets names the secrets of a set that the repository does -// not hold. +// not hold; the extension profiles only count when the app has extensions. func missingSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Config, set string) ([]string, error) { have, err := gh.ListSecretNames(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo) if err != nil { return nil, err // names the repository already } + names := config.SigningSecretNames(set) var missing []string - for _, name := range config.SigningSecretNames(set).Names() { + for _, name := range names.Names() { + if name == names.Extensions && len(cfg.IOS.Extensions) == 0 { + continue + } if !slices.Contains(have, name) { missing = append(missing, name) } @@ -502,14 +535,22 @@ func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretS return nil } typ, set := signing.Type(s.Distribution), s.SigningSet() + // A secret's contents cannot be read back, so an extension target that + // appeared since builder.json last listed it is provisioned like a + // missing secret. + newExtensions := syncExtensions(cfg, log) missing, err := missingSigningSecrets(ctx, store, cfg, set) if err != nil { return err } - if len(missing) == 0 { + if len(missing) == 0 && len(newExtensions) == 0 { return nil } - fmt.Fprintf(log, "Profile %q signs with set %s, but %s/%s is missing %s.\n", s.Profile, set, cfg.GitHub.Owner, cfg.GitHub.Repo, strings.Join(missing, ", ")) + if len(missing) > 0 { + fmt.Fprintf(log, "Profile %q signs with set %s, but %s/%s is missing %s.\n", s.Profile, set, cfg.GitHub.Owner, cfg.GitHub.Repo, strings.Join(missing, ", ")) + } else { + fmt.Fprintf(log, "Profile %q signs with set %s, but the Xcode project has extension targets the set has no profile for: %s.\n", s.Profile, set, strings.Join(newExtensions, ", ")) + } manual := fmt.Sprintf("builder signing setup --certificate --profile --name %s", s.Profile) if typ == signing.TypeEnterprise { return fmt.Errorf("enterprise (in-house) profiles are not issued through the App Store Connect API; upload the files from the portal with %s", manual) @@ -533,7 +574,7 @@ func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretS } fmt.Fprintf(log, "Provisioning %s signing for %s through App Store Connect...\n", typ, bundleID) res, err := signing.Auto(ctx, client, &signing.AutoOptions{ - BundleID: bundleID, Type: typ, KeyPEM: keyPEM, CommonName: cfg.Project, Password: password, OutDir: dirs[0], Log: log, + BundleID: bundleID, Extensions: cfg.IOS.Extensions, Type: typ, KeyPEM: keyPEM, CommonName: cfg.Project, Password: password, OutDir: dirs[0], Log: log, }) if err != nil { if keyPath == "" && certificateRefused(err) { @@ -546,13 +587,15 @@ func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretS // A build cannot go on without the set in the repository, so here the // upload is fatal. fmt.Fprintln(log) - if err := uploadSigningSet(ctx, store, nil, cfg, log, set, res.P12, password, res.ProfileContent); err != nil { + if err := uploadSigningSet(ctx, store, nil, cfg, log, set, res.P12, password, res.ProfileContent, res.ExtensionProfiles); err != nil { return err } fmt.Fprintln(log) printSigningFiles(log, res, password) - if cfg.IOS.BundleID == "" { - cfg.IOS.BundleID = bundleID + if cfg.IOS.BundleID == "" || len(newExtensions) > 0 { + if cfg.IOS.BundleID == "" { + cfg.IOS.BundleID = bundleID + } if err := config.NewManager().Save(cfg); err != nil { return fmt.Errorf("failed to update config: %w", err) } @@ -576,6 +619,9 @@ func printSigningFiles(w io.Writer, res *signing.AutoResult, generatedPassword s } fmt.Fprintf(w, "Certificate: %s\n", res.Files.P12) fmt.Fprintf(w, "Profile: %s\n", res.Files.Profile) + for i := range res.Extensions { + fmt.Fprintf(w, "Extension: %s\n", res.Extensions[i].File) + } if generatedPassword != "" { fmt.Fprintf(w, "Password: %s (generated; shown only now)\n", generatedPassword) } @@ -599,13 +645,19 @@ func printSigningSummary(w io.Writer, cfg *config.Config, res *signingAutoResult fmt.Fprintf(w, "Devices: %d in the profile, %d registered now\n", res.Devices.InProfile, len(res.Devices.Registered)) } fmt.Fprintf(w, "Profile: %s (%s, %s, expires %s)\n", res.Profile.Name, state(res.Profile.Created, res.Profile.Reason), strings.ToLower(res.Profile.State), res.Profile.ExpirationDate.Format("2006-01-02")) + extensionFiles := map[string]string{} + for i := range res.Extensions { + ext := &res.Extensions[i] + fmt.Fprintf(w, "Extension: %s (App ID %s, profile %s, expires %s)\n", ext.BundleID.Identifier, state(ext.BundleID.Created, ""), state(ext.Profile.Created, ext.Profile.Reason), ext.Profile.ExpirationDate.Format("2006-01-02")) + extensionFiles[ext.BundleID.Identifier] = ext.File + } fmt.Fprintln(w) printSigningFiles(w, res.AutoResult, res.GeneratedPassword) fmt.Fprintln(w) names := config.SigningSecretNames(res.SigningSet) fmt.Fprintln(w, signingUploadLine(cfg, names, uploadErr)) fmt.Fprintln(w) - printSigningSecretValues(w, names, res.Files.P12, res.Files.Profile) + printSigningSecretValues(w, names, res.Files.P12, res.Files.Profile, extensionFiles) fmt.Fprintln(w) printSigningNext(w, res.BuildProfile, res.Type) fmt.Fprintln(w, "Run builder signing setup again any time: it reuses what is valid and renews only what expired or changed.") @@ -616,17 +668,27 @@ func signingUploadLine(cfg *config.Config, names config.SigningSecrets, uploadEr if uploadErr != nil { return fmt.Sprintf("Secrets were NOT uploaded to %s/%s: %v", cfg.GitHub.Owner, cfg.GitHub.Repo, uploadErr) } - return fmt.Sprintf("Secrets %s, %s and %s uploaded to %s/%s.", names.Certificate, names.Password, names.Profile, cfg.GitHub.Owner, cfg.GitHub.Repo) + return fmt.Sprintf("Secrets %s uploaded to %s/%s.", strings.Join(names.Names(), ", "), cfg.GitHub.Owner, cfg.GitHub.Repo) } -// printSigningSecretValues names the three secrets of the set and where their +// printSigningSecretValues names the secrets of the set and where their // values come from, whether or not the upload worked: Codemagic, Bitrise and a // repository this token cannot write to are set by hand. -func printSigningSecretValues(w io.Writer, names config.SigningSecrets, p12Path, profilePath string) { +func printSigningSecretValues(w io.Writer, names config.SigningSecrets, p12Path, profilePath string, extensionFiles map[string]string) { fmt.Fprintln(w, "Set them by hand wherever Builder cannot (Codemagic, Bitrise, a repository this login cannot write to):") - fmt.Fprintf(w, " %-*s base64 of %s\n", len(names.Password), names.Certificate, p12Path) - fmt.Fprintf(w, " %s the .p12 password\n", names.Password) - fmt.Fprintf(w, " %-*s base64 of %s\n", len(names.Password), names.Profile, profilePath) + width := len(names.Extensions) + fmt.Fprintf(w, " %-*s base64 of %s\n", width, names.Certificate, p12Path) + fmt.Fprintf(w, " %-*s the .p12 password\n", width, names.Password) + fmt.Fprintf(w, " %-*s base64 of %s\n", width, names.Profile, profilePath) + if len(extensionFiles) == 0 { + fmt.Fprintf(w, " %s {} (no extension targets)\n", names.Extensions) + } else { + entries := make([]string, 0, len(extensionFiles)) + for _, id := range slices.Sorted(maps.Keys(extensionFiles)) { + entries = append(entries, fmt.Sprintf("%q: base64 of %s", id, extensionFiles[id])) + } + fmt.Fprintf(w, " %s JSON object {%s}\n", names.Extensions, strings.Join(entries, ", ")) + } fmt.Fprintf(w, "Steps: %s\n", providerSecretsDoc) } diff --git a/cmd/builder/signing_sets_test.go b/cmd/builder/signing_sets_test.go index 4064afb..1a4aecb 100644 --- a/cmd/builder/signing_sets_test.go +++ b/cmd/builder/signing_sets_test.go @@ -88,28 +88,32 @@ func TestUploadSigningSecretsWritesOneSet(t *testing.T) { store := newFakeSecrets(t) cfg := &config.Config{GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}} var log strings.Builder - if err := uploadSigningSecrets(context.Background(), store, cfg, &log, "STORE", []byte("p12"), "pw", []byte("profile")); err != nil { + if err := uploadSigningSecrets(context.Background(), store, cfg, &log, "STORE", []byte("p12"), "pw", []byte("profile"), map[string][]byte{"com.example.app.widget": []byte("widget")}); err != nil { t.Fatal(err) } - want := []string{"IOS_CERTIFICATE_STORE", "IOS_CERTIFICATE_PASSWORD_STORE", "IOS_PROVISIONING_PROFILE_STORE"} + want := []string{"IOS_CERTIFICATE_STORE", "IOS_CERTIFICATE_PASSWORD_STORE", "IOS_PROVISIONING_PROFILE_STORE", "IOS_EXTENSION_PROFILES_STORE"} if !slices.Equal(store.names, want) { t.Fatalf("secrets written: %v, want %v", store.names, want) } if store.stored["IOS_CERTIFICATE_STORE"] != base64.StdEncoding.EncodeToString([]byte("p12")) || store.stored["IOS_CERTIFICATE_PASSWORD_STORE"] != "pw" || store.stored["IOS_PROVISIONING_PROFILE_STORE"] != base64.StdEncoding.EncodeToString([]byte("profile")) { t.Fatalf("values: %v", store.stored) } + if got, err := signing.DecodeExtensionProfiles(store.stored["IOS_EXTENSION_PROFILES_STORE"]); err != nil || string(got["com.example.app.widget"]) != "widget" { + t.Fatalf("extension profiles: %q, %v", store.stored["IOS_EXTENSION_PROFILES_STORE"], err) + } for _, name := range want { if !strings.Contains(log.String(), "Uploaded: "+name) { t.Errorf("%s not reported:\n%s", name, log.String()) } } - // A second set adds to the first; the legacy names are never touched. - if err := uploadSigningSecrets(context.Background(), store, cfg, io.Discard, "DEVELOPMENT", []byte("dev"), "pw2", []byte("dev-profile")); err != nil { + // A second set adds to the first; the legacy names are never touched, and + // an app without extensions writes {} so nothing stale is left behind. + if err := uploadSigningSecrets(context.Background(), store, cfg, io.Discard, "DEVELOPMENT", []byte("dev"), "pw2", []byte("dev-profile"), nil); err != nil { t.Fatal(err) } - if len(store.stored) != 6 || store.stored["IOS_CERTIFICATE_STORE"] == "" || store.stored["IOS_CERTIFICATE_DEVELOPMENT"] == "" { - t.Fatalf("second set replaced the first: %v", store.names) + if len(store.stored) != 8 || store.stored["IOS_CERTIFICATE_STORE"] == "" || store.stored["IOS_CERTIFICATE_DEVELOPMENT"] == "" || store.stored["IOS_EXTENSION_PROFILES_DEVELOPMENT"] != "{}" { + t.Fatalf("second set replaced the first: %v", store.stored) } for name := range store.stored { if name == "IOS_CERTIFICATE" || name == "IOS_CERTIFICATE_PASSWORD" || name == "IOS_PROVISIONING_PROFILE" { @@ -124,6 +128,119 @@ func profileBytes(body string) []byte { return []byte("\x30\x82\x1a\x00 cms " + `` + body + `` + "\x00\xff trailer") } +// storeProfile is an App Store profile for the app id, team ABCDE12345. +func storeProfile(appID string) []byte { + return profileBytes("TeamIdentifierABCDE12345Entitlementsget-task-allowapplication-identifierABCDE12345." + appID + "") +} + +// appWithWidgetPbxproj is an app target and a widget extension target, in +// the OpenStep form Xcode writes. +const appWithWidgetPbxproj = `// !$*UTF8*$! +{ + objects = { + A1 = { isa = PBXNativeTarget; buildConfigurationList = LA; name = App; productType = "com.apple.product-type.application"; }; + W1 = { isa = PBXNativeTarget; buildConfigurationList = LW; name = Widget; productType = "com.apple.product-type.app-extension"; }; + AR = { isa = XCBuildConfiguration; buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.app; }; name = Release; }; + WR = { isa = XCBuildConfiguration; buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.app.widget; }; name = Release; }; + LA = { isa = XCConfigurationList; buildConfigurations = ( AR, ); }; + LW = { isa = XCConfigurationList; buildConfigurations = ( WR, ); }; + }; + rootObject = P0; +} +` + +// writeProject writes a project.pbxproj under ./ in the working directory. +func writeProject(t *testing.T, name, pbxproj string) { + t.Helper() + if err := os.MkdirAll(name, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(name, "project.pbxproj"), []byte(pbxproj), 0644); err != nil { + t.Fatal(err) + } +} + +func TestMatchExtensionProfiles(t *testing.T) { + widget, share, wildcard := storeProfile("com.example.app.widget"), storeProfile("com.example.app.share"), storeProfile("com.example.*") + extensions := []string{"com.example.app.share", "com.example.app.widget"} + + got, err := matchExtensionProfiles(extensions, map[string][]byte{"w.mobileprovision": widget, "s.mobileprovision": share}, signing.TypeStore) + if err != nil || got["com.example.app.widget"] != "w.mobileprovision" || got["com.example.app.share"] != "s.mobileprovision" { + t.Fatalf("exact profiles: %v, %v", got, err) + } + // One wildcard profile covers every extension under it; an exact one + // wins over it for its own extension. + if got, err = matchExtensionProfiles(extensions, map[string][]byte{"any.mobileprovision": wildcard}, signing.TypeStore); err != nil || got["com.example.app.widget"] != "any.mobileprovision" || got["com.example.app.share"] != "any.mobileprovision" { + t.Fatalf("wildcard profile: %v, %v", got, err) + } + if got, err = matchExtensionProfiles(extensions, map[string][]byte{"any.mobileprovision": wildcard, "w.mobileprovision": widget}, signing.TypeStore); err != nil || got["com.example.app.widget"] != "w.mobileprovision" || got["com.example.app.share"] != "any.mobileprovision" { + t.Fatalf("exact over wildcard: %v, %v", got, err) + } + // Nothing to match is fine both ways round only when both are empty. + if got, err = matchExtensionProfiles(nil, nil, signing.TypeStore); err != nil || len(got) != 0 { + t.Fatalf("no extensions: %v, %v", got, err) + } + // A missing profile names the extension and the flag; a profile for an + // unlisted extension names the file and the config field. + _, err = matchExtensionProfiles(extensions, map[string][]byte{"w.mobileprovision": widget}, signing.TypeStore) + if err == nil || !strings.Contains(err.Error(), "extension com.example.app.share has no profile") || !strings.Contains(err.Error(), "--extension-profile") { + t.Fatalf("missing profile: %v", err) + } + _, err = matchExtensionProfiles(nil, map[string][]byte{"w.mobileprovision": widget}, signing.TypeStore) + if err == nil || !strings.Contains(err.Error(), "w.mobileprovision covers com.example.app.widget, which is not in ios.extensions") { + t.Fatalf("unlisted extension: %v", err) + } + // Every extension profile is of the app profile's type. + _, err = matchExtensionProfiles(extensions[1:], map[string][]byte{"w.mobileprovision": widget}, signing.TypeDevelopment) + if err == nil || !strings.Contains(err.Error(), "w.mobileprovision is a store profile, but the app profile is development") { + t.Fatalf("type mismatch: %v", err) + } + if _, err = matchExtensionProfiles(extensions, map[string][]byte{"bad.mobileprovision": []byte("nope")}, signing.TypeStore); err == nil { + t.Fatal("unreadable profile accepted") + } +} + +// TestSigningSetupManualUploadsExtensionProfiles: the widget found in the +// project goes into ios.extensions, its --extension-profile into the fourth +// secret, and a run without that flag says which extension lacks a profile. +func TestSigningSetupManualUploadsExtensionProfiles(t *testing.T) { + t.Chdir(t.TempDir()) + cfg := &config.Config{Project: "App", Platform: "ios", GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}} + if err := config.NewManager().Save(cfg); err != nil { + t.Fatal(err) + } + writeProject(t, "App.xcodeproj", appWithWidgetPbxproj) + for name, data := range map[string][]byte{"ios-signing.p12": []byte("p12 bytes"), "App.mobileprovision": storeProfile("com.example.app"), "Widget.mobileprovision": storeProfile("com.example.app.widget")} { + if err := os.WriteFile(name, data, 0600); err != nil { + t.Fatal(err) + } + } + store := newFakeSecrets(t) + + cmd, _, _ := signingSetupCommand(t, store, "--certificate", "ios-signing.p12", "--profile", "App.mobileprovision", "--password", "pw") + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "extension com.example.app.widget has no profile") { + t.Fatalf("widget without a profile: %v", err) + } + if len(store.names) != 0 { + t.Fatalf("uploaded despite the missing profile: %v", store.names) + } + + cmd, stdout, stderr := signingSetupCommand(t, store, "--certificate", "ios-signing.p12", "--profile", "App.mobileprovision", "--password", "pw", "--extension-profile", "Widget.mobileprovision") + if err := cmd.Execute(); err != nil { + t.Fatalf("%v\n%s", err, stderr.String()) + } + if got, err := signing.DecodeExtensionProfiles(store.stored["IOS_EXTENSION_PROFILES_STORE"]); err != nil || !bytes.Equal(got["com.example.app.widget"], storeProfile("com.example.app.widget")) || len(got) != 1 { + t.Errorf("extension profiles secret: %q, %v", store.stored["IOS_EXTENSION_PROFILES_STORE"], err) + } + if !strings.Contains(stdout.String(), `IOS_EXTENSION_PROFILES_STORE JSON object {"com.example.app.widget": base64 of Widget.mobileprovision}`) { + t.Errorf("secret value not explained:\n%s", stdout.String()) + } + saved, err := config.NewManager().Load() + if err != nil || !slices.Equal(saved.IOS.Extensions, []string{"com.example.app.widget"}) { + t.Errorf("ios.extensions = %v, %v", saved.IOS.Extensions, err) + } +} + func TestManualSigningTypeReadsTheProfile(t *testing.T) { devices := "ProvisionedDevices00008030-1" dev := profileBytes(devices + "Entitlementsget-task-allow") @@ -290,13 +407,23 @@ func TestEnsureSigningSecretsChecksTheSet(t *testing.T) { t.Fatalf("no profile: %v, listed %d", err, store.listed) } - // The set is complete: dispatch as today, no Apple credentials needed. - for _, name := range config.SigningSecretNames("STORE").Names() { + // The set is complete: dispatch as today, no Apple credentials needed. The + // extension profiles are required only once the app has extensions. + for _, name := range config.SigningSecretNames("STORE").Names()[:3] { store.stored[name] = "x" } if err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", "", io.Discard); err != nil { t.Fatalf("complete set: %v", err) } + cfg.IOS.Extensions = []string{"com.example.app.widget"} + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", "", io.Discard); err == nil || !strings.Contains(err.Error(), "auth apple") { + t.Fatalf("missing extension profiles accepted: %v", err) + } + store.stored["IOS_EXTENSION_PROFILES_STORE"] = "{}" + if err := ensureSigningSecrets(ctx, cfg, store, noASC, "store", "", io.Discard); err != nil { + t.Fatalf("complete set with extensions: %v", err) + } + cfg.IOS.Extensions = nil // A partial set without Apple credentials stops before the dispatch and // names both ways out. @@ -385,17 +512,40 @@ func TestEnsureSigningSecretsProvisionsOnDemand(t *testing.T) { // Second build: the set is there, nothing is provisioned again. portal.Reset() - if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "store", "", io.Discard); err != nil || len(portal.Calls()) != 0 || len(store.names) != 3 { + if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "store", "", io.Discard); err != nil || len(portal.Calls()) != 0 || len(store.names) != 4 { t.Fatalf("second build: %v, calls %v, uploads %v", err, portal.Calls(), store.names) } + // An extension target found in the project is written to builder.json, + // gets its own profile, and the whole set is uploaded again with it. + writeProject(t, "App.xcodeproj", appWithWidgetPbxproj) + if err := config.NewManager().Save(cfg); err != nil { + t.Fatal(err) + } + portal.Reset() + if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "store", "", io.Discard); err != nil { + t.Fatalf("build with a new extension: %v", err) + } + if !slices.Equal(cfg.IOS.Extensions, []string{"com.example.app.widget"}) || portal.Count("POST /v1/profiles") != 1 || len(portal.Profiles) != 2 { + t.Errorf("extensions %v, calls %v", cfg.IOS.Extensions, portal.Calls()) + } + if saved, err := config.NewManager().Load(); err != nil || !slices.Equal(saved.IOS.Extensions, cfg.IOS.Extensions) { + t.Errorf("builder.json extensions = %v, %v", saved.IOS.Extensions, err) + } + if got, err := signing.DecodeExtensionProfiles(store.stored["IOS_EXTENSION_PROFILES_STORE"]); err != nil || string(got["com.example.app.widget"]) != "profile:prof-3" { + t.Errorf("extension profiles secret: %q, %v", store.stored["IOS_EXTENSION_PROFILES_STORE"], err) + } + if _, err := os.Stat("Builder-store-com.example.app.widget.mobileprovision"); err != nil { + t.Errorf("extension profile not written: %v", err) + } + // Development with no device anywhere cannot be provisioned without // prompting: the error sends the user to signing setup. err := ensureSigningSecrets(ctx, cfg, store, withPortal, "development", "", io.Discard) if err == nil || !strings.Contains(err.Error(), "builder signing setup --distribution development --devices-from-mobai") { t.Fatalf("development without devices: %v", err) } - if portal.Count("POST /v1/certificates") != 0 || len(store.names) != 3 { + if portal.Count("POST /v1/certificates") != 0 || len(store.names) != 8 { t.Fatalf("devices are checked before anything is issued: %v", portal.Calls()) } @@ -404,7 +554,7 @@ func TestEnsureSigningSecretsProvisionsOnDemand(t *testing.T) { if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "development", "", io.Discard); err != nil { t.Fatalf("development with a registered device: %v", err) } - if len(store.stored) != 6 || store.stored["IOS_CERTIFICATE_DEVELOPMENT"] == "" { + if len(store.stored) != 8 || store.stored["IOS_CERTIFICATE_DEVELOPMENT"] == "" { t.Fatalf("development set not uploaded: %v", store.names) } @@ -466,7 +616,7 @@ func TestEnsureSigningSecretsReusesTheKeyInTheRecordedDir(t *testing.T) { if err := ensureSigningSecrets(ctx, cfg, store, withPortal, "store", "", &log); err != nil { t.Fatalf("on-demand provisioning: %v\n%s", err, log.String()) } - if portal.Count("POST /v1/certificates") != 0 || len(store.names) != 3 { + if portal.Count("POST /v1/certificates") != 0 || len(store.names) != 4 { t.Errorf("the certificate must be reused, not requested: %v, uploads %v", portal.Calls(), store.names) } // The material lands next to the key, not in the working directory. diff --git a/internal/config/profile.go b/internal/config/profile.go index 11f4155..78d3fa4 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -33,7 +33,7 @@ var reservedEnv = []string{ "BUILD_ID", "SNAPSHOT_REF", "SNAPSHOT_SHA", "IOS_PATH", "SCHEME", "CONFIGURATION", "USE_SIGNING", "FLUTTER_VERSION", "JDK_VERSION", "BUILD_ENV", "DISTRIBUTION", "SIGNING_SET", "SIGNING_SET_USED", "DURATION", "PROJECT_TYPE", "EXPORT_METHOD", - "CODE_SIGN_IDENTITY", "DEVELOPMENT_TEAM", "PROVISIONING_PROFILE_NAME", "PROFILE_BUNDLE_ID", + "CODE_SIGN_IDENTITY", "DEVELOPMENT_TEAM", "PROVISIONING_PROFILE_NAME", "PROFILE_BUNDLE_ID", "EXTENSION_PROFILES", "MOBAI_API_KEY", "PATH", "HOME", "USER", "SHELL", "TMPDIR", "DEVELOPER_DIR", "NODE_OPTIONS", } @@ -43,7 +43,7 @@ var reservedEnv = []string{ // Bitrise's, and the signing secrets with every set suffix. var reservedEnvPrefixes = []string{ "BUILDER_", "GITHUB_", "RUNNER_", "ACTIONS_", "CM_", "FCI_", "BITRISE_", - "IOS_CERTIFICATE", "IOS_PROVISIONING_PROFILE", + "IOS_CERTIFICATE", "IOS_PROVISIONING_PROFILE", "IOS_EXTENSION_PROFILES", } var envNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) diff --git a/internal/config/signing.go b/internal/config/signing.go index 148dcea..23b6d8d 100644 --- a/internal/config/signing.go +++ b/internal/config/signing.go @@ -54,19 +54,25 @@ func SigningSet(distribution string) (string, error) { return strings.ToUpper(strings.ReplaceAll(d, "-", "_")), nil } -// SigningSecrets names the three secrets of a signing set. +// SigningSecrets names the secrets of a signing set. type SigningSecrets struct { Certificate string // base64 .p12 Password string // the .p12 password - Profile string // base64 .mobileprovision + Profile string // base64 .mobileprovision of the app + // Extensions is a JSON object of extension bundle id to base64 + // .mobileprovision; {} when the app has none. A build needs it only when + // ios.extensions is non-empty. + Extensions string } -// Names lists the three secret names in the order they are written. -func (s SigningSecrets) Names() []string { return []string{s.Certificate, s.Password, s.Profile} } +// Names lists the secret names in the order they are written. +func (s SigningSecrets) Names() []string { + return []string{s.Certificate, s.Password, s.Profile, s.Extensions} +} // SigningSecretNames returns the secret names of a set: IOS_CERTIFICATE_, -// IOS_CERTIFICATE_PASSWORD_ and IOS_PROVISIONING_PROFILE_. The empty -// set names the unsuffixed legacy secrets. +// IOS_CERTIFICATE_PASSWORD_, IOS_PROVISIONING_PROFILE_ and +// IOS_EXTENSION_PROFILES_. The empty set names the unsuffixed legacy secrets. func SigningSecretNames(set string) SigningSecrets { suffix := "" if set != "" { @@ -76,6 +82,7 @@ func SigningSecretNames(set string) SigningSecrets { Certificate: "IOS_CERTIFICATE" + suffix, Password: "IOS_CERTIFICATE_PASSWORD" + suffix, Profile: "IOS_PROVISIONING_PROFILE" + suffix, + Extensions: "IOS_EXTENSION_PROFILES" + suffix, } } diff --git a/internal/config/signing_test.go b/internal/config/signing_test.go index ef2cba3..94fde5b 100644 --- a/internal/config/signing_test.go +++ b/internal/config/signing_test.go @@ -33,15 +33,15 @@ func TestSigningSet(t *testing.T) { func TestSigningSecretNames(t *testing.T) { got := SigningSecretNames("STORE") - want := SigningSecrets{"IOS_CERTIFICATE_STORE", "IOS_CERTIFICATE_PASSWORD_STORE", "IOS_PROVISIONING_PROFILE_STORE"} + want := SigningSecrets{"IOS_CERTIFICATE_STORE", "IOS_CERTIFICATE_PASSWORD_STORE", "IOS_PROVISIONING_PROFILE_STORE", "IOS_EXTENSION_PROFILES_STORE"} if got != want { t.Errorf("suffixed = %+v, want %+v", got, want) } - if !slices.Equal(got.Names(), []string{"IOS_CERTIFICATE_STORE", "IOS_CERTIFICATE_PASSWORD_STORE", "IOS_PROVISIONING_PROFILE_STORE"}) { + if !slices.Equal(got.Names(), []string{"IOS_CERTIFICATE_STORE", "IOS_CERTIFICATE_PASSWORD_STORE", "IOS_PROVISIONING_PROFILE_STORE", "IOS_EXTENSION_PROFILES_STORE"}) { t.Errorf("Names = %v", got.Names()) } legacy := SigningSecretNames("") - if legacy != (SigningSecrets{"IOS_CERTIFICATE", "IOS_CERTIFICATE_PASSWORD", "IOS_PROVISIONING_PROFILE"}) { + if legacy != (SigningSecrets{"IOS_CERTIFICATE", "IOS_CERTIFICATE_PASSWORD", "IOS_PROVISIONING_PROFILE", "IOS_EXTENSION_PROFILES"}) { t.Errorf("legacy = %+v", legacy) } // Every name is one a profile's env may not set. diff --git a/internal/config/types.go b/internal/config/types.go index da21f64..f65f111 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -136,11 +136,16 @@ type KMPConfig struct { type IOSConfig struct { // Path to iOS project relative to repo root (e.g., "ios" for React Native, "platforms/ios" for Cordova) // Empty means root directory contains the Xcode project - Path string `json:"path,omitempty"` - Scheme string `json:"scheme,omitempty"` // Xcode scheme to build (auto-detected if empty) - BundleID string `json:"bundleId,omitempty"` // App bundle identifier, for signing setup (detected by init when unambiguous) - Signing bool `json:"signing,omitempty"` // Legacy: sign builds without a profile with the unsuffixed IOS_* secrets - Configuration string `json:"configuration,omitempty"` // Build configuration: Debug (faster) or Release (production) + Path string `json:"path,omitempty"` + Scheme string `json:"scheme,omitempty"` // Xcode scheme to build (auto-detected if empty) + BundleID string `json:"bundleId,omitempty"` // App bundle identifier, for signing setup (detected by init when unambiguous) + // Extensions are the bundle identifiers of the app's extension targets + // (widgets, share/notification extensions, watch apps, app clips), each of + // which signing setup provisions a profile for. init and signing setup fill + // it from the local Xcode project; a managed Expo project lists them by hand. + Extensions []string `json:"extensions,omitempty"` + Signing bool `json:"signing,omitempty"` // Legacy: sign builds without a profile with the unsuffixed IOS_* secrets + Configuration string `json:"configuration,omitempty"` // Build configuration: Debug (faster) or Release (production) } // MobAIConfig holds MobAI settings for local development diff --git a/internal/signing/auto.go b/internal/signing/auto.go index dfe7307..1b11351 100644 --- a/internal/signing/auto.go +++ b/internal/signing/auto.go @@ -88,6 +88,9 @@ func P12FileName(t Type) string { return fmt.Sprintf("ios-signing-%s.p12", t) } type AutoOptions struct { BundleID string Type Type + // Extensions are the bundle ids of the app's extension targets; each gets + // an App ID and a profile of the same type, certificate and devices. + Extensions []string // Devices are registered when missing; development and ad-hoc profiles // then cover every enabled iOS device on the account. Devices []Device @@ -149,6 +152,13 @@ type ProfileResult struct { Reason string `json:"reason,omitempty"` } +// ExtensionResult reports one extension's App ID and profile. +type ExtensionResult struct { + BundleID BundleIDResult `json:"bundle_id"` + Profile ProfileResult `json:"profile"` + File string `json:"file"` +} + // Files lists what Auto wrote. type Files struct { // Key is set only when a key was generated. @@ -164,10 +174,13 @@ type AutoResult struct { Certificate CertificateResult `json:"certificate"` Devices DevicesResult `json:"devices"` Profile ProfileResult `json:"profile"` + Extensions []ExtensionResult `json:"extensions,omitempty"` Files Files `json:"files"` - // P12 and ProfileContent are the bytes written, for uploading. - P12 []byte `json:"-"` - ProfileContent []byte `json:"-"` + // P12 and ProfileContent are the bytes written, for uploading; + // ExtensionProfiles maps each extension bundle id to its profile. + P12 []byte `json:"-"` + ProfileContent []byte `json:"-"` + ExtensionProfiles map[string][]byte `json:"-"` } // Auto provisions everything an iOS build needs to sign through the App Store @@ -195,23 +208,21 @@ func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResu if now == nil { now = time.Now } - res := &AutoResult{Type: opts.Type} + res := &AutoResult{Type: opts.Type, ExtensionProfiles: map[string][]byte{}} - // 1. Bundle ID - bundle, err := client.BundleIDByIdentifier(ctx, opts.BundleID) + // 1. Bundle IDs: the app's and every extension's, since Apple issues a + // profile per App ID and an extension is one of its own. + bundle, err := ensureBundleID(ctx, client, opts, opts.BundleID, &res.BundleID) if err != nil { return res, err } - if bundle == nil { - logf(opts.Log, "Registering App ID %s...", opts.BundleID) - if bundle, err = client.CreateBundleID(ctx, opts.BundleID, bundleIDName(opts.BundleID), asc.PlatformIOS); err != nil { - return res, fmt.Errorf("register App ID %s: %w", opts.BundleID, err) + extensionBundles := make([]*asc.BundleID, len(opts.Extensions)) + for i, id := range opts.Extensions { + res.Extensions = append(res.Extensions, ExtensionResult{}) + if extensionBundles[i], err = ensureBundleID(ctx, client, opts, id, &res.Extensions[i].BundleID); err != nil { + return res, err } - res.BundleID.Created = true - } else { - logf(opts.Log, "App ID %s is registered (%s)", bundle.Identifier, bundle.Name) } - res.BundleID.ID, res.BundleID.Identifier = bundle.ID, bundle.Identifier // 2. Devices, before anything that counts against a quota: a development // profile with no device to cover is an error, and it must not cost a @@ -247,12 +258,19 @@ func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResu return res, err } - // 4. Profile - profile, err := ensureProfile(ctx, client, opts, bundle.ID, cert.ID, deviceIDs, now(), &res.Profile) + // 4. Profiles: the app's, then one per extension + profile, err := ensureProfile(ctx, client, opts, bundle, cert.ID, deviceIDs, now(), &res.Profile) if err != nil { return res, err } res.ProfileContent = profile.Content + for i, b := range extensionBundles { + p, err := ensureProfile(ctx, client, opts, b, cert.ID, deviceIDs, now(), &res.Extensions[i].Profile) + if err != nil { + return res, err + } + res.ExtensionProfiles[b.Identifier] = p.Content + } // 5. Files res.Files.P12 = filepath.Join(opts.OutDir, P12FileName(opts.Type)) @@ -263,9 +281,35 @@ func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResu if err := os.WriteFile(res.Files.Profile, profile.Content, 0600); err != nil { return res, fmt.Errorf("write provisioning profile: %w", err) } + for i, b := range extensionBundles { + ext := &res.Extensions[i] + ext.File = filepath.Join(opts.OutDir, ProfileFileName(ext.Profile.Name)) + if err := os.WriteFile(ext.File, res.ExtensionProfiles[b.Identifier], 0600); err != nil { + return res, fmt.Errorf("write provisioning profile: %w", err) + } + } return res, nil } +// ensureBundleID registers the App ID for identifier when it is missing. +func ensureBundleID(ctx context.Context, client *asc.Client, opts *AutoOptions, identifier string, out *BundleIDResult) (*asc.BundleID, error) { + bundle, err := client.BundleIDByIdentifier(ctx, identifier) + if err != nil { + return nil, err + } + if bundle == nil { + logf(opts.Log, "Registering App ID %s...", identifier) + if bundle, err = client.CreateBundleID(ctx, identifier, bundleIDName(identifier), asc.PlatformIOS); err != nil { + return nil, fmt.Errorf("register App ID %s: %w", identifier, err) + } + out.Created = true + } else { + logf(opts.Log, "App ID %s is registered (%s)", bundle.Identifier, bundle.Name) + } + out.ID, out.Identifier = bundle.ID, bundle.Identifier + return bundle, nil +} + // ProfileName is the portal name of the profile Auto manages for a bundle ID. func ProfileName(t Type, bundleID string) string { return fmt.Sprintf("Builder %s %s", t, bundleID) @@ -397,8 +441,8 @@ func ensureDevices(ctx context.Context, client *asc.Client, opts *AutoOptions, o // ensureProfile reuses the Builder-managed profile when it is ACTIVE, // unexpired and still lists exactly this certificate and these devices; // otherwise it deletes and recreates it. Same-named duplicates go too. -func ensureProfile(ctx context.Context, client *asc.Client, opts *AutoOptions, bundleResourceID, certID string, deviceIDs []string, now time.Time, out *ProfileResult) (*asc.Profile, error) { - name := ProfileName(opts.Type, opts.BundleID) +func ensureProfile(ctx context.Context, client *asc.Client, opts *AutoOptions, bundle *asc.BundleID, certID string, deviceIDs []string, now time.Time, out *ProfileResult) (*asc.Profile, error) { + name := ProfileName(opts.Type, bundle.Identifier) profileType := opts.Type.profileType() existing, err := client.ListProfilesByName(ctx, name) if err != nil { @@ -427,7 +471,7 @@ func ensureProfile(ctx context.Context, client *asc.Client, opts *AutoOptions, b if !opts.Type.NeedsDevices() { deviceIDs = nil } - p, err := client.CreateProfile(ctx, name, profileType, bundleResourceID, []string{certID}, deviceIDs) + p, err := client.CreateProfile(ctx, name, profileType, bundle.ID, []string{certID}, deviceIDs) if err != nil { return nil, fmt.Errorf("create profile %q: %w", name, err) } diff --git a/internal/signing/auto_test.go b/internal/signing/auto_test.go index e5c47d5..c22b183 100644 --- a/internal/signing/auto_test.go +++ b/internal/signing/auto_test.go @@ -1,6 +1,7 @@ package signing import ( + "bytes" "context" "crypto/rsa" "os" @@ -361,3 +362,59 @@ func TestBundleIDName(t *testing.T) { } } } + +// An app with extensions gets one App ID and one profile per extension, all +// of the app's type, on the same certificate and devices; the second run +// reuses them all. +func TestAutoProvisionsExtensions(t *testing.T) { + p := signingtest.New(t) + p.BundleIDs = []string{"com.example.app.widget"} + dir := t.TempDir() + opts := devOpts(dir) + opts.Extensions = []string{"com.example.app.widget", "com.example.app.share"} + res := run(t, p, opts) + + if len(res.Extensions) != 2 || res.Extensions[0].BundleID.Created || !res.Extensions[1].BundleID.Created { + t.Fatalf("extensions = %+v", res.Extensions) + } + if p.Count("POST /v1/profiles") != 3 || p.Count("POST /v1/certificates") != 1 || len(p.Profiles) != 3 { + t.Errorf("calls = %v", p.Calls()) + } + for i, id := range opts.Extensions { + ext := res.Extensions[i] + if !ext.Profile.Created || ext.Profile.Name != "Builder development "+id || ext.Profile.Type != asc.ProfileTypeIOSAppDevelopment { + t.Errorf("%s: profile = %+v", id, ext.Profile) + } + if ext.File != filepath.Join(dir, "Builder-development-"+id+".mobileprovision") { + t.Errorf("%s: file = %s", id, ext.File) + } + if data, err := os.ReadFile(ext.File); err != nil || !bytes.Equal(data, res.ExtensionProfiles[id]) || !strings.HasPrefix(string(data), "profile:") { + t.Errorf("%s: file %q, %v, secret %q", id, data, err, res.ExtensionProfiles[id]) + } + } + for _, pr := range p.Profiles { + if !slices.Equal(pr.CertIDs, []string{res.Certificate.ID}) || len(pr.DeviceIDs) != 1 { + t.Errorf("profile %s: certificates %v, devices %v", pr.Name, pr.CertIDs, pr.DeviceIDs) + } + } + + keyPEM, err := os.ReadFile(res.Files.Key) + if err != nil { + t.Fatal(err) + } + opts.KeyPEM = keyPEM + again := run(t, p, opts) + for _, call := range p.Calls() { + if strings.HasPrefix(call, "POST") || strings.HasPrefix(call, "DELETE") { + t.Errorf("second run made %s", call) + } + } + if again.Extensions[0].Profile.Created || again.Extensions[1].Profile.Created || len(again.ExtensionProfiles) != 2 { + t.Errorf("second run = %+v", again.Extensions) + } + // No extensions: nothing extra, and an empty map to encode as {}. + opts.Extensions = nil + if none := run(t, p, opts); len(none.Extensions) != 0 || EncodeExtensionProfiles(none.ExtensionProfiles) != "{}" { + t.Errorf("no extensions = %+v", none.Extensions) + } +} diff --git a/internal/signing/extensions.go b/internal/signing/extensions.go new file mode 100644 index 0000000..08ed3df --- /dev/null +++ b/internal/signing/extensions.go @@ -0,0 +1,76 @@ +package signing + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" +) + +// EncodeExtensionProfiles is the IOS_EXTENSION_PROFILES_ secret: a JSON +// object of extension bundle id to base64 .mobileprovision, {} for none. +func EncodeExtensionProfiles(profiles map[string][]byte) string { + encoded := make(map[string]string, len(profiles)) + for id, data := range profiles { + encoded[id] = base64.StdEncoding.EncodeToString(data) + } + out, _ := json.Marshal(encoded) // a map of strings always marshals + return string(out) +} + +// DecodeExtensionProfiles reads what EncodeExtensionProfiles wrote; an empty +// value is no extensions. +func DecodeExtensionProfiles(secret string) (map[string][]byte, error) { + if strings.TrimSpace(secret) == "" { + return map[string][]byte{}, nil + } + var encoded map[string]string + if err := json.Unmarshal([]byte(secret), &encoded); err != nil { + return nil, fmt.Errorf("extension profiles must be a JSON object of bundle id to base64 .mobileprovision: %w", err) + } + profiles := make(map[string][]byte, len(encoded)) + for id, value := range encoded { + data, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return nil, fmt.Errorf("extension profile %s: %w", id, err) + } + profiles[id] = data + } + return profiles, nil +} + +// ProfileBundleID reads the app id a .mobileprovision covers, without the +// team prefix; a wildcard profile ends in "*". +func ProfileBundleID(data []byte) (string, error) { + dict, err := profilePlist(data) + if err != nil { + return "", err + } + entitlements, _ := dict["Entitlements"].(map[string]any) + appID, _ := entitlements["application-identifier"].(string) + if appID == "" { + return "", errors.New("provisioning profile has no application-identifier entitlement") + } + // The team prefix is the first element of TeamIdentifier, or whatever + // precedes the first dot when the profile does not list one. + if teams, _ := dict["TeamIdentifier"].([]any); len(teams) > 0 { + if team, _ := teams[0].(string); team != "" && strings.HasPrefix(appID, team+".") { + return strings.TrimPrefix(appID, team+"."), nil + } + } + _, id, found := strings.Cut(appID, ".") + if !found { + return "", fmt.Errorf("application-identifier %q has no team prefix", appID) + } + return id, nil +} + +// Covers reports whether a profile's app id (exact, or a "*" wildcard) covers +// a bundle id, the way the runner matches targets to profiles. +func Covers(appID, bundleID string) bool { + if prefix, ok := strings.CutSuffix(appID, "*"); ok { + return strings.HasPrefix(bundleID, prefix) + } + return appID == bundleID +} diff --git a/internal/signing/extensions_test.go b/internal/signing/extensions_test.go new file mode 100644 index 0000000..29c5da6 --- /dev/null +++ b/internal/signing/extensions_test.go @@ -0,0 +1,70 @@ +package signing + +import ( + "bytes" + "maps" + "strings" + "testing" +) + +func TestExtensionProfilesRoundTrip(t *testing.T) { + profiles := map[string][]byte{"com.example.app.widget": []byte("widget\x00bytes"), "com.example.app.share": []byte("share")} + secret := EncodeExtensionProfiles(profiles) + if !strings.HasPrefix(secret, `{"com.example.app.share":"c2hhcmU=","com.example.app.widget":"`) { + t.Errorf("secret = %s", secret) + } + got, err := DecodeExtensionProfiles(secret) + if err != nil || !maps.EqualFunc(got, profiles, bytes.Equal) { + t.Errorf("decoded = %q, %v", got, err) + } + if got, err := DecodeExtensionProfiles(""); err != nil || len(got) != 0 { + t.Errorf("empty secret = %q, %v", got, err) + } + if EncodeExtensionProfiles(nil) != "{}" { + t.Errorf("nil encodes as %s", EncodeExtensionProfiles(nil)) + } + for _, bad := range []string{"[]", `{"a":"not base64!"}`, "nope"} { + if _, err := DecodeExtensionProfiles(bad); err == nil { + t.Errorf("%q accepted", bad) + } + } +} + +func TestProfileBundleID(t *testing.T) { + team := "TeamIdentifierABCDE12345" + for name, tc := range map[string]struct{ body, want string }{ + "explicit": {team + "Entitlementsapplication-identifierABCDE12345.com.example.app.widget", "com.example.app.widget"}, + "wildcard": {team + "Entitlementsapplication-identifierABCDE12345.com.example.*", "com.example.*"}, + "no TeamIdentifier": {"Entitlementsapplication-identifierABCDE12345.com.example.app", "com.example.app"}, + "other team in prefix": {team + "Entitlementsapplication-identifierZZZZZ99999.com.example.app", "com.example.app"}, + } { + if got, err := ProfileBundleID(mobileprovision(tc.body)); err != nil || got != tc.want { + t.Errorf("%s: ProfileBundleID = %q, %v; want %q", name, got, err, tc.want) + } + } + for name, body := range map[string]string{"no entitlement": team, "no team prefix": "Entitlementsapplication-identifierbare"} { + if _, err := ProfileBundleID(mobileprovision(body)); err == nil { + t.Errorf("%s accepted", name) + } + } + if _, err := ProfileBundleID([]byte("not a profile")); err == nil { + t.Error("unreadable profile accepted") + } +} + +func TestCovers(t *testing.T) { + for _, tc := range []struct { + appID, bundleID string + want bool + }{ + {"com.example.app.widget", "com.example.app.widget", true}, + {"com.example.app", "com.example.app.widget", false}, + {"com.example.*", "com.example.app.widget", true}, + {"*", "com.example.app", true}, + {"com.example.*", "org.other.app", false}, + } { + if got := Covers(tc.appID, tc.bundleID); got != tc.want { + t.Errorf("Covers(%q, %q) = %v", tc.appID, tc.bundleID, got) + } + } +} diff --git a/internal/signing/profile.go b/internal/signing/profile.go index 380228b..cc596ac 100644 --- a/internal/signing/profile.go +++ b/internal/signing/profile.go @@ -14,14 +14,9 @@ import ( // enterprise, ProvisionedDevices with get-task-allow is development and // without it ad-hoc, and a profile with neither is App Store (store). func ProfileType(data []byte) (Type, error) { - start := bytes.Index(data, []byte("")) - if start < 0 || end < start { - return "", errors.New("not a provisioning profile: no plist inside") - } - var dict map[string]any - if _, err := plist.Unmarshal(data[start:end+len("")], &dict); err != nil { - return "", fmt.Errorf("parse provisioning profile: %w", err) + dict, err := profilePlist(data) + if err != nil { + return "", err } if all, _ := dict["ProvisionsAllDevices"].(bool); all { return TypeEnterprise, nil @@ -35,3 +30,17 @@ func ProfileType(data []byte) (Type, error) { } return TypeStore, nil } + +// profilePlist is the plist inside a .mobileprovision's CMS signature. +func profilePlist(data []byte) (map[string]any, error) { + start := bytes.Index(data, []byte("")) + if start < 0 || end < start { + return nil, errors.New("not a provisioning profile: no plist inside") + } + var dict map[string]any + if _, err := plist.Unmarshal(data[start:end+len("")], &dict); err != nil { + return nil, fmt.Errorf("parse provisioning profile: %w", err) + } + return dict, nil +} diff --git a/internal/xcodeproj/xcodeproj.go b/internal/xcodeproj/xcodeproj.go new file mode 100644 index 0000000..5b22061 --- /dev/null +++ b/internal/xcodeproj/xcodeproj.go @@ -0,0 +1,85 @@ +// Package xcodeproj reads what signing needs out of a project.pbxproj. +package xcodeproj + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "howett.net/plist" +) + +// ExtensionProductTypes are the target types that ship inside the app with a +// bundle id and profile of their own. The runner's apply_signing_to_app_target +// carries the same list and must agree. +var ExtensionProductTypes = []string{ + "com.apple.product-type.app-extension", + "com.apple.product-type.app-extension.messages", + "com.apple.product-type.extensionkit-extension", + "com.apple.product-type.application.watchapp2", + "com.apple.product-type.watchkit2-extension", + "com.apple.product-type.application.on-demand-install-capable", +} + +// ExtensionBundleIDs returns the distinct bundle identifiers of the extension +// targets of every *.xcodeproj directly under dir, sorted. Values built from +// other settings ($(...)) are skipped, and a dir without a project yields nil. +func ExtensionBundleIDs(dir string) ([]string, error) { + if dir == "" { + dir = "." + } + projects, _ := filepath.Glob(filepath.Join(dir, "*.xcodeproj", "project.pbxproj")) + var ids []string + for _, path := range projects { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + found, err := extensionBundleIDs(data) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + for _, id := range found { + if !slices.Contains(ids, id) { + ids = append(ids, id) + } + } + } + slices.Sort(ids) + return ids, nil +} + +// extensionBundleIDs parses one project.pbxproj (OpenStep text as Xcode +// writes it, or the XML plist a rewrite leaves). +func extensionBundleIDs(data []byte) ([]string, error) { + var project struct { + Objects map[string]struct { + Isa string `plist:"isa"` + ProductType string `plist:"productType"` + BuildConfigurationList string `plist:"buildConfigurationList"` + BuildConfigurations []string `plist:"buildConfigurations"` + BuildSettings struct { + BundleID string `plist:"PRODUCT_BUNDLE_IDENTIFIER"` + } `plist:"buildSettings"` + } `plist:"objects"` + } + if _, err := plist.Unmarshal(data, &project); err != nil { + return nil, fmt.Errorf("parse project.pbxproj: %w", err) + } + var ids []string + for _, target := range project.Objects { + if target.Isa != "PBXNativeTarget" || !slices.Contains(ExtensionProductTypes, target.ProductType) { + continue + } + for _, configID := range project.Objects[target.BuildConfigurationList].BuildConfigurations { + id := project.Objects[configID].BuildSettings.BundleID + if id == "" || strings.Contains(id, "$") || slices.Contains(ids, id) { + continue + } + ids = append(ids, id) + } + } + return ids, nil +} diff --git a/internal/xcodeproj/xcodeproj_test.go b/internal/xcodeproj/xcodeproj_test.go new file mode 100644 index 0000000..2ace4f8 --- /dev/null +++ b/internal/xcodeproj/xcodeproj_test.go @@ -0,0 +1,108 @@ +package xcodeproj + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +// fixture is an app, a widget, a share extension whose bundle id comes from +// another setting, and a framework, laid out as Xcode writes them. +const fixture = `// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXNativeTarget section */ + A1 /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = LA /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + ); + name = App; + productName = App; + productType = "com.apple.product-type.application"; + }; + W1 /* Widget */ = { + isa = PBXNativeTarget; + buildConfigurationList = LW; + name = WidgetExtension; + productType = "com.apple.product-type.app-extension"; + }; + S1 /* Share */ = { + isa = PBXNativeTarget; + buildConfigurationList = LS; + name = Share; + productType = "com.apple.product-type.app-extension"; + }; + K1 /* Kit */ = { + isa = PBXNativeTarget; + buildConfigurationList = LK; + name = Kit; + productType = "com.apple.product-type.framework"; + }; +/* End PBXNativeTarget section */ + +/* Begin XCBuildConfiguration section */ + AD = { isa = XCBuildConfiguration; buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.app; SWIFT_VERSION = 5.0; }; name = Debug; }; + AR = { isa = XCBuildConfiguration; buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.app; }; name = Release; }; + WD = { isa = XCBuildConfiguration; buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = "com.example.app.widget"; }; name = Debug; }; + WR = { isa = XCBuildConfiguration; buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = "com.example.app.widget"; }; name = Release; }; + SD = { isa = XCBuildConfiguration; buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID).share"; }; name = Debug; }; + SR = { isa = XCBuildConfiguration; buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID).share"; }; name = Release; }; + KD = { isa = XCBuildConfiguration; buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.app.Kit; }; name = Debug; }; + KR = { isa = XCBuildConfiguration; buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.app.Kit; }; name = Release; }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + LA = { isa = XCConfigurationList; buildConfigurations = ( AD, AR, ); defaultConfigurationName = Release; }; + LW = { isa = XCConfigurationList; buildConfigurations = ( WD, WR, ); defaultConfigurationName = Release; }; + LS = { isa = XCConfigurationList; buildConfigurations = ( SD, SR, ); defaultConfigurationName = Release; }; + LK = { isa = XCConfigurationList; buildConfigurations = ( KD, KR, ); defaultConfigurationName = Release; }; +/* End XCConfigurationList section */ + }; + rootObject = P0; +} +` + +func TestExtensionBundleIDs(t *testing.T) { + dir := t.TempDir() + project := filepath.Join(dir, "App.xcodeproj") + if err := os.MkdirAll(project, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(project, "project.pbxproj"), []byte(fixture), 0644); err != nil { + t.Fatal(err) + } + // The pods project sits a level down and is never read. + pods := filepath.Join(dir, "Pods", "Pods.xcodeproj") + if err := os.MkdirAll(pods, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pods, "project.pbxproj"), []byte("not a plist {"), 0644); err != nil { + t.Fatal(err) + } + + got, err := ExtensionBundleIDs(dir) + if err != nil { + t.Fatal(err) + } + // The app and the framework are not extensions; the share extension's + // id is built from another setting and cannot be provisioned by name. + if want := []string{"com.example.app.widget"}; !slices.Equal(got, want) { + t.Errorf("ExtensionBundleIDs = %v, want %v", got, want) + } + if got, err := ExtensionBundleIDs(filepath.Join(dir, "missing")); err != nil || got != nil { + t.Errorf("no project: %v, %v", got, err) + } + if err := os.WriteFile(filepath.Join(project, "project.pbxproj"), []byte("{ objects = ( broken"), 0644); err != nil { + t.Fatal(err) + } + if _, err := ExtensionBundleIDs(dir); err == nil { + t.Error("unparsable project accepted") + } +} From 54eb503aed99feefccad0874fd01ed18c363bda0 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 20:06:44 +0200 Subject: [PATCH 74/75] workflow: sign extension targets with their own profiles Both runners now decode IOS_EXTENSION_PROFILES_ (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 ; 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. --- internal/workflow/providers_test.go | 257 ++++++++++++++++++++-- internal/workflow/templates/ios-build.yml | 142 ++++++++---- internal/workflow/templates/runner.sh | 116 +++++++--- 3 files changed, 420 insertions(+), 95 deletions(-) diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index b675d08..49e637b 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -13,7 +13,9 @@ import ( "text/template" "github.com/MobAI-App/ios-builder/internal/signing" + "github.com/MobAI-App/ios-builder/internal/xcodeproj" "go.yaml.in/yaml/v3" + "howett.net/plist" ) func TestProviderYAMLAndPreservation(t *testing.T) { @@ -241,10 +243,15 @@ func TestExportMethodFollowsProfile(t *testing.T) { if err != nil { t.Fatal(err) } - fromWorkflow := shellFunc(t, string(workflowTemplate), "detect_export_method") - fromRunner := shellFunc(t, string(runner), "detect_export_method") - if fromWorkflow != fromRunner { - t.Fatalf("templates disagree on the export method:\n%s\n---\n%s", fromWorkflow, fromRunner) + var fromRunner string + for _, fn := range []string{"detect_export_method", "write_export_options"} { + fromWorkflow, fromRunnerFn := shellFunc(t, string(workflowTemplate), fn), shellFunc(t, string(runner), fn) + if fromWorkflow != fromRunnerFn { + t.Fatalf("templates disagree on %s:\n%s\n---\n%s", fn, fromWorkflow, fromRunnerFn) + } + if fn == "detect_export_method" { + fromRunner = fromRunnerFn + } } // Both must refuse a Debug distribution build, whose get-task-allow // entitlement no distribution profile grants, and both must feed the @@ -252,13 +259,11 @@ func TestExportMethodFollowsProfile(t *testing.T) { wiring := map[string][]string{ "ios-build.yml": { `EXPORT_METHOD=$(detect_export_method "$PROFILE_PLIST")`, - `" ${EXPORT_METHOD}"`, - "plutil -insert manageAppVersionAndBuildNumber -bool NO", + "write_export_options ExportOptions.plist", }, "runner.sh": { `detect_export_method "$signing_dir/profile.plist"`, - `'method': os.environ['EXPORT_METHOD']`, - "options['manageAppVersionAndBuildNumber'] = False", + `write_export_options "$signing_dir/ExportOptions.plist"`, }, } for name, data := range map[string]string{"ios-build.yml": string(workflowTemplate), "runner.sh": string(runner)} { @@ -268,7 +273,7 @@ func TestExportMethodFollowsProfile(t *testing.T) { if strings.Contains(data, "development") || strings.Contains(data, "'method': 'development'") { t.Errorf("%s: export method still hardcoded", name) } - for _, want := range wiring[name] { + for _, want := range append(wiring[name], `'method': os.environ['EXPORT_METHOD']`, "options['manageAppVersionAndBuildNumber'] = False") { if !strings.Contains(data, want) { t.Errorf("%s: export options no longer wired to the profile, missing %q", name, want) } @@ -531,9 +536,15 @@ func TestSigningSettingsOnAppTargetOnly(t *testing.T) { if fromWorkflow != fromRunner { t.Fatalf("templates disagree on apply_signing_to_app_target:\n%s\n---\n%s", fromWorkflow, fromRunner) } - if n := strings.Count(fromRunner, "\n"); n > 70 { + if n := strings.Count(fromRunner, "\n"); n > 90 { t.Errorf("apply_signing_to_app_target has grown to %d lines", n) } + // The runner and the CLI must call the same targets extensions. + for _, productType := range xcodeproj.ExtensionProductTypes { + if !strings.Contains(fromRunner, "'"+productType+"'") { + t.Errorf("apply_signing_to_app_target does not treat %s as an extension", productType) + } + } call := `apply_signing_to_app_target "$PROFILE_BUNDLE_ID"` wiring := map[string][]string{ @@ -577,10 +588,12 @@ func TestSigningSettingsOnAppTargetOnly(t *testing.T) { } script := "set -euo pipefail\nfail() { echo \"$*\" >&2; exit 1; }\n" + fromRunner + "\ncd \"$1\"\napply_signing_to_app_target \"$2\"\n" want := map[string]string{"CODE_SIGN_STYLE": "Manual", "DEVELOPMENT_TEAM": "ABCDE12345", "PROVISIONING_PROFILE_SPECIFIER": "Builder store run.mobai.flicker", "CODE_SIGN_IDENTITY": "Apple Distribution"} - run := func(t *testing.T, dir, appID string) (string, error) { + // run applies the settings for the app id; extra is more environment, + // such as the EXTENSION_PROFILES map the signing step installs. + run := func(t *testing.T, dir, appID string, extra ...string) (string, error) { t.Helper() cmd := exec.Command("bash", "-c", script, "bash", dir, appID) - cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + os.Getenv("HOME"), "DEVELOPMENT_TEAM=" + want["DEVELOPMENT_TEAM"], "PROVISIONING_PROFILE_NAME=" + want["PROVISIONING_PROFILE_SPECIFIER"], "CODE_SIGN_IDENTITY=" + want["CODE_SIGN_IDENTITY"]} + cmd.Env = append([]string{"PATH=" + os.Getenv("PATH"), "HOME=" + os.Getenv("HOME"), "DEVELOPMENT_TEAM=" + want["DEVELOPMENT_TEAM"], "PROVISIONING_PROFILE_NAME=" + want["PROVISIONING_PROFILE_SPECIFIER"], "CODE_SIGN_IDENTITY=" + want["CODE_SIGN_IDENTITY"]}, extra...) out, err := cmd.CombinedOutput() return string(out), err } @@ -595,12 +608,16 @@ func TestSigningSettingsOnAppTargetOnly(t *testing.T) { } return path } - // signed asserts the four settings on both configurations of a target and - // that nothing conditional is left to override them. - signed := func(t *testing.T, settings map[string]map[string]string, target string) { + // signedWith asserts the four settings, with the given profile, on both + // configurations of a target and that nothing conditional is left to + // override them; signed is the app's own profile. + signedWith := func(t *testing.T, settings map[string]map[string]string, target, profile string) { t.Helper() for _, config := range []string{"Debug", "Release"} { for k, v := range want { + if k == "PROVISIONING_PROFILE_SPECIFIER" { + v = profile + } if got := settings[config][k]; got != v { t.Errorf("%s %s: %s = %q, want %q", target, config, k, got, v) } @@ -612,6 +629,10 @@ func TestSigningSettingsOnAppTargetOnly(t *testing.T) { } } } + signed := func(t *testing.T, settings map[string]map[string]string, target string) { + t.Helper() + signedWith(t, settings, target, want["PROVISIONING_PROFILE_SPECIFIER"]) + } untouched := func(t *testing.T, settings map[string]map[string]string, target string) { t.Helper() for _, config := range []string{"Debug", "Release"} { @@ -627,7 +648,7 @@ func TestSigningSettingsOnAppTargetOnly(t *testing.T) { t.Run("app target only", func(t *testing.T) { dir := t.TempDir() - project := write(t, dir, "App.xcodeproj", app, kit, widget) + project := write(t, dir, "App.xcodeproj", app, kit) // The pods project sits a level down and is never a candidate. pods := write(t, filepath.Join(dir, "Pods"), "Pods.xcodeproj", pbxTarget{"FirebaseCore", "com.apple.product-type.framework", "org.cocoapods.FirebaseCore", nil}) before, _ := os.ReadFile(filepath.Join(pods, "project.pbxproj")) @@ -641,7 +662,6 @@ func TestSigningSettingsOnAppTargetOnly(t *testing.T) { settings := pbxSettings(t, project) signed(t, settings["App"], "App") untouched(t, settings["Kit"], "Kit") - untouched(t, settings["Widget"], "Widget") after, _ := os.ReadFile(filepath.Join(pods, "project.pbxproj")) if !bytes.Equal(before, after) { t.Error("Pods.xcodeproj was rewritten") @@ -657,6 +677,46 @@ func TestSigningSettingsOnAppTargetOnly(t *testing.T) { } } }) + t.Run("extension targets get their own profiles", func(t *testing.T) { + // A wildcard entry covers the extensions under it, but an exact entry + // is the more specific one and wins; the app keeps its own profile. + dir := t.TempDir() + share := pbxTarget{"Share", "com.apple.product-type.app-extension", "run.mobai.flicker.share", nil} + project := write(t, dir, "App.xcodeproj", app, widget, share, kit) + profiles := `{"run.mobai.flicker.widget": "Builder store run.mobai.flicker.widget", "run.mobai.flicker.*": "Wildcard extensions"}` + out, err := run(t, dir, "run.mobai.flicker", "EXTENSION_PROFILES="+profiles) + if err != nil { + t.Fatalf("%s %v", out, err) + } + if !strings.Contains(out, "target Widget in App.xcodeproj: Debug, Release (profile Builder store run.mobai.flicker.widget)") { + t.Errorf("log does not say what changed: %s", out) + } + settings := pbxSettings(t, project) + signed(t, settings["App"], "App") + signedWith(t, settings["Widget"], "Widget", "Builder store run.mobai.flicker.widget") + signedWith(t, settings["Share"], "Share", "Wildcard extensions") + untouched(t, settings["Kit"], "Kit") + }) + t.Run("extension target without a profile", func(t *testing.T) { + // The error names the target and its bundle id, says where to list it + // and which setup to run; the project stays as it was. + dir := t.TempDir() + project := write(t, dir, "App.xcodeproj", app, widget) + for _, env := range [][]string{nil, {"EXTENSION_PROFILES={}"}, {`EXTENSION_PROFILES={"run.mobai.other.widget": "Other"}`}} { + out, err := run(t, dir, "run.mobai.flicker", append(env, "DISTRIBUTION=store")...) + if err == nil { + t.Fatalf("%v: accepted: %s", env, out) + } + for _, want := range []string{"Widget in App.xcodeproj (run.mobai.flicker.widget)", "ios.extensions", "builder signing setup --distribution store"} { + if !strings.Contains(out, want) { + t.Errorf("%v: error does not say %q: %s", env, want, out) + } + } + } + if data, _ := os.ReadFile(filepath.Join(project, "project.pbxproj")); !strings.HasPrefix(string(data), "// !$*UTF8*$!") { + t.Error("project rewritten although the archive cannot be signed") + } + }) t.Run("several apps: the one the profile covers", func(t *testing.T) { dir := t.TempDir() project := write(t, dir, "App.xcodeproj", other, app, kit) @@ -768,7 +828,7 @@ func TestSigningSetSelection(t *testing.T) { t.Error("SIGNING_SET is not derived from the distribution") } for _, set := range []string{"DEVELOPMENT", "AD_HOC", "STORE", "ENTERPRISE"} { - for _, secret := range []string{"IOS_CERTIFICATE_", "IOS_CERTIFICATE_PASSWORD_", "IOS_PROVISIONING_PROFILE_"} { + for _, secret := range []string{"IOS_CERTIFICATE_", "IOS_CERTIFICATE_PASSWORD_", "IOS_PROVISIONING_PROFILE_", "IOS_EXTENSION_PROFILES_"} { if line := secret + set + ": ${{ secrets." + secret + set + " }}"; !strings.Contains(string(workflowTemplate), line) { t.Errorf("ios-build.yml does not pass %s%s to the signing step", secret, set) } @@ -782,7 +842,7 @@ func TestSigningSetSelection(t *testing.T) { script := "set -euo pipefail\nfail() { echo \"$*\" >&2; exit 1; }\n" + shared + "SIGNING_SET=$(signing_set \"$DISTRIBUTION\") || fail \"bad distribution $DISTRIBUTION\"\n" + "select_signing_set\ncheck_signing_set \"$METHOD\"\n" + - "printf '%s|%s|%s|%s' \"$IOS_CERTIFICATE\" \"$IOS_CERTIFICATE_PASSWORD\" \"$IOS_PROVISIONING_PROFILE\" \"$SIGNING_SET_USED\"\n" + "printf '%s|%s|%s|%s|%s' \"$IOS_CERTIFICATE\" \"$IOS_CERTIFICATE_PASSWORD\" \"$IOS_PROVISIONING_PROFILE\" \"$IOS_EXTENSION_PROFILES\" \"$SIGNING_SET_USED\"\n" run := func(env map[string]string) (string, error) { cmd := exec.Command("bash", "-c", script) // A bare environment: none of the secrets can leak in from the host. @@ -812,10 +872,13 @@ func TestSigningSetSelection(t *testing.T) { want string // "" expects a failure whose message holds wantErr errs []string }{ - {"suffixed set present", with(legacy, store, map[string]string{"DISTRIBUTION": "store", "METHOD": "app-store"}), "store-cert|store-pw|store-profile|STORE", nil}, - {"internal reads the ad-hoc set", with(adHoc, map[string]string{"DISTRIBUTION": "internal", "METHOD": "ad-hoc"}), "adhoc-cert|adhoc-pw|adhoc-profile|AD_HOC", nil}, - {"only legacy, no distribution, any profile type", with(legacy, map[string]string{"DISTRIBUTION": "", "METHOD": "ad-hoc"}), "legacy-cert|legacy-pw|legacy-profile|legacy", nil}, - {"only legacy without a password", map[string]string{"IOS_CERTIFICATE": "legacy-cert", "IOS_PROVISIONING_PROFILE": "legacy-profile", "DISTRIBUTION": "", "METHOD": "development"}, "legacy-cert||legacy-profile|legacy", nil}, + // The extension profiles follow the set and are optional: an app + // without extension targets has no such secret. + {"suffixed set present", with(legacy, store, map[string]string{"IOS_EXTENSION_PROFILES": "legacy-ext", "IOS_EXTENSION_PROFILES_STORE": "store-ext", "DISTRIBUTION": "store", "METHOD": "app-store"}), "store-cert|store-pw|store-profile|store-ext|STORE", nil}, + {"suffixed set without extension profiles", with(legacy, store, map[string]string{"IOS_EXTENSION_PROFILES": "legacy-ext", "DISTRIBUTION": "store", "METHOD": "app-store"}), "store-cert|store-pw|store-profile||STORE", nil}, + {"internal reads the ad-hoc set", with(adHoc, map[string]string{"DISTRIBUTION": "internal", "METHOD": "ad-hoc"}), "adhoc-cert|adhoc-pw|adhoc-profile||AD_HOC", nil}, + {"only legacy, no distribution, any profile type", with(legacy, map[string]string{"IOS_EXTENSION_PROFILES": "legacy-ext", "DISTRIBUTION": "", "METHOD": "ad-hoc"}), "legacy-cert|legacy-pw|legacy-profile|legacy-ext|legacy", nil}, + {"only legacy without a password", map[string]string{"IOS_CERTIFICATE": "legacy-cert", "IOS_PROVISIONING_PROFILE": "legacy-profile", "DISTRIBUTION": "", "METHOD": "development"}, "legacy-cert||legacy-profile||legacy", nil}, {"no distribution ignores the suffixed sets", with(store, map[string]string{"IOS_CERTIFICATE_DEVELOPMENT": "dev-cert", "IOS_CERTIFICATE_PASSWORD_DEVELOPMENT": "dev-pw", "IOS_PROVISIONING_PROFILE_DEVELOPMENT": "dev-profile", "DISTRIBUTION": "", "METHOD": "development"}), "", []string{"ios.signing needs IOS_CERTIFICATE", "IOS_*_"}}, {"requested set absent, legacy present", with(legacy, map[string]string{"DISTRIBUTION": "ad-hoc", "METHOD": "ad-hoc"}), "", []string{"Signing set AD_HOC for distribution ad-hoc is missing IOS_CERTIFICATE_AD_HOC, IOS_CERTIFICATE_PASSWORD_AD_HOC, IOS_PROVISIONING_PROFILE_AD_HOC", "--distribution ad-hoc"}}, {"suffixed set missing its profile", with(legacy, map[string]string{"IOS_CERTIFICATE_STORE": "store-cert", "IOS_CERTIFICATE_PASSWORD_STORE": "store-pw", "DISTRIBUTION": "store", "METHOD": "app-store"}), "", []string{"missing IOS_PROVISIONING_PROFILE_STORE.", "--distribution store"}}, @@ -847,6 +910,154 @@ func TestSigningSetSelection(t *testing.T) { } } +// TestExtensionProfilesInstalled holds both templates to one +// install_extension_profiles and runs it on the IOS_EXTENSION_PROFILES secret +// builder signing setup writes: every profile lands next to the app's, and +// the printed map (bundle id to profile name) is what the build reads. +func TestExtensionProfilesInstalled(t *testing.T) { + workflowTemplate, err := GetWorkflowTemplate() + if err != nil { + t.Fatal(err) + } + runner, err := GetTemplate("runner.sh") + if err != nil { + t.Fatal(err) + } + fromWorkflow := shellFunc(t, string(workflowTemplate), "install_extension_profiles") + fromRunner := shellFunc(t, string(runner), "install_extension_profiles") + if fromWorkflow != fromRunner { + t.Fatalf("templates disagree on install_extension_profiles:\n%s\n---\n%s", fromWorkflow, fromRunner) + } + // The map reaches the build step, right after the app's profile is installed. + for name, data := range map[string]string{"ios-build.yml": string(workflowTemplate), "runner.sh": string(runner)} { + data = strings.ReplaceAll(data, "\r\n", "\n") // Windows checkouts + if !strings.Contains(data, "EXTENSION_PROFILES=$(install_extension_profiles ") { + t.Errorf("%s: the extension profiles are not installed", name) + } + } + if !strings.Contains(string(workflowTemplate), `echo "EXTENSION_PROFILES=$EXTENSION_PROFILES" >> $GITHUB_ENV`) || !strings.Contains(string(runner), "export EXTENSION_PROFILES") { + t.Error("EXTENSION_PROFILES does not reach the build") + } + + if runtime.GOOS != "darwin" { + t.Skip("plutil is macOS only") + } + if _, err := exec.LookPath("jq"); err != nil { + t.Skip("jq unavailable") + } + // A stub security prints the file as it is: the fixtures are bare plists, + // not CMS blobs. + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + if err := os.MkdirAll(bin, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "security"), []byte("#!/bin/bash\n[ \"$1\" = cms ] || exit 2\ncat \"$4\"\n"), 0755); err != nil { + t.Fatal(err) + } + profile := func(name, uuid string) string { + return `Name` + name + `UUID` + uuid + `` + } + secret := signing.EncodeExtensionProfiles(map[string][]byte{ + "run.mobai.flicker.widget": []byte(profile("Builder store run.mobai.flicker.widget", "11111111-2222")), + "run.mobai.flicker.share": []byte(profile("Builder store run.mobai.flicker.share", "33333333-4444")), + }) + home := filepath.Join(dir, "home") + script := "set -euo pipefail\nfail() { echo \"$*\" >&2; exit 1; }\n" + fromRunner + "\ninstall_extension_profiles \"$1\"\n" + run := func(secret string) (string, error) { + cmd := exec.Command("bash", "-c", script, "bash", filepath.Join(dir, "extensions")) + cmd.Env = []string{"PATH=" + bin + string(os.PathListSeparator) + os.Getenv("PATH"), "HOME=" + home, "IOS_EXTENSION_PROFILES=" + secret, "SIGNING_SET=STORE"} + out, err := cmd.Output() + return string(out), err + } + out, err := run(secret) + if err != nil { + t.Fatalf("%s %v", out, err) + } + var got map[string]string + if err := json.Unmarshal([]byte(out), &got); err != nil || got["run.mobai.flicker.widget"] != "Builder store run.mobai.flicker.widget" || got["run.mobai.flicker.share"] != "Builder store run.mobai.flicker.share" || len(got) != 2 { + t.Errorf("map = %s, %v", out, err) + } + for _, uuid := range []string{"11111111-2222", "33333333-4444"} { + if _, err := os.Stat(filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles", uuid+".mobileprovision")); err != nil { + t.Errorf("profile %s not installed: %v", uuid, err) + } + } + // No extensions, or no secret at all, is an empty map; a value that is + // not an object is refused by name. + for _, empty := range []string{"{}", ""} { + if out, err := run(empty); err != nil || out != "{}" { + t.Errorf("secret %q: %q, %v", empty, out, err) + } + } + cmd := exec.Command("bash", "-c", script, "bash", filepath.Join(dir, "extensions")) + cmd.Env = []string{"PATH=" + bin + string(os.PathListSeparator) + os.Getenv("PATH"), "HOME=" + home, "IOS_EXTENSION_PROFILES=[1]", "SIGNING_SET=STORE"} + if out, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(out), "IOS_EXTENSION_PROFILES_STORE must be a JSON object") { + t.Errorf("bad secret: %s %v", out, err) + } +} + +// TestExportOptionsIncludeExtensions: the export maps the app's real bundle +// id to its profile as before, plus one entry per extension. +func TestExportOptionsIncludeExtensions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell test") + } + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 unavailable") + } + runner, err := GetTemplate("runner.sh") + if err != nil { + t.Fatal(err) + } + script := "set -euo pipefail\n" + shellFunc(t, string(runner), "write_export_options") + "\nwrite_export_options \"$1\"\n" + for _, tc := range []struct { + name, method, extensions string + want map[string]string + manage bool + }{ + {"app only", "development", "", map[string]string{"run.mobai.flicker": "Builder development run.mobai.flicker"}, false}, + {"with extensions", "app-store", `{"run.mobai.flicker.widget": "Builder store run.mobai.flicker.widget"}`, map[string]string{"run.mobai.flicker": "Builder development run.mobai.flicker", "run.mobai.flicker.widget": "Builder store run.mobai.flicker.widget"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "ExportOptions.plist") + cmd := exec.Command("bash", "-c", script, "bash", path) + cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "EXPORT_METHOD=" + tc.method, "DEVELOPMENT_TEAM=ABCDE12345", "APP_BUNDLE_ID=run.mobai.flicker", "PROVISIONING_PROFILE_NAME=Builder development run.mobai.flicker"} + if tc.extensions != "" { + cmd.Env = append(cmd.Env, "EXTENSION_PROFILES="+tc.extensions) + } + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s %v", out, err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var options struct { + Method string `plist:"method"` + Style string `plist:"signingStyle"` + Team string `plist:"teamID"` + Profiles map[string]string `plist:"provisioningProfiles"` + Manage *bool `plist:"manageAppVersionAndBuildNumber"` + } + if _, err := plist.Unmarshal(data, &options); err != nil { + t.Fatal(err) + } + if options.Method != tc.method || options.Style != "manual" || options.Team != "ABCDE12345" || len(options.Profiles) != len(tc.want) { + t.Errorf("options = %+v", options) + } + for id, name := range tc.want { + if options.Profiles[id] != name { + t.Errorf("provisioningProfiles[%s] = %q, want %q", id, options.Profiles[id], name) + } + } + if (options.Manage != nil && !*options.Manage) != tc.manage { + t.Errorf("manageAppVersionAndBuildNumber = %v, want set to false: %v", options.Manage, tc.manage) + } + }) + } +} + func TestWorkflowTemplatesParse(t *testing.T) { for _, name := range []string{"ios-build.yml", "ios-share.yml"} { data, err := GetTemplate(name) diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index 7cfd70d..5a3f480 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -354,21 +354,26 @@ jobs: IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} + IOS_EXTENSION_PROFILES: ${{ secrets.IOS_EXTENSION_PROFILES }} IOS_CERTIFICATE_DEVELOPMENT: ${{ secrets.IOS_CERTIFICATE_DEVELOPMENT }} IOS_CERTIFICATE_PASSWORD_DEVELOPMENT: ${{ secrets.IOS_CERTIFICATE_PASSWORD_DEVELOPMENT }} IOS_PROVISIONING_PROFILE_DEVELOPMENT: ${{ secrets.IOS_PROVISIONING_PROFILE_DEVELOPMENT }} + IOS_EXTENSION_PROFILES_DEVELOPMENT: ${{ secrets.IOS_EXTENSION_PROFILES_DEVELOPMENT }} IOS_CERTIFICATE_AD_HOC: ${{ secrets.IOS_CERTIFICATE_AD_HOC }} IOS_CERTIFICATE_PASSWORD_AD_HOC: ${{ secrets.IOS_CERTIFICATE_PASSWORD_AD_HOC }} IOS_PROVISIONING_PROFILE_AD_HOC: ${{ secrets.IOS_PROVISIONING_PROFILE_AD_HOC }} + IOS_EXTENSION_PROFILES_AD_HOC: ${{ secrets.IOS_EXTENSION_PROFILES_AD_HOC }} IOS_CERTIFICATE_STORE: ${{ secrets.IOS_CERTIFICATE_STORE }} IOS_CERTIFICATE_PASSWORD_STORE: ${{ secrets.IOS_CERTIFICATE_PASSWORD_STORE }} IOS_PROVISIONING_PROFILE_STORE: ${{ secrets.IOS_PROVISIONING_PROFILE_STORE }} + IOS_EXTENSION_PROFILES_STORE: ${{ secrets.IOS_EXTENSION_PROFILES_STORE }} IOS_CERTIFICATE_ENTERPRISE: ${{ secrets.IOS_CERTIFICATE_ENTERPRISE }} IOS_CERTIFICATE_PASSWORD_ENTERPRISE: ${{ secrets.IOS_CERTIFICATE_PASSWORD_ENTERPRISE }} IOS_PROVISIONING_PROFILE_ENTERPRISE: ${{ secrets.IOS_PROVISIONING_PROFILE_ENTERPRISE }} + IOS_EXTENSION_PROFILES_ENTERPRISE: ${{ secrets.IOS_EXTENSION_PROFILES_ENTERPRISE }} run: | set -e - fail() { echo "::error::$*"; exit 1; } + fail() { echo "::error::$*" >&2; exit 1; } # Picks the set's IOS_*_ secrets into the unsuffixed names, or with # no distribution takes the unsuffixed ones as they are (password @@ -380,9 +385,10 @@ jobs: fail "No signing secrets: ios.signing needs IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE; a build profile with a distribution reads its own IOS_*_ secrets instead (builder signing setup --distribution writes them)." fi IOS_CERTIFICATE_PASSWORD="${IOS_CERTIFICATE_PASSWORD:-}" + IOS_EXTENSION_PROFILES="${IOS_EXTENSION_PROFILES:-}" SIGNING_SET_USED=legacy else - local cert="IOS_CERTIFICATE_$SIGNING_SET" pass="IOS_CERTIFICATE_PASSWORD_$SIGNING_SET" prof="IOS_PROVISIONING_PROFILE_$SIGNING_SET" + local cert="IOS_CERTIFICATE_$SIGNING_SET" pass="IOS_CERTIFICATE_PASSWORD_$SIGNING_SET" prof="IOS_PROVISIONING_PROFILE_$SIGNING_SET" ext="IOS_EXTENSION_PROFILES_$SIGNING_SET" local missing="" name for name in "$cert" "$pass" "$prof"; do [ -n "${!name:-}" ] || missing="${missing:+$missing, }$name" @@ -391,11 +397,39 @@ jobs: IOS_CERTIFICATE="${!cert}" IOS_CERTIFICATE_PASSWORD="${!pass}" IOS_PROVISIONING_PROFILE="${!prof}" + IOS_EXTENSION_PROFILES="${!ext:-}" SIGNING_SET_USED="$SIGNING_SET" fi echo "Signing set: $SIGNING_SET_USED" } + # Decodes the set's extension profiles (IOS_EXTENSION_PROFILES, a JSON object + # of extension bundle id to base64 .mobileprovision) into $1, installs them + # next to the app's, and prints a JSON object of bundle id to profile name + # for apply_signing_to_app_target and write_export_options. + install_extension_profiles() { + local dir="$1" json="${IOS_EXTENSION_PROFILES:-}" i=0 id encoded path uuid name map='{}' + [ -n "$json" ] || json='{}' + if [ "$(jq -r 'type' <<< "$json" 2>/dev/null)" != "object" ]; then + fail "IOS_EXTENSION_PROFILES${SIGNING_SET:+_$SIGNING_SET} must be a JSON object of extension bundle id to base64 .mobileprovision, as builder signing setup writes it." + fi + mkdir -p "$dir" "$HOME/Library/MobileDevice/Provisioning Profiles" + while IFS=' ' read -r id encoded; do + id=$(printf '%s' "$id" | base64 --decode) + i=$((i + 1)) + path="$dir/extension-$i.mobileprovision" + printf '%s' "$encoded" | base64 --decode > "$path" + security cms -D -i "$path" > "$path.plist" + uuid=$(plutil -extract UUID raw -o - "$path.plist") + name=$(plutil -extract Name raw -o - "$path.plist") + cp "$path" "$HOME/Library/MobileDevice/Provisioning Profiles/$uuid.mobileprovision" + echo "$uuid" >> "$dir/installed" + map=$(jq -c --arg id "$id" --arg name "$name" '. + {($id): $name}' <<< "$map") + echo " extension: $id -> '$name' ($uuid)" >&2 + done < <(jq -r 'to_entries[] | "\(.key | @base64) \(.value)"' <<< "$json") + printf '%s' "$map" + } + # The profile in the set must be the type the build profile asked for, # or the IPA would not be what the profile promised. Compared # canonically: the export method says app-store for store, and a tag @@ -503,14 +537,16 @@ jobs: echo "$IDENTITIES" CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD" "$IDENTITIES") || fail "IOS_CERTIFICATE${SIGNING_SET:+_$SIGNING_SET} holds no $(signing_identities "$EXPORT_METHOD" | paste -sd '/' -) certificate, which an $EXPORT_METHOD profile must be signed with. Run builder signing setup --distribution ${DISTRIBUTION:-} to issue the right one." - # Install provisioning profile + # Install the provisioning profiles: the app's, then its extensions' mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/"$PROFILE_UUID".mobileprovision + EXTENSION_PROFILES=$(install_extension_profiles "$RUNNER_TEMP/extensions") echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV echo "DEVELOPMENT_TEAM=$TEAM_ID" >> $GITHUB_ENV echo "PROVISIONING_PROFILE_NAME=$PROFILE_NAME" >> $GITHUB_ENV echo "PROFILE_BUNDLE_ID=$PROFILE_BUNDLE_ID" >> $GITHUB_ENV + echo "EXTENSION_PROFILES=$EXTENSION_PROFILES" >> $GITHUB_ENV echo "EXPORT_METHOD=$EXPORT_METHOD" >> $GITHUB_ENV echo "CODE_SIGN_IDENTITY=$CODE_SIGN_IDENTITY" >> $GITHUB_ENV echo "SIGNING_SET_USED=$SIGNING_SET_USED" >> $GITHUB_ENV @@ -521,7 +557,7 @@ jobs: echo " set: $SIGNING_SET_USED" echo " export: $EXPORT_METHOD" echo " identity: $CODE_SIGN_IDENTITY" - echo "The build writes these into the app target's build settings; its PRODUCT_BUNDLE_IDENTIFIER must match the app id above." + echo "The build writes these into the app target's build settings; its PRODUCT_BUNDLE_IDENTIFIER must match the app id above. Extension targets get the profiles listed above by bundle id." - name: Build IPA env: @@ -531,15 +567,17 @@ jobs: PROJECT_TYPE: ${{ steps.detect.outputs.type }} USE_SIGNING: ${{ steps.params.outputs.use_signing }} CONFIGURATION: ${{ steps.params.outputs.configuration }} + DISTRIBUTION: ${{ steps.params.outputs.distribution }} run: | set -e - fail() { echo "::error::$*"; exit 1; } + fail() { echo "::error::$*" >&2; exit 1; } # Writes the manual signing settings (team, profile and identity from the # environment) into the application targets of ./*.xcodeproj that the - # profile's app id ($1, "*" wildcards) covers, as an XML plist Xcode reads. - # On the xcodebuild command line they would apply to every target, and a - # CocoaPods framework target refuses a provisioning profile. + # profile's app id ($1, "*" wildcards) covers, and into every extension target + # with the EXTENSION_PROFILES entry (bundle id to name) covering its bundle + # id, as an XML plist Xcode reads. On the xcodebuild command line they would + # apply to every target, and a CocoaPods framework target refuses a profile. apply_signing_to_app_target() { local projects=(*.xcodeproj) out [ -d "${projects[0]}" ] || fail "No .xcodeproj in $PWD to apply the signing settings to" @@ -549,37 +587,55 @@ jobs: settings = {'CODE_SIGN_STYLE': 'Manual', 'DEVELOPMENT_TEAM': os.environ['DEVELOPMENT_TEAM'], 'PROVISIONING_PROFILE_SPECIFIER': os.environ['PROVISIONING_PROFILE_NAME'], 'CODE_SIGN_IDENTITY': os.environ['CODE_SIGN_IDENTITY']} - - def covers(bundle_id): - if app_id.endswith('*'): - return bundle_id.startswith(app_id[:-1]) - return bundle_id == app_id - - apps, plists = [], {} + extension_profiles = json.loads(os.environ.get('EXTENSION_PROFILES') or '{}') + # The same list as ExtensionProductTypes in internal/xcodeproj. + extension_types = {'com.apple.product-type.app-extension', 'com.apple.product-type.app-extension.messages', + 'com.apple.product-type.extensionkit-extension', 'com.apple.product-type.application.watchapp2', + 'com.apple.product-type.watchkit2-extension', 'com.apple.product-type.application.on-demand-install-capable'} + + def covers(pattern, bundle_id): + if pattern.endswith('*'): + return bundle_id.startswith(pattern[:-1]) + return bundle_id == pattern + + apps, extensions, plists = [], [], {} for project in projects: path = os.path.join(project, 'project.pbxproj') plists[project] = json.loads(subprocess.check_output(['plutil', '-convert', 'json', '-o', '-', path])) objects = plists[project]['objects'] for target in objects.values(): - if target.get('isa') != 'PBXNativeTarget' or target.get('productType') != 'com.apple.product-type.application': + kind = target.get('productType') + if target.get('isa') != 'PBXNativeTarget' or (kind != 'com.apple.product-type.application' and kind not in extension_types): continue configs = [objects[c] for c in objects[target['buildConfigurationList']]['buildConfigurations']] ids = sorted({c.setdefault('buildSettings', {}).get('PRODUCT_BUNDLE_IDENTIFIER', '') for c in configs}) - apps.append((project, target['name'], configs, ids)) + (apps if kind == 'com.apple.product-type.application' else extensions).append((project, target['name'], configs, ids)) if not apps: sys.exit('No application target in %s to apply the signing settings to' % ', '.join(projects)) - chosen = apps if len(apps) == 1 else [a for a in apps if any(covers(i) for i in a[3])] + chosen = apps if len(apps) == 1 else [a for a in apps if any(covers(app_id, i) for i in a[3])] if not chosen: found = '; '.join('%s in %s (%s)' % (name, project, ', '.join(i or '?' for i in ids)) for project, name, _, ids in apps) sys.exit('No application target has the PRODUCT_BUNDLE_IDENTIFIER the provisioning profile covers (%s): %s' % (app_id, found)) - for project, name, configs, _ in chosen: + chosen = [a + (settings['PROVISIONING_PROFILE_SPECIFIER'],) for a in chosen] + # An extension is signed with the most specific profile covering it; the app's never does. + missing = [] + for project, name, configs, ids in extensions: + names = [extension_profiles[p] for p in sorted(extension_profiles, key=len, reverse=True) if any(covers(p, i) for i in ids)] + if names: + chosen.append((project, name, configs, ids, names[0])) + else: + missing.append('%s in %s (%s)' % (name, project, ', '.join(i or '?' for i in ids))) + if missing: + sys.exit('No provisioning profile for extension target %s. Add each bundle id to ios.extensions in builder.json and run builder signing setup --distribution %s.' % ('; '.join(missing), os.environ.get('DISTRIBUTION') or '')) + for project, name, configs, _, profile in chosen: for config in configs: build = config['buildSettings'] # A conditional setting (CODE_SIGN_IDENTITY[sdk=iphoneos*]) would win over the plain one. for key in [k for k in build if k.split('[')[0] in settings]: del build[key] build.update(settings) - print('Signing settings applied to target %s in %s: %s' % (name, project, ', '.join(c['name'] for c in configs))) + build['PROVISIONING_PROFILE_SPECIFIER'] = profile + print('Signing settings applied to target %s in %s: %s (profile %s)' % (name, project, ', '.join(c['name'] for c in configs), profile)) for project in sorted({a[0] for a in chosen}): subprocess.run(['plutil', '-convert', 'xml1', '-o', os.path.join(project, 'project.pbxproj'), '-'], input=json.dumps(plists[project]).encode(), check=True) @@ -588,6 +644,25 @@ jobs: echo "$out" } + # Writes the export options ($1) with the same manual signing as the archive: + # the app's real bundle id (APP_BUNDLE_ID) maps to its profile and every + # extension keeps its own from EXTENSION_PROFILES. + write_export_options() { + python3 - "$1" <<'PY' + import json, os, plistlib, sys + options = {'method': os.environ['EXPORT_METHOD'], 'signingStyle': 'manual', + 'teamID': os.environ['DEVELOPMENT_TEAM'], + 'provisioningProfiles': {os.environ['APP_BUNDLE_ID']: os.environ['PROVISIONING_PROFILE_NAME']}} + options['provisioningProfiles'].update(json.loads(os.environ.get('EXTENSION_PROFILES') or '{}')) + # Distribution exports keep the version numbers the archive was built with; + # Xcode would otherwise renumber the build on export. + if options['method'] != 'development': + options['manageAppVersionAndBuildNumber'] = False + with open(sys.argv[1], 'wb') as out: + plistlib.dump(options, out) + PY + } + # Navigate to iOS project cd "$IOS_PATH" @@ -788,31 +863,8 @@ jobs: # it needs the app's real bundle id to map it to the profile. APP_BUNDLE_ID=$(plutil -extract ApplicationProperties.CFBundleIdentifier raw -o - build/App.xcarchive/Info.plist) echo "Exporting $APP_BUNDLE_ID with profile '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM), method $EXPORT_METHOD" - - printf '%s\n' \ - '' \ - '' \ - '' \ - '' \ - ' method' \ - " ${EXPORT_METHOD}" \ - ' signingStyle' \ - ' manual' \ - ' teamID' \ - " ${DEVELOPMENT_TEAM}" \ - ' provisioningProfiles' \ - ' ' \ - " ${APP_BUNDLE_ID}" \ - " ${PROVISIONING_PROFILE_NAME}" \ - ' ' \ - '' \ - '' > ExportOptions.plist - - # Distribution exports keep the version numbers the archive was - # built with; Xcode would otherwise renumber the build on export. - if [ "$EXPORT_METHOD" != "development" ]; then - plutil -insert manageAppVersionAndBuildNumber -bool NO ExportOptions.plist - fi + export APP_BUNDLE_ID + write_export_options ExportOptions.plist xcodebuild -exportArchive \ -archivePath build/App.xcarchive \ diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 0049e2c..cc78259 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -145,6 +145,9 @@ select_project() { cleanup_signing() { if [ -n "${keychain_path:-}" ]; then security delete-keychain "$keychain_path" || true; fi if [ -n "${profile_dest:-}" ]; then rm -f "$profile_dest"; fi + if [ -f "${signing_dir:-}/extensions/installed" ]; then + while IFS= read -r uuid; do rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$uuid.mobileprovision"; done < "$signing_dir/extensions/installed" + fi if [ -n "${signing_dir:-}" ]; then rm -rf "$signing_dir"; fi } @@ -206,16 +209,18 @@ signing_set() { # Picks the set's IOS_*_ secrets into the unsuffixed names, or with # no distribution takes the unsuffixed ones as they are (password # optional). A suffixed set needs all three, since builder signing setup -# always writes a password. +# always writes a password; the extension profiles are optional, since an +# app without extension targets has none. select_signing_set() { if [ -z "$SIGNING_SET" ]; then if [ -z "${IOS_CERTIFICATE:-}" ] || [ -z "${IOS_PROVISIONING_PROFILE:-}" ]; then fail "No signing secrets: ios.signing needs IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD and IOS_PROVISIONING_PROFILE; a build profile with a distribution reads its own IOS_*_ secrets instead (builder signing setup --distribution writes them)." fi IOS_CERTIFICATE_PASSWORD="${IOS_CERTIFICATE_PASSWORD:-}" + IOS_EXTENSION_PROFILES="${IOS_EXTENSION_PROFILES:-}" SIGNING_SET_USED=legacy else - local cert="IOS_CERTIFICATE_$SIGNING_SET" pass="IOS_CERTIFICATE_PASSWORD_$SIGNING_SET" prof="IOS_PROVISIONING_PROFILE_$SIGNING_SET" + local cert="IOS_CERTIFICATE_$SIGNING_SET" pass="IOS_CERTIFICATE_PASSWORD_$SIGNING_SET" prof="IOS_PROVISIONING_PROFILE_$SIGNING_SET" ext="IOS_EXTENSION_PROFILES_$SIGNING_SET" local missing="" name for name in "$cert" "$pass" "$prof"; do [ -n "${!name:-}" ] || missing="${missing:+$missing, }$name" @@ -224,11 +229,39 @@ select_signing_set() { IOS_CERTIFICATE="${!cert}" IOS_CERTIFICATE_PASSWORD="${!pass}" IOS_PROVISIONING_PROFILE="${!prof}" + IOS_EXTENSION_PROFILES="${!ext:-}" SIGNING_SET_USED="$SIGNING_SET" fi echo "Signing set: $SIGNING_SET_USED" } +# Decodes the set's extension profiles (IOS_EXTENSION_PROFILES, a JSON object +# of extension bundle id to base64 .mobileprovision) into $1, installs them +# next to the app's, and prints a JSON object of bundle id to profile name +# for apply_signing_to_app_target and write_export_options. +install_extension_profiles() { + local dir="$1" json="${IOS_EXTENSION_PROFILES:-}" i=0 id encoded path uuid name map='{}' + [ -n "$json" ] || json='{}' + if [ "$(jq -r 'type' <<< "$json" 2>/dev/null)" != "object" ]; then + fail "IOS_EXTENSION_PROFILES${SIGNING_SET:+_$SIGNING_SET} must be a JSON object of extension bundle id to base64 .mobileprovision, as builder signing setup writes it." + fi + mkdir -p "$dir" "$HOME/Library/MobileDevice/Provisioning Profiles" + while IFS=' ' read -r id encoded; do + id=$(printf '%s' "$id" | base64 --decode) + i=$((i + 1)) + path="$dir/extension-$i.mobileprovision" + printf '%s' "$encoded" | base64 --decode > "$path" + security cms -D -i "$path" > "$path.plist" + uuid=$(plutil -extract UUID raw -o - "$path.plist") + name=$(plutil -extract Name raw -o - "$path.plist") + cp "$path" "$HOME/Library/MobileDevice/Provisioning Profiles/$uuid.mobileprovision" + echo "$uuid" >> "$dir/installed" + map=$(jq -c --arg id "$id" --arg name "$name" '. + {($id): $name}' <<< "$map") + echo " extension: $id -> '$name' ($uuid)" >&2 + done < <(jq -r 'to_entries[] | "\(.key | @base64) \(.value)"' <<< "$json") + printf '%s' "$map" +} + # The profile in the set must be the type the build profile asked for, # or the IPA would not be what the profile promised. Compared # canonically: the export method says app-store for store, and a tag @@ -245,9 +278,10 @@ check_signing_set() { # Writes the manual signing settings (team, profile and identity from the # environment) into the application targets of ./*.xcodeproj that the -# profile's app id ($1, "*" wildcards) covers, as an XML plist Xcode reads. -# On the xcodebuild command line they would apply to every target, and a -# CocoaPods framework target refuses a provisioning profile. +# profile's app id ($1, "*" wildcards) covers, and into every extension target +# with the EXTENSION_PROFILES entry (bundle id to name) covering its bundle +# id, as an XML plist Xcode reads. On the xcodebuild command line they would +# apply to every target, and a CocoaPods framework target refuses a profile. apply_signing_to_app_target() { local projects=(*.xcodeproj) out [ -d "${projects[0]}" ] || fail "No .xcodeproj in $PWD to apply the signing settings to" @@ -257,37 +291,55 @@ app_id, projects = sys.argv[1], sys.argv[2:] settings = {'CODE_SIGN_STYLE': 'Manual', 'DEVELOPMENT_TEAM': os.environ['DEVELOPMENT_TEAM'], 'PROVISIONING_PROFILE_SPECIFIER': os.environ['PROVISIONING_PROFILE_NAME'], 'CODE_SIGN_IDENTITY': os.environ['CODE_SIGN_IDENTITY']} +extension_profiles = json.loads(os.environ.get('EXTENSION_PROFILES') or '{}') +# The same list as ExtensionProductTypes in internal/xcodeproj. +extension_types = {'com.apple.product-type.app-extension', 'com.apple.product-type.app-extension.messages', + 'com.apple.product-type.extensionkit-extension', 'com.apple.product-type.application.watchapp2', + 'com.apple.product-type.watchkit2-extension', 'com.apple.product-type.application.on-demand-install-capable'} -def covers(bundle_id): - if app_id.endswith('*'): - return bundle_id.startswith(app_id[:-1]) - return bundle_id == app_id +def covers(pattern, bundle_id): + if pattern.endswith('*'): + return bundle_id.startswith(pattern[:-1]) + return bundle_id == pattern -apps, plists = [], {} +apps, extensions, plists = [], [], {} for project in projects: path = os.path.join(project, 'project.pbxproj') plists[project] = json.loads(subprocess.check_output(['plutil', '-convert', 'json', '-o', '-', path])) objects = plists[project]['objects'] for target in objects.values(): - if target.get('isa') != 'PBXNativeTarget' or target.get('productType') != 'com.apple.product-type.application': + kind = target.get('productType') + if target.get('isa') != 'PBXNativeTarget' or (kind != 'com.apple.product-type.application' and kind not in extension_types): continue configs = [objects[c] for c in objects[target['buildConfigurationList']]['buildConfigurations']] ids = sorted({c.setdefault('buildSettings', {}).get('PRODUCT_BUNDLE_IDENTIFIER', '') for c in configs}) - apps.append((project, target['name'], configs, ids)) + (apps if kind == 'com.apple.product-type.application' else extensions).append((project, target['name'], configs, ids)) if not apps: sys.exit('No application target in %s to apply the signing settings to' % ', '.join(projects)) -chosen = apps if len(apps) == 1 else [a for a in apps if any(covers(i) for i in a[3])] +chosen = apps if len(apps) == 1 else [a for a in apps if any(covers(app_id, i) for i in a[3])] if not chosen: found = '; '.join('%s in %s (%s)' % (name, project, ', '.join(i or '?' for i in ids)) for project, name, _, ids in apps) sys.exit('No application target has the PRODUCT_BUNDLE_IDENTIFIER the provisioning profile covers (%s): %s' % (app_id, found)) -for project, name, configs, _ in chosen: +chosen = [a + (settings['PROVISIONING_PROFILE_SPECIFIER'],) for a in chosen] +# An extension is signed with the most specific profile covering it; the app's never does. +missing = [] +for project, name, configs, ids in extensions: + names = [extension_profiles[p] for p in sorted(extension_profiles, key=len, reverse=True) if any(covers(p, i) for i in ids)] + if names: + chosen.append((project, name, configs, ids, names[0])) + else: + missing.append('%s in %s (%s)' % (name, project, ', '.join(i or '?' for i in ids))) +if missing: + sys.exit('No provisioning profile for extension target %s. Add each bundle id to ios.extensions in builder.json and run builder signing setup --distribution %s.' % ('; '.join(missing), os.environ.get('DISTRIBUTION') or '')) +for project, name, configs, _, profile in chosen: for config in configs: build = config['buildSettings'] # A conditional setting (CODE_SIGN_IDENTITY[sdk=iphoneos*]) would win over the plain one. for key in [k for k in build if k.split('[')[0] in settings]: del build[key] build.update(settings) - print('Signing settings applied to target %s in %s: %s' % (name, project, ', '.join(c['name'] for c in configs))) + build['PROVISIONING_PROFILE_SPECIFIER'] = profile + print('Signing settings applied to target %s in %s: %s (profile %s)' % (name, project, ', '.join(c['name'] for c in configs), profile)) for project in sorted({a[0] for a in chosen}): subprocess.run(['plutil', '-convert', 'xml1', '-o', os.path.join(project, 'project.pbxproj'), '-'], input=json.dumps(plists[project]).encode(), check=True) @@ -296,6 +348,25 @@ PY echo "$out" } +# Writes the export options ($1) with the same manual signing as the archive: +# the app's real bundle id (APP_BUNDLE_ID) maps to its profile and every +# extension keeps its own from EXTENSION_PROFILES. +write_export_options() { + python3 - "$1" <<'PY' +import json, os, plistlib, sys +options = {'method': os.environ['EXPORT_METHOD'], 'signingStyle': 'manual', + 'teamID': os.environ['DEVELOPMENT_TEAM'], + 'provisioningProfiles': {os.environ['APP_BUNDLE_ID']: os.environ['PROVISIONING_PROFILE_NAME']}} +options['provisioningProfiles'].update(json.loads(os.environ.get('EXTENSION_PROFILES') or '{}')) +# Distribution exports keep the version numbers the archive was built with; +# Xcode would otherwise renumber the build on export. +if options['method'] != 'development': + options['manageAppVersionAndBuildNumber'] = False +with open(sys.argv[1], 'wb') as out: + plistlib.dump(options, out) +PY +} + install_signing() { SIGNING_SET=$(signing_set "$DISTRIBUTION") || fail "DISTRIBUTION \"$DISTRIBUTION\" must be development, ad-hoc (or internal), store or enterprise" select_signing_set @@ -343,6 +414,8 @@ install_signing() { mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" profile_dest="$HOME/Library/MobileDevice/Provisioning Profiles/$profile_uuid.mobileprovision" cp "$signing_dir/profile.mobileprovision" "$profile_dest" + EXTENSION_PROFILES=$(install_extension_profiles "$signing_dir/extensions") + export EXTENSION_PROFILES } build_ipa() { @@ -363,18 +436,7 @@ build_ipa() { apply_signing_to_app_target "$PROFILE_BUNDLE_ID" xcodebuild "${args[@]}" -archivePath "$BUILDER_WORKSPACE/build/App.xcarchive" archive export APP_BUNDLE_ID="$(plutil -extract ApplicationProperties.CFBundleIdentifier raw -o - "$BUILDER_WORKSPACE/build/App.xcarchive/Info.plist")" - python3 - "$signing_dir/ExportOptions.plist" <<'PY' -import os, plistlib, sys -options = {'method': os.environ['EXPORT_METHOD'], 'signingStyle': 'manual', - 'teamID': os.environ['DEVELOPMENT_TEAM'], - 'provisioningProfiles': {os.environ['APP_BUNDLE_ID']: os.environ['PROVISIONING_PROFILE_NAME']}} -# Distribution exports keep the version numbers the archive was built with; -# Xcode would otherwise renumber the build on export. -if options['method'] != 'development': - options['manageAppVersionAndBuildNumber'] = False -with open(sys.argv[1], 'wb') as out: - plistlib.dump(options, out) -PY + write_export_options "$signing_dir/ExportOptions.plist" xcodebuild -exportArchive -archivePath "$BUILDER_WORKSPACE/build/App.xcarchive" \ -exportOptionsPlist "$signing_dir/ExportOptions.plist" -exportPath "$signing_dir/export" ipa=$(find "$signing_dir/export" -maxdepth 1 -name '*.ipa' -print -quit) From a9447161237b5d02c73a108034748b4f7f3e85de Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 20:06:44 +0200 Subject: [PATCH 75/75] docs: extension targets are signed with their own profiles --- CLAUDE.md | 14 +- README.md | 1752 +++++++++++++++++++------------------- docs/provider-secrets.md | 7 +- 3 files changed, 898 insertions(+), 875 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 44e0c8d..1f395cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,9 +282,17 @@ internal/ settings into app targets only via `plutil`; on the command line every Pods target would inherit them - **App Target Selection**: one app target is signed whatever its bundle id (the export reports a mismatch); with several, those whose `PRODUCT_BUNDLE_IDENTIFIER` `PROFILE_BUNDLE_ID` covers (`*` - wildcards), else `::error::` naming the ids found; conditional `NAME[sdk=…]` variants are dropped. - Extension targets (widgets, share/notification extensions) are not signed: they need their own - profiles, which Builder does not create, so such apps still fail at the archive + wildcards), else `::error::` naming the ids found; conditional `NAME[sdk=…]` variants are dropped +- **Extension Targets**: `ios.extensions` lists their bundle ids (`init`/`signing setup` append what + `xcodeproj.ExtensionBundleIDs` finds; managed Expo lists by hand). `signing.Auto` makes an App ID and + `Builder ` profile per entry, uploaded as `IOS_EXTENSION_PROFILES_` (JSON of id → base64, + always written, `{}` for none; required by `missingSigningSecrets` only when the list is non-empty). + Manual mode: one `--extension-profile` each, matched by the profile's app id. The runner's + `install_extension_profiles` installs them and hands `EXTENSION_PROFILES` (id → name) to + `apply_signing_to_app_target`, which signs each extension-type target (same list as + `xcodeproj.ExtensionProductTypes`, tested) with the longest covering entry, or fails naming the + targets and ids to add; `write_export_options` adds them to `provisioningProfiles`. Secrets are + unreadable, so a build re-provisions when the project has extensions builder.json did not list - **Extension Points**: a future `ios release` composes `distribute.Upload` and `distribute.SubmitTestFlight`, reading `asc.Client.ListBuilds` for the latest build number; the `pkg/` wrappers do not expose `asc` yet. diff --git a/README.md b/README.md index d382dc4..a9a4177 100644 --- a/README.md +++ b/README.md @@ -1,869 +1,883 @@ -# Builder - -Build and develop iOS apps from Windows, Linux, or any platform. - -Builder is a CLI tool for iOS development without a Mac. It uses GitHub Actions (default), Codemagic, or Bitrise for remote builds and [MobAI](https://mobai.run) for on-device development. - -![Builder Demo](assets/ios-builder-demo.gif) - -## Features - -- **Build from anywhere**: Build iOS apps via GitHub Actions, Codemagic, or Bitrise -- **Independent provider logins**: Stay signed in to all three and choose where each build runs -- **Try it on a simulator**: Use your build on an iOS simulator from Windows or Linux -- **Flutter & React Native dev tools**: Hot reload on real iOS devices from Windows/Linux -- **Simple setup**: One command to add the workflow to your repo -- **Code signing**: Optional signing with your certificate and provisioning profile -- **TestFlight and App Store**: Upload builds and submit them for review through the App Store Connect API, from any platform -- **Device integration**: Install and run apps via MobAI - -## How It Works - -``` -Your Repository GitHub Actions (macOS) - └─ .github/workflows/ └─ ios-build.yml - └─ ios-build.yml ├─ Check out the snapshot - ├─ Build with Xcode -builder ios build ───────────────────► Upload IPA artifact - │ pushes a snapshot of - │ your working tree - └─ Downloads IPA ◄─────────────── artifact: ipa -``` - -`builder ios build` builds what is on disk, not your last commit: uncommitted -and untracked files are included, so you can try a change without committing -it. The snapshot is a throwaway commit pushed to a hidden ref that is deleted -when the build finishes; no branch is created and nothing is committed on your -behalf. `.gitignore` still applies, so ignored files such as `.env` or -`GoogleService-Info.plist` are absent from the build. - -## Quick Start - -### 1. Authenticate with GitHub - -```bash -builder auth github -``` - -### 2. Initialize (in your project directory) - -```bash -cd your-ios-project -builder init -``` - -This detects your GitHub repo, creates the workflow files, and offers to commit, push, and trigger your first build - all interactively. - -### 3. Build - -```bash -builder ios build -``` - -The CLI triggers the workflow and downloads the IPA to `./dist/`. - -### 4. Try it on a simulator (optional) - -```bash -builder ios share -``` - -Builds the working tree for the iOS simulator and makes that simulator usable -from the [MobAI](https://mobai.run) app, so you can tap through a build without -a Mac. It shows up under CI Devices, stays available while you are using it, and -closes when you release it there or leave it unused (30 minutes by default, use -`--duration` to change). A coding agent connected to MobAI (Claude Code, Codex, -Cursor) can drive the simulator the same way. - -Free with any MobAI account, on [MobAI 3.0 or later](https://mobai.run). Needs -a `MOBAI_API_KEY` repository secret: create the key in the MobAI app under -Account → API Keys, then: - -```bash -gh secret set MOBAI_API_KEY -``` - -### Triggering from git only - -Where the GitHub API is not reachable, both workflows can also be started by -pushing a tag. Commit the tree you want built, then: - -```bash -git tag ios-build/my-build && git push origin ios-build/my-build # IPA build -git tag ios-share/my-build && git push origin ios-share/my-build # simulator -``` - -The run is named after the tag. Build settings come from `builder.json` in the -tagged commit (`ios.path`, `ios.scheme`, `ios.signing`, `ios.configuration`, -`flutter.version`, `kmp.jdkVersion`), the simulator stays available for the -default 30 minutes, and the tag is deleted when the run ends. The IPA is -attached to the run as an artifact. A tag carries no flags, so a tagged IPA -build cannot pick a [profile](#build-profiles) per run; it applies the profile -named by `defaultProfile`, if there is one. The simulator build takes no -profile at all. - -## Additional macOS Providers - -GitHub Actions remains the default, so existing commands continue to work. Add -Codemagic and Bitrise without logging out of GitHub. First follow the -[app creation and repository connection guide](docs/provider-setup.md) to create -each provider app, authorize GitHub access, and find its app ID: - -```bash -builder auth codemagic -builder auth bitrise -builder auth status -builder init --provider codemagic --app-id YOUR_APP_ID --branch main -builder init --provider bitrise --app-id YOUR_APP_SLUG --branch main -builder ios build --provider codemagic -builder ios build --provider bitrise -``` - -`init` writes `codemagic.yaml` or `bitrise.yml` at the repo root plus the shared -runner script `.builder/ci/runner.sh`. Commit them to the configured branch -and connect the same repository to each provider before building. See -[provider setup, signing, simulator sessions, and free allowances](docs/providers.md). - -## Supported Frameworks - -| Framework | iOS Path | Auto-detected | -|-----------|----------|---------------| -| Native iOS/Swift | `.` (root) | Yes | -| React Native | `ios/` | Yes | -| Expo (ejected) | `ios/` | Yes | -| Flutter | `ios/` | Yes | -| Kotlin Multiplatform | `iosApp/` | Yes | -| Cordova/Ionic | `platforms/ios/` | Yes | - -## Installation - -### Windows - -Download `builder-windows-amd64.exe` from [Releases](https://github.com/MobAI-App/ios-builder/releases), rename it to `builder.exe`, and add it to PATH. - -### Homebrew (macOS/Linux) - -```bash -brew install mobai-app/tap/ios-builder -``` - -The formula is named `ios-builder`; the command it installs is `builder`. - -### macOS/Linux/WSL - -```bash -curl -sSL https://raw.githubusercontent.com/MobAI-App/ios-builder/main/install.sh | bash -``` - -### From Source - -```bash -git clone https://github.com/MobAI-App/ios-builder.git -cd ios-builder -go build -o builder ./cmd/builder -``` - -## Commands - -```bash -# Setup -builder auth github # Authenticate with GitHub -builder auth codemagic # Authenticate with Codemagic (also: bitrise) -builder auth apple # Save an App Store Connect API key -builder auth status # Show which providers you are signed in to -builder auth logout [name] # Remove stored credentials (github, codemagic, bitrise, apple) -builder init # Set up workflows in current repo -builder update # Update builder to the latest release - -# Building (builds the working tree, including uncommitted changes) -builder ios build # Trigger build and download IPA to ./dist/ -builder ios build --unsigned # Build without code signing (if signing is configured) -builder ios build --provider codemagic # Build on another provider (also: bitrise) -builder ios build --profile production # Build with a profile from builder.json - -# Simulator (free, needs a MOBAI_API_KEY secret) -builder ios share # Try the build on a simulator in the MobAI app -builder ios share --duration 1h # Keep it available longer while unused - -# Development (requires MobAI) -builder dev flutter # Flutter hot reload with file watching -builder dev flutter --no-watch # Disable automatic file watching -builder dev flutter --no-attach # Print flutter attach command instead of running it -builder dev rn # React Native hot reload (short for: dev react-native) -builder dev kmp # Kotlin Multiplatform install + launch (alias: kotlin) -builder dev kmp --logs # Also stream the app's output -builder dev flutter --skip-install --bundle-id # Use already installed app -builder dev rn --metro-port 8082 # Use custom Metro port - -# MobAI (used by the dev commands; handy for troubleshooting) -builder mobai ping # Check MobAI connectivity -builder mobai install # Install an IPA on the device -builder mobai run-debug # Launch an app with the debugger attached -builder mobai forward # Forward a device port - -# Code signing (automatic mode needs builder auth apple) -builder signing setup --devices-from-mobai # development: certificate, devices, profile, GitHub secrets, no portal -builder signing setup --distribution store # Apple Distribution certificate + App Store profile -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision # Upload your own files -builder ios build --profile store # Signs with the set; provisions it first when missing -builder signing csr # Manual path: create a private key + certificate signing request -builder signing p12 # Manual path: assemble a .p12 from the key and Apple's certificate - -# TestFlight and App Store (needs builder auth apple) -builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing -builder ios submit --testflight --group "Beta Testers" --notes "What to test" -builder ios submit --app-store --release after-approval # Submit the version for App Review -``` - -Every `upload`/`submit` command takes `--json` for machine-readable output and -never prompts, so agents and CI jobs can drive them. - -## Configuration - -`builder.json`: - -```json -{ - "project": "MyApp", - "platform": "ios", - "github": { - "owner": "username", - "repo": "my-ios-app" - }, - "ios": { - "path": "ios", - "scheme": "", - "bundleId": "com.example.app", - "configuration": "Debug" - }, - "profiles": { - "development": { "distribution": "development" }, - "store": { "distribution": "store" } - }, - "mobai": { - "url": "http://localhost:8686", - "device_id": "" - }, - "flutter": { - "watch": { - "dirs": ["lib"], - "patterns": [".dart"], - "ignore": [".g.dart", ".freezed.dart"], - "debounce": 100 - } - } -} -``` - -### iOS Build Configuration - -| Field | Description | Default | -|-------|-------------|---------| -| `ios.path` | Path to the Xcode project relative to the repo root | detected by `init` | -| `ios.scheme` | Xcode scheme to build | auto-detected | -| `ios.bundleId` | App bundle identifier, used by `signing setup` | detected by `init` when the project has one app target; else saved by `signing setup` | -| `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` | -| `ios.signing` | Legacy: sign builds that select no profile, with the unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and `IOS_PROVISIONING_PROFILE` secrets. Profiles ignore it; use `distribution` there | `false` | - -### Build Profiles - -Profiles are named sets of build settings, in the spirit of `eas.json`, selected -with `--profile` on `ios build`: - -```json -{ - "ios": { "path": "ios", "bundleId": "com.example.app" }, - "defaultProfile": "development", - "profiles": { - "development": { "distribution": "development" }, - "preview": { "distribution": "internal", - "env": { "API_URL": "https://staging.example.com" } }, - "production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic" } - } -} -``` - -```bash -builder ios build --profile preview -``` - -| Field | Description | -|-------|-------------| -| `distribution` | The only signing setting: `development`, `ad-hoc` (or `internal`, the same thing), `store` or `enterprise`. The build signs with that distribution's [signing set](#code-signing) and its provisioning profile must be of that type; the IPA is exported with the matching method. Omitted means an unsigned build | -| `configuration` | Overrides the derived configuration: `Debug` for `development`, `Release` for every other distribution, `ios.configuration` for unsigned profiles | -| `scheme` | Overrides `ios.scheme` | -| `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) | -| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them | - -How a build's settings are resolved: - -- Without `--profile`, the profile named by `defaultProfile` applies. With - neither, the top-level `ios.*` and `provider` settings are used exactly as - before, so existing projects are unaffected. -- A profile only overrides the fields it sets; everything else comes from the - top level. An unknown profile name is an error that lists the available ones. -- `--unsigned` and `--provider` on the command line override the profile. -- The resolved settings (profile, configuration, scheme, signing set, provider, - env names) are printed before anything is dispatched. -- Profiles apply to `ios build` only. `ios share` takes no `--profile`: - simulator builds are always Debug and unsigned, and use `ios.scheme` and the - top-level `provider` (or `--provider`). - -**`env` values are build-time configuration, not secrets.** They are stored in -`builder.json`, sent to the CI provider as plain workflow inputs, and visible in -the run's inputs and logs. Keep tokens and passwords in the provider's secrets -(`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets -guide](docs/provider-secrets.md)); the build reads those as environment -variables too. Names the runner owns are rejected: its own parameters (`SCHEME`, -`CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing secrets, `PATH`, -`HOME`, `DEVELOPER_DIR`, and the `GITHUB_`, `RUNNER_`, `CM_`, `BITRISE_`, `BUILDER_` prefixes. - -Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow -file from this version of Builder, which declares a `profile` input; an older -committed workflow rejects the dispatch. Run `builder init` again to refresh -`.github/workflows/ios-build.yml` (or `builder init --provider ...` for -`runner.sh`) in a project set up earlier, then commit and push it to the -default branch. - -### MobAI Configuration - -| Field | Description | Default | -|-------|-------------|---------| -| `mobai.url` | MobAI API URL | `http://localhost:8686` | -| `mobai.device_id` | Preferred device ID (uses first available if empty) | `""` | - -**WSL users**: MobAI runs on Windows, and WSL has its own network by default. On -Windows 11, turn on -[mirrored networking](https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking) -and builder reaches MobAI on the default `http://localhost:8686`. See -[Using Builder from WSL](docs/wsl.md) for the steps, and for the setup without -mirrored networking. - -### Flutter File Watcher - -| Field | Description | Default | -|-------|-------------|---------| -| `flutter.watch.dirs` | Directories to watch | `["lib"]` | -| `flutter.watch.patterns` | File patterns to match | `[".dart"]` | -| `flutter.watch.ignore` | Patterns to ignore | `[".g.dart", ".freezed.dart"]` | -| `flutter.watch.debounce` | Debounce delay in ms | `100` | - -## Code Signing - -By default, builds are unsigned. A signed build needs a certificate and a -provisioning profile — and despite what many guides claim, **you do not need a -Mac to create either one**, nor a tour of the Apple Developer portal. Signing -is configured per [build profile](#build-profiles) with one field, -`distribution`, and `builder signing setup` produces the material for it -through the App Store Connect API (or takes your own files). - -You need a paid [Apple Developer Program](https://developer.apple.com/programs/) -membership — Apple only issues certificates to paid accounts. (Without one, -build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free -Apple ID.) - -### Profiles and signing sets - -Each distribution has its own set of three GitHub secrets, so a development set -for your devices and a store set for TestFlight live side by side: - -| `distribution` | Certificate, profile | Secrets | -|----------------|----------------------|---------| -| `development` | Apple Development, iOS App Development (devices required) | `IOS_CERTIFICATE_DEVELOPMENT`, `IOS_CERTIFICATE_PASSWORD_DEVELOPMENT`, `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | -| `ad-hoc` or `internal` | Apple Distribution, Ad Hoc (devices required) | `IOS_CERTIFICATE_AD_HOC`, `IOS_CERTIFICATE_PASSWORD_AD_HOC`, `IOS_PROVISIONING_PROFILE_AD_HOC` | -| `store` | Apple Distribution, App Store | `IOS_CERTIFICATE_STORE`, `IOS_CERTIFICATE_PASSWORD_STORE`, `IOS_PROVISIONING_PROFILE_STORE` | -| `enterprise` | In-house (portal only) | `IOS_CERTIFICATE_ENTERPRISE`, `IOS_CERTIFICATE_PASSWORD_ENTERPRISE`, `IOS_PROVISIONING_PROFILE_ENTERPRISE` | - -A build with `--profile ` signs with the set of that profile's -`distribution`; `configuration` follows it (`Debug` for `development`, -`Release` otherwise) unless the profile sets one. The runner checks that the -profile in the set is of the requested type and fails by name before compiling -anything, and a distribution profile refuses a `Debug` configuration. On -Codemagic and Bitrise the same names are variables you add in the dashboard, -see the [secrets guide](docs/provider-secrets.md). - -### Create an App Store Connect API key - -Automatic signing, `ios upload`, `ios submit` and `ios release` all use one -App Store Connect API key. You create it once, in the browser: - -1. Sign in to [App Store Connect](https://appstoreconnect.apple.com) as the - Account Holder or an Admin (only they can create team keys). -2. Go to **Users and Access → Integrations → App Store Connect API**, tab - **Team Keys**, and press **+** (or **Generate API Key**). -3. Name it (for example `Builder`) and choose the role **Admin**. **App - Manager** works too if you also tick *Access to Certificates, Identifiers & - Profiles*; a **Developer** key can upload builds but cannot create - certificates. -4. Press **Generate**, then **Download API Key**. The `AuthKey_.p8` - file downloads once; keep it somewhere private, never in the repo. -5. Note the **Issuer ID** at the top of the page and the **Key ID** in the - row of your key. - -Then save it in your keychain: - -```bash -builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key ~/Downloads/AuthKey_ABC123DEFG.p8 -``` - -`builder auth status` shows it, `builder auth logout apple` removes it. On a -machine without a keychain (CI, a coding agent), set `ASC_ISSUER_ID`, -`ASC_KEY_ID` and `ASC_KEY_PATH` (or `ASC_PRIVATE_KEY` with the file's contents) -instead. - -### `builder signing setup` - -```bash -builder auth apple # once: save the App Store Connect API key -builder signing setup --devices-from-mobai # development set for the devices MobAI sees -builder signing setup --distribution store # store set for TestFlight / App Store -``` - -Without files, `setup` works through the App Store Connect API for the given -`--distribution` (default `development`; `--name ` reads it from an -existing profile). The key needs the **Admin** role (or App Manager plus -*Access to Certificates, Identifiers & Profiles*): Developer-role keys cannot -create certificates. It then: - -1. Registers the **App ID** if the bundle identifier is not on the account yet. - The bundle ID comes from `--bundle-id`, `ios.bundleId` in `builder.json` - (which `init` fills when the Xcode project has a single app target), or the - newest IPA in `./dist/`; in a terminal it asks as a last resort. -2. Issues a **certificate** — Apple Development for `development`, Apple - Distribution for `ad-hoc` and `store` — for a private key generated on your - machine (`ios-signing-.key`; `--key` reuses one from - `signing csr`, and an `ios-signing.key` from an earlier version is picked up - too). A valid certificate on the account is reused only when its private - key is here, since the `.p12` needs it; otherwise a new one is issued. - Nothing is ever revoked: at Apple's limit (2 Development, 3 Distribution) - the error says so and points at the portal. -3. Registers **devices** from `--device ` (repeatable) and - `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has - connected; simulators and cloud farm devices are skipped). Development and - ad-hoc profiles cover every enabled iOS device on the account, so with none - given and none registered the command stops and says so. Store profiles - take no devices. Apple allows 100 devices per membership year and never - frees a slot; that error is passed through too. -4. Creates the **profile** `Builder `. An existing - one is reused while it is `ACTIVE`, unexpired and still lists exactly this - certificate and these devices; otherwise it is deleted and recreated, and - the summary says why (`invalid`, `expired`, `certificate changed`, `devices - changed`, `forced`). -5. Writes `ios-signing-.key` (when generated), - `ios-signing-.p12` and `Builder--.mobileprovision` to `--out-dir` (default `.`), uploads the set's three - secrets to GitHub, and writes `"distribution": ""` into the - `--name` profile (default: the distribution name) in `builder.json`, keeping - its other fields and reporting a replaced distribution; `defaultProfile` is - left alone. An `--out-dir` other than `.` is recorded as `signing.dir`, so a - later `ios build` that provisions a set reuses the key there instead of - asking Apple for a second certificate, which it refuses. -6. Prints the three secret names and where their values come from — the - `.p12` base64-encoded, the password, the `.mobileprovision` base64-encoded - — every time, so the same set can be pasted into Codemagic or Bitrise, - following the [secrets guide](docs/provider-secrets.md). Builder cannot - check those providers' secrets before a build, so `ios build` only reminds - you of this command when the profile signs there. - -The upload goes to the repository in `builder.json`, always. When it fails (no -GitHub login, or a token that cannot write secrets) the error is printed and -the command carries on: files, values and the build profile are written and -shown anyway, and it exits non-zero at the end so a script notices. `--json` -reports the same in `github_upload` (`ok` or the error). - -Signed builds cover the app target only. An app with extension targets (a -widget, a share or notification extension) needs a profile per extension, which -Builder does not create yet, so such projects still fail at the archive step. - -The command shows its plan and asks once before creating anything; `--yes` -skips that (required without a terminal), and then the `.p12` password is -generated and printed once unless `--password` is given. `--json` prints the -result as JSON with progress on stderr. Keep the written files out of git. -Run it again whenever you like: it reports what it found and recreates only -what is missing, expired, invalid or changed — add a device, re-run, rebuild. -`--force` issues a fresh certificate and profile regardless. - -With `--certificate` and `--profile`, `setup` takes your own files instead — a -`.p12` (from Keychain Access, or [assembled here](#manual-path-through-the-apple-developer-portal)) -and a `.mobileprovision` — reads the distribution out of the profile -(development, ad-hoc, store or enterprise; this is the only way in for -enterprise), uploads that set, prints its names and values, and writes the -build profile the same way: - -```bash -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision -``` - -### Provisioning from `ios build` - -`builder ios build --profile ` checks, before dispatching to GitHub, -that the repository holds all three secrets of the profile's set. When any is -missing and an App Store Connect key is saved, it runs the same provisioning -as `signing setup` without prompts, uploads the set and builds; a development -or ad-hoc profile with no registered device stops and points at `builder -signing setup --distribution development --devices-from-mobai`. Without an -Apple key it stops before anything is pushed and names both ways out (`builder -auth apple`, or `signing setup --certificate ... --profile ...`). `--unsigned` -skips the check, and so do Codemagic/Bitrise builds (no secrets API). - -### Legacy: `ios.signing` without profiles - -A project set up before build profiles has `ios.signing: true` and the -unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and -`IOS_PROVISIONING_PROFILE` secrets. Builds that select no profile still sign -with those, whatever the profile type, exactly as before; `setup` never -touches them. Profiles ignore `ios.signing` and read their own set. - -### Manual path through the Apple Developer portal - -The `.p12` certificate is normally created through Keychain Access, but Builder -does the same thing itself: it generates the private key and certificate -signing request, and assembles the `.p12` from the certificate Apple issues. - -#### 1. Create a certificate signing request - -```bash -builder signing csr -``` - -This asks for your name and email and writes two files to the current -directory: `ios-signing.key` (your private key) and `ios-signing.csr`. Keep -the key wherever suits you — just don't commit it (add it to `.gitignore`; -gitignored files are also excluded from build snapshots). - -#### 2. Create the certificate - -1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal -2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc). TestFlight and App Store uploads need **Apple Distribution** together with an App Store profile in step 4 -3. Upload `ios-signing.csr` and download the resulting `.cer` file - -#### 3. Assemble the .p12 - -```bash -builder signing p12 --certificate development.cer --key ios-signing.key -``` - -This combines the key and certificate into `ios-signing.p12` (`--out` to name -it), protected by a password you choose — byte-for-byte the same kind of file -Keychain Access exports, and usable anywhere one is: `builder signing setup`, -Sideloadly, AltStore, or importing it on a Mac. Keep it, and don't commit it. - -#### 4. Create a provisioning profile - -On the portal: - -1. **Identifiers** → register an App ID matching your app's bundle identifier -2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page) -3. **Profiles** → create an **iOS App Development** (or Ad Hoc, App Store) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file - -The profile type decides the distribution the set is written to and the method -the IPA is exported with: development, ad-hoc, enterprise or store. - -#### 5. Upload the signing secrets - -```bash -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision -``` - -You can also skip step 3 and hand `setup` the `.cer` together with the key — -`builder signing setup --certificate development.cer --key ios-signing.key ---profile MyApp.mobileprovision` — and it assembles the `.p12` on the way, -saving it as `ios-signing-.p12`. Then `builder ios build ---profile `; `--unsigned` skips signing for one build. - -## TestFlight and App Store - -Builder uploads builds to App Store Connect and submits them to TestFlight or -App Review through the App Store Connect API, from Windows, Linux or macOS. No -Transporter, `altool` or Xcode is involved, and the API key never leaves your -machine: the CI runner only builds and signs, the upload happens locally from -the IPA in `./dist/`. - -You need: - -- A paid [Apple Developer Program](https://developer.apple.com/programs/) - membership and an app record in App Store Connect for your bundle ID (step 2 - below; the API cannot create it) -- An IPA signed with an **Apple Distribution** certificate and an **App Store** - provisioning profile: `builder signing setup --distribution store` creates - both, stores them as the `STORE` signing set and writes a `store` build - profile, or pick those types on the portal in the manual path. An IPA signed - for development is rejected at upload. -- `builder ios build --profile store` (see [Build Profiles](#build-profiles)): - a `store` profile builds `Release` and signs with that set; a plain `ios - build` is Debug and unsigned, which is what the dev commands expect, not - what you want to ship. -- An App Store Connect API key saved with `builder auth apple`, see - [Create an App Store Connect API key](#create-an-app-store-connect-api-key). - -### 1. Save the API key - -`builder auth apple` as described in -[Create an App Store Connect API key](#create-an-app-store-connect-api-key). -Flags you leave out are prompted for; Builder verifies the key against App -Store Connect before storing it. - -### 2. Create the app record - -App Store Connect only accepts uploads for an app it already knows, and the API -cannot create one. Once, in the browser: - -1. Register the bundle ID first: `builder signing setup --distribution store` - does it (or **Certificates, Identifiers & Profiles → Identifiers → +** on - the developer portal). -2. Open [App Store Connect → My Apps](https://appstoreconnect.apple.com/apps), - press **+ → New App**, pick **iOS**, a name, the primary language, your - bundle ID from the list, and any SKU (an internal string, e.g. the bundle - ID). Press **Create**. - -Nothing else on the record is needed for TestFlight. App Store review needs the -rest of the metadata (screenshots, description, privacy policy) filled in there. - -### 3. Upload the build - -```bash -builder ios build --profile store # produces a signed dist/*.ipa -builder ios upload --wait -``` - -`upload` reads the bundle ID, version and build number from the newest IPA in -`./dist/` (or `--ipa `), finds the app, uploads the archive in chunks -and, with `--wait`, follows App Store Connect until the build has finished -processing and prints its build ID and TestFlight link. Without `--wait` it -returns as soon as Apple has the file. - -Two things Apple checks on every upload: - -- **Build numbers must increase.** A second upload with the same - `CFBundleVersion` for the same version is rejected (`ITMS-90189`), so bump - it before rebuilding. -- **Export compliance.** A build shows as *Missing Compliance* in TestFlight - until you say whether it uses non-exempt encryption. If your Info.plist sets - `ITSAppUsesNonExemptEncryption` to `false`, `upload --wait` answers that - automatically; otherwise pass `--no-encryption` (here or to `submit`) when - your app only uses standard iOS encryption. - -### 4. Distribute to TestFlight - -```bash -builder ios submit --testflight --group "Beta Testers" --notes "New login flow" -``` - -This takes the newest processed build (or `--build-number N`), sets the *What -to Test* notes and adds it to the named groups (`--group` repeats). Internal -groups get the build immediately; the first external group triggers Apple's -beta review, which Builder submits for you (`--wait` follows the decision). Run -it without `--group` to see the build and the groups the app has. - -### 5. Submit to the App Store - -```bash -builder ios submit --app-store --release after-approval -``` - -Builder finds or creates the App Store version matching the IPA's marketing -version (or `--version X.Y.Z`), attaches the build, sets the release type -(`manual` or `after-approval`) and submits it for review. The version's -metadata — description, screenshots, age rating, pricing, privacy — must -already be complete: App Store Connect refuses the submission otherwise and -Builder prints Apple's reasons verbatim. Builder does not manage metadata, -screenshots or in-app purchases; fill them in App Store Connect, or on a Mac -with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of -the `buildUploads` API also proved that the Mac-free upload path works and -served as the reference for Builder's implementation. - -## Installing the IPA - -Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account). - -## Development on Windows/Linux - -Builder supports hot reload for Flutter and React Native on Windows/Linux using [MobAI](https://mobai.run) for iOS device control. This allows you to develop iOS apps without a Mac. - -## Flutter Development - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` - This creates an IPA in `./dist/` -3. Start development with hot reload: - ```bash - builder dev flutter - ``` - Builder installs the IPA through MobAI and asks whether to re-sign it. Re-signing requires an iCloud account - we highly recommend creating a new one at [icloud.com](https://icloud.com) instead of using your primary account. A re-signed app gets a new bundle ID with a team ID suffix (e.g., `com.example.myapp.TEAMID`); Builder prefills it in the prompt that follows. - -### Subsequent Runs - -Once the app is installed, skip the install step: -```bash -builder dev flutter --skip-install --bundle-id com.example.myapp.TEAMID -``` - -### File Watching - -By default, `builder dev flutter` watches for Dart file changes and automatically triggers hot reload. When flutter attach connects, it also sends an initial hot restart to ensure your latest code is running. - -- **Automatic hot reload**: Edit a `.dart` file and save - hot reload triggers automatically -- **Generated files ignored**: Files like `.g.dart` and `.freezed.dart` are ignored by default -- **Configurable**: Customize watched directories, patterns, and debounce via `builder.json` - -To disable file watching: -```bash -builder dev flutter --no-watch -``` - -To print the `flutter attach` command instead of running it (useful for IDE integration): -```bash -builder dev flutter --no-attach -``` - -### When to Rebuild - -- **Native code changes** (Swift, Objective-C, Podfile, native dependencies): Run `builder ios build` and reinstall -- **Dart code changes only**: No rebuild needed - file watcher triggers hot reload automatically - -If you don't see your recent Dart changes after launching, press `R` in the terminal to perform a hot restart. - -### Troubleshooting - -**App won't launch / connection error** -- Close the app on your device before running `builder dev flutter` -- Reconnect the device (unplug/replug USB) -- Restart MobAI -- Run `builder mobai ping` to verify connection - -**"No devices found" error** -- Ensure MobAI is running and device is connected -- Only physical iOS devices are supported (no simulators) - -**Hot reload not working** -- Make sure you're using the correct bundle ID (with team ID suffix) -- Try hot restart with `R` key -- Check that MobAI shows the device as connected - -**File watcher not triggering** -- Ensure you're editing files in watched directories (default: `lib/`) -- Check if the file matches watch patterns (default: `.dart`) -- Generated files (`.g.dart`, `.freezed.dart`) are ignored by default -- Try running without `--no-watch` flag - -## React Native Development - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` -3. Start development with hot reload: - ```bash - builder dev rn - ``` - This will: - - Start Metro bundler if not running - - Install the IPA on your device (with optional re-signing) - - Launch the app with Metro URL configured automatically - -### Subsequent Runs - -Once the app is installed: -```bash -builder dev rn --skip-install --bundle-id com.example.myapp.TEAMID -``` - -### Custom Metro Port - -If port 8081 is in use: -```bash -builder dev rn --metro-port 8082 -``` - -### When to Rebuild - -- **Native code changes** (Swift, Objective-C, Podfile, native modules): Run `builder ios build` and reinstall -- **JavaScript changes only**: No rebuild needed - Metro handles it automatically - -### Troubleshooting - -**Metro not starting** -- Ensure Node.js and React Native CLI are installed -- Try starting Metro manually: `npx react-native start` - -**App not connecting to Metro** -- Device must be on the same WiFi network as the computer running Metro -- Check that Metro is running and accessible -- Verify the Metro port is correct (default: 8081) -- On WSL with mirrored networking, the Hyper-V firewall blocks the phone from reaching Metro by default; see [Using Builder from WSL](docs/wsl.md#react-native) for the firewall rule - -**Hot reload not working** -- Shake device or press `d` in Metro terminal to open dev menu -- Enable "Fast Refresh" in dev menu -- Try reloading with `r` in Metro terminal - -## Kotlin Multiplatform Development - -KMP iOS apps build and run on a device like any other project, with one -difference: **there is no hot reload.** Shared Kotlin is compiled into a native -framework at build time, so there is no runtime to swap code into — every code -change needs a rebuild. - -### Setup - -1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device -2. Build your app: - ```bash - builder ios build - ``` -3. Install and launch it on the device: - ```bash - builder dev kmp - ``` - -`builder init` detects Kotlin Multiplatform projects by looking for the -multiplatform Gradle plugin in the root and module build files, and asks which -JDK the CI build should use (default 17): - -```json -{ - "kmp": { "jdkVersion": "17" } -} -``` - -On CI, the iOS app is built with `xcodebuild`, whose run script phase (or -CocoaPods) invokes Gradle to compile the shared framework — which is why the -JDK version matters. Gradle output is cached between builds. - -### When to Rebuild - -Every change to Kotlin or Swift code needs `builder ios build` followed by -`builder dev kmp` again. Use `--skip-install --bundle-id ` to relaunch an -app that is already installed. - -### Troubleshooting - -**Build fails with "Unsupported class file major version" or a Gradle JDK error** -- The project needs a different JDK than the default: set `kmp.jdkVersion` in `builder.json` to match what the project uses locally - -**Build fails with "SDK does not contain 'libarclite'"** -- An old Kotlin/Native version against a newer Xcode; upgrade the Kotlin plugin in Gradle - -**App launches then immediately exits** -- Launch with `builder dev kmp --logs` to see the device output - -## Build Limits - -Free allowances belong to each provider account and depend on the plan and -machine. As published in September 2026: Codemagic personal accounts include -500 macOS M2 minutes per month; Bitrise Hobby includes 300 credits. GitHub has -separate allowances for private repositories and free standard runners for -public repositories. Providers change these, so check the -[current allowance links and switching guidance](docs/providers.md#free-allowances). - -## License - -[MIT License](LICENSE) +# Builder + +Build and develop iOS apps from Windows, Linux, or any platform. + +Builder is a CLI tool for iOS development without a Mac. It uses GitHub Actions (default), Codemagic, or Bitrise for remote builds and [MobAI](https://mobai.run) for on-device development. + +![Builder Demo](assets/ios-builder-demo.gif) + +## Features + +- **Build from anywhere**: Build iOS apps via GitHub Actions, Codemagic, or Bitrise +- **Independent provider logins**: Stay signed in to all three and choose where each build runs +- **Try it on a simulator**: Use your build on an iOS simulator from Windows or Linux +- **Flutter & React Native dev tools**: Hot reload on real iOS devices from Windows/Linux +- **Simple setup**: One command to add the workflow to your repo +- **Code signing**: Optional signing with your certificate and provisioning profile +- **TestFlight and App Store**: Upload builds and submit them for review through the App Store Connect API, from any platform +- **Device integration**: Install and run apps via MobAI + +## How It Works + +``` +Your Repository GitHub Actions (macOS) + └─ .github/workflows/ └─ ios-build.yml + └─ ios-build.yml ├─ Check out the snapshot + ├─ Build with Xcode +builder ios build ───────────────────► Upload IPA artifact + │ pushes a snapshot of + │ your working tree + └─ Downloads IPA ◄─────────────── artifact: ipa +``` + +`builder ios build` builds what is on disk, not your last commit: uncommitted +and untracked files are included, so you can try a change without committing +it. The snapshot is a throwaway commit pushed to a hidden ref that is deleted +when the build finishes; no branch is created and nothing is committed on your +behalf. `.gitignore` still applies, so ignored files such as `.env` or +`GoogleService-Info.plist` are absent from the build. + +## Quick Start + +### 1. Authenticate with GitHub + +```bash +builder auth github +``` + +### 2. Initialize (in your project directory) + +```bash +cd your-ios-project +builder init +``` + +This detects your GitHub repo, creates the workflow files, and offers to commit, push, and trigger your first build - all interactively. + +### 3. Build + +```bash +builder ios build +``` + +The CLI triggers the workflow and downloads the IPA to `./dist/`. + +### 4. Try it on a simulator (optional) + +```bash +builder ios share +``` + +Builds the working tree for the iOS simulator and makes that simulator usable +from the [MobAI](https://mobai.run) app, so you can tap through a build without +a Mac. It shows up under CI Devices, stays available while you are using it, and +closes when you release it there or leave it unused (30 minutes by default, use +`--duration` to change). A coding agent connected to MobAI (Claude Code, Codex, +Cursor) can drive the simulator the same way. + +Free with any MobAI account, on [MobAI 3.0 or later](https://mobai.run). Needs +a `MOBAI_API_KEY` repository secret: create the key in the MobAI app under +Account → API Keys, then: + +```bash +gh secret set MOBAI_API_KEY +``` + +### Triggering from git only + +Where the GitHub API is not reachable, both workflows can also be started by +pushing a tag. Commit the tree you want built, then: + +```bash +git tag ios-build/my-build && git push origin ios-build/my-build # IPA build +git tag ios-share/my-build && git push origin ios-share/my-build # simulator +``` + +The run is named after the tag. Build settings come from `builder.json` in the +tagged commit (`ios.path`, `ios.scheme`, `ios.signing`, `ios.configuration`, +`flutter.version`, `kmp.jdkVersion`), the simulator stays available for the +default 30 minutes, and the tag is deleted when the run ends. The IPA is +attached to the run as an artifact. A tag carries no flags, so a tagged IPA +build cannot pick a [profile](#build-profiles) per run; it applies the profile +named by `defaultProfile`, if there is one. The simulator build takes no +profile at all. + +## Additional macOS Providers + +GitHub Actions remains the default, so existing commands continue to work. Add +Codemagic and Bitrise without logging out of GitHub. First follow the +[app creation and repository connection guide](docs/provider-setup.md) to create +each provider app, authorize GitHub access, and find its app ID: + +```bash +builder auth codemagic +builder auth bitrise +builder auth status +builder init --provider codemagic --app-id YOUR_APP_ID --branch main +builder init --provider bitrise --app-id YOUR_APP_SLUG --branch main +builder ios build --provider codemagic +builder ios build --provider bitrise +``` + +`init` writes `codemagic.yaml` or `bitrise.yml` at the repo root plus the shared +runner script `.builder/ci/runner.sh`. Commit them to the configured branch +and connect the same repository to each provider before building. See +[provider setup, signing, simulator sessions, and free allowances](docs/providers.md). + +## Supported Frameworks + +| Framework | iOS Path | Auto-detected | +|-----------|----------|---------------| +| Native iOS/Swift | `.` (root) | Yes | +| React Native | `ios/` | Yes | +| Expo (ejected) | `ios/` | Yes | +| Flutter | `ios/` | Yes | +| Kotlin Multiplatform | `iosApp/` | Yes | +| Cordova/Ionic | `platforms/ios/` | Yes | + +## Installation + +### Windows + +Download `builder-windows-amd64.exe` from [Releases](https://github.com/MobAI-App/ios-builder/releases), rename it to `builder.exe`, and add it to PATH. + +### Homebrew (macOS/Linux) + +```bash +brew install mobai-app/tap/ios-builder +``` + +The formula is named `ios-builder`; the command it installs is `builder`. + +### macOS/Linux/WSL + +```bash +curl -sSL https://raw.githubusercontent.com/MobAI-App/ios-builder/main/install.sh | bash +``` + +### From Source + +```bash +git clone https://github.com/MobAI-App/ios-builder.git +cd ios-builder +go build -o builder ./cmd/builder +``` + +## Commands + +```bash +# Setup +builder auth github # Authenticate with GitHub +builder auth codemagic # Authenticate with Codemagic (also: bitrise) +builder auth apple # Save an App Store Connect API key +builder auth status # Show which providers you are signed in to +builder auth logout [name] # Remove stored credentials (github, codemagic, bitrise, apple) +builder init # Set up workflows in current repo +builder update # Update builder to the latest release + +# Building (builds the working tree, including uncommitted changes) +builder ios build # Trigger build and download IPA to ./dist/ +builder ios build --unsigned # Build without code signing (if signing is configured) +builder ios build --provider codemagic # Build on another provider (also: bitrise) +builder ios build --profile production # Build with a profile from builder.json + +# Simulator (free, needs a MOBAI_API_KEY secret) +builder ios share # Try the build on a simulator in the MobAI app +builder ios share --duration 1h # Keep it available longer while unused + +# Development (requires MobAI) +builder dev flutter # Flutter hot reload with file watching +builder dev flutter --no-watch # Disable automatic file watching +builder dev flutter --no-attach # Print flutter attach command instead of running it +builder dev rn # React Native hot reload (short for: dev react-native) +builder dev kmp # Kotlin Multiplatform install + launch (alias: kotlin) +builder dev kmp --logs # Also stream the app's output +builder dev flutter --skip-install --bundle-id # Use already installed app +builder dev rn --metro-port 8082 # Use custom Metro port + +# MobAI (used by the dev commands; handy for troubleshooting) +builder mobai ping # Check MobAI connectivity +builder mobai install # Install an IPA on the device +builder mobai run-debug # Launch an app with the debugger attached +builder mobai forward # Forward a device port + +# Code signing (automatic mode needs builder auth apple) +builder signing setup --devices-from-mobai # development: certificate, devices, profile, GitHub secrets, no portal +builder signing setup --distribution store # Apple Distribution certificate + App Store profile +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision # Upload your own files +builder ios build --profile store # Signs with the set; provisions it first when missing +builder signing csr # Manual path: create a private key + certificate signing request +builder signing p12 # Manual path: assemble a .p12 from the key and Apple's certificate + +# TestFlight and App Store (needs builder auth apple) +builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing +builder ios submit --testflight --group "Beta Testers" --notes "What to test" +builder ios submit --app-store --release after-approval # Submit the version for App Review +``` + +Every `upload`/`submit` command takes `--json` for machine-readable output and +never prompts, so agents and CI jobs can drive them. + +## Configuration + +`builder.json`: + +```json +{ + "project": "MyApp", + "platform": "ios", + "github": { + "owner": "username", + "repo": "my-ios-app" + }, + "ios": { + "path": "ios", + "scheme": "", + "bundleId": "com.example.app", + "configuration": "Debug" + }, + "profiles": { + "development": { "distribution": "development" }, + "store": { "distribution": "store" } + }, + "mobai": { + "url": "http://localhost:8686", + "device_id": "" + }, + "flutter": { + "watch": { + "dirs": ["lib"], + "patterns": [".dart"], + "ignore": [".g.dart", ".freezed.dart"], + "debounce": 100 + } + } +} +``` + +### iOS Build Configuration + +| Field | Description | Default | +|-------|-------------|---------| +| `ios.path` | Path to the Xcode project relative to the repo root | detected by `init` | +| `ios.scheme` | Xcode scheme to build | auto-detected | +| `ios.bundleId` | App bundle identifier, used by `signing setup` | detected by `init` when the project has one app target; else saved by `signing setup` | +| `ios.extensions` | Bundle identifiers of the app's extension targets (widgets, share/notification extensions, watch apps, app clips), each signed with its own profile | filled by `init` and `signing setup` from the Xcode project; list them by hand for a managed Expo project | +| `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` | +| `ios.signing` | Legacy: sign builds that select no profile, with the unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and `IOS_PROVISIONING_PROFILE` secrets. Profiles ignore it; use `distribution` there | `false` | + +### Build Profiles + +Profiles are named sets of build settings, in the spirit of `eas.json`, selected +with `--profile` on `ios build`: + +```json +{ + "ios": { "path": "ios", "bundleId": "com.example.app" }, + "defaultProfile": "development", + "profiles": { + "development": { "distribution": "development" }, + "preview": { "distribution": "internal", + "env": { "API_URL": "https://staging.example.com" } }, + "production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic" } + } +} +``` + +```bash +builder ios build --profile preview +``` + +| Field | Description | +|-------|-------------| +| `distribution` | The only signing setting: `development`, `ad-hoc` (or `internal`, the same thing), `store` or `enterprise`. The build signs with that distribution's [signing set](#code-signing) and its provisioning profile must be of that type; the IPA is exported with the matching method. Omitted means an unsigned build | +| `configuration` | Overrides the derived configuration: `Debug` for `development`, `Release` for every other distribution, `ios.configuration` for unsigned profiles | +| `scheme` | Overrides `ios.scheme` | +| `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) | +| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them | + +How a build's settings are resolved: + +- Without `--profile`, the profile named by `defaultProfile` applies. With + neither, the top-level `ios.*` and `provider` settings are used exactly as + before, so existing projects are unaffected. +- A profile only overrides the fields it sets; everything else comes from the + top level. An unknown profile name is an error that lists the available ones. +- `--unsigned` and `--provider` on the command line override the profile. +- The resolved settings (profile, configuration, scheme, signing set, provider, + env names) are printed before anything is dispatched. +- Profiles apply to `ios build` only. `ios share` takes no `--profile`: + simulator builds are always Debug and unsigned, and use `ios.scheme` and the + top-level `provider` (or `--provider`). + +**`env` values are build-time configuration, not secrets.** They are stored in +`builder.json`, sent to the CI provider as plain workflow inputs, and visible in +the run's inputs and logs. Keep tokens and passwords in the provider's secrets +(`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets +guide](docs/provider-secrets.md)); the build reads those as environment +variables too. Names the runner owns are rejected: its own parameters (`SCHEME`, +`CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing secrets, `PATH`, +`HOME`, `DEVELOPER_DIR`, and the `GITHUB_`, `RUNNER_`, `CM_`, `BITRISE_`, `BUILDER_` prefixes. + +Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow +file from this version of Builder, which declares a `profile` input; an older +committed workflow rejects the dispatch. Run `builder init` again to refresh +`.github/workflows/ios-build.yml` (or `builder init --provider ...` for +`runner.sh`) in a project set up earlier, then commit and push it to the +default branch. + +### MobAI Configuration + +| Field | Description | Default | +|-------|-------------|---------| +| `mobai.url` | MobAI API URL | `http://localhost:8686` | +| `mobai.device_id` | Preferred device ID (uses first available if empty) | `""` | + +**WSL users**: MobAI runs on Windows, and WSL has its own network by default. On +Windows 11, turn on +[mirrored networking](https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking) +and builder reaches MobAI on the default `http://localhost:8686`. See +[Using Builder from WSL](docs/wsl.md) for the steps, and for the setup without +mirrored networking. + +### Flutter File Watcher + +| Field | Description | Default | +|-------|-------------|---------| +| `flutter.watch.dirs` | Directories to watch | `["lib"]` | +| `flutter.watch.patterns` | File patterns to match | `[".dart"]` | +| `flutter.watch.ignore` | Patterns to ignore | `[".g.dart", ".freezed.dart"]` | +| `flutter.watch.debounce` | Debounce delay in ms | `100` | + +## Code Signing + +By default, builds are unsigned. A signed build needs a certificate and a +provisioning profile — and despite what many guides claim, **you do not need a +Mac to create either one**, nor a tour of the Apple Developer portal. Signing +is configured per [build profile](#build-profiles) with one field, +`distribution`, and `builder signing setup` produces the material for it +through the App Store Connect API (or takes your own files). + +You need a paid [Apple Developer Program](https://developer.apple.com/programs/) +membership — Apple only issues certificates to paid accounts. (Without one, +build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free +Apple ID.) + +### Profiles and signing sets + +Each distribution has its own set of GitHub secrets, so a development set +for your devices and a store set for TestFlight live side by side: + +| `distribution` | Certificate, profile | Secrets | +|----------------|----------------------|---------| +| `development` | Apple Development, iOS App Development (devices required) | `IOS_CERTIFICATE_DEVELOPMENT`, `IOS_CERTIFICATE_PASSWORD_DEVELOPMENT`, `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | +| `ad-hoc` or `internal` | Apple Distribution, Ad Hoc (devices required) | `IOS_CERTIFICATE_AD_HOC`, `IOS_CERTIFICATE_PASSWORD_AD_HOC`, `IOS_PROVISIONING_PROFILE_AD_HOC` | +| `store` | Apple Distribution, App Store | `IOS_CERTIFICATE_STORE`, `IOS_CERTIFICATE_PASSWORD_STORE`, `IOS_PROVISIONING_PROFILE_STORE` | +| `enterprise` | In-house (portal only) | `IOS_CERTIFICATE_ENTERPRISE`, `IOS_CERTIFICATE_PASSWORD_ENTERPRISE`, `IOS_PROVISIONING_PROFILE_ENTERPRISE` | + +An app with extension targets has a fourth secret per set, +`IOS_EXTENSION_PROFILES_`, holding their profiles (see +[Extensions](#builder-signing-setup) below). + +A build with `--profile ` signs with the set of that profile's +`distribution`; `configuration` follows it (`Debug` for `development`, +`Release` otherwise) unless the profile sets one. The runner checks that the +profile in the set is of the requested type and fails by name before compiling +anything, and a distribution profile refuses a `Debug` configuration. On +Codemagic and Bitrise the same names are variables you add in the dashboard, +see the [secrets guide](docs/provider-secrets.md). + +### Create an App Store Connect API key + +Automatic signing, `ios upload`, `ios submit` and `ios release` all use one +App Store Connect API key. You create it once, in the browser: + +1. Sign in to [App Store Connect](https://appstoreconnect.apple.com) as the + Account Holder or an Admin (only they can create team keys). +2. Go to **Users and Access → Integrations → App Store Connect API**, tab + **Team Keys**, and press **+** (or **Generate API Key**). +3. Name it (for example `Builder`) and choose the role **Admin**. **App + Manager** works too if you also tick *Access to Certificates, Identifiers & + Profiles*; a **Developer** key can upload builds but cannot create + certificates. +4. Press **Generate**, then **Download API Key**. The `AuthKey_.p8` + file downloads once; keep it somewhere private, never in the repo. +5. Note the **Issuer ID** at the top of the page and the **Key ID** in the + row of your key. + +Then save it in your keychain: + +```bash +builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key ~/Downloads/AuthKey_ABC123DEFG.p8 +``` + +`builder auth status` shows it, `builder auth logout apple` removes it. On a +machine without a keychain (CI, a coding agent), set `ASC_ISSUER_ID`, +`ASC_KEY_ID` and `ASC_KEY_PATH` (or `ASC_PRIVATE_KEY` with the file's contents) +instead. + +### `builder signing setup` + +```bash +builder auth apple # once: save the App Store Connect API key +builder signing setup --devices-from-mobai # development set for the devices MobAI sees +builder signing setup --distribution store # store set for TestFlight / App Store +``` + +Without files, `setup` works through the App Store Connect API for the given +`--distribution` (default `development`; `--name ` reads it from an +existing profile). The key needs the **Admin** role (or App Manager plus +*Access to Certificates, Identifiers & Profiles*): Developer-role keys cannot +create certificates. It then: + +1. Registers the **App ID** if the bundle identifier is not on the account yet. + The bundle ID comes from `--bundle-id`, `ios.bundleId` in `builder.json` + (which `init` fills when the Xcode project has a single app target), or the + newest IPA in `./dist/`; in a terminal it asks as a last resort. +2. Issues a **certificate** — Apple Development for `development`, Apple + Distribution for `ad-hoc` and `store` — for a private key generated on your + machine (`ios-signing-.key`; `--key` reuses one from + `signing csr`, and an `ios-signing.key` from an earlier version is picked up + too). A valid certificate on the account is reused only when its private + key is here, since the `.p12` needs it; otherwise a new one is issued. + Nothing is ever revoked: at Apple's limit (2 Development, 3 Distribution) + the error says so and points at the portal. +3. Registers **devices** from `--device ` (repeatable) and + `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has + connected; simulators and cloud farm devices are skipped). Development and + ad-hoc profiles cover every enabled iOS device on the account, so with none + given and none registered the command stops and says so. Store profiles + take no devices. Apple allows 100 devices per membership year and never + frees a slot; that error is passed through too. +4. Creates the **profile** `Builder `. An existing + one is reused while it is `ACTIVE`, unexpired and still lists exactly this + certificate and these devices; otherwise it is deleted and recreated, and + the summary says why (`invalid`, `expired`, `certificate changed`, `devices + changed`, `forced`). +5. Writes `ios-signing-.key` (when generated), + `ios-signing-.p12` and `Builder--.mobileprovision` to `--out-dir` (default `.`), uploads the set's + secrets to GitHub, and writes `"distribution": ""` into the + `--name` profile (default: the distribution name) in `builder.json`, keeping + its other fields and reporting a replaced distribution; `defaultProfile` is + left alone. An `--out-dir` other than `.` is recorded as `signing.dir`, so a + later `ios build` that provisions a set reuses the key there instead of + asking Apple for a second certificate, which it refuses. +6. Prints the secret names and where their values come from — the + `.p12` base64-encoded, the password, the `.mobileprovision` base64-encoded + — every time, so the same set can be pasted into Codemagic or Bitrise, + following the [secrets guide](docs/provider-secrets.md). Builder cannot + check those providers' secrets before a build, so `ios build` only reminds + you of this command when the profile signs there. + +The upload goes to the repository in `builder.json`, always. When it fails (no +GitHub login, or a token that cannot write secrets) the error is printed and +the command carries on: files, values and the build profile are written and +shown anyway, and it exits non-zero at the end so a script notices. `--json` +reports the same in `github_upload` (`ok` or the error). + +**Extensions.** Every extension target (a widget, a share or notification +extension, a watch app, an app clip) is signed with a profile of its own. +`init` and `signing setup` read their bundle IDs from the Xcode project into +`ios.extensions` in `builder.json`; a managed Expo project has no project to +read, so list them there by hand. Automatic `setup` then registers each App ID +and creates `Builder ` for it, with the same +certificate and devices as the app; in manual mode pass one `--extension-profile +` per extension. The profiles go into a fourth secret of the +set, `IOS_EXTENSION_PROFILES_` (a JSON object of bundle ID to base64 +profile, `{}` when there are none), and the runner signs each extension target +with the entry covering its bundle ID, failing by name — with the IDs to add to +`ios.extensions` — when one has none. + +The command shows its plan and asks once before creating anything; `--yes` +skips that (required without a terminal), and then the `.p12` password is +generated and printed once unless `--password` is given. `--json` prints the +result as JSON with progress on stderr. Keep the written files out of git. +Run it again whenever you like: it reports what it found and recreates only +what is missing, expired, invalid or changed — add a device, re-run, rebuild. +`--force` issues a fresh certificate and profile regardless. + +With `--certificate` and `--profile`, `setup` takes your own files instead — a +`.p12` (from Keychain Access, or [assembled here](#manual-path-through-the-apple-developer-portal)) +and a `.mobileprovision` — reads the distribution out of the profile +(development, ad-hoc, store or enterprise; this is the only way in for +enterprise), uploads that set, prints its names and values, and writes the +build profile the same way: + +```bash +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision +``` + +### Provisioning from `ios build` + +`builder ios build --profile ` checks, before dispatching to GitHub, +that the repository holds the secrets of the profile's set. When any is +missing and an App Store Connect key is saved, it runs the same provisioning +as `signing setup` without prompts, uploads the set and builds; a development +or ad-hoc profile with no registered device stops and points at `builder +signing setup --distribution development --devices-from-mobai`. Without an +Apple key it stops before anything is pushed and names both ways out (`builder +auth apple`, or `signing setup --certificate ... --profile ...`). `--unsigned` +skips the check, and so do Codemagic/Bitrise builds (no secrets API). + +### Legacy: `ios.signing` without profiles + +A project set up before build profiles has `ios.signing: true` and the +unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and +`IOS_PROVISIONING_PROFILE` secrets. Builds that select no profile still sign +with those, whatever the profile type, exactly as before; `setup` never +touches them. Profiles ignore `ios.signing` and read their own set. + +### Manual path through the Apple Developer portal + +The `.p12` certificate is normally created through Keychain Access, but Builder +does the same thing itself: it generates the private key and certificate +signing request, and assembles the `.p12` from the certificate Apple issues. + +#### 1. Create a certificate signing request + +```bash +builder signing csr +``` + +This asks for your name and email and writes two files to the current +directory: `ios-signing.key` (your private key) and `ios-signing.csr`. Keep +the key wherever suits you — just don't commit it (add it to `.gitignore`; +gitignored files are also excluded from build snapshots). + +#### 2. Create the certificate + +1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal +2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc). TestFlight and App Store uploads need **Apple Distribution** together with an App Store profile in step 4 +3. Upload `ios-signing.csr` and download the resulting `.cer` file + +#### 3. Assemble the .p12 + +```bash +builder signing p12 --certificate development.cer --key ios-signing.key +``` + +This combines the key and certificate into `ios-signing.p12` (`--out` to name +it), protected by a password you choose — byte-for-byte the same kind of file +Keychain Access exports, and usable anywhere one is: `builder signing setup`, +Sideloadly, AltStore, or importing it on a Mac. Keep it, and don't commit it. + +#### 4. Create a provisioning profile + +On the portal: + +1. **Identifiers** → register an App ID matching your app's bundle identifier +2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page) +3. **Profiles** → create an **iOS App Development** (or Ad Hoc, App Store) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file + +The profile type decides the distribution the set is written to and the method +the IPA is exported with: development, ad-hoc, enterprise or store. + +#### 5. Upload the signing secrets + +```bash +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision +``` + +You can also skip step 3 and hand `setup` the `.cer` together with the key — +`builder signing setup --certificate development.cer --key ios-signing.key +--profile MyApp.mobileprovision` — and it assembles the `.p12` on the way, +saving it as `ios-signing-.p12`. Then `builder ios build +--profile `; `--unsigned` skips signing for one build. + +## TestFlight and App Store + +Builder uploads builds to App Store Connect and submits them to TestFlight or +App Review through the App Store Connect API, from Windows, Linux or macOS. No +Transporter, `altool` or Xcode is involved, and the API key never leaves your +machine: the CI runner only builds and signs, the upload happens locally from +the IPA in `./dist/`. + +You need: + +- A paid [Apple Developer Program](https://developer.apple.com/programs/) + membership and an app record in App Store Connect for your bundle ID (step 2 + below; the API cannot create it) +- An IPA signed with an **Apple Distribution** certificate and an **App Store** + provisioning profile: `builder signing setup --distribution store` creates + both, stores them as the `STORE` signing set and writes a `store` build + profile, or pick those types on the portal in the manual path. An IPA signed + for development is rejected at upload. +- `builder ios build --profile store` (see [Build Profiles](#build-profiles)): + a `store` profile builds `Release` and signs with that set; a plain `ios + build` is Debug and unsigned, which is what the dev commands expect, not + what you want to ship. +- An App Store Connect API key saved with `builder auth apple`, see + [Create an App Store Connect API key](#create-an-app-store-connect-api-key). + +### 1. Save the API key + +`builder auth apple` as described in +[Create an App Store Connect API key](#create-an-app-store-connect-api-key). +Flags you leave out are prompted for; Builder verifies the key against App +Store Connect before storing it. + +### 2. Create the app record + +App Store Connect only accepts uploads for an app it already knows, and the API +cannot create one. Once, in the browser: + +1. Register the bundle ID first: `builder signing setup --distribution store` + does it (or **Certificates, Identifiers & Profiles → Identifiers → +** on + the developer portal). +2. Open [App Store Connect → My Apps](https://appstoreconnect.apple.com/apps), + press **+ → New App**, pick **iOS**, a name, the primary language, your + bundle ID from the list, and any SKU (an internal string, e.g. the bundle + ID). Press **Create**. + +Nothing else on the record is needed for TestFlight. App Store review needs the +rest of the metadata (screenshots, description, privacy policy) filled in there. + +### 3. Upload the build + +```bash +builder ios build --profile store # produces a signed dist/*.ipa +builder ios upload --wait +``` + +`upload` reads the bundle ID, version and build number from the newest IPA in +`./dist/` (or `--ipa `), finds the app, uploads the archive in chunks +and, with `--wait`, follows App Store Connect until the build has finished +processing and prints its build ID and TestFlight link. Without `--wait` it +returns as soon as Apple has the file. + +Two things Apple checks on every upload: + +- **Build numbers must increase.** A second upload with the same + `CFBundleVersion` for the same version is rejected (`ITMS-90189`), so bump + it before rebuilding. +- **Export compliance.** A build shows as *Missing Compliance* in TestFlight + until you say whether it uses non-exempt encryption. If your Info.plist sets + `ITSAppUsesNonExemptEncryption` to `false`, `upload --wait` answers that + automatically; otherwise pass `--no-encryption` (here or to `submit`) when + your app only uses standard iOS encryption. + +### 4. Distribute to TestFlight + +```bash +builder ios submit --testflight --group "Beta Testers" --notes "New login flow" +``` + +This takes the newest processed build (or `--build-number N`), sets the *What +to Test* notes and adds it to the named groups (`--group` repeats). Internal +groups get the build immediately; the first external group triggers Apple's +beta review, which Builder submits for you (`--wait` follows the decision). Run +it without `--group` to see the build and the groups the app has. + +### 5. Submit to the App Store + +```bash +builder ios submit --app-store --release after-approval +``` + +Builder finds or creates the App Store version matching the IPA's marketing +version (or `--version X.Y.Z`), attaches the build, sets the release type +(`manual` or `after-approval`) and submits it for review. The version's +metadata — description, screenshots, age rating, pricing, privacy — must +already be complete: App Store Connect refuses the submission otherwise and +Builder prints Apple's reasons verbatim. Builder does not manage metadata, +screenshots or in-app purchases; fill them in App Store Connect, or on a Mac +with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of +the `buildUploads` API also proved that the Mac-free upload path works and +served as the reference for Builder's implementation. + +## Installing the IPA + +Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account). + +## Development on Windows/Linux + +Builder supports hot reload for Flutter and React Native on Windows/Linux using [MobAI](https://mobai.run) for iOS device control. This allows you to develop iOS apps without a Mac. + +## Flutter Development + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` + This creates an IPA in `./dist/` +3. Start development with hot reload: + ```bash + builder dev flutter + ``` + Builder installs the IPA through MobAI and asks whether to re-sign it. Re-signing requires an iCloud account - we highly recommend creating a new one at [icloud.com](https://icloud.com) instead of using your primary account. A re-signed app gets a new bundle ID with a team ID suffix (e.g., `com.example.myapp.TEAMID`); Builder prefills it in the prompt that follows. + +### Subsequent Runs + +Once the app is installed, skip the install step: +```bash +builder dev flutter --skip-install --bundle-id com.example.myapp.TEAMID +``` + +### File Watching + +By default, `builder dev flutter` watches for Dart file changes and automatically triggers hot reload. When flutter attach connects, it also sends an initial hot restart to ensure your latest code is running. + +- **Automatic hot reload**: Edit a `.dart` file and save - hot reload triggers automatically +- **Generated files ignored**: Files like `.g.dart` and `.freezed.dart` are ignored by default +- **Configurable**: Customize watched directories, patterns, and debounce via `builder.json` + +To disable file watching: +```bash +builder dev flutter --no-watch +``` + +To print the `flutter attach` command instead of running it (useful for IDE integration): +```bash +builder dev flutter --no-attach +``` + +### When to Rebuild + +- **Native code changes** (Swift, Objective-C, Podfile, native dependencies): Run `builder ios build` and reinstall +- **Dart code changes only**: No rebuild needed - file watcher triggers hot reload automatically + +If you don't see your recent Dart changes after launching, press `R` in the terminal to perform a hot restart. + +### Troubleshooting + +**App won't launch / connection error** +- Close the app on your device before running `builder dev flutter` +- Reconnect the device (unplug/replug USB) +- Restart MobAI +- Run `builder mobai ping` to verify connection + +**"No devices found" error** +- Ensure MobAI is running and device is connected +- Only physical iOS devices are supported (no simulators) + +**Hot reload not working** +- Make sure you're using the correct bundle ID (with team ID suffix) +- Try hot restart with `R` key +- Check that MobAI shows the device as connected + +**File watcher not triggering** +- Ensure you're editing files in watched directories (default: `lib/`) +- Check if the file matches watch patterns (default: `.dart`) +- Generated files (`.g.dart`, `.freezed.dart`) are ignored by default +- Try running without `--no-watch` flag + +## React Native Development + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` +3. Start development with hot reload: + ```bash + builder dev rn + ``` + This will: + - Start Metro bundler if not running + - Install the IPA on your device (with optional re-signing) + - Launch the app with Metro URL configured automatically + +### Subsequent Runs + +Once the app is installed: +```bash +builder dev rn --skip-install --bundle-id com.example.myapp.TEAMID +``` + +### Custom Metro Port + +If port 8081 is in use: +```bash +builder dev rn --metro-port 8082 +``` + +### When to Rebuild + +- **Native code changes** (Swift, Objective-C, Podfile, native modules): Run `builder ios build` and reinstall +- **JavaScript changes only**: No rebuild needed - Metro handles it automatically + +### Troubleshooting + +**Metro not starting** +- Ensure Node.js and React Native CLI are installed +- Try starting Metro manually: `npx react-native start` + +**App not connecting to Metro** +- Device must be on the same WiFi network as the computer running Metro +- Check that Metro is running and accessible +- Verify the Metro port is correct (default: 8081) +- On WSL with mirrored networking, the Hyper-V firewall blocks the phone from reaching Metro by default; see [Using Builder from WSL](docs/wsl.md#react-native) for the firewall rule + +**Hot reload not working** +- Shake device or press `d` in Metro terminal to open dev menu +- Enable "Fast Refresh" in dev menu +- Try reloading with `r` in Metro terminal + +## Kotlin Multiplatform Development + +KMP iOS apps build and run on a device like any other project, with one +difference: **there is no hot reload.** Shared Kotlin is compiled into a native +framework at build time, so there is no runtime to swap code into — every code +change needs a rebuild. + +### Setup + +1. Download and install [MobAI](https://mobai.run/download), then connect your iOS device +2. Build your app: + ```bash + builder ios build + ``` +3. Install and launch it on the device: + ```bash + builder dev kmp + ``` + +`builder init` detects Kotlin Multiplatform projects by looking for the +multiplatform Gradle plugin in the root and module build files, and asks which +JDK the CI build should use (default 17): + +```json +{ + "kmp": { "jdkVersion": "17" } +} +``` + +On CI, the iOS app is built with `xcodebuild`, whose run script phase (or +CocoaPods) invokes Gradle to compile the shared framework — which is why the +JDK version matters. Gradle output is cached between builds. + +### When to Rebuild + +Every change to Kotlin or Swift code needs `builder ios build` followed by +`builder dev kmp` again. Use `--skip-install --bundle-id ` to relaunch an +app that is already installed. + +### Troubleshooting + +**Build fails with "Unsupported class file major version" or a Gradle JDK error** +- The project needs a different JDK than the default: set `kmp.jdkVersion` in `builder.json` to match what the project uses locally + +**Build fails with "SDK does not contain 'libarclite'"** +- An old Kotlin/Native version against a newer Xcode; upgrade the Kotlin plugin in Gradle + +**App launches then immediately exits** +- Launch with `builder dev kmp --logs` to see the device output + +## Build Limits + +Free allowances belong to each provider account and depend on the plan and +machine. As published in September 2026: Codemagic personal accounts include +500 macOS M2 minutes per month; Bitrise Hobby includes 300 credits. GitHub has +separate allowances for private repositories and free standard runners for +public repositories. Providers change these, so check the +[current allowance links and switching guidance](docs/providers.md#free-allowances). + +## License + +[MIT License](LICENSE) diff --git a/docs/provider-secrets.md b/docs/provider-secrets.md index 9a2599f..780243c 100644 --- a/docs/provider-secrets.md +++ b/docs/provider-secrets.md @@ -7,12 +7,12 @@ API login, the provider's GitHub connection, and build secrets are separate: | What you want to run | Secrets needed | | --- | --- | | Unsigned IPA build (`ios build`, or a profile without `distribution`) | None of the secrets below | -| Signed build (`ios build --profile `) | The three `IOS_*_` secrets of the profile's `distribution` | +| Signed build (`ios build --profile `) | The `IOS_*_` secrets of the profile's `distribution` | | Legacy signed build without a profile (`ios.signing: true`) | The unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD`, `IOS_PROVISIONING_PROFILE` | | Shared simulator (`ios share`) | `MOBAI_API_KEY`; no Apple signing files needed | `builder signing setup` uploads secrets to **GitHub Actions only**, but it always -prints the three names and the values to paste, so a run of it is also the source +prints the names and the values to paste, so a run of it is also the source for the two new providers; use the steps below even if GitHub signing/sharing already works. Existing GitHub secret values cannot be downloaded for copying to another service. @@ -53,7 +53,7 @@ builder signing setup --devices-from-mobai --out-dir ~/signing This creates the certificate, devices and profile through the API, writes `ios-signing-development.p12` and the `.mobileprovision` to `~/signing`, tries to upload the set to the GitHub repository in `builder.json` (a failure is printed -and the run continues, ending with a non-zero exit code), prints the three secret +and the run continues, ending with a non-zero exit code), prints the secret names and file paths to paste below either way, and writes the `development` build profile. Run it again with `--distribution store` for a second, App Store set: the files are named by distribution, so @@ -85,6 +85,7 @@ here for the development set: | `IOS_CERTIFICATE_DEVELOPMENT` | Base64 contents of `ios-signing-development.p12` | | `IOS_CERTIFICATE_PASSWORD_DEVELOPMENT` | The original P12 password, as plain text | | `IOS_PROVISIONING_PROFILE_DEVELOPMENT` | Base64 contents of the `.mobileprovision` file | +| `IOS_EXTENSION_PROFILES_DEVELOPMENT` | Only with extension targets (`ios.extensions`): a JSON object of extension bundle ID to base64 `.mobileprovision`, as `signing setup` prints it | | `MOBAI_API_KEY` | The original API key copied from MobAI, as plain text | For a store set add `IOS_CERTIFICATE_STORE`, `IOS_CERTIFICATE_PASSWORD_STORE`