Files
element-x-ios/docs/superpowers/plans/2026-07-15-healthchat-appstore-release.md
T
joel.poyet d7a93dddea Correct the release plan and design for the runner's measured state
Both documents told the reader to pin Xcode 26.4 and to install it via a
Brewfile. A probe of novaloop-mac-studio-001 refutes the premise: it carries
exactly one Xcode, 26.6, globally selected, with no versioned siblings and no
26.4 anywhere. The design justified the pin with "the runner has it and the
legacy pipeline proves it works" — both halves are false, and a pin naming an
absent version does not fail fast: xcodes starts a multi-GB authenticated
download that no CI secret can satisfy, so before_all hangs.

Record the no-pin decision and its cost — whatever Xcode the runner has is what
ships, unrecorded — rather than only deleting the wrong instruction, so nobody
reintroduces the pin from the same reasoning. Drop the Brewfile with it: its only
purpose was installing xcodes for that pin, and brew has no business running on a
shared release machine where it could upgrade xcodegen mid-release.

Add the runner's real constraints (no passwordless sudo, single Xcode, five
missing tools installed by hand) to the design, since they are why upstream's
setup_github_actions_environment cannot run here.

Two new risks. The legacy element-ios pipeline pins the same absent 26.4, so it
is very likely broken right now — not this fork's problem to fix, but the human
needs to know before the next legacy release. And with no pin, an Xcode upgrade
on the runner silently changes the compiler for every subsequent release.

The plan's embedded Fastfile and skill now match the branches that implement
them byte-for-byte, so the plan cannot drift from the artefacts it describes.
2026-10-19 09:17:26 +02:00

60 KiB
Raw Blame History

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

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:

  # 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):

        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):

        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:

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
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:

    @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):

    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
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
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
git checkout release/healthchat && git pull
git checkout -b healthchat/release-pipeline
  • Step 2: Create Gemfile
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
# 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
# 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
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
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

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
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

# 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 <version>` 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:

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
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

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<<NOTES_EOF'
            echo "$NOTES"
            echo 'NOTES_EOF'
          } >> "$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
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.

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):

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
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
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:

---
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<version>` 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 <new-version> (<date>)`
      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<MAJOR>.<MINOR>.<PATCH>
git push origin healthchat-v<MAJOR>.<MINOR>.<PATCH>
```

Example: `git tag healthchat-v26.7.4`

The `healthchat-` prefix keeps these tags clear of upstream's `release/<version>`
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 <version> — automated build from <ref>.` 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 records no Xcode provenance.** With the pin gone, the runner's current
state is the only fact about which compiler built a shipped build, and
`release-healthchat.yml` does not log it — `unit-tests.yml` runs `xcodebuild
-version`, the release path does not. Nothing in a release run's logs or artifacts
will tell you afterwards; read `xcodebuild -version` on the runner while it still
matches.

## 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`,
`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:

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
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:

## 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
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<version> && 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.