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.
60 KiB
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.elementand team6G37K72W82. 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.pbxprojis generated byxcodegenbut is tracked in git. Config changes are made inproject.yml/app.yml/*/SupportingFiles/target.yml, thenxcodegenregenerates 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.ymldirectly and never in the variant file. - This fork's main branch is
release/healthchat, notmain. All PRs target it. Dangerfile.swift:50-51fails any PR that does not carry exactly onepr-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
.requiresBackuppath 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, build17F113), globally selected viaxcode-select; no versionedXcode_*.appsiblings. Nothing pins it — the build uses whateverxcode-selectpoints 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, rbenvruby/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 areif which …guarded and degrade to a warning). The human installs them once:brew install swiftlint swiftformat pkl xcbeautify a7ex/homebrew-formulae/xcresultparser(xcresultparseris not in homebrew-core). - No passwordless sudo. Nothing in CI may call
sudo. - Also confirm
APPLE_IDis exported as a machine-level shell variable — theAppfilereads 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 like26.07.3-pipeline-testearns 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 pushproduces 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.