Files
element-x-ios/docs/superpowers/specs/2026-07-15-healthchat-appstore-release-design.md
T
joel.poyet 2387d27f2c Record the self-hosted-runner trade-off as an accepted risk
unit-tests.yml moving to [self-hosted, macOS] on a pull_request trigger lets
any PR run arbitrary code on novaloop-mac-studio-001 — the same persistent
machine, keychain and signing identities the release pipeline uses. The risk
was accepted on the strength of the repo being INTERNAL, but that reasoning
lived nowhere: nothing in the repo said why it was tolerable, and nothing in
CI would ever say it existed.

Worth recording because the trade-off is invisible to the required checks.
zizmor reports self-hosted-runner only at --persona=auditor; the default
persona CI runs suppresses it, so a green build is not evidence of absence.
Verified both halves against the fix-fork-ci workflow: auditor reports it at
medium severity, the default reports "No findings to report … (3 suppressed)".

Records the exits too, so a future reader inheriting this need not rederive
them: restrict the trigger to non-fork branches, or return to GitHub-hosted
macos-26 with a raised timeout-minutes.
2026-10-19 09:17:26 +02:00

31 KiB

HealthChat (element-x-ios) — App Store release capability

Date: 2026-07-15 Branch: release/healthchat Status: Design approved; revised after adversarial review (26 issues, 0 rejected)

Context

Novaloop-AG/element-x-ios is the HealthChat rebrand of Element X. The rebrand landed in 01dc703b4 (branding, fixed homeserver, variant seam). What it cannot do is ship: the repo has no release machinery of any kind — no fastlane/, no Gemfile, and none of its 17 GitHub workflows build or upload anything.

The legacy fork Novaloop-AG/element-ios ships HealthChat to the App Store today via a proven pipeline (last green run 2026-04-15, version 1.4.1). This design brings equivalent capability to element-x-ios and captures the process in a skill.

Both apps use the same bundle id (ch.healthchat.element) and the same Apple team (6G37K72W82). element-x therefore reaches the App Store as an in-place update of the existing HealthChat record. That single fact drives most of this design — including the fact that the two apps can never coexist on a device, which upstream's migration UX assumes they can.

Why upstream's own release path is unavailable

element-hq ships element-x via Xcode Cloud. ci_scripts/ci_post_clone.sh branches on $CI_WORKFLOW and calls swift run pipeline update-foss-secrets (Tools/Sources/Commands/CI/ConfigureProduction.swift:8). pipeline lives only in the private Enterprise submodule (.gitmodules → element-hq/element-ios-enterprise; Enterprise/ is empty here and Novaloop cannot obtain it). Separately ci_scripts/ci_post_xcodebuild.sh:13 unconditionally uploads dSYMs to Element's Sentry org. Xcode Cloud is a dead end for this fork.

Decisions

