diff --git a/docs/superpowers/plans/2026-07-15-healthchat-appstore-release.md b/docs/superpowers/plans/2026-07-15-healthchat-appstore-release.md new file mode 100644 index 000000000..617cf51ee --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-healthchat-appstore-release.md @@ -0,0 +1,1018 @@ +# HealthChat App Store Release Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give element-x-ios (HealthChat) the ability to ship signed builds to the existing HealthChat App Store record, without silently logging out every existing user. + +**Architecture:** Three independent PRs against `release/healthchat`. PR1 fixes a migration defect in product code (the only part with a test surface). PR2 adds a fork-owned fastlane pipeline plus a tag-triggered GitHub workflow on the org's self-hosted macOS runner. PR3 documents the process as a skill. + +**Tech Stack:** XcodeGen, fastlane (Ruby), GitHub Actions self-hosted runner, Swift Testing, App Store Connect API key auth. + +**Spec:** `docs/superpowers/specs/2026-07-15-healthchat-appstore-release-design.md` + +--- + +## Background an engineer needs before touching anything + +- **Both apps share bundle id `ch.healthchat.element` and team `6G37K72W82`.** element-x reaches the App Store as an **in-place update** of the legacy element-ios HealthChat app, not a new listing. The two can never coexist on a device. Upstream's code assumes they can. +- **`ElementX.xcodeproj/project.pbxproj` is generated by `xcodegen` but is tracked in git.** Config changes are made in `project.yml` / `app.yml` / `*/SupportingFiles/target.yml`, then `xcodegen` regenerates the pbxproj, which you commit. +- **XcodeGen include-merge:** later includes override earlier ones for **scalars**; **arrays concatenate**. This is why entitlement arrays are edited in `ElementX/SupportingFiles/target.yml` directly and never in the variant file. +- **This fork's main branch is `release/healthchat`**, not `main`. All PRs target it. +- `Dangerfile.swift:50-51` **fails** any PR that does not carry exactly one `pr-` label. + +## File Structure + +| File | Responsibility | PR | +|---|---|---| +| `Variants/HealthChat/healthchat.yml` | HealthChat identity scalars — gains the 3 `CLASSIC_APP_*` overrides | 1 | +| `ElementX/SupportingFiles/target.yml` | Entitlement arrays — regains the classic keychain group | 1 | +| `ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift` | Start screen logic — `.requiresBackup` redirect | 1 | +| `UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift` | Encodes the redirect behaviour | 1 | +| `fastlane/Fastfile` | `before_all` + `release_testflight` + private build/signing lanes | 2 | +| `fastlane/Appfile`, `fastlane/.env.default`, `fastlane/Pluginfile` | fastlane configuration | 2 | +| `Gemfile`, `Gemfile.lock`, `Brewfile` | Toolchain pinning | 2 | +| `HEALTHCHAT_CHANGES.md` | Release-notes source the workflow greps | 2 | +| `.github/workflows/release-healthchat.yml` | Tag-triggered build + TestFlight upload | 2 | +| `.gitignore` | Excludes `out/` and `provisioning_profiles/` | 2 | +| `.agents/skills/releasing-healthchat/SKILL.md` | The runbook | 3 | +| `AGENTS.md` | 2-line delegation to the skill | 3 | + +--- + +# PR1 — Classic migration fix (`pr-bugfix`) + +### Task 1: Repoint `CLASSIC_APP_*` at HealthChat's own identifiers + +**Why:** `app.yml:11-13` points the classic-migration ids at Element's `group.im.vector` / `im.vector.app.*`. Those are not provisionable by team `6G37K72W82`, so `loadAccounts()` throws `invalidAppGroupIdentifier` — which `AuthenticationService.swift:59-63` catches and logs without telling anyone. Every updating user is silently logged out. The plumbing already ships (`target.yml:78-81` maps these into `Info.plist`; `InfoPlistReader.swift:28-31,129-143` reads them); only the values are wrong. + +**Files:** +- Modify: `Variants/HealthChat/healthchat.yml` +- Modify: `ElementX/SupportingFiles/target.yml` +- Modify: `ElementX.xcodeproj/project.pbxproj` (generated) +- Modify: `ElementX/SupportingFiles/ElementX.entitlements` (generated) + +- [ ] **Step 1: Create the branch** + +```bash +git checkout release/healthchat +git pull +git checkout -b healthchat/classic-migration-fix +``` + +- [ ] **Step 2: Add the three scalar overrides** + +In `Variants/HealthChat/healthchat.yml`, add to the `settings:` block, below `DEVELOPMENT_TEAM`: + +```yaml + # The legacy element-ios HealthChat's identifiers. ClassicAppManager reads the account and + # crypto-store keys from these to migrate existing users on an in-place update. Upstream's + # defaults in app.yml point at Element's own (group.im.vector / im.vector.app.*), which this + # team cannot provision — repoint, never remove. Failure here is silent. + CLASSIC_APP_GROUP_IDENTIFIER: group.ch.healthchat + CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER: ch.healthchat.element.encryption-manager-service + CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER: "$(DEVELOPMENT_TEAM).ch.healthchat.element.keychain.shared" +``` + +These values are not guesses. `element-ios/Riot/Managers/EncryptionKeyManager/EncryptionKeyManager.swift:17,27` builds its keychain service as `BuildSettings.baseBundleIdentifier + ".encryption-manager-service"` in access group `BuildSettings.keychainAccessGroup`, which `element-ios/Config/Project.xcconfig:25` defines as `$(AppIdentifierPrefix)$(BASE_BUNDLE_IDENTIFIER).keychain.shared`. `$(AppIdentifierPrefix)` carries its own trailing dot, so it resolves to `6G37K72W82.ch.healthchat.element.keychain.shared` — byte-identical to what `$(DEVELOPMENT_TEAM).ch.healthchat.element.keychain.shared` produces. + +- [ ] **Step 3: Restore the classic keychain group to the entitlements array** + +In `ElementX/SupportingFiles/target.yml`, replace the `keychain-access-groups` block (around `:130-133`): + +```yaml + keychain-access-groups: + - $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER) + # HealthChat: the legacy app's keychain group. ClassicAppManager reads the account and + # crypto-store keys from here to migrate existing users. Same team prefix, so it is + # provisionable — unlike Element's im.vector.app.* default in app.yml. + - $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER) +``` + +- [ ] **Step 4: Correct the misleading app-groups comment** + +In the same file, replace the comment above `com.apple.security.application-groups` (around `:128`): + +```yaml + com.apple.security.application-groups: + # HealthChat: dropped $(CLASSIC_APP_GROUP_IDENTIFIER) — not because it is un-provisionable, + # but because it resolves to group.ch.healthchat, the same value as $(APP_GROUP_IDENTIFIER) + # below. The legacy container is already entitled; listing it twice would duplicate the entry. + - $(APP_GROUP_IDENTIFIER) +``` + +- [ ] **Step 5: Regenerate the project** + +Run: `xcodegen` +Expected: `Created project at ElementX.xcodeproj` + +- [ ] **Step 6: Verify the values actually landed** + +Run: +```bash +grep -c "CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER\|ch.healthchat.element.keychain.shared" ElementX/SupportingFiles/ElementX.entitlements +grep -n "CLASSIC_APP_GROUP_IDENTIFIER = " ElementX.xcodeproj/project.pbxproj | head -2 +``` +Expected: the entitlements file mentions the classic keychain group at least once, and the pbxproj shows `CLASSIC_APP_GROUP_IDENTIFIER = group.ch.healthchat;` — **not** `group.im.vector`. If it shows `group.im.vector`, the variant override did not win and everything downstream is void; stop and investigate `project.yml`'s include order (`healthchat.yml` must come after `app.yml`). + +- [ ] **Step 7: Commit** + +```bash +git add Variants/HealthChat/healthchat.yml ElementX/SupportingFiles/target.yml ElementX.xcodeproj/project.pbxproj ElementX/SupportingFiles/ElementX.entitlements +git commit -m "Point the classic app identifiers at HealthChat rather than Element + +ClassicAppManager migrates an existing user's session and encryption keys by +reading the legacy app's app-group container and keychain. Those identifiers +defaulted to Element's own, which this team cannot provision, so loadAccounts() +threw invalidAppGroupIdentifier — caught and logged, never surfaced. Since both +apps share a bundle id, the result would be every existing user silently logged +out and losing local history on update. + +The legacy ids belong to HealthChat and are provisionable under the same team, +so repoint them instead of dropping them." +``` + +--- + +### Task 2: Redirect `.requiresBackup` to a normal sign-in + +**Why:** With Task 1 in place, `loadAccounts()` now succeeds — which switches on a UI branch that is a dead end for HealthChat. `login(classicAppAccount:)` routes accounts whose secrets are `.requiresBackup` to a sheet telling the user to open Element Classic and set up key backup. Its only button fires `.openClassicApp` → `appMediator.open(element://open)`. element-x registers only `ch.healthchat.element` and `matrix` (`ElementX/SupportingFiles/target.yml:60-63`), and the legacy app that registered `element` was **replaced in place** by this very update. The button opens nothing. + +This is the **first edit to an upstream logic file** in this fork. Keep it minimal and comment it, so a future rebase does not silently revert it. + +**Files:** +- Modify: `ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift:114-128` +- Test: `UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift:240-272` + +- [ ] **Step 1: Rewrite the failing test** + +An upstream test already encodes the behaviour we are changing. Replace `classicAppAccountRequiresBackup` (`:240-272`) **entirely** with: + +```swift + @Test + func classicAppAccountRequiresBackupFallsThroughToLogin() async throws { + // Given a view model with a Classic app account that has no usable key backup. + let classicAppAccount = makeClassicAppAccount() + await setupViewModel(classicAppAccount: classicAppAccount, availableSecrets: .requiresBackup) + guard case .welcomeBack(let account) = context.viewState.classicAppMode else { + Issue.record("Expected classicAppMode to be .welcomeBack") + return + } + #expect(account.state.availableSecrets == .requiresBackup) + + // When continuing with the Classic account. + let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOAuth } + context.send(viewAction: .continueWithClassic(classicAppAccount)) + + // Then the flow continues to a normal sign-in rather than the backup instructions sheet. + // HealthChat replaces the Classic app in place, so those instructions could never be followed. + try await deferred.fulfill() + #expect(context.viewState.bindings.showClassicAppBackupInstructions == false) + #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == "mxid:\(classicAppAccount.userID)") + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `swift run tools ci unit-tests` +Expected: FAIL. `classicAppAccountRequiresBackupFallsThroughToLogin` times out waiting for `isLoginDirectlyWithOAuth`, because the view model sets `showClassicAppBackupInstructions = true` instead of continuing. + +- [ ] **Step 3: Make the change** + +In `AuthenticationStartScreenViewModel.swift`, replace `login(classicAppAccount:)` (`:114-128`): + +```swift + private func login(classicAppAccount: ClassicAppAccount? = nil) async { + if let classicAppAccount { + // HealthChat diverges from upstream here. Upstream sends `.requiresBackup` accounts to a sheet + // asking the user to set up key backup in Element Classic first. HealthChat ships with the same + // bundle id as the legacy app, so this update *replaces* it — there is no Classic app left to + // open, and the sheet's only button resolves to nothing. Fall through to a normal sign-in + // instead: the user re-verifies and loses only history that by definition has no backup. + await configureAccountProvider(classicAppAccount.serverName, + loginHint: "mxid:\(classicAppAccount.userID)", + fallbackHomeserverURL: classicAppAccount.homeserverURL) + } else if let serverName = state.serverName { + await configureAccountProvider(serverName, loginHint: provisioningParameters?.loginHint) + } else { + actionsSubject.send(.login) // No need to configure anything here, continue the flow. + } + } +``` + +Leave `showClassicAppBackupInstructions`, the sheet view, and `reloadClassicAppAccount()`'s dismissal logic in place. They become unreachable, but deleting them widens the rebase surface for no behavioural gain. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `swift run tools ci unit-tests` +Expected: PASS — `classicAppAccountRequiresBackupFallsThroughToLogin` plus every other test in the file (`classicAppAccount`, `classicAppAccountWithoutWellKnown`, `singleProviderWithMatchingClassicAppAccount`, and the rest) still green. + +- [ ] **Step 5: Verify formatting and lint are clean** + +Run: `swiftformat --lint . && swiftlint lint --quiet ElementX/Sources/Screens/Authentication` +Expected: `0/… files require formatting`, and no new SwiftLint warnings. + +- [ ] **Step 6: Commit** + +```bash +git add ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift +git commit -m "Sign in directly when a classic account has no key backup + +Upstream sends these accounts to a sheet asking the user to set up key backup in +Element Classic and come back. HealthChat ships under the same bundle id as the +legacy app, so the update replaces it — the app the sheet points at no longer +exists and its button opens a URL scheme nothing registers. + +Fall through to a normal sign-in instead. The user re-verifies the device and +loses only history that has no backup by definition." +``` + +--- + +### Task 3: Verify migration on a physical device + +**Why:** This behaviour cannot be proven by the test suite. It only exists on a real device with a real legacy install. + +- [ ] **Step 1: Understand the false-negative trap before you start** + +`AuthenticationService.swift:49-52` skips `loadAccounts()` entirely when `appSettings.hasSignedInBefore`. That flag lives in the `group.ch.healthchat` UserDefaults suite (`AppSettings.swift:56`), which **survives app deletion** and is never cleared. Any device that has previously run *any* element-x HealthChat build will show a plain login screen **while the fix works perfectly**. Use a device that has never run element-x, or clear that suite first. + +- [ ] **Step 2: Establish the legacy state** + +Install the shipping legacy HealthChat (App Store or TestFlight) on a physical device. Sign in. Send an encrypted message so the crypto store is populated. Note the account's MXID. + +- [ ] **Step 3: Confirm the D2 precondition** + +Check the MXID from Step 2 is `@user:healthchat.ch`, not `@user:matrix.healthchat.ch`. + +`AuthenticationStartScreenViewModel.swift:46` gates the whole classic flow on `appSettings.accountProviders.contains(serverName)`, where `serverName` is the **MXID domain** (`ClassicAppAccountManager.swift:107-111`) and HealthChat pins `accountProviders: ["healthchat.ch"]`. `curl https://healthchat.ch/.well-known/matrix/server` returns `{"m.server": "matrix.healthchat.ch:8448"}` — delegation, meaning the server is *named* `healthchat.ch` — so this should hold. Confirm rather than assume: if MXIDs carry `matrix.healthchat.ch`, the gate fails and Task 1 buys nothing. + +- [ ] **Step 4: Install over the top** + +Build the branch and install it **without deleting the legacy app** — same bundle id, so this is an in-place update. Deleting first destroys the keychain and container and invalidates the whole test. + +- [ ] **Step 5: Observe** + +Expected: the app finds the existing account and signs in without a manual login. +Watch the log for `ClassicAppManagerError` and for `"skipping loadAccounts"` — the latter means Step 1's trap caught you and the run is **void, not failed**. + +- [ ] **Step 6: Exercise the `.requiresBackup` path if you can reach it** + +An account without key backup must fall through to sign-in and **never** show the backup-instructions sheet. + +- [ ] **Step 7: Record the O1 answer in the spec** + +Whether the legacy crypto store was Rust-backed is unresolved (spec O1). Step 5's outcome answers it. If migration fails with a crypto-store error, D2 collapses to "forced re-login" and needs product sign-off before any submission. Update the spec's O1 row with the finding either way. + +- [ ] **Step 8: Open PR1** + +```bash +git push -u origin healthchat/classic-migration-fix +gh pr create --base release/healthchat --label pr-bugfix \ + --title "Point the classic app identifiers at HealthChat rather than Element" +``` +Leave the description template for the developer. Exactly one `pr-` label or `Dangerfile.swift:50-51` fails the PR. + +--- + +# PR2 — Release pipeline (`pr-build`) + +### Task 4: Add the toolchain files + +**Files:** +- Create: `Gemfile`, `Brewfile`, `fastlane/Pluginfile`, `fastlane/Appfile` +- Modify: `.gitignore` + +- [ ] **Step 1: Branch** + +```bash +git checkout release/healthchat && git pull +git checkout -b healthchat/release-pipeline +``` + +- [ ] **Step 2: Create `Gemfile`** + +```ruby +source "https://rubygems.org" + +gem "fastlane" + +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) +``` + +No cocoapods (element-x is SPM) and no slather (coverage runs through `swift run tools ci`). + +- [ ] **Step 3: Create `fastlane/Pluginfile`** + +```ruby +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +gem 'fastlane-plugin-xcodegen' +``` + +Only one plugin. The legacy also carries `fastlane-plugin-versioning`, but nothing in its Fastfile calls it — its version lanes are raw `File.read`/`gsub!`. Do not copy dead weight forward. + +- [ ] **Step 4: Create `Brewfile`** + +```ruby +brew "xcodesorg/made/xcodes" +``` + +`xcodegen` is deliberately **absent**: it is installed on the runner once by hand, so `brew bundle` cannot churn it on every run. The legacy documents the same convention. + +- [ ] **Step 5: Create `fastlane/Appfile`** + +```ruby +# For more information about the Appfile, see: +# https://docs.fastlane.tools/advanced/#appfile + +apple_id ENV["APPLE_ID"] +team_id ENV["TEAM_ID"] +``` + +`APPLE_ID` is a machine-level shell variable on the runner, not a repo secret. + +- [ ] **Step 6: Extend `.gitignore`** + +Append: + +``` +# fastlane release outputs +out/ +provisioning_profiles/ +``` + +`build` (`.gitignore:35`) and `fastlane/report.xml` (`:28`) are already covered upstream. `provisioning_profiles/` matters most: it holds downloaded signing material and must never be committed. + +- [ ] **Step 7: Resolve gems and commit the lock** + +Run: `bundle install` +Expected: creates `Gemfile.lock`. + +Commit the lock — the workflow's gem cache keys on `hashFiles('**/Gemfile.lock')`. Without it the key never changes, fastlane silently re-resolves, and a breaking fastlane release can change the pipeline between two runs of the same tag. + +- [ ] **Step 8: Commit** + +```bash +git add Gemfile Gemfile.lock Brewfile fastlane/Pluginfile fastlane/Appfile .gitignore +git commit -m "Add the fastlane toolchain for HealthChat releases" +``` + +--- + +### Task 5: Add `fastlane/.env.default` + +**Files:** +- Create: `fastlane/.env.default` + +- [ ] **Step 1: Write the file** + +``` +## Project configuration + +PROJECT_PATH=ElementX.xcodeproj +SCHEME=ElementX + +MAIN_TARGET=ElementX +NSE_TARGET=NSE +SHARE_EXTENSION_TARGET=ShareExtension + +MAIN_BUNDLE_ID=ch.healthchat.element +NSE_BUNDLE_ID=ch.healthchat.element.nse +SHARE_EXTENSION_BUNDLE_ID=ch.healthchat.element.shareextension + +## Build configuration + +DERIVED_DATA_PATH=build +IPA_NAME=ElementX +ARCHIVE_PATH=out/ElementX.xcarchive +BUILD_OUTPUT_DIRECTORY=out +BUILD_LOG_DIRECTORY=build/Logs/Gym/ + +## Provisioning profile files + +PROVISIONING_PROFILES_PATH=./provisioning_profiles/ + +MAIN_PROVISIONING_PROFILE_FILENAME=main.mobileprovision +NSE_PROVISIONING_PROFILE_FILENAME=nse.mobileprovision +SHARE_EXTENSION_PROVISIONING_PROFILE_FILENAME=share_extension.mobileprovision + +## App Store code signing + +APPSTORE_CODESIGNING_IDENTITY="Apple Distribution: E-HealthSolutions GmbH (6G37K72W82)" +APPSTORE_SIGNING_CERTIFICATE="Apple Distribution: E-HealthSolutions GmbH (6G37K72W82)" + +APPSTORE_MAIN_PROVISIONING_PROFILE_SPECIFIER="HealthChat App Store" +APPSTORE_NSE_PROVISIONING_PROFILE_SPECIFIER="HealthChat NSE: App Store" +APPSTORE_SHARE_EXTENSION_PROVISIONING_PROFILE_SPECIFIER="HealthChat Share Extension: App Store" + +## Account information + +TEAM_ID=6G37K72W82 +# APPLE_ID is set as a shell environment variable on the machine running fastlane. +``` + +Bundle ids are derived, not guessed: `NSE/SupportingFiles/target.yml:75` and `ShareExtension/SupportingFiles/target.yml:77` set `${BASE_BUNDLE_IDENTIFIER}.nse` / `.shareextension`, and `Variants/HealthChat/healthchat.yml:12` sets `BASE_BUNDLE_IDENTIFIER: ch.healthchat.element`. + +`APPSTORE_CODESIGNING_IDENTITY` and `APPSTORE_SIGNING_CERTIFICATE` are two distinct keys — `build_app` consumes them in different places. Both are required. + +- [ ] **Step 2: Commit** + +```bash +git add fastlane/.env.default +git commit -m "Add fastlane configuration for the HealthChat App Store build" +``` + +--- + +### Task 6: Add `fastlane/Fastfile` + +**Files:** +- Create: `fastlane/Fastfile` + +- [ ] **Step 1: Write the file** + +```ruby +default_platform :ios + +platform :ios do + #### Pre #### + + before_all do + # 26.4 is what novaloop-mac-studio-001 carries and what the legacy pipeline builds with. + # Bumping this requires installing the new version on the runner FIRST — otherwise xcodes + # attempts a multi-GB authenticated download, which no CI secret can satisfy, and hangs here. + xcodes(version: "26.4", select_for_current_build_only: true) + end + + #### Public #### + + desc "Builds the App Store ipa and uploads it to TestFlight (internal testers only)" + # Named release_testflight to avoid colliding with fastlane's built-in :testflight action. + lane :release_testflight do |options| + # Accept inputs via options (local) or env vars (CI — lets the workflow pass multi-line + # release notes without shell-escaping them). + marketing_version = options[:marketing_version] || ENV["HEALTHCHAT_MARKETING_VERSION"] + release_notes = options[:release_notes] || ENV["HEALTHCHAT_RELEASE_NOTES"] + + UI.user_error!("'marketing_version' is required (pass the option or set HEALTHCHAT_MARKETING_VERSION)") if marketing_version.to_s.empty? + + # Apple caps CFBundleShortVersionString at three period-separated integers. The workflow's + # regex is laxer and accepts markers like 26.07.3-rc1, so catch it here — otherwise the run + # dies at the upload step after ~30 minutes of signing, looking exactly like a signing failure. + unless marketing_version =~ /\A\d+(\.\d+){0,2}\z/ + UI.user_error!("'marketing_version' must be 1-3 dot-separated integers (got '#{marketing_version}'). " \ + "Apple rejects anything else at upload. Put markers in the tag or release notes instead.") + end + + build_number = options[:build_number] || generate_build_number + + build_release(marketing_version: marketing_version, build_number: build_number) + + upload_to_testflight( + ipa: lane_context[SharedValues::IPA_OUTPUT_PATH], + skip_submission: true, + skip_waiting_for_build_processing: true, + changelog: release_notes, + distribute_external: false, + ) + end + + #### Private #### + + desc "Builds the signed App Store ipa" + private_lane :build_release do |options| + marketing_version = options[:marketing_version] + build_number = options[:build_number] + UI.user_error!("'build_number' parameter is missing") if build_number.to_s.empty? + + if is_ci + %w[APPSTORECONNECT_KEY_ID APPSTORECONNECT_KEY_ISSUER_ID APPSTORECONNECT_KEY_CONTENT].each do |key| + UI.user_error!("'#{key}' environment variable should be set to use this lane") if ENV[key].to_s.empty? + end + + app_store_connect_api_key( + key_id: ENV["APPSTORECONNECT_KEY_ID"], + issuer_id: ENV["APPSTORECONNECT_KEY_ISSUER_ID"], + key_content: ENV["APPSTORECONNECT_KEY_CONTENT"] + ) + else + UI.user_error!("'APPLE_ID' environment variable should be set to use this lane") if ENV["APPLE_ID"].to_s.empty? + end + + team_id = CredentialsManager::AppfileConfig.try_fetch_value(:team_id) + UI.user_error!("Failed to fetch the team id from the Appfile") if team_id.to_s.empty? + + # Inject the version at build time rather than rewriting project.yml, so no tracked file ever + # carries a release-only value. All three targets resolve CFBundleShortVersionString and + # CFBundleVersion from these settings, which keeps the app and its extensions consistent — + # App Store rejects an upload where they disagree. + xcargs_hash = { + "MARKETING_VERSION" => marketing_version, + "CURRENT_PROJECT_VERSION" => build_number, + "CODE_SIGN_STYLE" => "Manual", + } + xcargs = xcargs_hash.map { |k, v| "#{k}=#{v.to_s.shellescape}" }.join(" ") + + xcodegen(spec: "project.yml") + clear_derived_data(derived_data_path: ENV["DERIVED_DATA_PATH"]) + + download_provisioning_profiles + disable_automatic_code_signing(path: ENV["PROJECT_PATH"]) + update_project_provisioning_profiles + + build_app( + clean: true, + scheme: ENV["SCHEME"], + xcargs: xcargs, + export_method: "app-store", + derived_data_path: ENV["DERIVED_DATA_PATH"], + archive_path: ENV["ARCHIVE_PATH"], + output_directory: ENV["BUILD_OUTPUT_DIRECTORY"], + output_name: "#{ENV['IPA_NAME']}.ipa", + buildlog_path: ENV["BUILD_LOG_DIRECTORY"], + codesigning_identity: ENV["APPSTORE_CODESIGNING_IDENTITY"], + skip_profile_detection: true, + export_options: { + method: "app-store", + signingStyle: "manual", + teamID: team_id, + signingCertificate: ENV["APPSTORE_SIGNING_CERTIFICATE"], + provisioningProfiles: { + ENV["MAIN_BUNDLE_ID"] => ENV["APPSTORE_MAIN_PROVISIONING_PROFILE_SPECIFIER"], + ENV["NSE_BUNDLE_ID"] => ENV["APPSTORE_NSE_PROVISIONING_PROFILE_SPECIFIER"], + ENV["SHARE_EXTENSION_BUNDLE_ID"] => ENV["APPSTORE_SHARE_EXTENSION_PROVISIONING_PROFILE_SPECIFIER"], + }, + }, + ) + end + + desc "Downloads the three App Store provisioning profiles" + private_lane :download_provisioning_profiles do + { + ENV["MAIN_BUNDLE_ID"] => [ENV["APPSTORE_MAIN_PROVISIONING_PROFILE_SPECIFIER"], ENV["MAIN_PROVISIONING_PROFILE_FILENAME"]], + ENV["NSE_BUNDLE_ID"] => [ENV["APPSTORE_NSE_PROVISIONING_PROFILE_SPECIFIER"], ENV["NSE_PROVISIONING_PROFILE_FILENAME"]], + ENV["SHARE_EXTENSION_BUNDLE_ID"] => [ENV["APPSTORE_SHARE_EXTENSION_PROVISIONING_PROFILE_SPECIFIER"], ENV["SHARE_EXTENSION_PROVISIONING_PROFILE_FILENAME"]], + }.each do |bundle_id, (profile_name, filename)| + get_provisioning_profile( + app_identifier: bundle_id, + provisioning_name: profile_name, + ignore_profiles_with_different_name: true, + adhoc: false, + skip_certificate_verification: false, + output_path: ENV["PROVISIONING_PROFILES_PATH"], + filename: filename, + readonly: true, + ) + end + end + + desc "Points each target at its downloaded provisioning profile" + private_lane :update_project_provisioning_profiles do + { + ENV["MAIN_TARGET"] => ENV["MAIN_PROVISIONING_PROFILE_FILENAME"], + ENV["NSE_TARGET"] => ENV["NSE_PROVISIONING_PROFILE_FILENAME"], + ENV["SHARE_EXTENSION_TARGET"] => ENV["SHARE_EXTENSION_PROVISIONING_PROFILE_FILENAME"], + }.each do |target, filename| + update_project_provisioning( + xcodeproj: ENV["PROJECT_PATH"], + profile: "#{ENV['PROVISIONING_PROFILES_PATH']}#{filename}", + # Anchored: target_filter is a regex, and an unanchored "NSE" or "ElementX" would also + # match any future target containing that substring. + target_filter: "^#{target}$", + build_configuration: "Release", + ) + end + end +end + +# Generates a build number from the current timestamp. Monotonic across runs, so a retry never +# collides with a build number App Store Connect has already seen. +def generate_build_number + require 'date' + DateTime.now.strftime("%Y%m%d%H%M%S") +end +``` + +- [ ] **Step 2: Verify the lanes parse and are discoverable** + +Run: `bundle exec fastlane lanes` +Expected: lists `ios release_testflight` with its description. A Ruby syntax error surfaces here rather than 40 minutes into a CI run. + +- [ ] **Step 3: Verify the version guard rejects a marker version** + +Run: `bundle exec fastlane release_testflight marketing_version:26.07.3-rc1` +Expected: fails immediately with `'marketing_version' must be 1-3 dot-separated integers`. This is the guard doing its job — it must fail *before* `xcodes` runs. + +- [ ] **Step 4: Commit** + +```bash +git add fastlane/Fastfile +git commit -m "Add the fastlane lane that builds and uploads HealthChat to TestFlight" +``` + +--- + +### Task 7: Create `HEALTHCHAT_CHANGES.md` + +**Why:** The workflow greps this file for release notes. It does **not** exist in this repo — the legacy's copy lives in the other fork. Under `set -euo pipefail` an absent file aborts the job; the "warning fallback" only covers an *empty match*, not a missing file. `CHANGES.md` cannot be used: upstream's `release-to-github` owns it. + +**Files:** +- Create: `HEALTHCHAT_CHANGES.md` + +- [ ] **Step 1: Write the file** + +```markdown +# HealthChat Changes + +Release notes for HealthChat builds of Element X. Each release adds a section at the +top; the release workflow extracts the one matching the version being released and +uses it as the TestFlight "What to Test" text. + +The heading format is load-bearing — `.github/workflows/release-healthchat.yml` +matches `## Changes in ` exactly. + +Upstream's `CHANGES.md` tracks element-hq's own releases and must not be edited here. + +## Changes in 26.07.3 (2026-07-15) + +🙌 Improvements + +- First HealthChat release built from Element X. +``` + +- [ ] **Step 2: Verify the workflow's extractor finds the section** + +Run: +```bash +awk -v ver="26.07.3" ' + /^## Changes in / { + if (found) { exit } + if ($0 ~ "## Changes in " ver "( |$|\\()") { found = 1; next } + } + found { print } +' HEALTHCHAT_CHANGES.md +``` +Expected: prints the `🙌 Improvements` block. Empty output means the heading does not match and a placeholder would ship as the release notes. + +- [ ] **Step 3: Commit** + +```bash +git add HEALTHCHAT_CHANGES.md +git commit -m "Add a HealthChat changelog for release notes" +``` + +--- + +### Task 8: Add the release workflow + +**Files:** +- Create: `.github/workflows/release-healthchat.yml` + +- [ ] **Step 1: Write the file** + +```yaml +name: Release HealthChat + +on: + push: + tags: + - 'healthchat-v*' + workflow_dispatch: + inputs: + version: + description: 'Marketing version (e.g. 26.7.4). Must be 1-3 dot-separated integers — Apple rejects anything else.' + required: true + type: string + release_notes_override: + description: 'Optional release notes. If empty, falls back to the HEALTHCHAT_CHANGES.md section for the given version.' + required: false + type: string + +permissions: + # Minimum needed for actions/checkout to clone an internal repo. The workflow does not push, + # comment, or create releases — everything else authenticates with the ASC key and .p12. + contents: read + +concurrency: + group: release-healthchat-${{ github.event.inputs.version || github.ref_name }} + cancel-in-progress: false + +jobs: + release: + name: Build & upload to TestFlight + runs-on: [self-hosted, macOS] + timeout-minutes: 90 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # This repo's only submodule is element-hq's private Enterprise repo, which Novaloop + # cannot clone. Checking out submodules fails the job on its very first step. + submodules: false + persist-credentials: false + + - name: Resolve marketing version + id: version + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + VERSION="${{ github.event.inputs.version }}" + else + VERSION="${GITHUB_REF_NAME#healthchat-v}" + fi + # Apple caps CFBundleShortVersionString at three integers. The Fastfile guards this too, + # but failing here costs seconds instead of a signing run. + if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then + echo "::error::Invalid marketing version '$VERSION' — expected 1-3 dot-separated integers, e.g. 26.7.4" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved marketing version: $VERSION" + + - name: Extract release notes + id: notes + env: + VERSION: ${{ steps.version.outputs.version }} + OVERRIDE: ${{ github.event.inputs.release_notes_override }} + run: | + set -euo pipefail + if [[ -n "$OVERRIDE" ]]; then + NOTES="$OVERRIDE" + echo "Using release notes from the workflow input" + elif [[ ! -f HEALTHCHAT_CHANGES.md ]]; then + echo "::error::HEALTHCHAT_CHANGES.md is missing — it is the release-notes source" + exit 1 + else + NOTES=$(awk -v ver="$VERSION" ' + /^## Changes in / { + if (found) { exit } + if ($0 ~ "## Changes in " ver "( |$|\\()") { found = 1; next } + } + found { print } + ' HEALTHCHAT_CHANGES.md | sed '/^[[:space:]]*$/{N;/^\n$/D;}') + if [[ -z "$NOTES" ]]; then + NOTES="HealthChat ${VERSION} — automated build from ${GITHUB_REF_NAME}." + echo "::warning::No HEALTHCHAT_CHANGES.md section found for $VERSION — using fallback" + fi + fi + { + echo 'notes<> "$GITHUB_OUTPUT" + + - name: Cache Ruby gems + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + path: vendor/bundle + key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-gems- + + - name: Brew bundle + run: brew bundle + + - name: Bundle install + run: | + bundle config path vendor/bundle + bundle install --jobs 4 --retry 3 + + - name: Import signing certificate + uses: apple-actions/import-codesign-certs@b610f78488812c1e56b20e6df63ec42d833f2d14 # v6.0.0 + with: + p12-file-base64: ${{ secrets.HEALTHCHAT_CERTIFICATES_P12 }} + p12-password: ${{ secrets.HEALTHCHAT_CERTIFICATES_P12_PASSWORD }} + + - name: Build & upload to TestFlight + run: bundle exec fastlane release_testflight + env: + # Passed via env rather than lane options so multi-line notes containing quotes, + # $, or backticks survive without shell escaping. + HEALTHCHAT_MARKETING_VERSION: ${{ steps.version.outputs.version }} + HEALTHCHAT_RELEASE_NOTES: ${{ steps.notes.outputs.notes }} + APPSTORECONNECT_KEY_ID: ${{ secrets.HEALTHCHAT_ASC_KEY_ID }} + APPSTORECONNECT_KEY_ISSUER_ID: ${{ secrets.HEALTHCHAT_ASC_KEY_ISSUER_ID }} + APPSTORECONNECT_KEY_CONTENT: ${{ secrets.HEALTHCHAT_ASC_KEY_CONTENT }} + TEAM_ID: '6G37K72W82' + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: healthchat-${{ steps.version.outputs.version }} + path: | + out/ElementX.ipa + out/ElementX.app.dSYM.zip + build/Logs/Gym/*.log + retention-days: 90 + if-no-files-found: warn +``` + +`import-codesign-certs` is given no `keychain-password`. The legacy workflow passes `secrets.HEALTHCHAT_KEYCHAIN_PASSWORD`, but that secret exists at neither repo nor org level and the legacy pipeline runs green regardless — the action tolerates its absence. Five secrets is the real requirement; do not invent a sixth. + +- [ ] **Step 2: Verify the workflow parses** + +Run: `gh workflow list --repo Novaloop-AG/element-x-ios` after pushing the branch, or lint locally with `actionlint .github/workflows/release-healthchat.yml` if installed. +Expected: no syntax errors. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/release-healthchat.yml +git commit -m "Add the tag-triggered HealthChat TestFlight release workflow" +``` + +--- + +### Task 9: Provision the Apple and GitHub prerequisites + +**Why:** Manual, one-time, and blocking. No signed archive is possible until every item is done. None of this is code. + +- [ ] **Step 1: Create the ShareExtension App ID and profile** + +Apple Developer portal → Identifiers → **+** → App ID `ch.healthchat.element.shareextension` (all lowercase). Enable **App Groups** and **Keychain Sharing**. Then Profiles → **+** → App Store → name it exactly `HealthChat Share Extension: App Store`. + +This is genuinely new. The legacy provisioned `ch.healthchat.element.shareExtension` — **camelCase, a different string to Apple** — and disabled the target anyway (`element-ios/fastlane/.env.default`: `SHARE_EXTENSION_TARGET=`). element-x embeds ShareExtension unconditionally. + +- [ ] **Step 2: Add the communication-notifications capability to the main App ID** + +App ID `ch.healthchat.element` → enable `com.apple.developer.usernotifications.communication` → regenerate the `HealthChat App Store` profile. New vs. the legacy, whose `Riot.entitlements` lacks it. + +- [ ] **Step 3: Add the notification-filtering capability to the NSE App ID** + +App ID `ch.healthchat.element.nse` → enable `com.apple.developer.usernotifications.filtering` → regenerate `HealthChat NSE: App Store`. New vs. the legacy, whose `RiotNSE.entitlements` has only app-groups and keychain. + +Leave the orphaned `HealthChat Broadcast Upload Extension: App Store` profile alone — the legacy pipeline still uses it. + +- [ ] **Step 4: Copy the five secrets** + +Requires the `snowping` GitHub account: `Novaloop-AG/element-x-ios` is INTERNAL and invisible to `joel-poyet_GALGH`. + +```bash +gh auth switch --user snowping +gh secret list --repo Novaloop-AG/element-ios # the source; values cannot be read back +``` + +Secret **values** cannot be exported from GitHub. Set each from its original source (the `.p8`, the `.p12`, App Store Connect): + +```bash +gh secret set HEALTHCHAT_ASC_KEY_ID --repo Novaloop-AG/element-x-ios +gh secret set HEALTHCHAT_ASC_KEY_ISSUER_ID --repo Novaloop-AG/element-x-ios +gh secret set HEALTHCHAT_ASC_KEY_CONTENT --repo Novaloop-AG/element-x-ios +gh secret set HEALTHCHAT_CERTIFICATES_P12 --repo Novaloop-AG/element-x-ios +gh secret set HEALTHCHAT_CERTIFICATES_P12_PASSWORD --repo Novaloop-AG/element-x-ios +``` + +Then restore the previous account: `gh auth switch --user joel-poyet_GALGH`. + +- [ ] **Step 5: Confirm the runner is reachable and equipped (spec O4)** + +`novaloop-mac-studio-001` is an **org-level** runner (`[self-hosted, macOS, ARM64]`, online). Confirm the org runner group's visibility includes element-x-ios — two `Default` groups exist, one `all` and one `selected`. + +On the runner itself, confirm: `xcodegen` on PATH (`brew install xcodegen`, deliberately not in `Brewfile`), Xcode **26.4** present and selectable via `xcodes`, Homebrew, Ruby + bundler, and `APPLE_ID` exported as a machine-level shell variable. + +--- + +### Task 10: Verify the pipeline end to end + +- [ ] **Step 1: Push the branch and open PR2** + +```bash +git push -u origin healthchat/release-pipeline +gh pr create --base release/healthchat --label pr-build \ + --title "Add a fastlane pipeline that releases HealthChat to TestFlight" +``` + +- [ ] **Step 2: Run the pipeline manually** + +Merge PR2 first (`workflow_dispatch` only offers workflows on the default branch), then GitHub → Actions → "Release HealthChat" → **Run workflow**: +- **version:** `26.7.99` — **numeric only.** A marker like `26.07.3-pipeline-test` earns an ITMS rejection at the final upload step after ~30 minutes of signing, indistinguishable from a real signing failure. +- **release_notes_override:** `Pipeline verification build — discard.` + +- [ ] **Step 3: Confirm the outcome** + +Expected: green run; build appears in App Store Connect → TestFlight within ~10 minutes; IPA, dSYM and Gym logs attached to the run. +Then discard the TestFlight build. + +Do **not** verify by submitting to the App Store. TestFlight acceptance already proves signing, entitlements and upload; review proves nothing more about the pipeline. + +- [ ] **Step 4: Send a push notification to a migrated device** + +PR2 ships two brand-new notification entitlements and the first-ever NSE profile, and nothing else exercises delivery — upload proves signing, not push. Install the TestFlight build on the device from Task 3 and confirm a notification arrives. + +Note (spec O3): element-x's `pusherAppID` and device pushkey match the legacy's, so first launch overwrites the user's sygnal pusher in place and the format changes to `.eventIdOnly`. Benign under an in-place update, but a rollback to 1.4.1 loses push. + +--- + +# PR3 — Release skill (`pr-doc`) + +### Task 11: Write the skill + +**Files:** +- Create: `.agents/skills/releasing-healthchat/SKILL.md` + +Reachable as a project skill via the existing `.claude/skills -> ../.agents/skills` symlink. Mirror `element-ios/.claude/skills/releasing-healthchat/SKILL.md` — read it first; it is a good model. Two-field frontmatter, terse imperative prose, checklists and tables, **rationale inline on every prohibition**. + +- [ ] **Step 1: Branch** + +```bash +git checkout release/healthchat && git pull +git checkout -b healthchat/release-skill +``` + +- [ ] **Step 2: Write `SKILL.md` with these sections** + +Frontmatter: + +```markdown +--- +name: releasing-healthchat +description: Cut a HealthChat TestFlight release of element-x-ios by tagging healthchat-v*. Use when the user asks to release, publish, tag, deploy, ship, or cut a new HealthChat iOS version, asks about the TestFlight or App Store release process, asks about renewing the Apple Distribution signing certificate, or asks about the required GitHub Actions secrets for the release pipeline. +--- +``` + +Body sections, each corrected for **this** repo rather than copied from the legacy: + +1. **Preflight checklist** — on `release/healthchat`, clean tree; `HEALTHCHAT_CHANGES.md` has a `## Changes in ()` section at the top. +2. **Cutting a release** — `git tag healthchat-v` + push. The `healthchat-` prefix keeps these clear of upstream's `release/` and `nightly/*` tags. +3. **Version rules** — **numeric only, 1-3 integers.** Explain the 30-minute ITMS trap. The tag is the source of truth; `project.yml:56` is only a default. +4. **Manual / retry path** — `workflow_dispatch` with `version` + `release_notes_override`. +5. **Release notes** — `HEALTHCHAT_CHANGES.md` only. Upstream owns `CHANGES.md`. +6. **Required secrets** — the **five** `HEALTHCHAT_*`. State explicitly that `HEALTHCHAT_KEYCHAIN_PASSWORD` appears in the legacy skill's table but does not exist and is not needed, so nobody re-adds it. +7. **Runner requirements** — org-level `novaloop-mac-studio-001`; Xcode 26.4 via `xcodes`; `xcodegen` installed by hand and deliberately not in `Brewfile`; `APPLE_ID` as a machine-level shell variable. +8. **Never commit the post-lane tree** — a local run rewrites `ElementX.xcodeproj/project.pbxproj` (xcodegen regenerates it; `disable_automatic_code_signing` and `update_project_provisioning` write Manual signing and profile UUIDs). Recover with `git checkout -- ElementX.xcodeproj`. +9. **Certificate renewal** — port the legacy's annual runbook verbatim; it is good. Correct the profile list to main / NSE / **ShareExtension**. Next renewal ~April 2027 (renewed 2026-04-07). +10. **Promoting TestFlight → App Store** — manual, deliberately not automated (needs `fastlane/metadata/` + screenshots, neither exists). +11. **Rollback.** +12. **The migration contract** — no legacy equivalent, and the most important section: + - `CLASSIC_APP_*` in `healthchat.yml` is load-bearing for every existing user and **fails silently**. Never "clean it up" back to upstream's `im.vector` values. + - The `.requiresBackup` fall-through in `AuthenticationStartScreenViewModel` is a deliberate upstream divergence. A rebase that reverts it reintroduces a button that opens nothing. + - Both apps share the `group.ch.healthchat` UserDefaults suite (`AppSettings.swift:56`), so an unqualified `@UserPreference` name lands in a suite the legacy also writes to. Collisions are cheap to introduce. +13. **Rebase divergence** — `fastlane/`, the release workflow, `HEALTHCHAT_CHANGES.md` and this skill are **fork-only**; upstream has no `fastlane/` at all, so the legacy skill's `--ours` guidance does not apply. What does: `automatic-calendar-version.yml` and `post-release.yml` are gated on `github.repository == 'element-hq/element-x-ios'` and silently no-op here, so rebases drag `project.yml`'s version along. + +- [ ] **Step 3: Verify the skill is discoverable** + +Run: `ls -L .claude/skills/releasing-healthchat/SKILL.md` +Expected: the file resolves through the symlink. If it does not, the skill will never load. + +- [ ] **Step 4: Commit** + +```bash +git add .agents/skills/releasing-healthchat/SKILL.md +git commit -m "Add a skill for releasing HealthChat" +``` + +--- + +### Task 12: Delegate to the skill from AGENTS.md + +**Why:** `AGENTS.md`'s own "keep current" rule: a new fact about how this repo works belongs there. It has no release section today. Delegate rather than duplicate — the legacy does exactly this (`element-ios/CLAUDE.md:288-290`). + +**Files:** +- Modify: `AGENTS.md` + +- [ ] **Step 1: Add the section** + +After the "Pull Request Guidelines" section, in the file's terse caveman voice: + +```markdown +## Releasing + +HealthChat ship via tag `healthchat-v*` → TestFlight. Full runbook in +`.agents/skills/releasing-healthchat/SKILL.md`. Read skill before release, cert +renewal, or touching `fastlane/`. No duplicate runbook here. +``` + +- [ ] **Step 2: Open PR3** + +```bash +git add AGENTS.md +git commit -m "Point AGENTS.md at the HealthChat release skill" +git push -u origin healthchat/release-skill +gh pr create --base release/healthchat --label pr-doc \ + --title "Add a skill for releasing HealthChat to TestFlight" +``` + +--- + +## Before the first App Store submission (not TestFlight) + +- [ ] **Check the install base against the iOS floor (spec O2).** element-x requires **iOS 18.5** + (`project.yml:14-15`); the legacy required **15.1** (`element-ios/Config/Project.xcconfig:29`). Every + existing HealthChat user below 18.5 will silently stay on 1.4.1 forever — the App Store simply will not + offer them the update, with no error and no notice to them or to you. This is upstream's floor and + cannot be lowered, so this is a product decision, not an engineering task: pull the iOS version + distribution from App Store Connect → Analytics and confirm the stranded fraction is acceptable, and + that those users have a communication plan. + +## Definition of done + +- [ ] An existing legacy user updating in place keeps their session and encrypted history (Task 3). +- [ ] An account without key backup signs in normally and never sees the backup sheet (Tasks 2, 3). +- [ ] `git tag healthchat-v && git push` produces a TestFlight build with no manual steps (Task 10). +- [ ] Push notifications arrive on a migrated device (Task 10 Step 4). +- [ ] Spec O1 is answered and recorded (Task 3 Step 7). +- [ ] All three PRs merged, each with exactly one `pr-` label.