- name
- continuum-release
- description
- Use when building, running, screenshotting, or releasing the Continuum iOS app — launching it in the simulator, driving its UI to verify a visual change, or shipping a version to TestFlight/App Store. Contains the verified account facts, the toolchain floor (iOS 26 SDK), the signing setup, and the archive→upload→submit runbook.
# Continuum release & verification runbook
SwiftUI + SwiftData habit tracker with a widget extension. Bundle
`orion-labs.continuum`, widget `continuumWidget`, app group
`group.com.orionlabs.continuum`, CloudKit container
`iCloud.com.orionlabs.continuum`.
Release *gates* (what to test before shipping) live in `RELEASE_CHECKLIST.md`.
This file is the *mechanics*: commands, toolchain floors, signing, and how to
verify a change visually.
## Verified account facts
| Thing | Value |
|---|---|
| Team ID | `NVN2NY8GZC` |
| ASC API key ID | `6M245PSNS9` |
| ASC key path | `~/.appstoreconnect/private_keys/AuthKey_6M245PSNS9.p8` |
| ASC issuer UUID | `9603f3d8-7877-4300-93fb-f740c62cd44e` (also a GitHub secret). One key = one team; this orion-labs key covers Continuum, WakeEarly and Siftbird. |
| Apple ID | `rf@stoaked.co` |
## Toolchain floor — check FIRST, it invalidates everything else
Since **April 28, 2026** App Store Connect rejects any iOS upload not built
with the **iOS 26 SDK**, i.e. **Xcode 26+**. Verify before any release work:
```bash
xcodebuild -version # need 26.x
xcodebuild -showsdks | grep -i "iOS " # need iphoneos26.x
sw_vers -productVersion # Xcode 26.0–26.3 need macOS >= 15.6
```
- Xcode **26.0–26.3** run on macOS Sequoia **15.6+**.
- Xcode **26.4+** requires macOS Tahoe **26.2+**.
- Pick the newest Xcode matching the installed macOS; don't upgrade macOS just
to get a newer Xcode unless something needs it.
Install a specific version without the Mac App Store:
```bash
brew install xcodes
xcodes list | grep -E "^26\.[0-9.]+ \(" | grep -viE "beta|candidate"
xcodes install 26.3 # prompts for Apple ID + 2FA — needs a human
xcodes select 26.3
```
`xcodes install` needs interactive Apple ID auth. An agent cannot complete the
2FA prompt alone; hand this step to the user.
### You probably don't need local Xcode 26 at all — build on CI
**Preferred path.** `.github/workflows/release.yml` builds on GitHub's
`macos-26` runner, which ships **Xcode 26.6** (and 26.0–26.3). That sidesteps
the local toolchain, the macOS upgrade, and the ~10 GB download completely:
```bash
gh workflow run release.yml # build what's on main
gh workflow run release.yml -f build_number=5 # ASC rejects duplicate build numbers
gh run watch $(gh run list -w release.yml -L1 --json databaseId -q '.[0].databaseId')
```
All three repo secrets are set (`gh secret list` to confirm): `ASC_KEY_ID`,
`ASC_KEY_P8_BASE64`, `ASC_ISSUER_ID`. **This path shipped 3.4 to the App Store
on 2026-08-11**, so it is proven, not theoretical. Check the live version
before assuming what's shipped:
```bash
curl -s "https://itunes.apple.com/lookup?bundleId=orion-labs.continuum" | python3 -m json.tool | grep -E '"version"|ReleaseDate'
```
Signing needs no p12: `-allowProvisioningUpdates` plus the ASC API key mints the
Apple Distribution cert on the clean runner.
### Local Xcode 26 — only if you truly need to build on this Mac
Every route ends at an Apple authentication boundary, so prefer CI above:
| Route | Blocker |
|---|---|
| `xcodes install 26.x` | Apple ID + 2FA prompt |
| Mac App Store / `mas` | signed-in App Store account |
| developer.apple.com .xip | authenticated web session |
| macOS update (needed to *run* Xcode 26) | `sudo` password, then a restart |
Browser automation does not rescue the local path either. Both stacks were
unavailable on 2026-08-11:
`claude-in-chrome` reported "extension is not connected", and
`chrome-devtools` MCP refused with "browser is already running for
`~/.cache/chrome-devtools-mcp/chrome-profile`" (its Chrome uses
`--remote-debugging-pipe`, so there's no TCP port to attach to, and
`--isolated` isn't settable from the tool call). Even when it works, that MCP
uses its own scratch profile with **no Apple session**, so a human sign-in is
still required.
**Conclusion: use CI.** The only thing a human must supply is the issuer UUID,
because it lives solely behind an authenticated App Store Connect session.
Record it in the table above so it's a one-time cost. Never attempt to ship with
an older SDK — App Store Connect rejects it outright, so there is no
partial-credit path, and never fake `DTSDKName` to look newer.
### Disk space
Xcode 26 needs roughly **60–80 GB free** (≈10 GB .xip + expansion + install).
Safe, fully regenerable things to clear, largest first:
```bash
rm -rf "$HOME/Library/Developer/Xcode/iOS DeviceSupport" # tens of GB; rebuilt per device
rm -rf "$HOME/Library/Developer/Xcode/DerivedData" # rebuilt on next build
xcrun simctl delete unavailable # keeps available sims + their data
brew cleanup --prune=all
```
Never clear `~/Documents`, `~/dev`, `~/models`, or
`~/Library/Developer/Xcode/Archives` (real release artifacts).
If a GateGuard hook blocks `rm -rf`, don't disable the hook — hand the user the
command prefixed with `!` so they run it themselves.
## Signing
The App Store needs an **Apple Distribution** identity. Check:
```bash
security find-identity -v -p codesigning | grep -i distribution
defaults read com.apple.dt.Xcode DVTDeveloperAccountManagerAppleIDLists # empty = no Xcode account
```
An `Apple Development` cert alone is **not** enough. Archiving still succeeds
(development-signed, and `-allowProvisioningUpdates` quietly falls back to it),
but export/upload fails — so check the archive's `SigningIdentity`, don't trust
`ARCHIVE SUCCEEDED`. Fix by signing into Xcode (Settings → Accounts) as Team
Agent/Admin, or let `-allowProvisioningUpdates` mint one once the ASC API key
is supplied.
## Issuer ID
**Recorded above.** It was in `.claude/skills/siftbird-appstore-ship` the whole
time — same key, same team — while this file said it was unavailable and cost
several sessions' worth of dead ends. If a value looks missing, grep the other
skills for the key id before concluding it can't be had.
Historical note on obtaining one: **App Store Connect → Users and Access →
Integrations → App Store Connect API**, shown as "Issuer ID" at the top. It
cannot be derived from the `.p8`. Once you have it, **write it into the table
above** so future releases are unattended.
Don't re-test these — all verified dead ends on 2026-08-11:
- Key `6M245PSNS9` is **team-scoped**, not an individual key. The individual-key
JWT form (omit `iss`, use `sub: "user"`) returns `401 NOT_AUTHORIZED`, so the
issuer really is mandatory.
- Not cached anywhere on this Mac: shell history, `~/.appstoreconnect`,
`~/.fastlane`, `com.apple.dt.Xcode.plist`, the login keychain, `~/.config`.
- No ASC endpoint returns it, and a wrong value just fails auth — never guess.
- Browser rescue re-tested 2026-09-17: `claude-in-chrome` still reports
"extension is not connected", and `chrome-devtools` MCP now runs but on a
blank profile — appstoreconnect.apple.com bounces to `authResult=FAILED`.
Don't retry; run it through CI instead.
## Release runbook
**Use the script** — it encodes every preflight below and fails fast:
```bash
./scripts/release.sh <ASC_ISSUER_UUID>
```
It verifies the ASC key, the iOS 26+ SDK, the distribution identity, and
version consistency across all 8 configs; runs the tests; archives; asserts the
archive is *actually* distribution-signed; then uploads. It stops before
submission on purpose.
The manual equivalent, step by step:
```bash
# 1. Preflight
xcodebuild test -scheme continuum -testPlan continuum \
-destination 'platform=iOS Simulator,name=iPhone 16 Plus' # 93 tests as of 3.8 (incl. 2 screenshot seeds)
# 2. Bump version — 8 occurrences each, app + widget + test targets.
# Widget Info.plist already tracks $(MARKETING_VERSION)/$(CURRENT_PROJECT_VERSION).
sed -i '' 's/MARKETING_VERSION = 3\.4;/MARKETING_VERSION = 3.5;/g; \
s/CURRENT_PROJECT_VERSION = 4;/CURRENT_PROJECT_VERSION = 5;/g' \
continuum.xcodeproj/project.pbxproj
grep -o "MARKETING_VERSION = [0-9.]*;" continuum.xcodeproj/project.pbxproj | sort | uniq -c
# 3. Archive
xcodebuild archive -scheme continuum -configuration Release \
-destination 'generic/platform=iOS' \
-archivePath build/Continuum.xcarchive -allowProvisioningUpdates
# verify what you actually built
/usr/libexec/PlistBuddy -c 'Print :ApplicationProperties:CFBundleShortVersionString' \
-c 'Print :ApplicationProperties:CFBundleVersion' \
-c 'Print :ApplicationProperties:SigningIdentity' build/Continuum.xcarchive/Info.plist
plutil -extract DTSDKName raw build/Continuum.xcarchive/Products/Applications/continuum.app/Info.plist
# 4. Upload
cat > build/exportOptions.plist <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>method</key><string>app-store-connect</string>
<key>teamID</key><string>NVN2NY8GZC</string>
<key>signingStyle</key><string>automatic</string>
<key>destination</key><string>upload</string>
</dict></plist>
PLIST
xcodebuild -exportArchive \
-archivePath build/Continuum.xcarchive \
-exportOptionsPlist build/exportOptions.plist \
-allowProvisioningUpdates \
-authenticationKeyPath ~/.appstoreconnect/private_keys/AuthKey_6M245PSNS9.p8 \
-authenticationKeyID 6M245PSNS9 \
-authenticationKeyIssuerID <ISSUER-UUID>
```
### Submitting for review — scriptable after all
`scripts/asc-submit.mjs` does what the upload stops short of: create the
version, set What's New from `scripts/whats-new.txt`, attach the build, turn on
phased release, submit.
```bash
node scripts/asc-submit.mjs --version 3.5 --build 6 # prepare; prints what it staged
node scripts/asc-submit.mjs --version 3.5 --build 6 --submit # sends it to Apple
```
Prepare is idempotent — run it first and read the output. The build must report
`VALID` (processing finished) or the attach leaves the version buildless and
review bounces it. Used for 3.5 on 2026-09-21 → `WAITING_FOR_REVIEW`.
Sign-off is a *people* gate, not a tooling one: run the script only once Ryan
has said to ship. The gates in
`RELEASE_CHECKLIST.md` are deliberate: upgrade-path test on real data,
TestFlight soak, phased release ON (this app touches the data layer + CloudKit).
## CloudKit schema
**A new `@Model` type does not sync until its record type exists in the
PRODUCTION schema.** Nothing crashes when it's missing — the container falls
back to local-only — so this fails completely silently. It bit hard: the schema
was never deployed at all until 2026-09-21, meaning iCloud sync had never once
worked for a real user since 3.3 shipped it as the headline feature in July.
Check Production first, and don't trust the console UI alone:
```bash
xcrun cktool save-token --type management # CloudKit console → Tokens & Keys
xcrun cktool export-schema --team-id NVN2NY8GZC \
--container-id iCloud.com.orionlabs.continuum --environment production
```
To add a record type without a device or an iCloud sign-in: export the
Development schema, hand-write the `RECORD TYPE` block mirroring an existing
one for field types (SwiftData maps UUID → STRING, Int/Bool → INT64, Date →
TIMESTAMP, `[Date]?` → BYTES, plus `CD_entityName STRING QUERYABLE SEARCHABLE
SORTABLE` and the same GRANTs), then:
```bash
xcrun cktool validate-schema ... --environment development --file new.ckdb
xcrun cktool import-schema ... --environment development --file new.ckdb
```
`import-schema` **refuses Production** ("endpoint not applicable in the
environment 'production'") and cktool has **no deploy/promote command** — the
Development → Production step is console-only, by Apple's design: CloudKit
Console → container → Development → *Deploy Schema Changes…*. A human has to
click it. Verify afterwards with `export-schema --environment production` and
diff it against development.
Production schema changes are **permanent and additive**: you can add a record
type or field, never remove one or change a field's type.
## Running it in the simulator
```bash
SIM=$(xcrun simctl list devices available | grep -m1 "iPhone 16 Plus" | grep -oE "[0-9A-F-]{36}")
open -a Simulator
xcodebuild -scheme continuum -configuration Debug \
-destination "platform=iOS Simulator,id=$SIM" -derivedDataPath /tmp/dd build
xcrun simctl install $SIM /tmp/dd/Build/Products/Debug-iphonesimulator/continuum.app
xcrun simctl launch $SIM orion-labs.continuum
xcrun simctl io $SIM screenshot shot.png
```
**Always look at the screenshot.** A green build proves nothing about layout,
z-order, or fonts — three real bugs in the 3.4 celebration work were invisible
to the compiler and obvious on screen.
### Driving the UI (taps)
`simctl` has no tap command. Use `cliclick` against the Simulator window:
```bash
osascript -e 'tell application "System Events" to tell process "Simulator" \
to get {position, size} of window 1' # e.g. 1174, 50, 484, 1030
```
Device origin = window origin + **27pt bezel**, plus **44pt titlebar** on y.
For a 430x932pt device in a 484x1030 window at (1174,50):
`screen_x = 1201 + pt_x`, `screen_y = 121 + pt_y`, and `pt = screenshot_px / 3`.
```bash
osascript -e 'tell application "Simulator" to activate' # REQUIRED before each batch
sleep 1
cliclick c:1233,196 # tap
cliclick dd:1311,559; sleep 2; cliclick du:1311,559 # hold-to-complete gesture
```
Gotchas: clicks are silently dropped unless the window was just activated, and
`kp:esc` does **not** dismiss a SwiftUI context menu — tap empty space instead.
### Xcode 27: no Simulator.app, use DeviceHub (2026-09-29)
Xcode 27 (installed locally, macOS 26.6) ships `DeviceHub.app` in
`/Applications/Xcode.app/Contents/Applications/` instead of Simulator.app.
Booted simulators run headless — `simctl io … screenshot` and `simctl launch`
work without it — but to tap you open DeviceHub, select the device in its
sidebar, and click the live screen it shows. `xcodebuild` wants the device by
id (`-destination 'platform=iOS Simulator,id=…'`); the name alone was
ambiguous across the iOS 18.6 and 27.0 runtimes.
With the window at (314,175) 1100x800 and the 16 Pro Max at DeviceHub's
default zoom: `screen_x = 838.4 + 0.681 * pt_x`, `screen_y = 253.8 + 0.682 * pt_y`.
Re-measure from a `screencapture` of the window if it moves.
Roughly one click in two is dropped, most often the first after a relaunch or
an `activate`. A tap on empty space first helps, but check every step with a
screenshot. Menus (the card's ⋯, the share sheet) often need the item tapped
twice. The home-screen widget gallery never accepted the "Add Widget" tap;
it's easier to check widget layout another way.
**Prefer launch switches to DeviceHub taps.** The 2026-09-30 screenshots
took no clicks. Temporary env-gated code, reverted after:
`HARNESS_COMPLETE=<habit>` in ContentView's `onAppear` marks the habit done
and calls `checkForMilestones` (the comeback tile, or graduation after
`seedForGraduationScreenshot`); `HARNESS_HOLD=<habit>` in HabitCardView sets
`isAnimatingCompletion`, `completionProgress = 0.5`, `cardScale = 0.97`;
`TileCelebration.duration = 120` keeps a tile up. Launch with
`SIMCTL_CHILD_HARNESS_…=… xcrun simctl launch`, capture with `simctl io`.
Commit the real work first so `git checkout` strips the switches cleanly.
The stats sheet works the same way: present `HabitStatsView` in a `.sheet`
over `ContentView` from a harness in `continuumApp.swift`.
**Widgets without the home screen:** copy `continuumWidget/continuumWidget.swift`
into `continuum/` (no `@main` there, no name clashes) and render
`SmallWidgetView` at 170x170 and `MediumWidgetView` at 364x170, each with
`.padding(16)` for the content margins and `.fontDesign(.monospaced)`. That's
how the medium widget's overflow turned up. Delete the copy after.
**Never `screencapture -R` the DeviceHub rectangle** to check its state: other
apps' windows share that rectangle, and one capture caught a private Messages
thread. `simctl io … screenshot` sees only the simulator.
View on GitHub