# Decision Rationale
D1 In-place update of the existing App Store record Identity already aligns. 26.07.3 already clears the legacy's 1.4.1, so no version bump is needed.
D2 Preserve legacy sessions + E2EE keys The migration code already ships; the fix is 3 scalars + 1 entitlement, all HealthChat's own provisionable ids.
D3 Port the legacy fastlane (slim, purpose-built) Xcode Cloud is blocked. The legacy pipeline is proven and reuses the identical team, cert and ASC key.
D4 Migration fix ships first, as its own PR It corrects a defect in 01dc703b4. Note: it is not monotonic — see D9.
D5 Skill lives at .agents/skills/releasing-healthchat/SKILL.md Reachable via the existing .claude/skills -> ../.agents/skills symlink. A new subdirectory cannot conflict on rebase.
D6 Reuse the legacy's HEALTHCHAT_* secrets Same team, cert and ASC key. Reject fastlane match — the legacy deliberately has none.
D7 Keep the healthchat-v* tag namespace Upstream owns release/<version> (ReleaseToGithub.swift:83-85) and nightly/*.
D8 Create HEALTHCHAT_CHANGES.md as the release-notes source element-x's CHANGES.md is owned by upstream's release-to-github. The file does not exist in this repo — it must be created, not carried over.
D9 Fix the .requiresBackup dead-end with a small upstream edit Accepts the first-ever edit to an upstream logic file. Required for D4 to be safe — see Part 1.

Part 1 — Classic migration fix (PR1, pr-bugfix)

The defect

01dc703b4 dropped $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER) from the entitlements with the rationale "not provisionable here". That was correct about Element's identifiers — but the fix is to repoint them at HealthChat's own, not to remove them.

Left as-is, every existing user who updates is silently logged out and loses local encrypted history unless they had key backup. loadAccounts() throws invalidAppGroupIdentifier, caught and logged only — AuthenticationService.swift:59-63: "No need to alert the user of the failure, just log it."

Why the fix is small

The plumbing already ships. ElementX/SupportingFiles/target.yml:78-81 maps the values into Info.plist; InfoPlistReader.swift:28-31,129-143 reads them. ClassicAppManager reads exactly accountIv, accountAesKey, cryptoSDKStoreKey (ClassicAppManager.swift:31-35,61-68) — and the legacy EncryptionKeyManager writes exactly those three names, to service ch.healthchat.element.encryption-manager-service, in access group $(AppIdentifierPrefix)ch.healthchat.element.keychain.shared (element-ios/Riot/Managers/EncryptionKeyManager/EncryptionKeyManager.swift:17,20-25,27).

Only the scalars are wrong: app.yml:11-13 points them at Element's group.im.vector / im.vector.app.*, and healthchat.yml does not override them.

Verified mechanics (these were the design's load-bearing assumptions):

  • XcodeGen scalar precedence. healthchat.yml is included after app.yml (project.yml:71 vs :61) and wins — proven by the already-generated pbxproj, where APP_GROUP_IDENTIFIER = group.ch.healthchat and BASE_BUNDLE_IDENTIFIER = ch.healthchat.element beat app.yml's Element values.
  • No trailing-dot bug. $(AppIdentifierPrefix) carries its own trailing dot, so the legacy resolves to 6G37K72W82.ch.healthchat.element.keychain.shared. $(DEVELOPMENT_TEAM).ch.healthchat.element.keychain.shared is byte-equivalent and matches upstream's own convention (app.yml:13).

The change

In Variants/HealthChat/healthchat.yml:

CLASSIC_APP_GROUP_IDENTIFIER: group.ch.healthchat
CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER: ch.healthchat.element.encryption-manager-service
CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER: "$(DEVELOPMENT_TEAM).ch.healthchat.element.keychain.shared"

In ElementX/SupportingFiles/target.yml, restore the classic keychain group to keychain-access-groups and correct the misleading comment at :132:

keychain-access-groups:
- $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER)
# HealthChat: the legacy app's keychain group — ClassicAppManager reads the account + crypto store
# keys from here to migrate existing users. Same team prefix, so it is provisionable.
- $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER)

Do not re-add $(CLASSIC_APP_GROUP_IDENTIFIER) to com.apple.security.application-groups: it resolves to group.ch.healthchat, already the value of $(APP_GROUP_IDENTIFIER), so it would duplicate the entry. Correct the comment at :128 to say this rather than implying the group was dropped as un-provisionable.

CLASSIC_APP_DEEP_LINK_URL needs no override — legacy HealthChat registers the element scheme (element-ios/Config/AppIdentifiers.xcconfig:22) — but see the .requiresBackup fix below, which makes it moot.

Finally: run xcodegen and commit both ElementX.xcodeproj/project.pbxproj and ElementX/SupportingFiles/ElementX.entitlements. Both are tracked and not ignored. Without this the checked-in entitlements still lack the classic group and anyone building the branch gets the un-fixed, silently-failing behaviour.

The .requiresBackup fix (D9) — why Part 1 is not monotonic

availableSecrets maps .withoutBackup / .unusedBackup → .requiresBackup (ClassicAppManager.swift:82-85). login(classicAppAccount:) routes that to state.bindings.showClassicAppBackupInstructions, whose sheet's only button fires .openClassicApp → appMediator.open(element://open) (AuthenticationStartScreenViewModel.swift:106).

That URL resolves to nothing. element-x registers only $(BASE_BUNDLE_IDENTIFIER) and matrix (ElementX/SupportingFiles/target.yml:60-63) — no element scheme — and the legacy app that did register it has been replaced in place by D1's same-bundle-id update. Upstream's UX assumes Element Classic (im.vector.app) and Element X (io.element.elementx) coexist as separate apps. HealthChat cannot.

So making loadAccounts() succeed switches on a UI branch that is worse than the status quo for .requiresBackup users: today they see a working login screen; after the fix they see a modal urging them to open an app that no longer exists. The sheet is dismissible (@Environment(\.dismiss)) and otherOptions (AuthenticationClassicAppAccountView.swift:106) is an escape hatch, so it is not a hard lockout — but it is a knowingly broken call-to-action.

Fix: in AuthenticationStartScreenViewModel.login(classicAppAccount:), treat .requiresBackup the same way .unavailable is already treated — skip the sheet and fall through to configureAccountProvider(account.serverName, loginHint: "mxid:\(account.userID)", fallbackHomeserverURL:). Those users sign into their own account and re-verify the device, losing only history that by definition has no backup.

This is the first edit to an upstream logic file in this fork, ending the rebrand's "no upstream logic edits" property. It is ~3 lines in a method upstream rarely touches. Record it in the skill's divergence section so a future rebase does not silently revert it.

Precondition for D2 (verified, but confirm on device)

AuthenticationStartScreenViewModel:46 gates the whole classic flow on appSettings.accountProviders.contains(serverName), where serverName is derived from the MXID domain (ClassicAppAccountManager.swift:107-111), not the homeserver URL. HealthChat pins accountProviders: ["healthchat.ch"].

curl https://healthchat.ch/.well-known/matrix/server → {"m.server": "matrix.healthchat.ch:8448"}. This is server-name delegation: the server is named healthchat.ch and merely reachable at matrix.healthchat.ch:8448. Legacy MXIDs are therefore @user:healthchat.ch, the gate passes, and D2 holds. Confirm against a real legacy MXID during device testing.


Part 2 — Release pipeline (PR2, pr-build)

Design note: this fork owns fastlane/ outright

Upstream element-x-ios has no fastlane/ directory. The legacy skill devotes a whole section to rebase divergence in fastlane/.env.default and fastlane/Fastfile precisely because those are upstream-owned in element-ios. Here that class of conflict does not exist — which is why this is a slim purpose-built Fastfile (~120 lines), not a copy of Vector's ~600-line multi-variant one.

Dropped: cocoapods (element-x is SPM), diawi, sentry (HealthChat has no Sentry), and the adhoc / alpha / setup_app_variant lanes. Siri-intents and broadcast-upload have no counterpart target here.

Files

File Purpose
fastlane/Fastfile Public release_testflight + private helpers. No before_all, no Xcode pin — see runner notes.
fastlane/Appfile apple_id ENV["APPLE_ID"], team_id ENV["TEAM_ID"]
fastlane/.env.default Project/target/bundle-id/profile configuration (full key list below)
fastlane/Pluginfile fastlane-plugin-xcodegen only
Gemfile + Gemfile.lock fastlane + plugins. No cocoapods, no slather. Commit the lock — the workflow's gem cache keys on hashFiles('**/Gemfile.lock'); without it the key is constant and fastlane silently re-resolves between two runs of the same tag.
HEALTHCHAT_CHANGES.md New file (D8). Seed with a ## Changes in 26.07.3 (<date>) section matching the awk pattern.
.gitignore Add out/ and provisioning_profiles/ (signing material — security-relevant). build (:35) and fastlane/report.xml (:28) are already covered upstream.

