Documents the release process this fork actually uses: tagging, the numeric-only version rule, the five required secrets, the runner's hand-installed toolchain, certificate renewal, and promoting a TestFlight build to the App Store. Modelled on the legacy element-ios skill, corrected wherever this fork differs — notably that there is no Xcode pin and no Brewfile, and that only five secrets exist rather than the six the legacy skill lists. The section with no upstream equivalent is the migration contract. CLASSIC_APP_* and the .requiresBackup fall-through are load-bearing for every existing user and fail silently, so a well-meaning cleanup back to upstream's values would sign everyone out with nothing in CI to catch it. AGENTS.md delegates to the skill rather than duplicating the runbook.
15 KiB
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/healthchatwith a clean working tree (git status). HEALTHCHAT_CHANGES.mdhas 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.mdis not used for HealthChat releases — upstream'srelease-to-githubowns it. Only editHEALTHCHAT_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)
# 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.
- GitHub → Actions → "Release HealthChat" → Run workflow.
- Fill in:
- version (required): e.g.
26.7.99. Numeric only — same rule as tags. - release_notes_override (optional): replaces the
HEALTHCHAT_CHANGES.mdlookup. Use this to mark test builds, since the version cannot carry a marker.
- version (required): e.g.
- 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 Xcode, globally selected. A single install at
/Applications/Xcode.app(26.6, build17F113, today) — no versionedXcode_*.appsiblings. Nothing pins it. The Fastfile builds with whateverxcode-selectpoints 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
xcodesinto 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(xcresultparseris not in homebrew-core — onlya7ex/homebrew-formulae/xcresultparserships it).git-lfs,xcodes,brewand rbenvruby/bundleare already present. There is deliberately nobrew bundlestep and noBrewfile: this machine is shared with CI, and a per-runbrew updatecould upgradexcodegenunderneath 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'ssetup_github_actions_environmentcannot run here. APPLE_IDexported as a machine-level shell variable — theAppfilereads 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.
- ~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 - 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".
- Apple Developer portal → Certificates → + → Apple Distribution → upload
the CSR → download the
.cer→ double-click to install. - Regenerate all 3 App Store provisioning profiles — they reference the old
cert and become invalid:
HealthChat App StoreHealthChat NSE: App StoreHealthChat Share Extension: App Store
- Verify locally: archive in Xcode → Distribute App → App Store Connect → Upload. TestFlight accepting the build proves the cert works.
- Export as
.p12: Keychain Access → My Certificates → right-click the newApple Distribution: E-HealthSolutions GmbH→ Export → set a password. - Re-encode and update secrets:
base64 -i healthchat-new.p12 | pbcopy, then replaceHEALTHCHAT_CERTIFICATES_P12andHEALTHCHAT_CERTIFICATES_P12_PASSWORD. - Run the workflow via
workflow_dispatchwith a throwaway numeric version to verify end-to-end. Discard the resulting build. - 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:
- App Store Connect → My Apps → HealthChat → TestFlight → find the build.
- Confirm it passed internal testing.
- Distribution tab → + next to "iOS App" → "Prepare for Submission" → fill in metadata → Add for Review.
- Wait for review (24–48 hours typical).
- 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_*inVariants/HealthChat/healthchat.ymlis load-bearing.ClassicAppManagerreads the legacy account and crypto-store keys through them. Upstream's defaults inapp.ymlpoint at Element's owngroup.im.vector/im.vector.app.*, which this team cannot provision. Never restore upstream's values. Failure is silent —loadAccounts()throws, andAuthenticationServicelogs it without telling the user or you.- The classic keychain group in
ElementX/SupportingFiles/target.ymlmust stay in thekeychain-access-groupsarray. Entitlement arrays live there, never in the variant — XcodeGen concatenates arrays on include-merge and only overrides scalars. - The
.requiresBackupfall-through inAuthenticationStartScreenViewModelis 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.healthchatUserDefaults suite (AppSettings.swift:56). An unqualified@UserPreferencename lands in a suite the legacy also writes to. Collisions are cheap to introduce. hasSignedInBeforevoids migration testing. It lives in that shared suite, survives app deletion, and short-circuitsloadAccounts(). 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 thegroup.ch.healthchatsuite 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 — theCLASSIC_APP_*Info.plist keys are missing,ClassicAppManagernever 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'sMARKETING_VERSION—automatic-calendar-version.ymlandpost-release.ymlare gated ongithub.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'sCLASSIC_APP_*— upstream owns these and they stay pointed at Element's ids. The variant overrides them. Do not "fix"app.yml.