Files
element-x-ios/.agents/skills/releasing-healthchat/SKILL.md
T

17 KiB
Raw Blame History

name, description
name description
releasing-healthchat 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.

Store version ≠ bundle version, by decision (2026-10-16). App Store Connect shows the 2.x line (2.0.0 shipped the 26.10.1 build; next is 2.1.0), entered by hand when the store version is created. The tag/bundle version keeps the calendar scheme and must exceed the previous one. Apple tolerates the mismatch today but documents that they "should match" — if that ever gets enforced, align the store label to the bundle version.

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)

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

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

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 machine, three names: novaloop-mac-studio-001 in GitHub, hostname novaloop-mac-studio-2 (macOS appended -2 in October 2026), novaloops-mac-studio on Tailscale.

  • One Xcode, globally selected. A single install at /Applications/Xcode.app (27.1, build 27A9275, since 2026-10-16; upgraded in place via the App Store) — no versioned Xcode_*.app siblings. Upstream 26.10.3+ needs the iOS 27 SDK. Nothing pins it. The Fastfile builds with whatever xcode-select points at, so whatever the runner has is what ships. Upgrading the runner's Xcode silently changes the compiler every subsequent release is built with — no tag, no diff, no review. Treat that upgrade as a release-affecting change.
  • Do not reintroduce a version pin. Pinning a version the runner lacks sends xcodes into a multi-GB authenticated download that no CI secret can satisfy, and the run hangs. Change the runner's Xcode instead.
  • Toolchain installed once by hand, never by CI: xcodegen swiftlint swiftformat xcbeautify xcresultparser pkl (xcresultparser is not in homebrew-core — only a7ex/homebrew-formulae/xcresultparser ships it). git-lfs, xcodes, brew and rbenv ruby/bundle are already present. There is deliberately no brew bundle step and no Brewfile: this machine is shared with CI, and a per-run brew update could upgrade xcodegen underneath a running release. CI asserts the tools are present and fails loudly naming the fix; it never provisions.
  • No passwordless sudo. Nothing in CI may call sudo — that is why upstream's setup_github_actions_environment cannot run here.
  • APPLE_ID exported as a machine-level shell variable — the Appfile reads it and it is not carried in any secret.
  • Network access to developer.apple.com, api.appstoreconnect.apple.com, github.com, rubygems.org.

A release's only Xcode provenance is its own log. release-healthchat.yml runs xcodebuild -version (Record toolchain) before every build, so each run records the compiler that produced it. Nothing pins that version — upgrading the runner's Xcode silently changes the compiler every subsequent release is built with, and that log line is the only place it shows up. Do not reconstruct a past build's toolchain from the runner: it answers for today, not for the build you are asking about. Read the run's log.

Renewing the Apple Distribution certificate (annual)

Apple Distribution certificates are valid for exactly 1 year. There is no longer-lived option. Last renewed 2026-04-07, so the next is due around April 2027. Renew proactively.

  1. ~4 weeks before expiry, check on the Mac holding the private key:
    security find-certificate -c "Apple Distribution: E-HealthSolutions GmbH" -p | openssl x509 -noout -enddate
    
  2. Generate a CSR on whichever Mac will hold the new private key: Keychain Access → Certificate Assistant → Request a Certificate from a Certificate Authority… → "Saved to disk".
  3. Apple Developer portal → Certificates → + → Apple Distribution → upload the CSR → download the .cer → double-click to install.
  4. Regenerate all 3 App Store provisioning profiles — they reference the old cert and become invalid:
    • HealthChat App Store
    • HealthChat NSE: App Store
    • HealthChat Share Extension: App Store
  5. Verify locally: archive in Xcode → Distribute App → App Store Connect → Upload. TestFlight accepting the build proves the cert works.
  6. Export as .p12: Keychain Access → My Certificates → right-click the new Apple Distribution: E-HealthSolutions GmbH → Export → set a password.
  7. Re-encode and update secrets: base64 -i healthchat-new.p12 | pbcopy, then replace HEALTHCHAT_CERTIFICATES_P12 and HEALTHCHAT_CERTIFICATES_P12_PASSWORD.
  8. Run the workflow via workflow_dispatch with a throwaway numeric version to verify end-to-end. Discard the resulting build.
  9. Revoke the old cert once the new one works in both Xcode and CI.

Keep the expired .p12 archived for 2 weeks in case a rollback is needed.

Promoting a TestFlight build to the App Store

TestFlight and App Store builds are the same binary. To release a validated one:

  1. App Store Connect → My Apps → HealthChat → TestFlight → find the build.
  2. Confirm it passed internal testing.
  3. Distribution tab → + next to "iOS App" → "Prepare for Submission" → fill in metadata → Add for Review.
  4. Wait for review (24–48 hours typical).
  5. Release manually, or configure automatic release.

