storebound/skills/build-and-sign/SKILL.md

130 lines
12 KiB
Markdown

---
name: build-and-sign
description: Sign, build and upload a mobile app or game from the terminal - App Store Connect API signing (bundle id capabilities, distribution certificate, provisioning profiles), xcodebuild archive and export with a generated ExportOptions.plist, IPA upload through the App Store Connect API or altool with processing wait, and the Android counterpart (Gradle bundleRelease with the upload key, AAB to a testing track). Native, Capacitor, React Native, Flutter and Unity-exported projects. Use when the user wants to build, sign, archive, export, upload or ship a new build, hits code signing or provisioning errors, or needs a build number / versionCode.
---
# Build, sign and upload
The CLI is `storebound` (`storebound help`). Credentials only from env or `.storebound/credentials.json`
(gitignored): `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_P8` (Admin key for signing), `PLAY_SERVICE_ACCOUNT`, and the
Android upload key `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
Never ask for them in chat, never print them. Shared agent rules: `docs/agents-core.md`.
**Every write is dry first, then asks.** Certificates, profiles and uploads change the owner's developer account:
show the `--dry` output, run it for real only after a clear yes (the command asks again in the terminal; `--yes` only
when the owner said so for this run). The MCP server offers only `apple_signing_status` (read-only) from this skill.
## iOS
1. **Read.** `storebound apple signing status`: is the bundle id registered (else `storebound apple bundle`), which
capabilities it has vs `apple.signing.capabilities`, the distribution certificates of the team and whether this
Mac holds a private key for one (a key file in `.storebound/signing/` or an identity in the keychain), App Store
profiles per bundle id (app + `apple.signing.extraBundleIds` for extensions) and whether Xcode has them installed.
It also compares three places a capability must be and names what is missing where: the App ID, the config's
`apple.signing.capabilities` and the iOS project's `.entitlements` (`aps-environment` = PUSH_NOTIFICATIONS,
`com.apple.developer.game-center` = GAME_CENTER, associated domains, Sign in with Apple, iCloud, app groups, ...;
an entitlements file must also be set in CODE_SIGN_ENTITLEMENTS). `apple status` and `validate` warn on the same.
2. **Pick the style.** `apple.signing.style`:
- `automatic` (default): `xcodebuild -allowProvisioningUpdates` with the API key creates and fetches what it needs.
`ensure` only adds missing capabilities to the App ID: it never creates certificates or profiles (Xcode owns
them; its managed profile is renewed with the new capabilities at the next archive). Easiest for one developer.
- `manual`: certificate and profiles from step 3, named in ExportOptions.plist. Best for CI and teams; set the app
target to manual signing with the installed profile in Xcode (Signing & Capabilities) once.
3. **Ensure.** `storebound apple signing ensure --dry`, show it, then without `--dry` after a yes:
- adds missing capabilities (never removes one; extra ones are only reported), then reads the App ID back (a few
tries, about 45 s at most) and fails with the API's answers when one did not stick. Never report a capability
as added from the POST alone. With manual signing, adding one makes existing profiles stale: `ensure` creates
new ones in the same run.
- lists provisioning profiles storebound made that nothing uses ("storebound <bundle id> App Store <date>"), with
the exact delete command; deleting them is the owner's call, storebound never runs it.
- manual signing only from here on. Creates an Apple Distribution certificate only when no valid one has its key on this machine. The RSA key is made
locally, kept 0600 in `.storebound/signing/<id>.key` next to `<id>.cer`; it is never printed and never leaves the
machine. `--keychain` imports both into the login keychain (or `--keychain <path>`) so xcodebuild can sign.
Back the key up (encrypted). Apple allows 3 distribution certificates per team: at the limit, revoking one is
the owner's decision (developer.apple.com > Certificates).
- Developer ID certificates (Mac apps outside the App Store): only the **Account Holder** can create them; the API
answers 403 for every other key, and storebound says so. The Account Holder makes it in Xcode > Settings >
Accounts > Manage Certificates and exports a .p12.
- creates or reuses an App Store profile per bundle id and installs it into
`~/Library/Developer/Xcode/UserData/Provisioning Profiles` (Xcode 16+) and `~/Library/MobileDevice/Provisioning
Profiles` (older Xcode). `--no-install` skips that.
4. **Build.** `storebound apple build --dry --version 1.4.0` shows the project it found (Capacitor `ios/App`, React
Native `ios/<Name>.xcworkspace`, Flutter `ios/Runner`, Unity `Unity-iPhone`, or `apple.build.workspace` /
`project` / `scheme`), the build number (next free one from App Store Connect, or `--build-number N`), the
ExportOptions.plist (`method` app-store-connect, `destination` export, team, style, profiles) and both xcodebuild
commands. Then run it without `--dry` (minutes; full logs in `build/ios/xcodebuild-*.log`). It ends with the IPA
path (`build/ios/export/<version>-<build>/App.ipa`, a fresh folder per build), version and build. **Note the build
number**: later steps use exactly it. Any archive or export failure exits non-zero, so `apple build && apple upload`
stops there; an export failure shows the IDEDistribution log's real error lines. xcodebuild runs with Apple's tool
folders first on PATH (a Homebrew rsync breaks Xcode's IPA step: "exportArchive Copy failed"; `storebound doctor`
flags it).
- Capacitor: the archive ships the web assets `npx cap sync` copied last. Set `build.prebuild` (e.g.
`"npm run build && npx cap sync"`; per store `apple.prebuild` / `play.prebuild`) and `apple build` / `play build`
run it first (once per `flow run`; `--no-prebuild` skips it). Then the copied `public/index.html` must be newer
than the source (last commit outside ios/ android/ node_modules/ store/ .storebound/, `<webDir>/index.html`),
else the build stops; `--allow-stale-web` only with the owner's OK. The output names the web bundle (sha256 +
mtime). In a monorepo (`apps/native/capacitor.config.ts` with its own android/ and ios/) the one Capacitor
project found one or two levels down is used and named; with several, set `play.build.dir` and
`apple.build.workspace` / `project`. React Native / CocoaPods: `pod install` first (the dry
run warns when Pods/ is missing). Flutter: storebound runs `flutter build ios --config-only` with the build
number. Unity: build the Xcode project from Unity first.
- Capacitor needs a UTF-8 locale; storebound sets `LANG` / `LC_ALL` for xcodebuild.
5. **Upload.** `storebound apple upload --build N --dry` (the IPA of build N; without `--build`, the newest IPA under
`build/ios/export`): bundle id, version, build, and exit 1 when that build number is already on App Store Connect
(a stale IPA from a failed build: build again). `--allow-existing` only re-checks a build this command uploaded
before (flows resuming). Then without `--dry` after a yes. Default
`--via api` is the App Store Connect API build upload (Build Uploads: reserve, upload the parts, commit with the
MD5, poll the upload state; any OS). `--via altool` uses `xcrun altool --upload-package` with the API key. Apple
lists both (and Xcode / Transporter); neither is deprecated. storebound then waits until the build is VALID
(exit 0), INVALID / FAILED (exit 1, Apple emails the ITMS reason) or `--timeout` (default 60m, exit 7). Run long
uploads in the background with a log: `storebound apple upload --yes > .storebound/upload.log 2>&1 &` (only
after the owner's yes).
The upload is never silent: the size at the start, a line per part, a heartbeat every 20 s ("still uploading,
4m30s, 312/890 MB (35%), part 4/12"), the same while altool runs and while the build is processed. Past
`--timeout` it stops with exit 7; the same command again resumes the build upload (raise `--timeout`).
6. **Next.** `storebound apple encryption --build N`, `storebound apple testflight distribute --build N --group Team
--dry` (`testflight` skill), `storebound apple listing --version x.y.z --build N` (`appstore-first-release`).
## Android
1. **Upload key.** Generate once (`keytool -genkeypair -v -storetype PKCS12 -keystore upload.p12 -alias upload
-keyalg RSA -keysize 4096 -validity 10950`), keep it outside git, put the four `ANDROID_*` values in env or
`.storebound/credentials.json`. storebound hands them to Gradle as injected signing properties through the
environment, never on the command line. Without them the project's own `signingConfigs.release` signs (e.g.
Flutter's `key.properties`).
2. **Build.** `storebound play build --dry` shows the Gradle project (`android/` for Capacitor, React Native and
Flutter, the root for native, a Unity export with `launcher/` + `unityLibrary/`; or `play.build.dir` / `module` /
`flavor`), the task (`:app:bundleRelease`; Flutter: `flutter build appbundle`), the next free versionCode from Play
(or `--version-code N`) and where the key comes from. Then without `--dry`. The AAB lands in
`build/android/<package>-<versionCode>.aab`. The app module's `defaultConfig` must read the project properties
storebound passes; `play build` (and `check`) warn before Gradle runs when `versionCode` / `versionName` is a
literal or comes from an env var, with the line to use:
Groovy `versionCode((project.findProperty('versionCode') ?: 1) as Integer)` and
`versionName project.findProperty('versionName') ?: '1.0'`; Kotlin
`versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: 1` and
`versionName = project.findProperty("versionName") as String? ?: "1.0"` (keep your current values as the
defaults). `android.injected.version.code` is passed too but does NOT override `defaultConfig` for
`bundleRelease` (checked with AGP 8.13); don't count on it. After the build storebound reads the versionCode back
from the bundle; another number than planned is a hard stop (the AAB is not copied).
The Android SDK is found the way Android Studio finds it: `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `sdk.dir` in the
Gradle project's `local.properties`, then `~/Library/Android/sdk` (macOS) / `~/Android/Sdk` (Linux) /
`%LOCALAPPDATA%\Android\Sdk` (Windows), and handed to Gradle through `ANDROID_HOME` / `ANDROID_SDK_ROOT`;
`local.properties` is never written. None found: it stops before Gradle and lists the places checked.
`storebound doctor` shows the SDK path and where it came from.
3. **Upload.** `storebound play upload --track internal --dry` (read-only: checks the versionCode is free, shows
what is on the track now and the release notes languages). Then without `--dry` after a yes. Tracks: internal,
alpha (closed), beta (open). Production stays `storebound play release --version-code N --production draft` plus
the owner's click in the Console (`play-first-release`, `release-management`). A never-published app only takes
drafts: `--status draft`.
## Gotchas
- "No signing certificate iOS Distribution found": the certificate exists on the team but its key is on another Mac.
`apple signing status` shows this as "without key"; import that Mac's .p12, or `ensure` makes a new one.
- "Provisioning profile doesn't include the currently selected device / capability": a capability was added after the
profile was made. `apple signing ensure` (profiles are recreated when they are not ACTIVE).
- Xcode 16+ reads profiles only from `~/Library/Developer/Xcode/UserData/Provisioning Profiles`: profiles copied into
the old folder alone are not seen.
- ITMS-90189 / "bundle version must be higher": the build number is taken. `apple next-build` gives a free one;
`apple build` uses it by default.
- Play "Version code N has already been used": `play next-version-code`; `play build` uses it by default.