Drop fastlane-plugin-versioning. The legacy carries it but never uses it — its update_marketing_version is raw File.read/gsub!, and no increment_version_number / get_version_number / update_info_plist call exists anywhere. Copying it into a file we call "slim and purpose-built" would propagate dead weight.

.env.default

PROJECT_PATH=ElementX.xcodeproj
SCHEME=ElementX
MAIN_TARGET=ElementX
NSE_TARGET=NSE
SHARE_EXTENSION_TARGET=ShareExtension

MAIN_BUNDLE_ID=ch.healthchat.element
NSE_BUNDLE_ID=ch.healthchat.element.nse
SHARE_EXTENSION_BUNDLE_ID=ch.healthchat.element.shareextension

DERIVED_DATA_PATH=build
IPA_NAME=ElementX
ARCHIVE_PATH=out/ElementX.xcarchive
BUILD_OUTPUT_DIRECTORY=out
BUILD_LOG_DIRECTORY=build/Logs/Gym/

PROVISIONING_PROFILES_PATH=./provisioning_profiles/
MAIN_PROVISIONING_PROFILE_FILENAME=main.mobileprovision
NSE_PROVISIONING_PROFILE_FILENAME=nse.mobileprovision
SHARE_EXTENSION_PROVISIONING_PROFILE_FILENAME=share_extension.mobileprovision

