diff --git a/CLAUDE.md b/CLAUDE.md index cbe53f4..2254aa7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,20 +25,19 @@ 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) ./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 # 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 (creates the group if missing) +./builder ios submit --testflight --group --notes # TestFlight ./builder ios submit --app-store --release after-approval # App Review -./builder asc apps|builds|groups|testers|users # App Store Connect listings (--json) -./builder asc groups create [--external] # also: groups delete, groups add-build -./builder asc testers add ... --group # also: testers remove, users invite -./builder asc testers invite ... # send/resend the TestFlight email -./builder asc builds expire --build-number N --yes # groups delete needs --yes too ``` ## Architecture @@ -111,6 +110,28 @@ 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 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 + │ + ▼ + 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 │ ▼ @@ -140,12 +161,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, apps, builds, uploads, TestFlight, - # beta groups, beta testers, team users/invitations, review) - distribute/ # Upload / TestFlight / App Store / tester flows on top of asc + 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 @@ -169,6 +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**: 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 - **Expo Detection**: an `expo` dependency in `package.json` (the CLI parses the dependency maps; the runners grep `'"expo"'`) with no `.xcodeproj`/`.xcworkspace` anywhere and no `pubspec.yaml` is @@ -231,7 +276,8 @@ internal/ `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. + `ASC_PRIVATE_KEY`|`ASC_KEY_PATH` win; a partial environment is an error. Only `auth apple` prompts, + and it verifies with `GET /v1/certificates?limit=1`, which needs the 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`, then `builds` until VALID. The IPA must be App Store signed with an ever-higher `CFBundleVersion`. @@ -241,36 +287,34 @@ internal/ - **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. -- **Group Auto-Create**: `SubmitTestFlight` creates any `--group` name the app lacks (internal, or - external with `External`/`--external`) and marks it `GroupRef.Created`; existing groups keep - their type. `asc groups add-build` reuses it, so it inherits the beta-review step too. -- **Automatic Distribution Groups**: an internal group with `hasAccessToAllBuilds: true` gets every - build by itself, so `POST builds/{id}/relationships/betaGroups` answers 422 and the add-build path - skips it (`GroupRef.AutoBuilds`, exit 0). `asc groups create` sets it unless `--no-auto-builds`. -- **Internal Testers**: internal groups take team members only, so `distribute.AddTester` routes by - group type — external creates the tester in the group (409 → find by email → add), internal joins - the member's record or `POST userInvitations` for a stranger, who must accept first. -- **ASC Filters Are Substrings**: Apple's `filter[email]`/`filter[username]` match substrings, so - `FindBetaTester`/`FindUser` compare the address exactly; `filter[email]` goes lowercased because - ASC stores addresses that way. -- **NOT_INVITED Testers**: a team member put into an internal group stays `NOT_INVITED` with no - email until `POST betaTesterInvitations`, so `AddTester` re-reads the state after a group add and - `asc testers invite` sends it on demand (ACCEPTED/INSTALLED are left alone). -- **No Installable Build**: while no group of a tester's has a build, `betaTesterInvitations` - answers 409 `asc.CodeNoInstallableBuilds`: `InviteTester` turns it into a `noBuildError` naming - `asc groups add-build`, and `AddTester` into a plain "added" rather than a failure. -- **Group Name Matching**: `asc.MatchBetaGroup` is the only name lookup (command layer and - `findOrCreateGroup`): case-insensitive, nil when absent, and an error listing the candidates when - several groups fold to the same name, so nothing is created, deleted or linked on a guess. -- **Destructive asc Commands**: `groups delete`, `testers remove` without `--group` and - `builds expire` resolve everything first, print a "Will ..." line naming exactly what goes, and - then need `--yes`; `testers remove` looks every address up before the first deletion. -- **asc Command Layer**: `cmd/builder/asc.go` is thin cobra over `asc` and `distribute`; - `resolveApp` (`--bundle-id` → `--ipa` → `ios.bundleId` → newest `dist/*.ipa`) and `runTestFlight` - are shared with `ios submit`, and builds list with `include=preReleaseVersion,betaGroups`. -- **asc Command Tests**: `getASCClient` is a package var so tests can point it at an httptest - server, and their `run` helper resets every flag first, since cobra keeps flag values on the - shared command tree. +- **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 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. @@ -283,16 +327,30 @@ internal/ "project": "MyApp", "platform": "ios", "github": { "owner": "username", "repo": "my-ios-app" }, - "ios": { "path": "ios", "scheme": "", "bundleId": "com.example.myapp" } + "ios": { "path": "ios", "scheme": "", "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" } + } } ``` -`ios.bundleId` is optional; the `asc` commands fall back to the newest IPA in `./dist/`. +`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` +- 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 @@ -300,7 +358,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 `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` @@ -310,6 +370,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` 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 cef1072..a9a4177 100644 --- a/README.md +++ b/README.md @@ -1,737 +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. - -## 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 (managed or ejected) | `ios/` | Yes | -| Flutter | `ios/` | Yes | -| Kotlin Multiplatform | `iosApp/` | Yes | -| Cordova/Ionic | `platforms/ios/` | Yes | - -### React Native - -The runner installs JavaScript dependencies with the package manager the project -already uses — npm, Yarn, pnpm or Bun, from `packageManager` in `package.json` or -from the lockfile — on the Node version from `.nvmrc`, `.node-version` or -`engines.node`. - -### Expo - -Dependencies install the way they do for React Native: the project's own package -manager (npm, Yarn, pnpm or Bun) and Node version, with `expo prebuild` running -through that same manager. - -A managed Expo project has no `ios/` directory in git. `builder init` detects it -as *Expo (managed)*, still records `"ios": { "path": "ios" }`, and the runner -generates the native project with `expo prebuild --platform ios --no-install` -before building it. Ejected projects keep the committed `ios/` they have: the -prebuild step skips a directory that already holds an Xcode project. - -`expo prebuild` has to run unattended, so the app config must set the bundle -identifier — `expo.ios.bundleIdentifier` in `app.json`, or `ios.bundleIdentifier` -in `app.config.js` / `app.config.ts`. Without one, prebuild would stop and ask -for it; instead the build fails immediately and names the missing setting. - -The default `Debug` configuration builds an IPA that loads its JavaScript from -Metro, so set `"ios": { "configuration": "Release" }` in `builder.json` for a -standalone IPA with the bundle baked in. - -An `ios/` directory left over from running `expo prebuild` locally is not -uploaded: managed projects gitignore it, and the working-tree snapshot skips -gitignored files. That is what you want — the runner prebuilds from the app -config on every build, so it cannot drift from a stale local copy. - -## 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) - -# 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 -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 - -# App Store Connect management (needs builder auth apple) -builder asc apps # Apps the API key can see -builder asc builds # Builds of the newest version, with their TestFlight groups -builder asc builds expire --build-number 42 --yes -builder asc groups # TestFlight groups with tester counts -builder asc groups create Nightly # Internal group (add --external for external) -builder asc groups add-build Nightly # Newest VALID build (or --build-number) -builder asc groups delete Nightly --yes -builder asc testers --group Nightly # With each tester's state -builder asc testers add a@example.com --group Nightly --first Ann --last Lee -builder asc testers invite a@example.com # Send or resend the TestFlight email -builder asc testers remove a@example.com --group Nightly -builder asc users # Team members and whether they can test internally -builder asc users invite dev@example.com --role DEVELOPER --first Dee --last Vee -``` - -Every `upload`/`submit`/`asc` 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.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` | - -### 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 - -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. - -You need a paid [Apple Developer Program](https://developer.apple.com/programs/) -membership — the portal 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 - -```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) -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`, 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) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file - -### 5. Upload the signing secrets - -```bash -builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision -``` - -This 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 - -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. - -`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` 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 - `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). A group -that does not exist yet is created — internal by default, external with -`--external`. 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. - -## Managing TestFlight - -`builder asc` covers the App Store Connect housekeeping around TestFlight -without the website: apps, builds, groups, testers and team members. Every -command takes `--json` (result on stdout, progress on stderr), never prompts, -and finds the app through `--bundle-id`, then `ios.bundleId` in -`builder.json`, then the newest IPA in `./dist/`. - -```bash -builder asc builds # newest version's builds and their groups -builder asc groups create Nightly # internal group; --external for outsiders -builder asc groups add-build Nightly # same as ios submit --testflight --group -builder asc testers add a@example.com b@example.com --group Nightly -builder asc testers # every tester with their state -builder asc testers invite a@example.com # send or resend the TestFlight email -builder asc testers remove a@example.com --group Nightly -builder asc builds expire --build-number 42 --yes -``` - -Two things about internal groups: - -- **They take team members only.** `asc testers add` puts a member's tester - record into the group and invites a stranger to the App Store Connect team - first (`--role`, default `CUSTOMER_SUPPORT`, only this app visible; - `--first` and `--last` required). They must accept that email before a build - reaches them, so rerun the command afterwards. `asc users` shows the team and - who already has TestFlight access; `asc users invite` invites on its own. -- **Automatic distribution.** An internal group with "automatic distribution" - (the default of `asc groups create`, off with `--no-auto-builds`) receives - every processed build by itself and Apple refuses to add builds by hand, so - `asc groups` marks it `internal, all builds` and `ios submit --group` and - `asc groups add-build` skip it with a note instead of failing. - -`NOT_INVITED` means no email has gone out — how a team member added to an -internal group in App Store Connect shows up. `asc testers invite` sends it -(or resends while `INVITED`) and `asc testers add` does so by itself, unless -the group has no build yet: Apple refuses to invite anyone into a group with -nothing to install, so `add` reports "invite goes out once the group has a -build" and `invite` says to run `asc groups add-build` first (an external -group's build must also pass Beta App Review). - -External groups take anyone by email, reusing a tester the team already has. -`asc groups delete`, and `asc testers remove` without `--group` (which drops -the tester from TestFlight team-wide), print what goes and then need `--yes`. -Group names match case-insensitively; when two differ only by case, the -command refuses and lists both. - -## 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/cmd/builder/root.go b/cmd/builder/root.go index e38ac4e..460bf49 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -250,6 +250,44 @@ 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, 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 = "." + } + 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) @@ -432,6 +470,10 @@ 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) + } + syncExtensions(cfg, os.Stdout) if flutterVersion != "" { cfg.Flutter.Version = flutterVersion } @@ -506,7 +548,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: build.DefaultTimeout, Remote: remoteName, @@ -587,6 +629,7 @@ 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 @@ -596,6 +639,20 @@ func init() { 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 { @@ -610,13 +667,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 @@ -626,8 +688,9 @@ 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, Timeout: timeout, Unsigned: unsigned, @@ -646,6 +709,8 @@ func runIOSShare(cmd *cobra.Command, args []string) error { duration, _ := cmd.Flags().GetDuration("duration") remote, _ := cmd.Flags().GetString("remote") + // A simulator build takes no profile, so the provider is the flag, else + // builder.json's. provider, _ := cmd.Flags().GetString("provider") ctx := cmd.Context() @@ -688,11 +753,18 @@ 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 } + // A GitHub build with a distribution needs its signing set in the + // 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 + } + } coordinator := build.NewCoordinator(cfg, ghClient) 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..26e84f1 100644 --- a/cmd/builder/signing.go +++ b/cmd/builder/signing.go @@ -2,14 +2,14 @@ package main import ( "context" - "encoding/base64" "fmt" + "maps" "os" "path/filepath" + "slices" "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" @@ -23,23 +23,48 @@ 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 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. + + --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 - -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.`, +The distribution is read from the .mobileprovision (development, ad-hoc, +store or enterprise). + +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_ 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 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, } @@ -73,9 +98,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)") + addSigningSetupFlags(signingSetupCmd) signingCSRCmd.Flags().String("name", "", "Your name (certificate common name)") signingCSRCmd.Flags().String("email", "", "Email address of your Apple Developer account") @@ -86,6 +109,25 @@ 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().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)") + 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") @@ -235,15 +277,20 @@ func expandPath(path string) string { } func runSigningSetup(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() - if err != nil { - return err + if certFlag, _ := cmd.Flags().GetString("certificate"); certFlag == "" { + if profileFlag, _ := cmd.Flags().GetString("profile"); profileFlag == "" { + return runSigningAuto(cmd) + } } - ghClient, err := getGitHubClient() + cfg, err := loadConfig() if err != nil { return err } + // 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") @@ -260,7 +307,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") @@ -277,9 +324,46 @@ 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) - var password string + distributionFlag, _ := cmd.Flags().GetString("distribution") + typ, err := manualSigningType(profileData, distributionFlag) + if err != nil { + return err + } + set, err := config.SigningSet(string(typ)) + if err != nil { + return err + } + profileName, _ := cmd.Flags().GetString("name") + if profileName == "" { + profileName = string(typ) + } + 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) { // A .cer from the Apple Developer portal: assemble the .p12 locally // from the private key that produced the CSR. @@ -294,9 +378,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 { @@ -304,68 +389,117 @@ 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) } - 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 } } - 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() } - - // 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) + fmt.Fprintln(out) + uploadErr := uploadSigningSet(ctx, store, storeErr, cfg, out, set, certData, password, profileData, extensionProfiles) + if uploadErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", uploadErr) } - // 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, + // 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.Fprintln(out, profileWritten(profileName, typ, replaced)) + + names := config.SigningSecretNames(set) + fmt.Fprintln(out) + fmt.Fprintln(out, signingUploadLine(cfg, names, uploadErr)) + fmt.Fprintln(out) + printSigningSecretValues(out, names, p12Path, profilePath, extensionPathByID) + 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 +} - for name, value := range secrets { - encrypted, err := github.EncryptSecret(publicKey.Key, value) +// 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 fmt.Errorf("failed to encrypt %s: %w", name, err) + return nil, fmt.Errorf("%s: %w", path, 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) + 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) } - fmt.Printf(" Uploaded: %s\n", name) } - - // Update config to indicate signing is enabled - cfg.IOS.Signing = true - mgr := config.NewManager() - if err := mgr.Save(cfg); err != nil { - return fmt.Errorf("failed to update config: %w", 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])) + } } - fmt.Println(" Updated: builder.json (signing enabled)") - - fmt.Println() - fmt.Println("Code signing configured successfully!") - fmt.Println() - fmt.Println("Your next build will be signed. To build unsigned, use:") - fmt.Println(" builder ios build --unsigned") + 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 +} - return 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 distributionFlag == "" { + return typ, nil + } + want, err := signing.ParseType(distributionFlag) + if err != nil { + return "", err + } + 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 new file mode 100644 index 0000000..8a2fd6a --- /dev/null +++ b/cmd/builder/signing_auto.go @@ -0,0 +1,701 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "maps" + "net/http" + "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" + "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" +) + +// 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 + // 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 + // 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 + } + 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 + } + client, err := signingASCClient() + if err != nil { + return err + } + // 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") + outDirFlag, _ := cmd.Flags().GetString("out-dir") + outDir := expandPath(outDirFlag) + ctx, cancel := commandContext(cmd, false) + defer cancel() + + bundleID, err := resolveSigningBundleID(cmd, cfg, out) + if err != nil { + return err + } + syncExtensions(cfg, out.log) + devices, err := signingDevices(ctx, cmd, cfg, typ) + if err != nil { + return err + } + keyFlag, _ := cmd.Flags().GetString("key") + keyPEM, keyPath, err := signingKey(keyFlag, typ, outDir) + if err != nil { + return err + } + + // 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() { + 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(typ))) + } + 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") + } + 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{SigningSet: set, BuildProfile: profileName, GeneratedPassword: generated} + res.AutoResult, err = signing.Auto(ctx, client, &signing.AutoOptions{ + 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, res.ExtensionProfiles) + 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) + recordSigningDir(cfg, outDirFlag) + 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, profileWritten(profileName, typ, replaced)) + + // 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 +// 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 +} + +// 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, 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, 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 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 +// 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 +// 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 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") + } + 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 +} + +// 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("%s profiles list no devices; drop --device/--devices-from-mobai", typ) + } + var devices []signing.Device + for _, u := range udids { + 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 + } + 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) + } + 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.Cloud || (d.Platform != "" && !strings.EqualFold(d.Platform, "ios")) || !udidRe.MatchString(d.ID) { + continue + } + devices = append(devices, signing.Device{Name: d.Name, UDID: d.ID}) + } + return devices +} + +// 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) + if keyPath == "" { + return nil, "", nil + } + } + 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 +} + +// 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 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) == "." { + 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" + } + 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 +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + 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 { + 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 signing secrets of a set +// (IOS_CERTIFICATE_, ...). Other sets, and the unsuffixed secrets of +// 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) + } + names := config.SigningSecretNames(set) + secrets := []struct{ name, value string }{ + {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) + 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 +} + +// missingSigningSecrets names the secrets of a set that the repository does +// 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 names.Names() { + if name == names.Extensions && len(cfg.IOS.Extensions) == 0 { + continue + } + if !slices.Contains(have, name) { + missing = append(missing, name) + } + } + return missing, nil +} + +// 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 { + return err + } + if provider == "" { + provider = s.Provider + } + name, err := cfg.ProviderName(provider) + if err != nil { + return err + } + if s.Distribution == "" { + return nil + } + if name != "github" { + // 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 + } + 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 && len(newExtensions) == 0 { + return nil + } + 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) + } + 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) + } + dirs := signingKeyDirs(cfg) + keyPEM, keyPath, err := signingKey("", typ, dirs...) + 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 := signing.Auto(ctx, client, &signing.AutoOptions{ + 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) { + // 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 + // upload is fatal. + fmt.Fprintln(log) + 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 == "" || 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) + } + } + fmt.Fprintln(log) + 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) { + 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) + 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) + } + fmt.Fprintln(w, "Keep these out of git (add them to .gitignore); gitignored files are also left out of build snapshots.") +} + +func printSigningSummary(w io.Writer, cfg *config.Config, res *signingAutoResult, uploadErr error) { + state := func(created bool, reason string) string { + if !created { + return "reused" + } + if reason != "" && reason != "missing" { + return "new (" + reason + ")" + } + return "new" + } + 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.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, 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.") +} + +// 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) + } + return fmt.Sprintf("Secrets %s uploaded to %s/%s.", strings.Join(names.Names(), ", "), cfg.GitHub.Owner, cfg.GitHub.Repo) +} + +// 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, extensionFiles map[string]string) { + fmt.Fprintln(w, "Set them by hand wherever Builder cannot (Codemagic, Bitrise, a repository this login cannot write to):") + 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) +} + +// printSigningNext names the build that reads the set just written. +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.Fprintln(w, "then builder ios upload --wait.") + } +} 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/cmd/builder/signing_sets_test.go b/cmd/builder/signing_sets_test.go new file mode 100644 index 0000000..1a4aecb --- /dev/null +++ b/cmd/builder/signing_sets_test.go @@ -0,0 +1,806 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "io" + "os" + "path/filepath" + "slices" + "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/MobAI-App/ios-builder/internal/signing/signingtest" + "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, 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 + // writeErr is a repository the login cannot write to. + writeErr error + listed int +} + +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 f.writeErr != nil { + return f.writeErr + } + 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 (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, "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", "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, 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) != 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" { + 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") +} + +// 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") + store := profileBytes("Entitlementsget-task-allow") + + 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 != "STORE" { + t.Fatalf("set = %s", set) + } + if typ, err = manualSigningType(dev, ""); err != nil || typ != signing.TypeDevelopment { + t.Fatalf("development profile: %q %v", typ, 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, "app-store"); err == nil { + t.Fatal("bad --distribution accepted") + } + // 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 _, err := setupDistribution(cfg, "", "app-store"); err == nil { + t.Error("bad --distribution accepted") + } +} + +func TestSigningKeyPrefersTheTypeThenLegacy(t *testing.T) { + dir := t.TempDir() + + // Nothing on disk: generate. + 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. + legacy := filepath.Join(dir, signing.LegacyKeyFileName) + if err := os.WriteFile(legacy, []byte("legacy"), 0600); err != nil { + t.Fatal(err) + } + 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. + 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("", 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("", 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. + explicit := filepath.Join(dir, "mine.key") + if err := os.WriteFile(explicit, []byte("mine"), 0600); err != nil { + t.Fatal(err) + } + 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) + } +} + +// 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) + } + 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{} + 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) + } +} + +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. 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. + 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) + } + + // 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"} + 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") { + 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) + } + 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) { + 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) != 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) != 8 { + 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) != 8 || 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()) + } +} + +// 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) + } + 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) + } + 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) != 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. + 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), "--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), "--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, args ...string) (cmd *cobra.Command, stdout, stderr *bytes.Buffer) { + t.Helper() + prev := signingSecretStore + signingSecretStore = func() (secretStore, error) { return store, nil } + 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, + "--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, "--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, "--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/cmd/builder/submit.go b/cmd/builder/submit.go index f7701ea..c3e664a 100644 --- a/cmd/builder/submit.go +++ b/cmd/builder/submit.go @@ -1,12 +1,12 @@ package main import ( - "context" "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" ) @@ -17,17 +17,15 @@ var iosSubmitCmd = &cobra.Command{ --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. A group that does not exist is - created (internal, or external with --external). Without --group - it reports the build and lists the available 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 --bundle-id, else --ipa, else ios.bundleId in -builder.json, else the newest IPA in ./dist. The newest VALID build is used -unless --build-number is given.`, +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, } @@ -38,9 +36,8 @@ func init() { 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 when no IPA is read)") - iosSubmitCmd.Flags().StringArray("group", nil, "TestFlight group name to add the build to (repeatable; created if missing)") - iosSubmitCmd.Flags().Bool("external", false, "Create missing --group names as external groups (default: internal)") + 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") @@ -61,13 +58,21 @@ func runIOSSubmit(cmd *cobra.Command, _ []string) error { if err != nil { return err } - bundleID, ipaVersion, err := resolveApp(cmd) - if err != nil { - return err - } + bundleID, _ := cmd.Flags().GetString("bundle-id") version, _ := cmd.Flags().GetString("version") - if version == "" && appStore { - version = ipaVersion + 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") @@ -78,13 +83,20 @@ func runIOSSubmit(cmd *cobra.Command, _ []string) error { if testflight { groups, _ := cmd.Flags().GetStringArray("group") - external, _ := cmd.Flags().GetBool("external") notes, _ := cmd.Flags().GetString("notes") locale, _ := cmd.Flags().GetString("locale") - return runTestFlight(ctx, cmd, client, out, &distribute.TestFlightOptions{ - BundleID: bundleID, Version: version, BuildNumber: buildNumber, Groups: groups, External: external, Notes: notes, Locale: 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 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 { + fmt.Printf("Beta review: %s\n", res.BetaReview.State) + } + fmt.Printf("Link: %s\n", res.Link) + }) } releaseFlag, _ := cmd.Flags().GetString("release") @@ -104,21 +116,6 @@ func runIOSSubmit(cmd *cobra.Command, _ []string) error { }) } -// runTestFlight hands the build to TestFlight and prints the outcome; ios -// submit --testflight and asc groups add-build share it. -func runTestFlight(ctx context.Context, cmd *cobra.Command, client *asc.Client, out output, opts *distribute.TestFlightOptions) error { - res, err := distribute.SubmitTestFlight(ctx, client, opts) - return finish(out, cmd, res, err, func() { - w := cmd.OutOrStdout() - fmt.Fprintln(w) - fmt.Fprintf(w, "Build ID: %s (build %s)\n", res.Build.ID, res.Build.BuildNumber) - if res.BetaReview != nil { - fmt.Fprintf(w, "Beta review: %s\n", res.BetaReview.State) - } - fmt.Fprintf(w, "Link: %s\n", res.Link) - }) -} - func parseReleaseType(flag string) (string, error) { switch flag { case "": diff --git a/cmd/builder/upload.go b/cmd/builder/upload.go index 74a6d2d..606c211 100644 --- a/cmd/builder/upload.go +++ b/cmd/builder/upload.go @@ -45,9 +45,7 @@ func init() { iosCmd.AddCommand(iosUploadCmd) } -// getASCClient builds an App Store Connect client from the saved Apple login -// or the ASC_* environment variables. Tests point it at a fake server. -var getASCClient = func() (*asc.Client, error) { +func getASCClient() (*asc.Client, error) { creds, _, err := auth.GetAppleCredentials() if err != nil { if errors.Is(err, auth.ErrNotAuthenticated) { diff --git a/docs/provider-secrets.md b/docs/provider-secrets.md index e47a643..780243c 100644 --- a/docs/provider-secrets.md +++ b/docs/provider-secrets.md @@ -6,17 +6,25 @@ 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 | +| Unsigned IPA build (`ios build`, or a profile without `distribution`) | None of the secrets below | +| 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**. 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 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 -Builder's generated provider runner exports development-signed IPAs. Prepare: +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. @@ -27,12 +35,30 @@ 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. 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, -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#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 +``` + +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 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): ```sh builder signing csr @@ -40,44 +66,60 @@ 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. -A `.cer` alone is not the value for `IOS_CERTIFICATE`; assemble the P12 first. +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. ## 2. Prepare the secret values -Use these exact, case-sensitive names: +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 | | --- | --- | -| `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 | +| `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` +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: ```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 ``` -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. 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 @@ -127,19 +169,29 @@ 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 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..81de29b 100644 --- a/docs/provider-setup.md +++ b/docs/provider-setup.md @@ -136,11 +136,16 @@ 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 (required; only the unsuffixed legacy one may be empty) | +| `IOS_PROVISIONING_PROFILE_` | Base64 provisioning profile matching the app | | `MOBAI_API_KEY` | MobAI simulator sharing | +`` 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 providers, creating the MobAI key, and signed-build/simulator verification. diff --git a/docs/providers.md b/docs/providers.md index 34673cf..9e8d7f7 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 @@ -147,16 +149,21 @@ 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` - -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. +`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): + +- `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` + +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 diff --git a/internal/asc/apps.go b/internal/asc/apps.go index 97e488f..2acd811 100644 --- a/internal/asc/apps.go +++ b/internal/asc/apps.go @@ -41,22 +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) } - -// ListApps lists every app the API key can see, by name. -func (c *Client) ListApps(ctx context.Context) ([]App, error) { - rs, err := getAll[appAttributes](ctx, c, "/v1/apps", url.Values{"sort": {"name"}}) - 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 index e6ad0f4..6c1451e 100644 --- a/internal/asc/builds.go +++ b/internal/asc/builds.go @@ -30,10 +30,6 @@ type Build struct { // UsesNonExemptEncryption is nil while the export compliance question is // unanswered ("Missing Compliance" in TestFlight). UsesNonExemptEncryption *bool - // Version (marketing version) and BetaGroups (TestFlight group names) are - // only filled in when BuildFilter.Details asked for them. - Version string - BetaGroups []string } type buildAttributes struct { @@ -77,17 +73,11 @@ type BuildFilter struct { ExcludeExpired bool // Limit caps the result to the newest N builds; 0 returns every match. Limit int - // Details also fetches each build's marketing version and TestFlight groups. - Details bool } // ListBuilds lists builds, newest first. func (c *Client) ListBuilds(ctx context.Context, f *BuildFilter) ([]Build, error) { q := url.Values{"sort": {"-uploadedDate"}} - if f.Details { - q.Set("include", "preReleaseVersion,betaGroups") - q.Set("limit[betaGroups]", "50") - } if f.AppID != "" { q.Set("filter[app]", f.AppID) } @@ -106,51 +96,24 @@ func (c *Client) ListBuilds(ctx context.Context, f *BuildFilter) ([]Build, error if f.ExcludeExpired { q.Set("filter[expired]", "false") } - // One page covers the limit; larger limits fetch everything and cut. - follow := f.Limit <= 0 || f.Limit > pageLimit - if !follow { + 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) } - rs, included, err := collect[buildAttributes](ctx, c, "/v1/builds", q, follow) if err != nil { return nil, err } - if f.Limit > 0 && len(rs) > f.Limit { - rs = rs[:f.Limit] - } builds := make([]Build, 0, len(rs)) for _, r := range rs { - b := toBuild(r) - if f.Details { - if pre, ok := r.Relationships.One("preReleaseVersion"); ok { - b.Version = includedAttr(included, "preReleaseVersions", pre.ID, "version") - } - b.BetaGroups = []string{} - for _, g := range r.Relationships.Many("betaGroups") { - name := includedAttr(included, "betaGroups", g.ID, "name") - if name == "" { - name = g.ID - } - b.BetaGroups = append(b.BetaGroups, name) - } - } - builds = append(builds, b) + builds = append(builds, toBuild(r)) } return builds, nil } -// ExpireBuild removes the build from TestFlight for good. -func (c *Client) ExpireBuild(ctx context.Context, buildID string) (*Build, error) { - expired := true - req := Resource[buildAttributes]{Type: "builds", ID: buildID, Attributes: buildAttributes{Expired: &expired}} - r, err := patch[buildAttributes, buildAttributes](ctx, c, "/v1/builds/"+buildID, req) - if err != nil { - return nil, err - } - b := toBuild(*r) - return &b, 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) 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..1c17a1b --- /dev/null +++ b/internal/asc/certificates.go @@ -0,0 +1,105 @@ +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 +} + +// 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 +} + +// 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..91b75f7 --- /dev/null +++ b/internal/asc/certificates_test.go @@ -0,0 +1,115 @@ +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 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!"}}}}) + })) + 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/client.go b/internal/asc/client.go index c1416bf..586e8bf 100644 --- a/internal/asc/client.go +++ b/internal/asc/client.go @@ -139,21 +139,6 @@ func IsStatus(err error, status int) bool { return errors.As(err, &e) && e.StatusCode == status } -// HasCode reports whether err is an App Store Connect error carrying the -// given error code (STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS, ...). -func HasCode(err error, code string) bool { - var e *Error - if !errors.As(err, &e) { - return false - } - for _, d := range e.Errors { - if d.Code == code { - return true - } - } - return false -} - // 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 { @@ -257,7 +242,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/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/jsonapi.go b/internal/asc/jsonapi.go index d85ea69..fb6d202 100644 --- a/internal/asc/jsonapi.go +++ b/internal/asc/jsonapi.go @@ -13,8 +13,6 @@ type Document[T any] struct { Data T `json:"data"` Links Links `json:"links,omitzero"` Meta *Meta `json:"meta,omitempty"` - // Included carries the related resources an include parameter asked for. - Included []Resource[json.RawMessage] `json:"included,omitempty"` } // Links carries pagination links. @@ -86,31 +84,6 @@ func (r Relationships) One(name string) (Linkage, bool) { return rel.One() } -// Many returns the named to-many linkages; nil when absent or not requested -// (App Store Connect only lists them when the relationship is included). -func (r Relationships) Many(name string) []Linkage { - var linkages []Linkage - if rel, ok := r[name]; ok && len(rel.Data) > 0 { - _ = json.Unmarshal(rel.Data, &linkages) - } - return linkages -} - -// includedAttr returns one string attribute of an included resource by type and ID. -func includedAttr(included []Resource[json.RawMessage], resourceType, id, attr string) string { - for _, r := range included { - if r.Type != resourceType || r.ID != id { - continue - } - var attrs map[string]json.RawMessage - var s string - if json.Unmarshal(r.Attributes, &attrs) == nil && json.Unmarshal(attrs[attr], &s) == nil { - return s - } - } - return "" -} - // pageLimit is the largest page App Store Connect serves. const pageLimit = 200 @@ -124,45 +97,37 @@ func getOne[A any](ctx context.Context, c *Client, path string, query url.Values // 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) { - rs, _, err := collect[A](ctx, c, path, query, true) - return rs, err -} - -// 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) { - rs, _, err := collect[A](ctx, c, path, query, false) - return rs, err -} - -// collect fetches a collection and the resources its include parameter pulled -// in, following links.next when follow is set. -func collect[A any](ctx context.Context, c *Client, path string, query url.Values, follow bool) ([]Resource[A], []Resource[json.RawMessage], error) { - if follow { - if query == nil { - query = url.Values{} - } - if query.Get("limit") == "" { - query.Set("limit", strconv.Itoa(pageLimit)) - } + if query == nil { + query = url.Values{} + } + if query.Get("limit") == "" { + query.Set("limit", strconv.Itoa(pageLimit)) } var all []Resource[A] - var included []Resource[json.RawMessage] next := path for { var doc Document[[]Resource[A]] if err := c.Get(ctx, next, query, &doc); err != nil { - return nil, nil, err + return nil, err } all = append(all, doc.Data...) - included = append(included, doc.Included...) - if !follow || doc.Links.Next == "" { - return all, included, nil + 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 +} + 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 { diff --git a/internal/asc/jwt.go b/internal/asc/jwt.go index f952ad7..e835023 100644 --- a/internal/asc/jwt.go +++ b/internal/asc/jwt.go @@ -40,7 +40,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 @@ -57,7 +57,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 new file mode 100644 index 0000000..efe396c --- /dev/null +++ b/internal/asc/profiles.go @@ -0,0 +1,150 @@ +package asc + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "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; one that is already gone is not an error. +// Certificates and devices are untouched. +func (c *Client) DeleteProfile(ctx context.Context, profileID string) error { + 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 new file mode 100644 index 0000000..d2ecc30 --- /dev/null +++ b/internal/asc/profiles_test.go @@ -0,0 +1,146 @@ +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) + } +} + +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) + } +} diff --git a/internal/asc/testflight.go b/internal/asc/testflight.go index f263a07..aaa2165 100644 --- a/internal/asc/testflight.go +++ b/internal/asc/testflight.go @@ -2,9 +2,45 @@ package asc import ( "context" + "net/url" "time" ) +// 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 diff --git a/internal/build/coordinator.go b/internal/build/coordinator.go index 19e510c..fe4dc44 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" @@ -56,13 +57,86 @@ 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 nil, "", err + } + if provider != "" { + s.Provider = provider + } + name, err := c.config.ProviderName(s.Provider) + if err != nil { + return nil, "", 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. +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 + } + // The Flutter SDK version must match the local one for hot reload. + if c.config.Flutter.Version != "" { + inputs["flutter_version"] = c.config.Flutter.Version + } + if c.config.KMP.JDKVersion != "" { + inputs["jdk_version"] = c.config.KMP.JDKVersion + } + return inputs +} + +// buildInputs are the ios-build.yml inputs: the shared ones plus signing, +// 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" + } + if s.Configuration != "" { + inputs["configuration"] = s.Configuration + } + if p := s.ProfileInput(); p != "" { + inputs["profile"] = p + } + 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 @@ -73,13 +147,16 @@ 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) +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 } 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 +175,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,37 +195,11 @@ 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 { + 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 new file mode 100644 index 0000000..8d99036 --- /dev/null +++ b/internal/build/inputs_test.go @@ -0,0 +1,172 @@ +package build + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "reflect" + "strings" + "testing" + + "github.com/MobAI-App/ios-builder/internal/config" +) + +func profiledConfig() *config.Config { + 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": { + Scheme: "AppPreview", Distribution: "internal", + 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) + } + // 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" || s.Distribution != "ad-hoc" { + 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)) + } + + // 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", "profile"} { + if _, ok := share[k]; ok { + t.Fatalf("simulator workflow does not declare %s", k) + } + } + + 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("the share inputs must match the pre-profiles set:\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) { + c := NewCoordinatorWithOutput(profiledConfig(), nil, io.Discard) + + s, _, _ := c.settings("", "", false) + got := c.inputs("abcdef12", "ref", "sha", s) + 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 !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) + 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 (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()) + } + } + 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 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: signed (unsuffixed IOS_* secrets)") { + t.Errorf("legacy path not printed:\n%s", out.String()) + } + out.Reset() + 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 956eb77..f4c736a 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,41 @@ 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" + 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) + 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..2cb6d92 100644 --- a/internal/build/remote.go +++ b/internal/build/remote.go @@ -61,10 +61,13 @@ 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, 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": 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 +79,22 @@ 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 } +// 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.Start(buildID) c.progress.Update(PhaseSnapshot, "Snapshotting working tree...") sha, err := snapshot.Create(ctx, fmt.Sprintf("ios-builder snapshot %s", buildID)) if err != nil { @@ -94,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) (*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 } + // 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") } @@ -115,14 +132,13 @@ func (c *Coordinator) buildRemote(ctx context.Context, opts BuildOptions) (*Buil defer cancel() started := time.Now() buildID := uuid.New().String()[:8] + 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 } - 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 +279,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 +302,12 @@ func (c *Coordinator) shareRemote(ctx context.Context, opts ShareOptions) (*Shar ctx, cancel := context.WithTimeout(ctx, opts.Timeout) defer cancel() buildID := uuid.New().String()[:8] + c.progress.Start(buildID) ref, sha, err := c.pushSnapshot(ctx, opts.Remote, buildID) 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/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/build/share.go b/internal/build/share.go index 9e931b6..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" ) @@ -44,12 +45,22 @@ 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) + // 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 } 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") @@ -82,26 +93,12 @@ 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 { + 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") 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) { diff --git a/internal/config/profile.go b/internal/config/profile.go new file mode 100644 index 0000000..78d3fa4 --- /dev/null +++ b/internal/config/profile.go @@ -0,0 +1,162 @@ +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 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 +} + +// 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", + "SIGNING_SET", "SIGNING_SET_USED", "DURATION", "PROJECT_TYPE", "EXPORT_METHOD", + "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", +} + +// reservedEnvPrefixes cover the runners' own namespaces: Builder's, GitHub +// 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", "IOS_EXTENSION_PROFILES", +} + +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)) + 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 +// 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, + Scheme: c.IOS.Scheme, + Signing: c.IOS.Signing, + Provider: c.Provider, + } + source := "profile" + if name == "" { + name, source = c.DefaultProfile, "defaultProfile" + } + if name == "" { + return s, nil + } + p, ok := c.Profiles[name] + if !ok { + if len(c.Profiles) == 0 { + return s, fmt.Errorf("%s %q is not defined; builder.json has no profiles", source, name) + } + return s, fmt.Errorf("%s %q is not defined; available profiles: %s", source, name, strings.Join(c.ProfileNames(), ", ")) + } + 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) { + return s, fmt.Errorf("profile %q: env name %q is not a valid environment variable name", name, k) + } + if reservedEnvName(k) { + return s, fmt.Errorf("profile %q: env name %q is reserved for the runner", name, k) + } + } + s.Profile = name + 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.Provider != "" { + s.Provider = p.Provider + } + if len(p.Env) > 0 { + s.Env = p.Env + } + return s, nil +} + +// 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 "" + } + data, _ := json.Marshal(s.Env) // a map[string]string cannot fail to marshal + return string(data) +} + +// 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 "" + } + 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..d025923 --- /dev/null +++ b/internal/config/profile_test.go @@ -0,0 +1,166 @@ +package config + +import ( + "encoding/json" + "strings" + "testing" +) + +func profileConfig() *Config { + return &Config{ + Provider: "github", + IOS: IOSConfig{Path: "ios", Scheme: "Top", Signing: true, Configuration: "Debug"}, + Profiles: map[string]Profile{ + "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"}, + }, + } +} + +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"}}, + // 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) + 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 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) + } + } + // 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) { + 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(), `defaultProfile "nightly"`) { + t.Fatalf("unknown defaultProfile accepted or not named as the source: %v", err) + } +} + +func TestResolveProfileErrors(t *testing.T) { + cfg := profileConfig() + _, err := cfg.ResolveProfile("staging") + 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") { + t.Fatalf("missing profiles section: %v", err) + } + 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_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"}}, + "Bitrise space": {Env: map[string]string{"BITRISE_GIT_BRANCH": "x"}}, + } { + 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","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.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) { + 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") + } + noEnv := BuildSettings{Profile: "development"} + if got := noEnv.ProfileInput(); !strings.Contains(got, `"env":{}`) { + t.Fatalf("env should be an object even when empty: %s", got) + } +} diff --git a/internal/config/signing.go b/internal/config/signing.go new file mode 100644 index 0000000..23b6d8d --- /dev/null +++ b/internal/config/signing.go @@ -0,0 +1,94 @@ +package config + +import ( + "fmt" + "strings" +) + +// 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 + } + 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, ", ")) +} + +// 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 { + return "", err + } + return strings.ToUpper(strings.ReplaceAll(d, "-", "_")), nil +} + +// SigningSecrets names the secrets of a signing set. +type SigningSecrets struct { + Certificate string // base64 .p12 + Password string // the .p12 password + 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 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_, IOS_PROVISIONING_PROFILE_ and +// IOS_EXTENSION_PROFILES_. The empty set names the unsuffixed legacy secrets. +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, + Extensions: "IOS_EXTENSION_PROFILES" + suffix, + } +} + +// 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 new file mode 100644 index 0000000..94fde5b --- /dev/null +++ b/internal/config/signing_test.go @@ -0,0 +1,53 @@ +package config + +import ( + "slices" + "testing" +) + +func TestSigningSet(t *testing.T) { + for distribution, want := range map[string]string{ + "": "", "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", "app-store", "AD_HOC", "Development"} { + if _, err := SigningSet(bad); err == nil { + t.Errorf("SigningSet(%q) accepted", bad) + } + } + // 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("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", "IOS_EXTENSION_PROFILES_STORE"}) { + t.Errorf("Names = %v", got.Names()) + } + legacy := SigningSecretNames("") + 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. + 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 38b5188..f65f111 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -18,6 +18,34 @@ 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. +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, enterprise; empty is unsigned): it selects the + // signing set and the type the provisioning profile in it must have. + Distribution string `json:"distribution,omitempty"` } // CIConfig identifies an app already connected to the project's GitHub repository. @@ -108,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 Store Connect app for the asc commands (default: read from dist/*.ipa) - Signing bool `json:"signing,omitempty"` // Whether code signing is configured - 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/distribute/distribute_test.go b/internal/distribute/distribute_test.go index 05bc1de..8b1597e 100644 --- a/internal/distribute/distribute_test.go +++ b/internal/distribute/distribute_test.go @@ -10,7 +10,6 @@ import ( "crypto/x509" "encoding/json" "encoding/pem" - "fmt" "io" "net/http" "net/http/httptest" @@ -69,30 +68,10 @@ type fake struct { openSubmission bool submitStatus int betaReviewExists bool - // autoGroup adds an internal group with automatic distribution, dupGroup a - // second "beta testers"; created collects groups made through the API. - noGroups bool - autoGroup bool - dupGroup bool - created []map[string]any - // testerStates defaults to ACCEPTED, flipping to INVITED once an - // invitation is posted; noBuilds makes those invitations fail instead. - users map[string]bool - testers map[string]string - testerStates map[string]string - pendingInvite bool - noBuilds 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, users: map[string]bool{}, testers: map[string]string{}, testerStates: map[string]string{}} - tester := func(email, id string) map[string]any { - state := f.testerStates[id] - if state == "" { - state = "ACCEPTED" - } - return map[string]any{"type": "betaTesters", "id": id, "attributes": map[string]any{"email": email, "inviteType": "EMAIL", "state": state}} - } + 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} @@ -180,96 +159,9 @@ func newFake(t *testing.T) *fake { one(w, 200, build()) })) mux.HandleFunc("GET /v1/betaGroups", wrap(func(w http.ResponseWriter, r *http.Request) { - if f.noGroups { - many(w) - return - } - groups := []any{ - res("betaGroups", "g-int", map[string]any{"name": "Team", "isInternalGroup": true, "hasAccessToAllBuilds": false}, nil), - res("betaGroups", "g-ext", map[string]any{"name": "Beta Testers", "isInternalGroup": false, "publicLinkEnabled": true}, nil), - } - if f.autoGroup { - groups = append(groups, res("betaGroups", "g-auto", map[string]any{"name": "Everyone", "isInternalGroup": true, "hasAccessToAllBuilds": true}, nil)) - } - if f.dupGroup { - groups = append(groups, res("betaGroups", "g-dup", map[string]any{"name": "beta testers", "isInternalGroup": false, "publicLinkEnabled": nil}, nil)) - } - for _, g := range f.created { - groups = append(groups, g) - } - many(w, groups...) - })) - mux.HandleFunc("POST /v1/betaGroups", wrap(func(w http.ResponseWriter, r *http.Request) { - attrs := obj(f.t, f.bodies["POST /v1/betaGroups"], "data", "attributes") - g := res("betaGroups", fmt.Sprintf("g-new-%d", len(f.created)+1), attrs, nil) - f.created = append(f.created, g) - one(w, 201, g) - })) - mux.HandleFunc("POST /v1/betaGroups/{id}/relationships/betaTesters", wrap(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })) - mux.HandleFunc("GET /v1/betaTesters", wrap(func(w http.ResponseWriter, r *http.Request) { - email := r.URL.Query().Get("filter[email]") - if email != strings.ToLower(email) { - f.t.Errorf("filter[email] must be lowercased: %q", email) - } - var testers []any - for e, id := range f.testers { - if email == "" || strings.EqualFold(e, email) { - testers = append(testers, tester(e, id)) - } - } - many(w, testers...) - })) - mux.HandleFunc("GET /v1/betaTesters/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { - for e, id := range f.testers { - if id == r.PathValue("id") { - one(w, 200, tester(e, id)) - return - } - } - w.WriteHeader(404) - })) - mux.HandleFunc("POST /v1/betaTesterInvitations", wrap(func(w http.ResponseWriter, r *http.Request) { - if f.noBuilds { - writeJSON(w, 409, map[string]any{"errors": []map[string]any{{"status": "409", "code": "STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS", "title": "The request cannot be fulfilled because of the state of another resource.", "detail": "The tester has no installable builds."}}}) - return - } - id, _ := obj(f.t, f.bodies["POST /v1/betaTesterInvitations"], "data", "relationships", "betaTester", "data")["id"].(string) - f.testerStates[id] = "INVITED" - one(w, 201, res("betaTesterInvitations", "bti-1", nil, nil)) - })) - mux.HandleFunc("POST /v1/betaTesters", wrap(func(w http.ResponseWriter, r *http.Request) { - email, _ := obj(f.t, f.bodies["POST /v1/betaTesters"], "data", "attributes")["email"].(string) - if _, exists := f.testers[email]; exists { - writeJSON(w, 409, map[string]any{"errors": []map[string]any{{"status": "409", "code": "ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE", "title": "duplicate", "detail": "A beta tester with the email '" + email + "' already exists."}}}) - return - } - id := fmt.Sprintf("t-new-%d", len(f.testers)+1) - f.testers[email] = id - state := "INVITED" - if f.noBuilds { - state = "NOT_INVITED" - } - one(w, 201, res("betaTesters", id, map[string]any{"email": email, "state": state}, nil)) - })) - mux.HandleFunc("GET /v1/users", wrap(func(w http.ResponseWriter, r *http.Request) { - email := r.URL.Query().Get("filter[username]") - if f.users[email] { - many(w, res("users", "u-"+email, map[string]any{"username": email, "firstName": "Team", "lastName": "Member", "roles": []string{"DEVELOPER"}}, nil)) - return - } - many(w) - })) - mux.HandleFunc("GET /v1/userInvitations", wrap(func(w http.ResponseWriter, r *http.Request) { - email := r.URL.Query().Get("filter[email]") - if f.pendingInvite { - many(w, res("userInvitations", "inv-0", map[string]any{"email": email, "roles": []string{"CUSTOMER_SUPPORT"}}, nil)) - return - } - many(w) - })) - mux.HandleFunc("POST /v1/userInvitations", wrap(func(w http.ResponseWriter, r *http.Request) { - attrs := obj(f.t, f.bodies["POST /v1/userInvitations"], "data", "attributes") - one(w, 201, res("userInvitations", "inv-1", attrs, nil)) + 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)) @@ -473,22 +365,6 @@ func TestUploadUndeclaredEncryptionStaysPending(t *testing.T) { } } -func TestProgressSize(t *testing.T) { - for _, tc := range []struct { - sent, total int64 - want string - }{ - {0, 200 << 10, "0/200 KB"}, - {200 << 10, 200 << 10, "200/200 KB"}, - {1 << 20, 3<<20 + 1<<19, "1.0/3.5 MB"}, - {0, 24 << 20, "0.0/24.0 MB"}, - } { - if got := progressSize(tc.sent, tc.total); got != tc.want { - t.Errorf("progressSize(%d, %d) = %q, want %q", tc.sent, tc.total, got, tc.want) - } - } -} - 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"))}) diff --git a/internal/distribute/submit_test.go b/internal/distribute/submit_test.go index 8fa3d0b..2a4f740 100644 --- a/internal/distribute/submit_test.go +++ b/internal/distribute/submit_test.go @@ -68,113 +68,29 @@ func TestSubmitTestFlightUpdatesExistingNotesAndSkipsReviewForInternal(t *testin func TestSubmitTestFlightListsGroupsWithoutGroupFlag(t *testing.T) { f := newFake(t) - var log bytes.Buffer - res, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app", Log: &log}) + 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) } - if !strings.Contains(log.String(), "Available groups:\n Team (internal)\n Beta Testers (external)") { - t.Errorf("log = %q", log.String()) - } - f.noGroups = true - log.Reset() - if _, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app", Log: &log}); err != nil { - t.Fatal(err) - } - if !strings.Contains(log.String(), "Available groups: (none)") { - t.Errorf("log = %q", log.String()) - } -} - -func TestSubmitTestFlightCreatesMissingGroup(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{"Nightly", "team"}, NoEncryption: true, Log: &log}) - if err != nil { - t.Fatalf("%v\n%s", err, log.String()) - } - if len(res.Groups) != 2 || !res.Groups[0].Created || !res.Groups[0].Internal || res.Groups[0].ID != "g-new-1" || res.Groups[1].Created || res.Groups[1].ID != "g-int" { - t.Errorf("groups = %+v", res.Groups) - } - create := obj(t, f.body("POST /v1/betaGroups"), "data") - attrs := obj(t, create, "attributes") - if attrs["name"] != "Nightly" || attrs["isInternalGroup"] != true || attrs["hasAccessToAllBuilds"] != false || obj(t, create, "relationships", "app", "data")["id"] != "app-1" { - t.Errorf("create body = %v", create) - } - if !strings.Contains(log.String(), "Created TestFlight group Nightly (internal)") { - t.Errorf("log = %q", log.String()) - } - links := arr(t, f.body("POST /v1/builds/build-9/relationships/betaGroups"), "data") - if len(links) != 2 || obj(t, links[0])["id"] != "g-new-1" || obj(t, links[1])["id"] != "g-int" { - t.Errorf("linkage = %v", links) - } - if f.called("POST /v1/betaAppReviewSubmissions") { - t.Error("internal groups need no beta review") - } - - // --external creates an external group, which goes through beta review. - f = newFake(t) - res, err = SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Public"}, External: true, NoEncryption: true}) - if err != nil { - t.Fatal(err) - } - attrs = obj(t, f.body("POST /v1/betaGroups"), "data", "attributes") - if attrs["isInternalGroup"] != false || res.Groups[0].Internal || res.BetaReview == nil || !f.called("POST /v1/betaAppReviewSubmissions") { - t.Errorf("attrs = %v, result = %+v", attrs, res) - } - if _, has := attrs["hasAccessToAllBuilds"]; has { - t.Errorf("external groups take no hasAccessToAllBuilds: %v", attrs) - } -} - -func TestSubmitTestFlightSkipsAutomaticDistributionGroups(t *testing.T) { - f := newFake(t) - f.autoGroup = true - var log bytes.Buffer - res, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Everyone"}, NoEncryption: true, Log: &log}) - if err != nil { - t.Fatalf("%v\n%s", err, log.String()) - } - if len(res.Groups) != 1 || !res.Groups[0].AutoBuilds || f.called("POST /v1/builds/build-9/relationships/betaGroups") { - t.Errorf("result = %+v, calls = %v (adding to such a group is a 422)", res, f.calls) - } - if !strings.Contains(log.String(), "Everyone is an internal group with automatic distribution: every processed build is already available to its testers") { - t.Errorf("log = %q", log.String()) - } - - // Mixed with a manual group, only the manual one is linked and reported. - f = newFake(t) - f.autoGroup = true - log.Reset() - res, err = SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Everyone", "Team"}, NoEncryption: true, Log: &log}) - if err != nil { - t.Fatal(err) - } - links := arr(t, f.body("POST /v1/builds/build-9/relationships/betaGroups"), "data") - if len(links) != 1 || obj(t, links[0])["id"] != "g-int" || len(res.Groups) != 2 || res.Groups[1].AutoBuilds { - t.Errorf("linkage = %v, groups = %+v", links, res.Groups) - } - if !strings.Contains(log.String(), "Added build 7 to Team\n") { - t.Errorf("log names the skipped group as added: %q", log.String()) - } } func TestSubmitTestFlightErrors(t *testing.T) { f := newFake(t) c := f.client(t) - _, 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{"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.dupGroup = true - _, err = SubmitTestFlight(context.Background(), c, &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"BETA TESTERS"}, NoEncryption: true}) - if err == nil || !strings.Contains(err.Error(), "2 TestFlight groups match BETA TESTERS") || f.called("POST /v1/betaGroups") || f.called("POST /v1/builds/build-9/relationships/betaGroups") { - t.Errorf("an ambiguous group name must neither create nor add: %v, calls = %v", err, f.calls) - } - f.dupGroup = false f.buildState = "PROCESSING" _, err = SubmitTestFlight(context.Background(), c, &TestFlightOptions{BundleID: "com.example.app", BuildNumber: "7"}) if err == nil || !strings.Contains(err.Error(), "PROCESSING") { diff --git a/internal/distribute/testflight.go b/internal/distribute/testflight.go index cd51e42..a43cfe8 100644 --- a/internal/distribute/testflight.go +++ b/internal/distribute/testflight.go @@ -16,11 +16,9 @@ type TestFlightOptions struct { // Version and BuildNumber narrow the build; empty picks the newest VALID build. Version string BuildNumber string - // Groups are TestFlight group names (case-insensitive); an unknown name is - // created, internal or external with External. Empty adds the build - // nowhere and reports the available groups instead. - Groups []string - External bool + // 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 @@ -37,11 +35,6 @@ type GroupRef struct { ID string `json:"id"` Name string `json:"name"` Internal bool `json:"internal"` - // Created is set when the group did not exist and was made for this submit. - Created bool `json:"created,omitempty"` - // AutoBuilds marks an internal group with automatic distribution; the - // build was not added to it because every build already reaches it. - AutoBuilds bool `json:"auto_builds,omitempty"` } // ReviewRef describes a review's state. @@ -105,14 +98,13 @@ func SubmitTestFlight(ctx context.Context, client *asc.Client, opts *TestFlightO 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.") - if len(groups) == 0 { - logf(opts.Log, "Available groups: (none)") - } else { - logf(opts.Log, "Available groups:") - } + logf(opts.Log, "No --group given; the build was added to no TestFlight group. Available groups:") for _, g := range groups { - logf(opts.Log, " %s (%s)", g.Name, groupKind(g.Internal)) + 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.") @@ -120,29 +112,34 @@ func SubmitTestFlight(ctx context.Context, client *asc.Client, opts *TestFlightO return res, nil } - var ids, names []string + var ids []string var external bool + var unknown []string for _, name := range opts.Groups { - g, err := findOrCreateGroup(ctx, client, opts.Log, app.ID, groups, name, !opts.External) - if err != nil { - return res, err + 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 + } } - res.Groups = append(res.Groups, *g) - if g.AutoBuilds { - logf(opts.Log, "%s is an internal group with automatic distribution: every processed build is already available to its testers", g.Name) - continue + if !found { + unknown = append(unknown, name) } - ids = append(ids, g.ID) - names = append(names, g.Name) - external = external || !g.Internal + } + 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 len(ids) == 0 { - logf(opts.Log, "TestFlight: %s", res.Link) - return res, nil - } if external { review, err := client.GetBuildBetaAppReviewSubmission(ctx, build.ID) @@ -164,7 +161,7 @@ func SubmitTestFlight(ctx context.Context, client *asc.Client, opts *TestFlightO 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(names, ", ")) + logf(opts.Log, "Added build %s to %s", build.BuildNumber, strings.Join(opts.Groups, ", ")) if opts.Wait && res.BetaReview != nil { review, err := client.WaitForBetaAppReview(ctx, res.BetaReview.ID, pollInterval(opts.PollInterval), func(r *asc.BetaAppReviewSubmission) { @@ -183,26 +180,3 @@ func SubmitTestFlight(ctx context.Context, client *asc.Client, opts *TestFlightO logf(opts.Log, "TestFlight: %s", res.Link) return res, nil } - -func groupKind(internal bool) string { - if internal { - return "internal" - } - return "external" -} - -// findOrCreateGroup matches name against the app's groups (case-insensitive) -// and creates it when none matches. Existing groups keep their type. -func findOrCreateGroup(ctx context.Context, client *asc.Client, log io.Writer, appID string, groups []asc.BetaGroup, name string, internal bool) (*GroupRef, error) { - if g, err := asc.MatchBetaGroup(groups, name); err != nil { - return nil, err - } else if g != nil { - return &GroupRef{ID: g.ID, Name: g.Name, Internal: g.Internal, AutoBuilds: g.Internal && g.HasAccessToAllBuilds}, nil - } - g, err := client.CreateBetaGroup(ctx, asc.BetaGroupSpec{AppID: appID, Name: name, Internal: internal}) - if err != nil { - return nil, fmt.Errorf("create TestFlight group %s: %w", name, err) - } - logf(log, "Created TestFlight group %s (%s)", g.Name, groupKind(g.Internal)) - return &GroupRef{ID: g.ID, Name: g.Name, Internal: g.Internal, Created: true}, nil -} diff --git a/internal/distribute/upload.go b/internal/distribute/upload.go index 1eb1f94..a608497 100644 --- a/internal/distribute/upload.go +++ b/internal/distribute/upload.go @@ -24,16 +24,6 @@ type UploadOptions struct { Log io.Writer } -// progressSize renders "sent/total" in MB with one decimal, or in KB while -// the whole upload is under a megabyte, so a small IPA never reads 0/0. -func progressSize(sent, total int64) string { - if total < 1<<20 { - return fmt.Sprintf("%d/%d KB", sent>>10, total>>10) - } - const mb = float64(1 << 20) - return fmt.Sprintf("%.1f/%.1f MB", float64(sent)/mb, float64(total)/mb) -} - // IPARef describes the uploaded archive. type IPARef struct { Path string `json:"path"` @@ -94,7 +84,7 @@ func Upload(ctx context.Context, client *asc.Client, opts *UploadOptions) (*Uplo } if pct := sent * 100 / total; pct/10 > lastPercent/10 || pct == 100 { lastPercent = pct - logf(opts.Log, " %d%% (%s)", pct, progressSize(sent, total)) + logf(opts.Log, " %d%% (%d/%d MB)", pct, sent>>20, total>>20) } }, }) 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 90df44b..b88e277 100644 --- a/internal/github/repo.go +++ b/internal/github/repo.go @@ -2,6 +2,7 @@ package github import ( "context" + "errors" "fmt" "net/http" ) @@ -30,6 +31,31 @@ 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), 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++ { + 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 { + 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) + } + 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/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) + } +} diff --git a/internal/github/types.go b/internal/github/types.go index 1e5ab9c..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"` @@ -71,6 +82,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"` diff --git a/internal/github/workflow.go b/internal/github/workflow.go index ff63d1a..51f7232 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) @@ -290,16 +375,11 @@ func (c *Client) PollForArtifact(ctx context.Context, owner, repo string, runID // Check if workflow failed (no point waiting for artifact) run, err := c.GetWorkflowRun(ctx, owner, repo, runID) - switch { - case err != nil: - statusErrors++ - if statusErrors >= maxConsecutiveStatusErrors { - return nil, fmt.Errorf("failed to check workflow status: %w", err) - } - case run.Status == "completed" && run.Conclusion != "success": - return nil, fmt.Errorf("workflow failed with conclusion: %s", run.Conclusion) - default: - statusErrors = 0 + if err != nil { + return nil, fmt.Errorf("failed to check workflow status: %w", err) + } + if run.Status == "completed" && run.Conclusion != "success" { + 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 index 8f3d149..470a227 100644 --- a/internal/github/workflow_test.go +++ b/internal/github/workflow_test.go @@ -2,70 +2,76 @@ package github import ( "context" + "fmt" "net/http" "net/http/httptest" "strings" - "sync/atomic" "testing" "time" ) -// pollServer fakes the two endpoints PollForArtifact hits: the artifact list, -// empty until artifactAfter run-status calls have happened, and the run status, -// which answers 503 for the first statusErrors calls. -func pollServer(t *testing.T, statusErrors int, artifactAfter int) (*Client, *int32) { +// 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() - var statusCalls int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case strings.HasSuffix(r.URL.Path, "/artifacts"): - if artifactAfter >= 0 && int(atomic.LoadInt32(&statusCalls)) >= artifactAfter { - w.Write([]byte(`{"artifacts":[{"id":7,"name":"ipa"}]}`)) + 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 } - w.Write([]byte(`{"artifacts":[]}`)) + 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: - n := atomic.AddInt32(&statusCalls, 1) - if int(n) <= statusErrors { - w.WriteHeader(http.StatusServiceUnavailable) - return - } - w.Write([]byte(`{"id":1,"status":"in_progress","conclusion":""}`)) + t.Errorf("unexpected request: %s %s", r.Method, r.URL) + w.WriteHeader(http.StatusNotFound) } })) t.Cleanup(srv.Close) - c := NewClient("token") + c := NewClient("tok") c.baseURL = srv.URL - return c, &statusCalls + return c } -func TestPollForArtifactSurvivesTransientStatusErrors(t *testing.T) { - old := artifactPollInterval - artifactPollInterval = time.Millisecond - t.Cleanup(func() { artifactPollInterval = old }) - - // Two 503s in a row, then the artifact appears: the poll must not give up. - c, calls := pollServer(t, 2, 3) - artifact, err := c.PollForArtifact(context.Background(), "o", "r", 1, "ipa", time.Second, nil) - if err != nil { - t.Fatalf("poll gave up on a transient error: %v", err) +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") } - if artifact.ID != 7 || atomic.LoadInt32(calls) < 3 { - t.Fatalf("artifact %+v after %d status calls", artifact, *calls) + 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 TestPollForArtifactGivesUpWhenStatusKeepsFailing(t *testing.T) { - old := artifactPollInterval - artifactPollInterval = time.Millisecond - t.Cleanup(func() { artifactPollInterval = old }) - - c, calls := pollServer(t, 1000, -1) - _, err := c.PollForArtifact(context.Background(), "o", "r", 1, "ipa", time.Second, nil) - if err == nil || !strings.Contains(err.Error(), "failed to check workflow status") { - t.Fatalf("expected a status error, got %v", err) - } - if n := atomic.LoadInt32(calls); n != maxConsecutiveStatusErrors { - t.Fatalf("gave up after %d status calls, want %d", n, maxConsecutiveStatusErrors) +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) } } 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 diff --git a/internal/signing/auto.go b/internal/signing/auto.go new file mode 100644 index 0000000..1b11351 --- /dev/null +++ b/internal/signing/auto.go @@ -0,0 +1,542 @@ +package signing + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + "time" + "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. 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 --distribution. +const ( + 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 = config.DistributionEnterprise +) + +// 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) { + 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 +// 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 { + return asc.CertificateTypeDevelopment + } + return asc.CertificateTypeDistribution +} + +func (t Type) profileType() string { + switch t { + case TypeAdHoc: + return asc.ProfileTypeIOSAppAdHoc + case TypeStore: + 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"` +} + +// 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 { + 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 + // 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"` +} + +// 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. + 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"` + Extensions []ExtensionResult `json:"extensions,omitempty"` + Files Files `json:"files"` + // 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 +// 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 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") + } + 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") + } + if opts.OutDir == "" { + opts.OutDir = "." + } + now := opts.now + if now == nil { + now = time.Now + } + res := &AutoResult{Type: opts.Type, ExtensionProfiles: map[string][]byte{}} + + // 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 + } + 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 + } + } + + // 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, 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) + } + keyPEM := opts.KeyPEM + if keyPEM == nil { + if keyPEM, err = generateKey(); err != nil { + return res, err + } + 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) + } + } + 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 + } + + // 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)) + 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) + } + 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) +} + +// 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 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) + } + 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: run builder signing setup --distribution %s --devices-from-mobai, or --device (repeatable)", opts.Type, 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, 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 { + 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, bundle.ID, []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..c22b183 --- /dev/null +++ b/internal/signing/auto_test.go @@ -0,0 +1,420 @@ +package signing + +import ( + "bytes" + "context" + "crypto/rsa" + "os" + "path/filepath" + "slices" + "strings" + "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" +) + +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 signingtest.Now }, + } +} + +func run(t *testing.T, p *signingtest.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 := signingtest.New(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-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) + 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 := signingtest.New(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 := signingtest.New(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 := signingtest.New(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 := signingtest.New(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 := signingtest.New(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 := signingtest.New(t) + p.BundleIDs = []string{"com.example.app.widget", "com.example.app"} + opts := devOpts(t.TempDir()) + 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 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 := signingtest.New(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) + } + // 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(TypeDevelopment))); err == nil { + t.Error("a key was written although no certificate was requested") + } +} + +func TestAutoDisabledDevicesStayOutOfProfile(t *testing.T) { + 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) + } +} + +func TestAutoWithSuppliedKeyReusesMatchingCertificate(t *testing.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, signingtest.Now.AddDate(0, 6, 0)) + // An expired one for the same key must not be picked. + 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 { + 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 := signingtest.New(t) + p.RefuseCertificates = true + 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(TypeDevelopment)) { + 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) { + 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 := 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" }, + "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()) + } +} + +// 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 := signingtest.New(t) + dir := t.TempDir() + dev := run(t, p, devOpts(dir)) + opts := devOpts(dir) + 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) + } + 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 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 := 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()) + } +} + +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) + } + } +} + +// 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 new file mode 100644 index 0000000..cc596ac --- /dev/null +++ b/internal/signing/profile.go @@ -0,0 +1,46 @@ +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 (store). +func ProfileType(data []byte) (Type, error) { + dict, err := profilePlist(data) + if err != nil { + return "", 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 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/signing/profile_test.go b/internal/signing/profile_test.go new file mode 100644 index 0000000..c37c804 --- /dev/null +++ b/internal/signing/profile_test.go @@ -0,0 +1,78 @@ +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}, + {"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}, + } { + 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, "internal": TypeAdHoc, "store": TypeStore, "enterprise": TypeEnterprise, + } { + // 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) + } + } + for _, bad := range []string{"", "distribution", "app-store", "adhoc"} { + if _, err := ParseType(bad); err == nil { + t.Errorf("ParseType(%q) accepted", bad) + } + } + if TypeStore.NeedsDevices() || TypeEnterprise.NeedsDevices() || !TypeDevelopment.NeedsDevices() || !TypeAdHoc.NeedsDevices() { + t.Error("NeedsDevices: only development and ad-hoc profiles list devices") + } + 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/signing.go b/internal/signing/signing.go index 5e71360..b0e140f 100644 --- a/internal/signing/signing.go +++ b/internal/signing/signing.go @@ -15,63 +15,85 @@ 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). +// 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 { return nil, nil, fmt.Errorf("failed to generate private key: %w", err) } + 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 + } + 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) { +// 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 +} +func parseCertificate(certData []byte) (*x509.Certificate, error) { certDER := certData if certBlock, _ := pem.Decode(certData); certBlock != nil { certDER = certBlock.Bytes @@ -80,6 +102,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()) { 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 { 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 +} diff --git a/internal/workflow/profile_test.go b/internal/workflow/profile_test.go new file mode 100644 index 0000000..979dd08 --- /dev/null +++ b/internal/workflow/profile_test.go @@ -0,0 +1,239 @@ +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++ { + // GitHub's heredoc form: NAME< "$ENV_LOG" app="$dd/Build/Products/Debug-iphoneos/App.app" if [ "$settings" = true ]; then python3 - "$dd/Build/Products/Debug-iphoneos" <<'PY' @@ -166,10 +173,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) } @@ -187,6 +201,888 @@ 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() + // 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 { + 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) + } + 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 + // detected method — not a constant — into ExportOptions.plist. + wiring := map[string][]string{ + "ios-build.yml": { + `EXPORT_METHOD=$(detect_export_method "$PROFILE_PLIST")`, + "write_export_options ExportOptions.plist", + }, + "runner.sh": { + `detect_export_method "$signing_dir/profile.plist"`, + `write_export_options "$signing_dir/ExportOptions.plist"`, + }, + } + 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) + } + 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) + } + } + } + + 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"}, + {"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; + // the export method's app-store is the store distribution. + for _, tc := range cases { + 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) + } + } + if runtime.GOOS != "darwin" { + t.Skip("plutil is macOS only") + } + 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) + } + }) + } +} + +// 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) + } + 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" "$IDENTITIES")`, + `echo "CODE_SIGN_IDENTITY=$CODE_SIGN_IDENTITY" >> $GITHUB_ENV`, + }, + "runner.sh": { + `CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD" "$identities")`, + "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) + } + } + // 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 + // 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") + } + // 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.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 for %s: %s", tc.identities, 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) + } + }) + } +} + +// 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 > 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{ + "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"} + 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 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 = 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 + } + 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 + } + // 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) + } + } + for k := range settings[config] { + if strings.Contains(k, "[") { + t.Errorf("%s %s: conditional %s left in place", target, config, k) + } + } + } + } + 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"} { + 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) + // 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") + 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), " 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")`) { + 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_", "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) + } + } + } + + if runtime.GOOS == "windows" { + t.Skip("shell test") + } + // 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|%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. + 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"} + 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 { + 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 + }{ + // 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"}}, + {"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) + 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) + } + } + }) + } +} + +// 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) + 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 240fda1..dee4a59 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: @@ -74,9 +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. + # 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: @@ -87,27 +94,94 @@ 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.*. 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" '.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 $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' + # 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 + 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") + # 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; no + # distribution means the legacy unsuffixed secrets. Same table in runner.sh. + signing_set() { + case "$1" in + '') 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 (or internal), 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, 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 + 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") + - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: @@ -559,14 +633,177 @@ jobs: restore-keys: | pods-${{ runner.os }}- + # 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: + 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 }} + 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::$*" >&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 + # 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 + 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" ext="IOS_EXTENSION_PROFILES_$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 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}" + 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 + # build may say internal for ad-hoc. + check_signing_set() { + [ "$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 + } + + # 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 + 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 + } + + # The certificate names that can sign for a profile of this type, the + # 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" ;; + 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 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 + 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 + # profile before any keychain exists or the certificate is imported. + PROFILE_PATH=$RUNNER_TEMP/profile.mobileprovision + echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > "$PROFILE_PATH" + PROFILE_PLIST=$RUNNER_TEMP/profile.plist + security cms -D -i "$PROFILE_PATH" > "$PROFILE_PLIST" + PROFILE_UUID=$(plutil -extract UUID raw -o - "$PROFILE_PLIST") + + # 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 + # build as manual signing settings. + TEAM_ID=$(plutil -extract TeamIdentifier.0 raw -o - "$PROFILE_PLIST") + PROFILE_NAME=$(plutil -extract Name raw -o - "$PROFILE_PLIST") + 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") + 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 + # 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. Drop the profile's \"configuration\" (a distribution build defaults to Release) or set \"configuration\": \"Release\", or build with a development profile." + exit 1 + fi # Create temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db @@ -583,32 +820,34 @@ jobs: 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 - PROFILE_PATH=$RUNNER_TEMP/profile.mobileprovision - echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > "$PROFILE_PATH" + # 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" + 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 the provisioning profiles: the app's, then its extensions' 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 - # build as manual signing settings. - TEAM_ID=$(plutil -extract TeamIdentifier.0 raw -o - "$PROFILE_PLIST") - PROFILE_NAME=$(plutil -extract Name raw -o - "$PROFILE_PLIST") - APP_ID=$(plutil -extract Entitlements.application-identifier raw -o - "$PROFILE_PLIST") - PROFILE_BUNDLE_ID=${APP_ID#"$TEAM_ID".} + 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 echo "Certificate and provisioning profile installed" echo " team: $TEAM_ID" echo " profile: $PROFILE_NAME ($PROFILE_UUID)" echo " app id: $PROFILE_BUNDLE_ID" - echo "If the build fails on a provisioning mismatch, the app's PRODUCT_BUNDLE_IDENTIFIER must match the app id above." + 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. Extension targets get the profiles listed above by bundle id." - name: Build IPA env: @@ -618,8 +857,101 @@ 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::$*" >&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, 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" + 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']} + 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(): + 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 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(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)) + 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) + 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) + PY + ) || fail "$out" + 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" @@ -714,12 +1046,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 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 @@ -761,10 +1093,9 @@ 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 PROVISIONING_PROFILE_SPECIFIER='$PROVISIONING_PROFILE_NAME'" + # 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 BUILD_CMD="$BUILD_CMD CODE_SIGNING_ALLOWED=NO build" @@ -797,11 +1128,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 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 @@ -823,26 +1152,10 @@ 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)" - - printf '%s\n' \ - '' \ - '' \ - '' \ - '' \ - ' method' \ - ' development' \ - ' signingStyle' \ - ' manual' \ - ' teamID' \ - " ${DEVELOPMENT_TEAM}" \ - ' provisioningProfiles' \ - ' ' \ - " ${APP_BUNDLE_ID}" \ - " ${PROVISIONING_PROFILE_NAME}" \ - ' ' \ - '' \ - '' > ExportOptions.plist + echo "Exporting $APP_BUNDLE_ID with profile '$PROVISIONING_PROFILE_NAME' (team $DEVELOPMENT_TEAM), method $EXPORT_METHOD" + export APP_BUNDLE_ID + write_export_options ExportOptions.plist + xcodebuild -exportArchive \ -archivePath build/App.xcarchive \ -exportOptionsPlist ExportOptions.plist \ @@ -933,17 +1246,21 @@ 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 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 e2405fc..82ee534 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -7,6 +7,25 @@ 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, 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; } + +# 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 + 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 + 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 @@ -188,6 +207,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 \ @@ -270,17 +290,256 @@ 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 } +# 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 + 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 +} + +# The certificate names that can sign for a profile of this type, the +# 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" ;; + 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 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 + 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; no +# distribution means the legacy unsuffixed secrets. Same table in ios-build.yml. +signing_set() { + case "$1" in + '') echo '' ;; + development) echo DEVELOPMENT ;; + ad-hoc|internal) echo AD_HOC ;; + store) echo STORE ;; + enterprise) echo ENTERPRISE ;; + *) return 1 ;; + esac +} + +# 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; 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" ext="IOS_EXTENSION_PROFILES_$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 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}" + 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 +# build may say internal for ad-hoc. +check_signing_set() { + [ "$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 +} + +# 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, 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" + 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']} +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(): + 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 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(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)) +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) + 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) +PY + ) || fail "$out" + 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() { - : "${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 (or internal), store or enterprise" + select_signing_set signing_dir=$(mktemp -d "$ci_dir/signing.XXXXXX") + trap cleanup_signing EXIT + # 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")" + # 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, 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. + 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. 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" 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" @@ -288,17 +547,26 @@ 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" - 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")" + # 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" + 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" + EXTENSION_PROFILES=$(install_extension_profiles "$signing_dir/extensions") + export EXTENSION_PROFILES } build_ipa() { + # 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" case "$CONFIGURATION" in Debug) flutter build ios --debug --no-codesign ;; *) flutter build ios --release --no-codesign ;; esac @@ -308,17 +576,12 @@ 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 + # 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 -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) -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) 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") + } +}