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