APPSTORE_CODESIGNING_IDENTITY="Apple Distribution: E-HealthSolutions GmbH (6G37K72W82)"
APPSTORE_SIGNING_CERTIFICATE="Apple Distribution: E-HealthSolutions GmbH (6G37K72W82)"
APPSTORE_MAIN_PROVISIONING_PROFILE_SPECIFIER="HealthChat App Store"
APPSTORE_NSE_PROVISIONING_PROFILE_SPECIFIER="HealthChat NSE: App Store"
APPSTORE_SHARE_EXTENSION_PROVISIONING_PROFILE_SPECIFIER="HealthChat Share Extension: App Store"

TEAM_ID=6G37K72W82
# APPLE_ID is a machine-level shell variable on the runner, not a repo secret.

APPSTORE_CODESIGNING_IDENTITY and APPSTORE_SIGNING_CERTIFICATE are distinct keys in the legacy — both are needed. The out/ElementX.ipa artifact path is only true because BUILD_OUTPUT_DIRECTORY/IPA_NAME are set here.

Verified: bundle ids from NSE/SupportingFiles/target.yml:75 and ShareExtension/SupportingFiles/target.yml:77; schemes/targets from ElementX/SupportingFiles/target.yml:3-4,41, NSE/SupportingFiles/target.yml:3-4,30, ShareExtension/SupportingFiles/target.yml:3-4,30.

release_testflight lane

Mirrors element-ios/fastlane/Fastfile:199-224: accept inputs via options (local) or env vars (CI, avoiding shell-escaping of multi-line notes), then:

  1. app_store_connect_api_key(...) from APPSTORECONNECT_KEY_{ID,ISSUER_ID,CONTENT} when is_ci.
  2. generate_build_number → YYYYMMDDHHMMSS (ported from Fastfile:562). Monotonic; makes retries free.
  3. xcodegen(spec: "project.yml").
  4. download_provisioning_profiles → three profiles (main, NSE, ShareExtension).
  5. disable_automatic_code_signing + update_project_provisioning per target.
  6. build_app, export_method: "app-store", CODE_SIGN_STYLE=Manual.
  7. upload_to_testflight(skip_submission: true, distribute_external: false, changelog: release_notes).