Deliberately not automated. Automating submission needs fastlane/metadata/ plus HealthChat-branded screenshots (neither exists) and a Deliverfile refactored for API key auth.

Before the first submission, confirm the install base against the iOS floor: element-x requires iOS 18.5, the legacy required 15.1. Every user below 18.5 stays on 1.4.1 forever — the App Store will not offer them the update, silently. That floor is upstream's and cannot be lowered.

Rollback

  • Bad build, notes fine → fix, commit, tag the next patch version. TestFlight supersedes the bad build. (Build numbers are timestamps, so no version games.)
  • Bad release notes only → edit HEALTHCHAT_CHANGES.md, then update the TestFlight "What to Test" text in App Store Connect by hand — the pipeline only writes it at upload time.
  • Wrong version number → treat as burned; tag a higher one.
  • Rolling back to legacy 1.4.1 loses push — element-x overwrites the user's sygnal pusher in place on first launch (same app id and pushkey).

The migration contract

Read this before "cleaning up" anything. HealthChat replaces the legacy app in place, and these are what stop existing users being silently logged out.

  • CLASSIC_APP_* in Variants/HealthChat/healthchat.yml is load-bearing. ClassicAppManager reads the legacy account and crypto-store keys through them. Upstream's defaults in app.yml point at Element's own group.im.vector / im.vector.app.*, which this team cannot provision. Never restore upstream's values. Failure is silent — loadAccounts() throws, and AuthenticationService logs it without telling the user or you.
  • The classic keychain group in ElementX/SupportingFiles/target.yml must stay in the keychain-access-groups array. Entitlement arrays live there, never in the variant — XcodeGen concatenates arrays on include-merge and only overrides scalars.
  • The .requiresBackup fall-through in AuthenticationStartScreenViewModel is a deliberate upstream divergence. Upstream sends those accounts to a sheet that tells the user to open Element Classic. That app no longer exists — this update replaced it — and the sheet's button opens a URL scheme nothing registers. A rebase that reverts this reintroduces a dead button.
  • Both apps share the group.ch.healthchat UserDefaults suite (AppSettings.swift:56). An unqualified @UserPreference name lands in a suite the legacy also writes to. Collisions are cheap to introduce.
  • hasSignedInBefore voids migration testing. It lives in that shared suite, survives app deletion, and short-circuits loadAccounts(). Any device that has run an element-x HealthChat build shows a plain login screen while migration works perfectly. Test on a device that has never run an element-x HealthChat build, or clear the group.ch.healthchat suite first; "Already signed in to Element X before, skipping loadAccounts" in the log means the run is void, not failed. A "Classic App not configured, skipping loadAccounts" line is the opposite — the CLASSIC_APP_* Info.plist keys are missing, ClassicAppManager never initialised, and migration is broken for every user. Investigate, do not dismiss.

Known branch divergence on rebase

fastlane/, .github/workflows/release-healthchat.yml, HEALTHCHAT_CHANGES.md, Gemfile and this skill are fork-only — upstream element-x-ios has no fastlane/ directory at all. A rebase conflict in any of them means something upstream has started to overlap; keep --ours and investigate.

What does need watching:

  • project.yml's MARKETING_VERSION — automatic-calendar-version.yml and post-release.yml are gated on github.repository == 'element-hq/element-x-ios' and silently no-op here, so rebases drag upstream's version along. Harmless: the tag is the source of truth.
  • Upstream files this fork edits (expect conflicts here on every sync): AuthenticationStartScreenViewModel (migration fall-through, see "The migration contract"), AppHooks/UserHooks.swift (two HealthChat hook fields), AppSettings.swift (VoIP push properties for the NSE), ElementCallService.swift (PushKit token storage, expiration parsing), NSE/Sources/NotificationHandler.swift (VoIP push request to Sygnal), UserSessionFlowCoordinator (Members tab), the SettingsScreen files, RoomMemberDetailsScreen and UserProfileScreen (extended profile section).
  • Sync strategy: patch stack, like healthchat-web. release/healthchat is upstream's release tag plus the HealthChat commits on top, nothing else. To sync: rebase the stack onto the new upstream tag (or rebuild it from the resolved final tree, one commit per topic), verify tree and tests, then force-push release/healthchat. Older release tags keep their commits alive but leave the branch line, as on the web fork after its rebase onto v1.12.21. AGENTS.md's no-rewrite rule is about PR branches; this sync is the one sanctioned rewrite.
  • Unit tests need an iOS 27 simulator. Upstream's video-size expectations are encoder-dependent; on iOS 26.5 two MediaUploadingPreprocessorTests fail for no real reason.
  • 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.