diff --git a/docs/superpowers/plans/2026-07-15-healthchat-appstore-release.md b/docs/superpowers/plans/2026-07-15-healthchat-appstore-release.md deleted file mode 100644 index 47013e3b7..000000000 --- a/docs/superpowers/plans/2026-07-15-healthchat-appstore-release.md +++ /dev/null @@ -1,1306 +0,0 @@ -# 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` | `release_testflight` + private build/signing lanes | 2 | -| `fastlane/Appfile`, `fastlane/.env.default`, `fastlane/Pluginfile` | fastlane configuration | 2 | -| `Gemfile`, `Gemfile.lock` | 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`. Two *different* lines end in `"skipping loadAccounts"` (`AuthenticationService.swift:52` and `:57`) and they mean opposite things — never match the bare substring. `"Already signed in to Element X before, skipping loadAccounts"` means Step 1's trap caught you and the run is **void, not failed**. `"Classic App not configured, skipping loadAccounts"` is the opposite: the `CLASSIC_APP_*` Info.plist keys are missing, `ClassicAppManager` never initialised, and migration is broken for every user — investigate, do not dismiss. - -- [ ] **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`, `fastlane/Pluginfile`, `fastlane/Appfile` -- Modify: `.gitignore` - -**No `Brewfile`.** Nothing in this pipeline installs tools. The runner's toolchain is provisioned once by hand (Task 9 Step 5): `novaloop-mac-studio-001` is shared with the legacy release pipeline, so a per-run `brew` could upgrade `xcodegen` underneath a running release. Its only other purpose was installing `xcodes` for a version pin that no longer exists (Task 6). - -- [ ] **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 `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 5: 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 6: 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 7: Commit** - -```bash -git add Gemfile Gemfile.lock 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 - -# No Xcode version pin: novaloop-mac-studio-001 carries a single, globally-selected Xcode, so -# whatever xcode-select points at is by definition the one we build with. Pinning bought nothing -# and risked everything — a version the runner lacks sends xcodes into a multi-GB authenticated -# download that no CI secret can satisfy, hanging the release. Change the runner's Xcode instead. -platform :ios do - #### 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 with `'marketing_version' must be 1-3 dot-separated integers`, before any build or signing work. - -**On ordering.** With no `before_all`, the guard is the first thing the lane does — nothing runs ahead of it, so this step needs no Xcode selection and no tool beyond fastlane itself. That is the whole point: it costs seconds instead of the ~30 minutes of signing it exists to prevent. Do not reintroduce a `before_all` to pin the toolchain; see Task 9 Step 5 for why the runner's Xcode is a property of the machine. - -- [ ] **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: Record toolchain - # HealthChat: nothing pins Xcode (the runner carries a single globally-selected one), so this - # log line is the only record of which compiler produced a given TestFlight build. - run: xcodebuild -version - - - name: Resolve marketing version - id: version - env: - # Read via env, never `${{ }}` inside the script: the input is attacker-controlled text - # that GitHub would splice into the shell before the regex below could reject it. - INPUT_VERSION: ${{ github.event.inputs.version }} - run: | - set -euo pipefail - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - VERSION="$INPUT_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: 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 the toolchain is installed **by hand** — CI never provisions it. Measured today on `novaloop-mac-studio-001` (user `novaloop`, arm64, macOS 26.5.2): - -- **Xcode:** one install, `/Applications/Xcode.app` (**26.6**, build `17F113`), globally selected via `xcode-select`; no versioned `Xcode_*.app` siblings. Nothing pins it — the build uses whatever `xcode-select` points at, so upgrading it silently changes the compiler every subsequent release is built with. Treat that upgrade as a release-affecting change. -- **Present:** `xcodes`, `xcodegen`, `git-lfs`, `brew`, `swift`, rbenv `ruby`/`bundle`/`gem`. -- **Missing:** `swiftlint`, `swiftformat`, `pkl`, `xcresultparser`, `xcbeautify`. The unit-test workflow asserts all five; this release lane requires none (its SwiftLint/SwiftFormat build phases are `if which …` guarded and degrade to a warning). The human installs them once: `brew install swiftlint swiftformat pkl xcbeautify a7ex/homebrew-formulae/xcresultparser` (`xcresultparser` is not in homebrew-core). -- **No passwordless sudo.** Nothing in CI may call `sudo`. -- Also confirm `APPLE_ID` is exported as a machine-level shell variable — the `Appfile` reads it and no secret carries it. - ---- - -### 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`** - -Read `element-ios/.claude/skills/releasing-healthchat/SKILL.md` first for voice. Then write this file verbatim: - -````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. ---- - -# Releasing HealthChat (element-x-ios) - -Releases are driven by git tags of the form `healthchat-v` pushed to -`origin`. The tag triggers `.github/workflows/release-healthchat.yml`, which runs -on the org's self-hosted macOS runner, builds a signed App Store IPA, and uploads -it to **TestFlight only**. No GitHub Release is created. - -**App Store submission is always a manual step** in App Store Connect after a -TestFlight build has been validated — see "Promoting to App Store" below. - -**HealthChat ships as an in-place update of the legacy element-ios app.** Same -bundle id (`ch.healthchat.element`), same team (`6G37K72W82`), same App Store -record. Existing users' keychain and app-group container survive the update. Read -"The migration contract" before changing anything that looks like an identifier. - -## Preflight checklist - -Verify each item before tagging. If any fail, fix and commit first. - -- [ ] On branch `release/healthchat` with a clean working tree (`git status`). -- [ ] `HEALTHCHAT_CHANGES.md` has a section `## Changes in ()` - at the top. The pipeline's awk extractor matches this exact heading format; - no match ships a fallback placeholder as the TestFlight "What to Test" text. -- [ ] The version is **numeric only** — see "Version rules". This is the single - most common way to waste 30 minutes here. -- [ ] `CHANGES.md` is **not** used for HealthChat releases — upstream's - `release-to-github` owns it. Only edit `HEALTHCHAT_CHANGES.md`. - -## Version rules - -**Numeric only. One to three dot-separated integers.** `26.7.4`. Never `26.7.4-rc1`. - -The marketing version lands verbatim in `CFBundleShortVersionString`, and Apple -rejects anything that is not at most three period-separated integers. A marker -version passes the tag regex and every local gate, then dies at the **upload -step, after ~30 minutes of signing**, with an ITMS error indistinguishable from a -signing failure. - -The Fastfile guards this in `release_testflight`, before any build or signing work. Do not relax that guard. - -The legacy element-ios skill documents `-rc1` / `-hotfix1` / `-renew-test` tag -suffixes. **Do not carry that convention over.** The workflow derives the version -by stripping only the `healthchat-v` prefix, so a suffix becomes part of the -version and hits the wall above. Put markers in the release notes, and use the -`workflow_dispatch` path for test builds. - -`project.yml:56`'s `MARKETING_VERSION` is only a default — **the tag is the source -of truth**, and the pipeline injects the version at build time without touching -any tracked file. Rebases from upstream will drag `project.yml`'s version around -(`automatic-calendar-version.yml` no-ops in this fork); ignore it. - -## Cutting the release (normal path) - -```bash -# from release/healthchat, fully committed: -git tag healthchat-v.. -git push origin healthchat-v.. -``` - -Example: `git tag healthchat-v26.7.4` - -The `healthchat-` prefix keeps these tags clear of upstream's `release/` -and `nightly/*` namespaces. - -The workflow triggers automatically. Watch it with `gh run watch` or the Actions -UI. The TestFlight build appears in App Store Connect within a few minutes of the -run finishing (Apple's processing queue). - -**Build numbers are not encoded in tags.** `generate_build_number` in -`fastlane/Fastfile` produces a `YYYYMMDDHHMMSS` timestamp, so ordering is -monotonic and a retry never collides with a build number Apple has already seen. - -## Manual / retry path (workflow_dispatch) - -Use when retrying a failed run, testing the pipeline (e.g. cert-renewal -verification), or rebuilding without moving tags. - -1. GitHub → Actions → "Release HealthChat" → **Run workflow**. -2. Fill in: - - **version** (required): e.g. `26.7.99`. Numeric only — same rule as tags. - - **release_notes_override** (optional): replaces the `HEALTHCHAT_CHANGES.md` - lookup. Use this to mark test builds, since the version cannot carry a marker. -3. Run. The IPA is built and uploaded to TestFlight exactly as the tag path would. - -## Release notes - -`HEALTHCHAT_CHANGES.md` is the single source. The workflow extracts the section -matching the version being released. Format: - -```markdown -## Changes in 26.7.4 (2026-08-01) - -🙌 Improvements - -- Bullet one. -- Bullet two. -``` - -No matching section emits a workflow warning and falls back to -`HealthChat — automated build from .` Prefer fixing the changelog. -A **missing file** is a hard error, not a fallback. - -## After the release - -- [ ] Workflow run is green in GitHub Actions. -- [ ] Build appears under App Store Connect → TestFlight within ~10 minutes. -- [ ] Internal testers are invited (the auto-distribution group may handle this). -- [ ] Artifacts (IPA, dSYM, Gym logs) attached to the run, retained 90 days. - -## Never commit the post-lane tree - -A local lane run rewrites `ElementX.xcodeproj/project.pbxproj` — `xcodegen` -regenerates it, then `disable_automatic_code_signing` and -`update_project_provisioning` write Manual signing and profile UUIDs into it. That -file is tracked. - -```bash -git checkout -- ElementX.xcodeproj -``` - -Committing it pins every future build to one machine's profile UUIDs. - -## Required GitHub Actions secrets - -Five. Missing any fails the workflow at the signing step with a clear error. - -| Secret | Source | -|---|---| -| `HEALTHCHAT_ASC_KEY_ID` | App Store Connect → Users and Access → Integrations → Team Keys (10-char Key ID) | -| `HEALTHCHAT_ASC_KEY_ISSUER_ID` | Same page, UUID at the top | -| `HEALTHCHAT_ASC_KEY_CONTENT` | `.p8` contents including the `-----BEGIN/END PRIVATE KEY-----` lines | -| `HEALTHCHAT_CERTIFICATES_P12` | Base64 distribution cert (`base64 -i healthchat.p12 \| pbcopy`) | -| `HEALTHCHAT_CERTIFICATES_P12_PASSWORD` | Password used when exporting the `.p12` | - -**There is no `HEALTHCHAT_KEYCHAIN_PASSWORD`.** The legacy skill lists one and the -legacy workflow references it, but it exists at neither repo nor org level and -that pipeline runs green anyway — `import-codesign-certs` tolerates its absence. -Do not add it back. - -Managing these needs the `snowping` GitHub account; this repo is INTERNAL and -invisible to the other account. - -## Self-hosted runner requirements - -Runs on `novaloop-mac-studio-001` — an **org-level** runner (`[self-hosted, -macOS, ARM64]`) shared with the legacy fork, not registered per-repo. - -- **One Xcode, globally selected.** A single install at `/Applications/Xcode.app` - (**26.6**, build `17F113`, today) — no versioned `Xcode_*.app` siblings. - **Nothing pins it.** The Fastfile builds with whatever `xcode-select` points at, so - **whatever the runner has is what ships**. Upgrading the runner's Xcode silently - changes the compiler every subsequent release is built with — no tag, no diff, no - review. Treat that upgrade as a release-affecting change. -- **Do not reintroduce a version pin.** Pinning a version the runner lacks sends - `xcodes` into a multi-GB authenticated download that no CI secret can satisfy, and - the run hangs. Change the runner's Xcode instead. -- **Toolchain installed once by hand**, never by CI: `xcodegen swiftlint swiftformat - xcbeautify xcresultparser pkl` (`xcresultparser` is not in homebrew-core — only - `a7ex/homebrew-formulae/xcresultparser` ships it). `git-lfs`, `xcodes`, `brew` and - rbenv `ruby`/`bundle` are already present. There is deliberately **no `brew bundle` - step and no `Brewfile`**: this machine is shared with CI, and a per-run - `brew update` could upgrade `xcodegen` underneath a running release. CI asserts the - tools are present and fails loudly naming the fix; it never provisions. -- **No passwordless sudo.** Nothing in CI may call `sudo` — that is why upstream's - `setup_github_actions_environment` cannot run here. -- **`APPLE_ID`** exported as a machine-level shell variable — the `Appfile` reads - it and it is not carried in any secret. -- Network access to `developer.apple.com`, `api.appstoreconnect.apple.com`, - `github.com`, `rubygems.org`. - -**A release's only Xcode provenance is its own log.** `release-healthchat.yml` runs -`xcodebuild -version` (`Record toolchain`) before every build, so each run records the -compiler that produced it. Nothing pins that version — upgrading the runner's Xcode -silently changes the compiler every subsequent release is built with, and that log -line is the only place it shows up. Do not reconstruct a past build's toolchain from -the runner: it answers for today, not for the build you are asking about. Read the -run's log. - -## Renewing the Apple Distribution certificate (annual) - -Apple Distribution certificates are valid for **exactly 1 year**. There is no -longer-lived option. Last renewed **2026-04-07**, so the next is due around -**April 2027**. Renew proactively. - -1. **~4 weeks before expiry**, check on the Mac holding the private key: - ```bash - security find-certificate -c "Apple Distribution: E-HealthSolutions GmbH" -p | openssl x509 -noout -enddate - ``` -2. **Generate a CSR** on whichever Mac will hold the new private key: Keychain - Access → Certificate Assistant → Request a Certificate from a Certificate - Authority… → "Saved to disk". -3. **Apple Developer portal → Certificates** → **+** → Apple Distribution → upload - the CSR → download the `.cer` → double-click to install. -4. **Regenerate all 3 App Store provisioning profiles** — they reference the old - cert and become invalid: - - `HealthChat App Store` - - `HealthChat NSE: App Store` - - `HealthChat Share Extension: App Store` -5. **Verify locally**: archive in Xcode → Distribute App → App Store Connect → - Upload. TestFlight accepting the build proves the cert works. -6. **Export as `.p12`**: Keychain Access → My Certificates → right-click the new - `Apple Distribution: E-HealthSolutions GmbH` → Export → set a password. -7. **Re-encode and update secrets**: `base64 -i healthchat-new.p12 | pbcopy`, then - replace `HEALTHCHAT_CERTIFICATES_P12` and `HEALTHCHAT_CERTIFICATES_P12_PASSWORD`. -8. **Run the workflow** via `workflow_dispatch` with a throwaway numeric version - to verify end-to-end. Discard the resulting build. -9. **Revoke the old cert** once the new one works in both Xcode and CI. - -**Keep the expired `.p12` archived for 2 weeks** in case a rollback is needed. - -## Promoting a TestFlight build to the App Store - -TestFlight and App Store builds are the same binary. To release a validated one: - -1. App Store Connect → My Apps → HealthChat → **TestFlight** → find the build. -2. Confirm it passed internal testing. -3. **Distribution** tab → **+** next to "iOS App" → "Prepare for Submission" → - fill in metadata → **Add for Review**. -4. Wait for review (24–48 hours typical). -5. Release manually, or configure automatic release. - -**Deliberately not automated.** Automating submission needs `fastlane/metadata/` -plus HealthChat-branded screenshots (neither exists) and a `Deliverfile` -refactored for API key auth. - -**Before the first submission**, confirm the install base against the iOS floor: -element-x requires **iOS 18.5**, the legacy required 15.1. Every user below 18.5 -stays on 1.4.1 forever — the App Store will not offer them the update, silently. -That floor is upstream's and cannot be lowered. - -## Rollback - -- **Bad build, notes fine** → fix, commit, tag the next patch version. TestFlight - supersedes the bad build. (Build numbers are timestamps, so no version games.) -- **Bad release notes only** → edit `HEALTHCHAT_CHANGES.md`, then update the - TestFlight "What to Test" text in App Store Connect by hand — the pipeline only - writes it at upload time. -- **Wrong version number** → treat as burned; tag a higher one. -- **Rolling back to legacy 1.4.1 loses push** — element-x overwrites the user's - sygnal pusher in place on first launch (same app id and pushkey). - -## The migration contract - -**Read this before "cleaning up" anything.** HealthChat replaces the legacy app in -place, and these are what stop existing users being silently logged out. - -- **`CLASSIC_APP_*` in `Variants/HealthChat/healthchat.yml` is load-bearing.** - `ClassicAppManager` reads the legacy account and crypto-store keys through them. - Upstream's defaults in `app.yml` point at Element's own `group.im.vector` / - `im.vector.app.*`, which this team cannot provision. **Never restore upstream's - values.** Failure is silent — `loadAccounts()` throws, and - `AuthenticationService` logs it without telling the user or you. -- **The classic keychain group in `ElementX/SupportingFiles/target.yml` must stay - in the `keychain-access-groups` array.** Entitlement arrays live there, never in - the variant — XcodeGen concatenates arrays on include-merge and only overrides - scalars. -- **The `.requiresBackup` fall-through in `AuthenticationStartScreenViewModel` is a - deliberate upstream divergence.** Upstream sends those accounts to a sheet that - tells the user to open Element Classic. That app no longer exists — this update - replaced it — and the sheet's button opens a URL scheme nothing registers. A - rebase that reverts this reintroduces a dead button. -- **Both apps share the `group.ch.healthchat` UserDefaults suite** - (`AppSettings.swift:56`). An unqualified `@UserPreference` name lands in a suite - the legacy also writes to. Collisions are cheap to introduce. -- **`hasSignedInBefore` voids migration testing.** It lives in that shared suite, - survives app deletion, and short-circuits `loadAccounts()`. Any device that has - run an element-x HealthChat build shows a plain login screen *while migration - works perfectly*. Test on a device that has never run an element-x HealthChat - build, or clear the `group.ch.healthchat` suite first; - `"Already signed in to Element X before, skipping loadAccounts"` in the log - means the run is **void, not failed**. A - `"Classic App not configured, skipping loadAccounts"` line is the opposite — - the `CLASSIC_APP_*` Info.plist keys are missing, `ClassicAppManager` never - initialised, and migration is broken for every user. Investigate, do not - dismiss. - -## Known branch divergence on rebase - -`fastlane/`, `.github/workflows/release-healthchat.yml`, `HEALTHCHAT_CHANGES.md`, -`Gemfile` and this skill are **fork-only** — upstream element-x-ios has -no `fastlane/` directory at all. A rebase conflict in any of them means something -upstream has started to overlap; keep `--ours` and investigate. - -What does need watching: - -- **`project.yml`'s `MARKETING_VERSION`** — `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 upstream's version along. Harmless: the - tag is the source of truth. -- **`AuthenticationStartScreenViewModel.login(classicAppAccount:)`** — the only - upstream logic file this fork edits. See "The migration contract". -- **`app.yml`'s `CLASSIC_APP_*`** — upstream owns these and they stay pointed at - Element's ids. The variant overrides them. Do not "fix" `app.yml`. -```` - -- [ ] **Step 3: Verify every factual claim in the skill still holds** - -Run: -```bash -grep -n "MARKETING_VERSION" project.yml | head -1 # section "Version rules" cites :56 -grep -c "CLASSIC_APP" Variants/HealthChat/healthchat.yml # migration contract: must be 3 -grep -rn "xcodes(version:\|before_all" fastlane/Fastfile # runner section says nothing pins Xcode -``` -Expected: `MARKETING_VERSION` at `project.yml:56`; three `CLASSIC_APP_*` entries in the variant; **no match** for the pin — a pin's return would contradict the runner section. A skill that misquotes the repo is worse than no skill. - -- [ ] **Step 4: 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 5: 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. diff --git a/docs/superpowers/specs/2026-07-15-healthchat-appstore-release-design.md b/docs/superpowers/specs/2026-07-15-healthchat-appstore-release-design.md deleted file mode 100644 index d42004a64..000000000 --- a/docs/superpowers/specs/2026-07-15-healthchat-appstore-release-design.md +++ /dev/null @@ -1,470 +0,0 @@ -# HealthChat (element-x-ios) — App Store release capability - -**Date:** 2026-07-15 -**Branch:** `release/healthchat` -**Status:** Design approved; revised after adversarial review (26 issues, 0 rejected) - -## Context - -`Novaloop-AG/element-x-ios` is the HealthChat rebrand of Element X. The rebrand landed in `01dc703b4` -(branding, fixed homeserver, variant seam). What it cannot do is ship: the repo has **no release machinery -of any kind** — no `fastlane/`, no `Gemfile`, and none of its 17 GitHub workflows build or upload anything. - -The legacy fork `Novaloop-AG/element-ios` ships HealthChat to the App Store today via a proven pipeline -(last green run 2026-04-15, version 1.4.1). This design brings equivalent capability to element-x-ios and -captures the process in a skill. - -Both apps use **the same bundle id** (`ch.healthchat.element`) and **the same Apple team** (`6G37K72W82`). -element-x therefore reaches the App Store as an **in-place update** of the existing HealthChat record. That -single fact drives most of this design — including the fact that the two apps can never coexist on a -device, which upstream's migration UX assumes they can. - -### Why upstream's own release path is unavailable - -element-hq ships element-x via Xcode Cloud. `ci_scripts/ci_post_clone.sh` branches on `$CI_WORKFLOW` and -calls `swift run pipeline update-foss-secrets` (`Tools/Sources/Commands/CI/ConfigureProduction.swift:8`). -`pipeline` lives only in the private `Enterprise` submodule (`.gitmodules` → `element-hq/element-ios-enterprise`; -`Enterprise/` is empty here and Novaloop cannot obtain it). Separately `ci_scripts/ci_post_xcodebuild.sh:13` -unconditionally uploads dSYMs to Element's Sentry org. Xcode Cloud is a dead end for this fork. - -## Decisions - -| # | Decision | Rationale | -|---|---|---| -| D1 | **In-place update** of the existing App Store record | Identity already aligns. `26.07.3` already clears the legacy's `1.4.1`, so no version bump is needed. | -| D2 | **Preserve** legacy sessions + E2EE keys | The migration code already ships; the fix is 3 scalars + 1 entitlement, all HealthChat's own provisionable ids. | -| D3 | **Port the legacy fastlane** (slim, purpose-built) | Xcode Cloud is blocked. The legacy pipeline is proven and reuses the identical team, cert and ASC key. | -| D4 | Migration fix ships **first, as its own PR** | It corrects a defect in `01dc703b4`. **Note: it is _not_ monotonic — see D9.** | -| D5 | Skill lives at `.agents/skills/releasing-healthchat/SKILL.md` | Reachable via the existing `.claude/skills -> ../.agents/skills` symlink. A new subdirectory cannot conflict on rebase. | -| D6 | Reuse the legacy's `HEALTHCHAT_*` secrets | Same team, cert and ASC key. **Reject fastlane match** — the legacy deliberately has none. | -| D7 | Keep the `healthchat-v*` tag namespace | Upstream owns `release/` (`ReleaseToGithub.swift:83-85`) and `nightly/*`. | -| D8 | **Create** `HEALTHCHAT_CHANGES.md` as the release-notes source | element-x's `CHANGES.md` is owned by upstream's `release-to-github`. The file does **not exist** in this repo — it must be created, not carried over. | -| D9 | Fix the `.requiresBackup` dead-end with a **small upstream edit** | Accepts the first-ever edit to an upstream logic file. Required for D4 to be safe — see Part 1. | - ---- - -## Part 1 — Classic migration fix (PR1, `pr-bugfix`) - -### The defect - -`01dc703b4` dropped `$(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER)` from the entitlements with the -rationale "not provisionable here". That was correct *about Element's identifiers* — but the fix is to -**repoint them at HealthChat's own**, not to remove them. - -Left as-is, every existing user who updates is silently logged out and loses local encrypted history -unless they had key backup. `loadAccounts()` throws `invalidAppGroupIdentifier`, caught and logged only — -`AuthenticationService.swift:59-63`: *"No need to alert the user of the failure, just log it."* - -### Why the fix is small - -The plumbing already ships. `ElementX/SupportingFiles/target.yml:78-81` maps the values into `Info.plist`; -`InfoPlistReader.swift:28-31,129-143` reads them. `ClassicAppManager` reads exactly `accountIv`, -`accountAesKey`, `cryptoSDKStoreKey` (`ClassicAppManager.swift:31-35,61-68`) — and the legacy -`EncryptionKeyManager` writes exactly those three names, to service -`ch.healthchat.element.encryption-manager-service`, in access group -`$(AppIdentifierPrefix)ch.healthchat.element.keychain.shared` -(`element-ios/Riot/Managers/EncryptionKeyManager/EncryptionKeyManager.swift:17,20-25,27`). - -Only the scalars are wrong: `app.yml:11-13` points them at Element's `group.im.vector` / `im.vector.app.*`, -and `healthchat.yml` does not override them. - -**Verified mechanics** (these were the design's load-bearing assumptions): -- **XcodeGen scalar precedence.** `healthchat.yml` is included after `app.yml` (`project.yml:71` vs `:61`) - and wins — proven by the already-generated pbxproj, where `APP_GROUP_IDENTIFIER = group.ch.healthchat` - and `BASE_BUNDLE_IDENTIFIER = ch.healthchat.element` beat app.yml's Element values. -- **No trailing-dot bug.** `$(AppIdentifierPrefix)` carries its own trailing dot, so the legacy resolves to - `6G37K72W82.ch.healthchat.element.keychain.shared`. `$(DEVELOPMENT_TEAM).ch.healthchat.element.keychain.shared` - is byte-equivalent and matches upstream's own convention (`app.yml:13`). - -### The change - -In `Variants/HealthChat/healthchat.yml`: - -```yaml -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" -``` - -In `ElementX/SupportingFiles/target.yml`, restore the classic keychain group to `keychain-access-groups` -and correct the misleading comment at `:132`: - -```yaml -keychain-access-groups: -- $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER) -# HealthChat: the legacy app's keychain group — ClassicAppManager reads the account + crypto store -# keys from here to migrate existing users. Same team prefix, so it is provisionable. -- $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER) -``` - -**Do not** re-add `$(CLASSIC_APP_GROUP_IDENTIFIER)` to `com.apple.security.application-groups`: it resolves -to `group.ch.healthchat`, already the value of `$(APP_GROUP_IDENTIFIER)`, so it would duplicate the entry. -Correct the comment at `:128` to say this rather than implying the group was dropped as un-provisionable. - -`CLASSIC_APP_DEEP_LINK_URL` needs no override — legacy HealthChat registers the `element` scheme -(`element-ios/Config/AppIdentifiers.xcconfig:22`) — but see the `.requiresBackup` fix below, which makes it -moot. - -**Finally: run `xcodegen` and commit both `ElementX.xcodeproj/project.pbxproj` and -`ElementX/SupportingFiles/ElementX.entitlements`.** Both are tracked and not ignored. Without this the -checked-in entitlements still lack the classic group and anyone building the branch gets the un-fixed, -silently-failing behaviour. - -### The `.requiresBackup` fix (D9) — why Part 1 is not monotonic - -`availableSecrets` maps `.withoutBackup` / `.unusedBackup` → `.requiresBackup` -(`ClassicAppManager.swift:82-85`). `login(classicAppAccount:)` routes that to -`state.bindings.showClassicAppBackupInstructions`, whose sheet's only button fires `.openClassicApp` → -`appMediator.open(element://open)` (`AuthenticationStartScreenViewModel.swift:106`). - -**That URL resolves to nothing.** element-x registers only `$(BASE_BUNDLE_IDENTIFIER)` and `matrix` -(`ElementX/SupportingFiles/target.yml:60-63`) — no `element` scheme — and the legacy app that *did* -register it has been replaced in place by D1's same-bundle-id update. Upstream's UX assumes Element Classic -(`im.vector.app`) and Element X (`io.element.elementx`) coexist as separate apps. HealthChat cannot. - -So making `loadAccounts()` succeed switches on a UI branch that is worse than the status quo for -`.requiresBackup` users: today they see a working login screen; after the fix they see a modal urging them -to open an app that no longer exists. The sheet is dismissible (`@Environment(\.dismiss)`) and `otherOptions` -(`AuthenticationClassicAppAccountView.swift:106`) is an escape hatch, so it is not a hard lockout — but it -is a knowingly broken call-to-action. - -**Fix:** in `AuthenticationStartScreenViewModel.login(classicAppAccount:)`, treat `.requiresBackup` the same -way `.unavailable` is already treated — skip the sheet and fall through to -`configureAccountProvider(account.serverName, loginHint: "mxid:\(account.userID)", fallbackHomeserverURL:)`. -Those users sign into their own account and re-verify the device, losing only history that by definition has -no backup. - -This is the **first edit to an upstream logic file** in this fork, ending the rebrand's "no upstream logic -edits" property. It is ~3 lines in a method upstream rarely touches. Record it in the skill's divergence -section so a future rebase does not silently revert it. - -### Precondition for D2 (verified, but confirm on device) - -`AuthenticationStartScreenViewModel:46` gates the whole classic flow on -`appSettings.accountProviders.contains(serverName)`, where `serverName` is derived from the **MXID domain** -(`ClassicAppAccountManager.swift:107-111`), not the homeserver URL. HealthChat pins -`accountProviders: ["healthchat.ch"]`. - -`curl https://healthchat.ch/.well-known/matrix/server` → `{"m.server": "matrix.healthchat.ch:8448"}`. This is -*server-name delegation*: the server is **named** `healthchat.ch` and merely reachable at -`matrix.healthchat.ch:8448`. Legacy MXIDs are therefore `@user:healthchat.ch`, the gate passes, and D2 holds. -Confirm against a real legacy MXID during device testing. - ---- - -## Part 2 — Release pipeline (PR2, `pr-build`) - -### Design note: this fork owns `fastlane/` outright - -Upstream element-x-ios has **no `fastlane/` directory**. The legacy skill devotes a whole section to rebase -divergence in `fastlane/.env.default` and `fastlane/Fastfile` precisely because those are upstream-owned in -element-ios. Here that class of conflict does not exist — which is why this is a slim purpose-built Fastfile -(~120 lines), not a copy of Vector's ~600-line multi-variant one. - -**Dropped:** cocoapods (element-x is SPM), diawi, sentry (HealthChat has no Sentry), and the `adhoc` / -`alpha` / `setup_app_variant` lanes. Siri-intents and broadcast-upload have no counterpart target here. - -### Files - -| File | Purpose | -|---|---| -| `fastlane/Fastfile` | Public `release_testflight` + private helpers. **No `before_all`, no Xcode pin** — see runner notes. | -| `fastlane/Appfile` | `apple_id ENV["APPLE_ID"]`, `team_id ENV["TEAM_ID"]` | -| `fastlane/.env.default` | Project/target/bundle-id/profile configuration (full key list below) | -| `fastlane/Pluginfile` | **`fastlane-plugin-xcodegen` only** | -| `Gemfile` + `Gemfile.lock` | `fastlane` + plugins. No cocoapods, no slather. **Commit the lock** — the workflow's gem cache keys on `hashFiles('**/Gemfile.lock')`; without it the key is constant and fastlane silently re-resolves between two runs of the same tag. | -| `HEALTHCHAT_CHANGES.md` | **New file** (D8). Seed with a `## Changes in 26.07.3 ()` section matching the awk pattern. | -| `.gitignore` | Add `out/` and **`provisioning_profiles/`** (signing material — security-relevant). `build` (`:35`) and `fastlane/report.xml` (`:28`) are already covered upstream. | - -**Drop `fastlane-plugin-versioning`.** The legacy carries it but never uses it — its `update_marketing_version` -is raw `File.read`/`gsub!`, and no `increment_version_number` / `get_version_number` / `update_info_plist` -call exists anywhere. Copying it into a file we call "slim and purpose-built" would propagate dead weight. - -### `.env.default` - -``` -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 - -DERIVED_DATA_PATH=build -IPA_NAME=ElementX -ARCHIVE_PATH=out/ElementX.xcarchive -BUILD_OUTPUT_DIRECTORY=out -BUILD_LOG_DIRECTORY=build/Logs/Gym/ - -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 - -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" - -TEAM_ID=6G37K72W82 -# APPLE_ID is a machine-level shell variable on the runner, not a repo secret. -``` - -`APPSTORE_CODESIGNING_IDENTITY` and `APPSTORE_SIGNING_CERTIFICATE` are **distinct keys** in the legacy — both -are needed. The `out/ElementX.ipa` artifact path is only true because `BUILD_OUTPUT_DIRECTORY`/`IPA_NAME` are -set here. - -Verified: bundle ids from `NSE/SupportingFiles/target.yml:75` and `ShareExtension/SupportingFiles/target.yml:77`; -schemes/targets from `ElementX/SupportingFiles/target.yml:3-4,41`, `NSE/SupportingFiles/target.yml:3-4,30`, -`ShareExtension/SupportingFiles/target.yml:3-4,30`. - -### `release_testflight` lane - -Mirrors `element-ios/fastlane/Fastfile:199-224`: accept inputs via options (local) or env vars (CI, avoiding -shell-escaping of multi-line notes), then: - -1. `app_store_connect_api_key(...)` from `APPSTORECONNECT_KEY_{ID,ISSUER_ID,CONTENT}` when `is_ci`. -2. `generate_build_number` → `YYYYMMDDHHMMSS` (ported from `Fastfile:562`). Monotonic; makes retries free. -3. `xcodegen(spec: "project.yml")`. -4. `download_provisioning_profiles` → three profiles (main, NSE, ShareExtension). -5. `disable_automatic_code_signing` + `update_project_provisioning` per target. -6. `build_app`, `export_method: "app-store"`, `CODE_SIGN_STYLE=Manual`. -7. `upload_to_testflight(skip_submission: true, distribute_external: false, changelog: release_notes)`. - -**Version injection.** element-x holds `MARKETING_VERSION: 26.07.3` / `CURRENT_PROJECT_VERSION: 1` in -`project.yml:56-57`, consumed by XcodeGen at *generate* time (unlike the legacy's `AppVersion.xcconfig`, read -at *build* time). Pass both via `xcargs` to `build_app` instead of rewriting `project.yml`. Verified sound: -NSE (`:51-52,76-77`) and ShareExtension (`:46-47,78-79`) both resolve their version keys from -`$(MARKETING_VERSION)`/`$(CURRENT_PROJECT_VERSION)` at build time, and xcodebuild command-line settings -override project *and* target level uniformly — so all three targets stay version-consistent. This matters: -App Store rejects an upload whose extension version disagrees with the app's. - -This spares `project.yml`, but **it does not mean "no tracked file is mutated"** — `ElementX.xcodeproj/project.pbxproj` -is tracked and steps 3 and 5 rewrite it (xcodegen regenerates; `disable_automatic_code_signing` / -`update_project_provisioning` write Manual signing and profile UUIDs). The pbxproj footgun **moved, it did not -vanish**. The skill must say: never commit the post-lane tree; `git checkout -- ElementX.xcodeproj`. - -### GitHub workflow - -`.github/workflows/release-healthchat.yml` — near-copy of the legacy's, which is already well-built (pinned -action SHAs, `contents: read`, concurrency group, 90-minute timeout, awk notes extraction, artifact upload). - -Changes from the legacy: - -- **`submodules: false`** — critical. element-x-ios's only submodule is the **private** - `element-hq/element-ios-enterprise` over SSH; the legacy's `submodules: 'true'` would fail checkout on step - one of every run. -- Drop the CocoaPods cache and `rm -rf Pods/MatrixSDK`. -- Drop `MX_GIT_BRANCH` (legacy `:41`) — no `mx_git_branch` helper exists in the slim Fastfile. -- Re-evaluate `fetch-depth: 0` (legacy `:48`); keep only if something needs history. -- **Drop the `brew bundle` step** (legacy `:115`) — there is no `Brewfile`, and the runner is provisioned by - hand. See runner notes. -- `bundle exec fastlane release_testflight`; artifacts `out/ElementX.ipa`, `out/ElementX.app.dSYM.zip`. -- Harden the notes step with `[[ -f HEALTHCHAT_CHANGES.md ]]`. The legacy's "warning fallback" only fires on - an *empty match*, not an absent file — under `set -euo pipefail` a missing file aborts the job. - -**Do not carry `HEALTHCHAT_KEYCHAIN_PASSWORD` forward as mandatory.** The legacy skill lists it among six -required secrets and the legacy workflow references it, but it exists at neither repo nor org level — and the -pipeline runs green regardless. `apple-actions/import-codesign-certs` tolerates its absence. **Five secrets is -the real requirement.** - -### Runner - -`novaloop-mac-studio-001` — org-level, online, labels `[self-hosted, macOS, ARM64]`; already satisfies -`runs-on: [self-hosted, macOS]`. Confirm the org runner group's visibility includes element-x-ios (two -`Default` groups exist, one `all`, one `selected`). - -**No Xcode pin.** Measured on the runner today (user `novaloop`, arm64, macOS 26.5.2): exactly **one** Xcode, -`/Applications/Xcode.app` — **26.6, build `17F113`**, `xcode-select`ed and the only version `xcodes` lists. No -versioned `Xcode_*.app` siblings exist. - -An earlier draft said to pin `"26.4"` because "the runner has it and the legacy pipeline proves it works". -**Both halves were wrong.** The runner does not have 26.4 and never will; a pin naming an absent version does -not fail fast — `xcodes` starts a multi-GB authenticated download needing `XCODES_USERNAME`/`XCODES_PASSWORD`, -which none of the five secrets supply, so `before_all` hangs and takes the release with it. Since the runner -carries a single globally-selected Xcode, whatever `xcode-select` points at *is* the build toolchain, and a pin -could only ever disagree with reality. **Drop it entirely**; the Xcode version becomes a property of the -machine, changed by provisioning it rather than by editing a tracked file. The reasoning about -`ci_scripts/ci_common.sh:34` (`sudo xcode-select -s /Applications/Xcode_26.5.0.app`) still stands — it is an -Xcode Cloud image path, not an `xcodes` version identifier, and says nothing about this runner. - -The cost of dropping it: **whatever the runner has is what ships**, and no release run records which Xcode -built it (`release-healthchat.yml` does not log `xcodebuild -version`; `unit-tests.yml` does). Upgrading the -runner's Xcode is therefore a release-affecting change with no tag, diff or review. Accepted — the alternative -pins a version that does not exist. Captured in the skill. - -**The runner is provisioned by hand, never by CI.** Upstream's `setup_github_actions_environment` -(`ci_scripts/ci_common.sh`) cannot run here: it calls `sudo xcode-select -s /Applications/Xcode_26.5.0.app` — -there is **no passwordless sudo** and no such path — and `brew update && brew install …`, which on this -*shared release machine* could upgrade `xcodegen` underneath a running release. CI must **verify and fail -loudly**, naming the fix, instead. - -Present today: `xcodes`, `xcodegen`, `git-lfs`, `brew`, `swift`, rbenv `ruby`/`bundle`/`gem`, and `APPLE_ID` -exported as a machine-level shell variable (the `Appfile` reads it; it is not repo-carried). -**Missing today** — the human installs these once, before the test workflow can pass: `swiftlint`, -`swiftformat`, `pkl`, `xcresultparser`, `xcbeautify`. Only `xcresultparser` is outside homebrew-core -(`a7ex/homebrew-formulae/xcresultparser`). The unit-test job asserts all of them. The release lane requires -none: its SwiftLint/SwiftFormat build phases are `if which …` guarded and degrade to a build warning when the -tool is absent (`ElementX/SupportingFiles/target.yml:202-214`), and nothing in the build reads `pkl`. - -### Secrets to copy - -element-x-ios has **zero** repository secrets. Copy these five from `Novaloop-AG/element-ios`: -`HEALTHCHAT_ASC_KEY_ID`, `HEALTHCHAT_ASC_KEY_ISSUER_ID`, `HEALTHCHAT_ASC_KEY_CONTENT`, -`HEALTHCHAT_CERTIFICATES_P12`, `HEALTHCHAT_CERTIFICATES_P12_PASSWORD`. - -Requires the `snowping` GitHub account — `Novaloop-AG/element-x-ios` is INTERNAL and invisible to -`joel-poyet_GALGH`. - ---- - -## Part 3 — Release skill (PR3, `pr-doc`) - -`.agents/skills/releasing-healthchat/SKILL.md`, reachable as a project skill through the existing -`.claude/skills -> ../.agents/skills` symlink. - -Mirror the legacy skill's shape — it is a good model: two-field frontmatter (`name`, `description`), terse -imperative prose, checklists and tables, **rationale inline on every prohibition**. Sections: preflight, -cutting a release, `workflow_dispatch` retry path, tag format, release notes, post-release checks, required -secrets, runner requirements, certificate renewal, TestFlight → App Store promotion, rollback, divergence. - -Corrected for this repo: - -- **Five** required secrets, not six. -- Version source is `project.yml:56`, not `Config/AppVersion.xcconfig`. -- Three profiles (main, NSE, **ShareExtension**), not main/NSE/broadcast. -- **Never commit the post-lane tree** — `git checkout -- ElementX.xcodeproj` after a local run. -- **Test versions must be numeric-only** (see Verification) — this trap costs 30 minutes per hit. -- The divergence section shrinks: `fastlane/` and the release workflow are fork-only here, so the legacy's - `--ours` guidance for `.env.default`/`Fastfile` does not apply. What *does* need calling out: - `automatic-calendar-version.yml` and `post-release.yml` are gated on - `github.repository == 'element-hq/element-x-ios'` and silently no-op in the fork, so rebases drag - `project.yml`'s version along — **the tag is the source of truth**. -- **New section — the migration contract** (no legacy equivalent): `CLASSIC_APP_*` in `healthchat.yml` is - load-bearing for existing users and fails *silently*; it must never be "cleaned up" to upstream's values. - The `.requiresBackup` edit in `AuthenticationStartScreenViewModel` is a deliberate upstream divergence — a - rebase that reverts it reintroduces a broken CTA. Both apps share the `group.ch.healthchat` UserDefaults - suite (`AppSettings.swift:56`), so unqualified `@UserPreference` property names land in a suite the legacy - also writes to — future key collisions are cheap to introduce. - -Add a 2-line delegation in `AGENTS.md` pointing at the skill rather than duplicating the runbook — the legacy -does exactly this (`element-ios/CLAUDE.md:288-290`). element-x's `AGENTS.md` has no release section today, and -per its own "keep current" rule this is a new fact about the repo, so it belongs there, in caveman voice. - ---- - -## Landing plan - -`Dangerfile.swift:50-51` **fails** the PR unless it carries exactly one `pr-` label. (The empty-body rule -`:24` and the >1000-additions rule `:18-19` are `warn`s, not fails.) - -| PR | Contents | Label | -|---|---|---| -| PR1 | Part 1 — `CLASSIC_APP_*`, entitlement, `.requiresBackup` edit, regenerated pbxproj | `pr-bugfix` | -| PR2 | Part 2 — fastlane, workflow, Gemfile(.lock), .gitignore, HEALTHCHAT_CHANGES.md | `pr-build` | -| PR3 | Part 3 — skill + AGENTS.md delegation | `pr-doc` | - -AGENTS.md's 500-addition guidance argues for keeping these separate rather than combining PR2 and PR3. - ---- - -## Apple Developer portal prerequisites - -Manual, one-time, **blocking** — no signed archive is possible until all three are done. - -1. **Create App ID `ch.healthchat.element.shareextension`** with App Groups + Keychain Sharing, then the - `HealthChat Share Extension: App Store` profile. Genuinely new: the legacy provisioned - `ch.healthchat.element.shareExtension` — **camelCase, a different string** — and disabled the target anyway - (`element-ios/fastlane/.env.default`: `SHARE_EXTENSION_TARGET=`). element-x embeds ShareExtension - unconditionally (`ElementX/SupportingFiles/target.yml:227-228`). -2. **Add `com.apple.developer.usernotifications.communication`** to `ch.healthchat.element`; regenerate - `HealthChat App Store`. New vs. legacy (absent from `Riot/SupportingFiles/Riot.entitlements`). -3. **Add `com.apple.developer.usernotifications.filtering`** to `ch.healthchat.element.nse`; regenerate - `HealthChat NSE: App Store`. New vs. legacy (`RiotNSE.entitlements` has only app-groups + keychain). - -The `HealthChat Broadcast Upload Extension: App Store` profile becomes orphaned — leave it; the legacy -pipeline still uses it. - -Certificate: **not due.** `HEALTHCHAT_CERTIFICATES_P12` was updated 2026-04-07 and the workflow ran green -2026-04-15. Apple Distribution certs last one year → renewal due around **April 2027**. - ---- - -## Verification - -### Part 1 — device-only - -**Step 0 (or the test silently false-negatives).** `AuthenticationService.swift:49-52` short-circuits -`loadAccounts()` entirely when `appSettings.hasSignedInBefore` — which lives in the `group.ch.healthchat` -UserDefaults suite (`AppSettings.swift:56`), survives app deletion via the shared container, and is never -cleared. **Any dev device that has previously run a HealthChat element-x build will show a plain login screen -while the fix works perfectly.** Delete the app before step 1. A `"skipping loadAccounts"` log line means the -run is **void, not failed**. - -1. Install the *shipping* legacy HealthChat on a physical device. Sign in. Send an encrypted message so the - crypto store is populated. -2. Build element-x with the fix and install **over it** — same bundle id → in-place update. **Do not delete - the app first**; that destroys the keychain and container and invalidates the test. -3. Expected: the app finds the account and migrates without a re-login. -4. Confirm the legacy MXID is `@user:healthchat.ch` (the D2 precondition). -5. Exercise **both** secrets paths if possible: `.complete` (migrates) and `.requiresBackup` (must fall - through to sign-in, never the backup sheet). -6. Watch the log for `ClassicAppManagerError` — the failure is silent in the UI. -7. **Send a push notification to the migrated device.** Part 2 adds two brand-new notification entitlements - and a first-ever NSE profile; nothing else exercises delivery, and upload proves signing, not push. - -### Part 2 — pipeline - -Use `workflow_dispatch` with a throwaway version, as the legacy skill prescribes for cert renewal: - -1. Run "Release HealthChat" manually with version **`26.7.99`** and a release-notes override. - **The version must be numeric-only.** It lands verbatim in `CFBundleShortVersionString` (`Info.plist:36-37`) - and Apple requires ≤3 period-separated integers. A marker like `26.07.3-pipeline-test` passes the - workflow's own regex (`:59`) but earns an **ITMS rejection at the final step, after ~30 minutes of - signing** — indistinguishable from a real signing failure. Put markers in the tag or release notes only. -2. Green run → build appears in App Store Connect → TestFlight within ~10 minutes. -3. Discard the build. - -Do **not** verify by submitting to the App Store. TestFlight acceptance proves signing, entitlements and -upload; review proves nothing extra about the pipeline. - -`swiftformat --lint .` and SwiftLint run on build and are currently clean. - ---- - -## Risks & open questions - -| # | Risk | Status | -|---|---|---| -| **O1** | **Was the legacy build on the Rust crypto store?** `cryptoSDKStoreKey` is generated unconditionally (`EncryptionKeyManager.swift:42`), but whether the store is Rust-backed decides if `SecretsBundleWithUserId.fromDatabase` can read it. If it was still Olm, D2 collapses to "forced re-login" and needs product sign-off. **Settle on a real device before the first submission.** | -| **O2** | **iOS floor rises 15.1 → 18.5** (`element-ios/Config/Project.xcconfig:29` vs `project.yml:14-15`). Every user below 18.5 silently stays on 1.4.1 forever — the App Store will not offer the update. Not fixable (upstream's floor); check the install base before shipping. | -| **O3** | **Pusher overwrite.** element-x's `pusherAppID` and device pushkey are identical to the legacy's, so first launch overwrites the user's sygnal pusher in place and the format changes to `.eventIdOnly`. Benign under D1 — but a rollback to 1.4.1 loses push. | -| **O4** | **Runner group visibility** — confirm `novaloop-mac-studio-001` is reachable from element-x-ios. | -| **O5** | `Secrets/Secrets.swift` holds placeholders; `Secrets/Secrets.pkl:12-17` wants MAPLIBRE_API_KEY among others. Maps degrade without it. Not a release blocker — track separately. | -| **O6** | Cosmetic Element strings ship in a HealthChat binary, e.g. `BGTaskSchedulerPermittedIdentifiers: io.element.elementx.background.refresh` (`ElementX/SupportingFiles/target.yml:102-104`). Working; cosmetic only. | -| **O7** | **The legacy element-ios pipeline is probably broken right now — unverified, and worth verifying.** It pins `xcodes(version: "26.4")` and runs on this same runner, which carries only **26.6**. Its last green run was 2026-04-15, so the machine plausibly had 26.4 then and lost it to an upgrade since. If so, the next legacy release does not fail — it **hangs** in `before_all` on an authenticated multi-GB download, and only a timeout ends it. **This fork no longer depends on that pipeline** (D3's "the legacy pipeline is proven" means *was* proven, on a toolchain the machine no longer has), so this is not a blocker here. Confirm the pin against `Novaloop-AG/element-ios` and de-pin it there before its next release. Out of scope for these three PRs — raise it. | -| **O8** | **Whatever Xcode the runner has is what ships.** The pin is gone (see Runner), so upgrading `/Applications/Xcode.app` silently changes the compiler for every subsequent release, with no tag, diff or review. `release-healthchat.yml` records it via the `Record toolchain` step (`xcodebuild -version`) before every build, so each run logs the compiler that produced it — but that log line is the only record, and it detects the change after the fact rather than preventing it. Treat a runner Xcode upgrade as a release-affecting change. | -| **O9** | **A pull request can execute arbitrary code on the release machine.** `unit-tests.yml` runs on `[self-hosted, macOS]` under a `pull_request` trigger (`.github/workflows/unit-tests.yml:9,20`), so any PR's build scripts run on `novaloop-mac-studio-001` — the same persistent runner, keychain and signing identities the release pipeline uses. **Accepted because the repo is INTERNAL**, which narrows PR authors to org members. The trade-off is invisible to CI: zizmor reports it (`self-hosted-runner`, medium) **only at `--persona=auditor`**, and the default persona CI runs suppresses it — so no required check will ever warn. If org membership stops being a sufficient trust boundary: restrict the trigger to non-fork branches, or move back to GitHub-hosted `macos-26` with a raised `timeout-minutes`. | - -**Closed:** the app-group collision question — container paths are disjoint, and the shared -`group.ch.healthchat` UserDefaults suite has a verified-empty key intersection. Captured as a standing hazard -in the Part 3 migration contract rather than an open risk. - -### Corrected prior belief - -An earlier session recorded that a legacy app-lock PIN could lock the new build out via a shared keychain -group. **Refuted.** The two differ in *group, service and account* — `…keychain.shared` vs `…element`; -`…pin-service` vs `…keychain.sessions`; `pin` vs `appLockPINCode` -(`element-ios/Riot/Modules/SetPinCode/PinCodePreferences.swift:20-24,37-38` vs -`ElementX/.../KeychainController.swift:21-23,34-37`). The groups were never shared. The conclusion -("harmless") was right; the mechanism was wrong — and the wrong mechanism is what motivated dropping the -classic keychain group in `01dc703b4`. - -## Out of scope - -App Store submission automation (needs `fastlane/metadata/` + HealthChat screenshots, neither exists), German -localisation, the `to.healthchat.ch` links service, blue outgoing bubbles.