Version injection. element-x holds MARKETING_VERSION: 26.07.3 / CURRENT_PROJECT_VERSION: 1 in project.yml:56-57, consumed by XcodeGen at generate time (unlike the legacy's AppVersion.xcconfig, read at build time). Pass both via xcargs to build_app instead of rewriting project.yml. Verified sound: NSE (:51-52,76-77) and ShareExtension (:46-47,78-79) both resolve their version keys from $(MARKETING_VERSION)/$(CURRENT_PROJECT_VERSION) at build time, and xcodebuild command-line settings override project and target level uniformly — so all three targets stay version-consistent. This matters: App Store rejects an upload whose extension version disagrees with the app's.

This spares project.yml, but it does not mean "no tracked file is mutated" — ElementX.xcodeproj/project.pbxproj is tracked and steps 3 and 5 rewrite it (xcodegen regenerates; disable_automatic_code_signing / update_project_provisioning write Manual signing and profile UUIDs). The pbxproj footgun moved, it did not vanish. The skill must say: never commit the post-lane tree; git checkout -- ElementX.xcodeproj.

GitHub workflow

.github/workflows/release-healthchat.yml — near-copy of the legacy's, which is already well-built (pinned action SHAs, contents: read, concurrency group, 90-minute timeout, awk notes extraction, artifact upload).

Changes from the legacy:

  • submodules: false — critical. element-x-ios's only submodule is the private element-hq/element-ios-enterprise over SSH; the legacy's submodules: 'true' would fail checkout on step one of every run.
  • Drop the CocoaPods cache and rm -rf Pods/MatrixSDK.
  • Drop MX_GIT_BRANCH (legacy :41) — no mx_git_branch helper exists in the slim Fastfile.
  • Re-evaluate fetch-depth: 0 (legacy :48); keep only if something needs history.
  • Drop the brew bundle step (legacy :115) — there is no Brewfile, and the runner is provisioned by hand. See runner notes.
  • bundle exec fastlane release_testflight; artifacts out/ElementX.ipa, out/ElementX.app.dSYM.zip.
  • Harden the notes step with [[ -f HEALTHCHAT_CHANGES.md ]]. The legacy's "warning fallback" only fires on an empty match, not an absent file — under set -euo pipefail a missing file aborts the job.

Do not carry HEALTHCHAT_KEYCHAIN_PASSWORD forward as mandatory. The legacy skill lists it among six required secrets and the legacy workflow references it, but it exists at neither repo nor org level — and the pipeline runs green regardless. apple-actions/import-codesign-certs tolerates its absence. Five secrets is the real requirement.

Runner

novaloop-mac-studio-001 — org-level, online, labels [self-hosted, macOS, ARM64]; already satisfies runs-on: [self-hosted, macOS]. Confirm the org runner group's visibility includes element-x-ios (two Default groups exist, one all, one selected).

No Xcode pin. Measured on the runner today (user novaloop, arm64, macOS 26.5.2): exactly one Xcode, /Applications/Xcode.app — 26.6, build 17F113, xcode-selected and the only version xcodes lists. No versioned Xcode_*.app siblings exist.

An earlier draft said to pin "26.4" because "the runner has it and the legacy pipeline proves it works". Both halves were wrong. The runner does not have 26.4 and never will; a pin naming an absent version does not fail fast — xcodes starts a multi-GB authenticated download needing XCODES_USERNAME/XCODES_PASSWORD, which none of the five secrets supply, so before_all hangs and takes the release with it. Since the runner carries a single globally-selected Xcode, whatever xcode-select points at is the build toolchain, and a pin could only ever disagree with reality. Drop it entirely; the Xcode version becomes a property of the machine, changed by provisioning it rather than by editing a tracked file. The reasoning about ci_scripts/ci_common.sh:34 (sudo xcode-select -s /Applications/Xcode_26.5.0.app) still stands — it is an Xcode Cloud image path, not an xcodes version identifier, and says nothing about this runner.

The cost of dropping it: whatever the runner has is what ships, and no release run records which Xcode built it (release-healthchat.yml does not log xcodebuild -version; unit-tests.yml does). Upgrading the runner's Xcode is therefore a release-affecting change with no tag, diff or review. Accepted — the alternative pins a version that does not exist. Captured in the skill.

The runner is provisioned by hand, never by CI. Upstream's setup_github_actions_environment (ci_scripts/ci_common.sh) cannot run here: it calls sudo xcode-select -s /Applications/Xcode_26.5.0.app — there is no passwordless sudo and no such path — and brew update && brew install …, which on this shared release machine could upgrade xcodegen underneath a running release. CI must verify and fail loudly, naming the fix, instead.

Present today: xcodes, xcodegen, git-lfs, brew, swift, rbenv ruby/bundle/gem, and APPLE_ID exported as a machine-level shell variable (the Appfile reads it; it is not repo-carried). Missing today — the human installs these once, before the test workflow can pass: swiftlint, swiftformat, pkl, xcresultparser, xcbeautify. Only xcresultparser is outside homebrew-core (a7ex/homebrew-formulae/xcresultparser). The unit-test job asserts all of them. The release lane requires none: its SwiftLint/SwiftFormat build phases are if which … guarded and degrade to a build warning when the tool is absent (ElementX/SupportingFiles/target.yml:202-214), and nothing in the build reads pkl.

Secrets to copy

element-x-ios has zero repository secrets. Copy these five from Novaloop-AG/element-ios: HEALTHCHAT_ASC_KEY_ID, HEALTHCHAT_ASC_KEY_ISSUER_ID, HEALTHCHAT_ASC_KEY_CONTENT, HEALTHCHAT_CERTIFICATES_P12, HEALTHCHAT_CERTIFICATES_P12_PASSWORD.

Requires the snowping GitHub account — Novaloop-AG/element-x-ios is INTERNAL and invisible to joel-poyet_GALGH.


Part 3 — Release skill (PR3, pr-doc)

.agents/skills/releasing-healthchat/SKILL.md, reachable as a project skill through the existing .claude/skills -> ../.agents/skills symlink.

Mirror the legacy skill's shape — it is a good model: two-field frontmatter (name, description), terse imperative prose, checklists and tables, rationale inline on every prohibition. Sections: preflight, cutting a release, workflow_dispatch retry path, tag format, release notes, post-release checks, required secrets, runner requirements, certificate renewal, TestFlight → App Store promotion, rollback, divergence.

Corrected for this repo:

  • Five required secrets, not six.
  • Version source is project.yml:56, not Config/AppVersion.xcconfig.
  • Three profiles (main, NSE, ShareExtension), not main/NSE/broadcast.
  • Never commit the post-lane tree — git checkout -- ElementX.xcodeproj after a local run.
  • Test versions must be numeric-only (see Verification) — this trap costs 30 minutes per hit.
  • The divergence section shrinks: fastlane/ and the release workflow are fork-only here, so the legacy's --ours guidance for .env.default/Fastfile does not apply. What does need calling out: automatic-calendar-version.yml and post-release.yml are gated on github.repository == 'element-hq/element-x-ios' and silently no-op in the fork, so rebases drag project.yml's version along — the tag is the source of truth.
  • New section — the migration contract (no legacy equivalent): CLASSIC_APP_* in healthchat.yml is load-bearing for existing users and fails silently; it must never be "cleaned up" to upstream's values. The .requiresBackup edit in AuthenticationStartScreenViewModel is a deliberate upstream divergence — a rebase that reverts it reintroduces a broken CTA. Both apps share the group.ch.healthchat UserDefaults suite (AppSettings.swift:56), so unqualified @UserPreference property names land in a suite the legacy also writes to — future key collisions are cheap to introduce.

Add a 2-line delegation in AGENTS.md pointing at the skill rather than duplicating the runbook — the legacy does exactly this (element-ios/CLAUDE.md:288-290). element-x's AGENTS.md has no release section today, and per its own "keep current" rule this is a new fact about the repo, so it belongs there, in caveman voice.


Landing plan

Dangerfile.swift:50-51 fails the PR unless it carries exactly one pr- label. (The empty-body rule :24 and the >1000-additions rule :18-19 are warns, not fails.)

PR Contents Label
PR1 Part 1 — CLASSIC_APP_*, entitlement, .requiresBackup edit, regenerated pbxproj pr-bugfix
PR2 Part 2 — fastlane, workflow, Gemfile(.lock), .gitignore, HEALTHCHAT_CHANGES.md pr-build
PR3 Part 3 — skill + AGENTS.md delegation pr-doc

AGENTS.md's 500-addition guidance argues for keeping these separate rather than combining PR2 and PR3.


Apple Developer portal prerequisites

Manual, one-time, blocking — no signed archive is possible until all three are done.

  1. Create App ID ch.healthchat.element.shareextension with App Groups + Keychain Sharing, then the HealthChat Share Extension: App Store profile. Genuinely new: the legacy provisioned ch.healthchat.element.shareExtension — camelCase, a different string — and disabled the target anyway (element-ios/fastlane/.env.default: SHARE_EXTENSION_TARGET=). element-x embeds ShareExtension unconditionally (ElementX/SupportingFiles/target.yml:227-228).
  2. Add com.apple.developer.usernotifications.communication to ch.healthchat.element; regenerate HealthChat App Store. New vs. legacy (absent from Riot/SupportingFiles/Riot.entitlements).
  3. Add com.apple.developer.usernotifications.filtering to ch.healthchat.element.nse; regenerate HealthChat NSE: App Store. New vs. legacy (RiotNSE.entitlements has only app-groups + keychain).

The HealthChat Broadcast Upload Extension: App Store profile becomes orphaned — leave it; the legacy pipeline still uses it.

Certificate: not due. HEALTHCHAT_CERTIFICATES_P12 was updated 2026-04-07 and the workflow ran green 2026-04-15. Apple Distribution certs last one year → renewal due around April 2027.


Verification

Part 1 — device-only

Step 0 (or the test silently false-negatives). AuthenticationService.swift:49-52 short-circuits loadAccounts() entirely when appSettings.hasSignedInBefore — which lives in the group.ch.healthchat UserDefaults suite (AppSettings.swift:56), survives app deletion via the shared container, and is never cleared. Any dev device that has previously run a HealthChat element-x build will show a plain login screen while the fix works perfectly. Delete the app before step 1. A "skipping loadAccounts" log line means the run is void, not failed.

  1. Install the shipping legacy HealthChat on a physical device. Sign in. Send an encrypted message so the crypto store is populated.
  2. Build element-x with the fix and install over it — same bundle id → in-place update. Do not delete the app first; that destroys the keychain and container and invalidates the test.
  3. Expected: the app finds the account and migrates without a re-login.
  4. Confirm the legacy MXID is @user:healthchat.ch (the D2 precondition).
  5. Exercise both secrets paths if possible: .complete (migrates) and .requiresBackup (must fall through to sign-in, never the backup sheet).
  6. Watch the log for ClassicAppManagerError — the failure is silent in the UI.
  7. Send a push notification to the migrated device. Part 2 adds two brand-new notification entitlements and a first-ever NSE profile; nothing else exercises delivery, and upload proves signing, not push.

Part 2 — pipeline

Use workflow_dispatch with a throwaway version, as the legacy skill prescribes for cert renewal:

  1. Run "Release HealthChat" manually with version 26.7.99 and a release-notes override. The version must be numeric-only. It lands verbatim in CFBundleShortVersionString (Info.plist:36-37) and Apple requires ≤3 period-separated integers. A marker like 26.07.3-pipeline-test passes the workflow's own regex (:59) but earns an ITMS rejection at the final step, after ~30 minutes of signing — indistinguishable from a real signing failure. Put markers in the tag or release notes only.
  2. Green run → build appears in App Store Connect → TestFlight within ~10 minutes.
  3. Discard the build.

Do not verify by submitting to the App Store. TestFlight acceptance proves signing, entitlements and upload; review proves nothing extra about the pipeline.

swiftformat --lint . and SwiftLint run on build and are currently clean.


Risks & open questions

# Risk Status
O1 Was the legacy build on the Rust crypto store? cryptoSDKStoreKey is generated unconditionally (EncryptionKeyManager.swift:42), but whether the store is Rust-backed decides if SecretsBundleWithUserId.fromDatabase can read it. If it was still Olm, D2 collapses to "forced re-login" and needs product sign-off. Settle on a real device before the first submission.
O2 iOS floor rises 15.1 → 18.5 (element-ios/Config/Project.xcconfig:29 vs project.yml:14-15). Every user below 18.5 silently stays on 1.4.1 forever — the App Store will not offer the update. Not fixable (upstream's floor); check the install base before shipping.
O3 Pusher overwrite. element-x's pusherAppID and device pushkey are identical to the legacy's, so first launch overwrites the user's sygnal pusher in place and the format changes to .eventIdOnly. Benign under D1 — but a rollback to 1.4.1 loses push.
O4 Runner group visibility — confirm novaloop-mac-studio-001 is reachable from element-x-ios.
O5 Secrets/Secrets.swift holds placeholders; Secrets/Secrets.pkl:12-17 wants MAPLIBRE_API_KEY among others. Maps degrade without it. Not a release blocker — track separately.
O6 Cosmetic Element strings ship in a HealthChat binary, e.g. BGTaskSchedulerPermittedIdentifiers: io.element.elementx.background.refresh (ElementX/SupportingFiles/target.yml:102-104). Working; cosmetic only.
O7 The legacy element-ios pipeline is probably broken right now — unverified, and worth verifying. It pins xcodes(version: "26.4") and runs on this same runner, which carries only 26.6. Its last green run was 2026-04-15, so the machine plausibly had 26.4 then and lost it to an upgrade since. If so, the next legacy release does not fail — it hangs in before_all on an authenticated multi-GB download, and only a timeout ends it. This fork no longer depends on that pipeline (D3's "the legacy pipeline is proven" means was proven, on a toolchain the machine no longer has), so this is not a blocker here. Confirm the pin against Novaloop-AG/element-ios and de-pin it there before its next release. Out of scope for these three PRs — raise it.
O8 Whatever Xcode the runner has is what ships, and no release run records it. The pin is gone (see Runner), so upgrading /Applications/Xcode.app silently changes the compiler for every subsequent release, with no tag, diff or review. Mitigated only by documentation in the skill. Adding xcodebuild -version to release-healthchat.yml would give provenance cheaply — worth doing.
O9 A pull request can execute arbitrary code on the release machine. unit-tests.yml runs on [self-hosted, macOS] under a pull_request trigger (.github/workflows/unit-tests.yml:9,20), so any PR's build scripts run on novaloop-mac-studio-001 — the same persistent runner, keychain and signing identities the release pipeline uses. Accepted because the repo is INTERNAL, which narrows PR authors to org members. The trade-off is invisible to CI: zizmor reports it (self-hosted-runner, medium) only at --persona=auditor, and the default persona CI runs suppresses it — so no required check will ever warn. If org membership stops being a sufficient trust boundary: restrict the trigger to non-fork branches, or move back to GitHub-hosted macos-26 with a raised timeout-minutes.

Closed: the app-group collision question — container paths are disjoint, and the shared group.ch.healthchat UserDefaults suite has a verified-empty key intersection. Captured as a standing hazard in the Part 3 migration contract rather than an open risk.

Corrected prior belief

An earlier session recorded that a legacy app-lock PIN could lock the new build out via a shared keychain group. Refuted. The two differ in group, service and account — …keychain.shared vs …element; …pin-service vs …keychain.sessions; pin vs appLockPINCode (element-ios/Riot/Modules/SetPinCode/PinCodePreferences.swift:20-24,37-38 vs ElementX/.../KeychainController.swift:21-23,34-37). The groups were never shared. The conclusion ("harmless") was right; the mechanism was wrong — and the wrong mechanism is what motivated dropping the classic keychain group in 01dc703b4.

Out of scope

App Store submission automation (needs fastlane/metadata/ + HealthChat screenshots, neither exists), German localisation, the to.healthchat.ch links service, blue outgoing bubbles.