Write the release skill out in full in the plan

Task 11 described the skill's sections rather than showing them, the one place
the plan told an implementer what to write instead of what to type. Replace it
with the complete SKILL.md.

Writing it surfaced a broken convention worth not copying: the legacy skill
documents healthchat-v1.5.0-rc1 style tag suffixes, but the workflow derives the
marketing version by stripping only the healthchat-v prefix, so the suffix reaches
CFBundleShortVersionString, which Apple caps at three integers. Tags are numeric
only here, and test builds go through workflow_dispatch with the marker in the
release notes.
This commit is contained in:
2026-07-15 11:52:43 +02:00
committed by Sedat Qaja
parent 8c6e4169a8
commit b9f1e0fbda
@@ -923,42 +923,305 @@ git checkout release/healthchat && git pull
git checkout -b healthchat/release-skill
```
- [ ] **Step 2: Write `SKILL.md` with these sections**
- [ ] **Step 2: Write `SKILL.md`**
Frontmatter:
Read `element-ios/.claude/skills/releasing-healthchat/SKILL.md` first for voice. Then write this file verbatim:
```markdown
````markdown
---
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 up front, before `xcodes` runs. 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>
```
Body sections, each corrected for **this** repo rather than copied from the legacy:
Example: `git tag healthchat-v26.7.4`
1. **Preflight checklist** — on `release/healthchat`, clean tree; `HEALTHCHAT_CHANGES.md` has a `## Changes in <version> (<date>)` section at the top.
2. **Cutting a release** — `git tag healthchat-v<version>` + push. The `healthchat-` prefix keeps these clear of upstream's `release/<version>` and `nightly/*` tags.
3. **Version rules** — **numeric only, 1-3 integers.** Explain the 30-minute ITMS trap. The tag is the source of truth; `project.yml:56` is only a default.
4. **Manual / retry path** — `workflow_dispatch` with `version` + `release_notes_override`.
5. **Release notes** — `HEALTHCHAT_CHANGES.md` only. Upstream owns `CHANGES.md`.
6. **Required secrets** — the **five** `HEALTHCHAT_*`. State explicitly that `HEALTHCHAT_KEYCHAIN_PASSWORD` appears in the legacy skill's table but does not exist and is not needed, so nobody re-adds it.
7. **Runner requirements** — org-level `novaloop-mac-studio-001`; Xcode 26.4 via `xcodes`; `xcodegen` installed by hand and deliberately not in `Brewfile`; `APPLE_ID` as a machine-level shell variable.
8. **Never commit the post-lane tree** — a local run rewrites `ElementX.xcodeproj/project.pbxproj` (xcodegen regenerates it; `disable_automatic_code_signing` and `update_project_provisioning` write Manual signing and profile UUIDs). Recover with `git checkout -- ElementX.xcodeproj`.
9. **Certificate renewal** — port the legacy's annual runbook verbatim; it is good. Correct the profile list to main / NSE / **ShareExtension**. Next renewal ~April 2027 (renewed 2026-04-07).
10. **Promoting TestFlight → App Store** — manual, deliberately not automated (needs `fastlane/metadata/` + screenshots, neither exists).
11. **Rollback.**
12. **The migration contract** — no legacy equivalent, and the most important section:
- `CLASSIC_APP_*` in `healthchat.yml` is load-bearing for every existing user and **fails silently**. Never "clean it up" back to upstream's `im.vector` values.
- The `.requiresBackup` fall-through in `AuthenticationStartScreenViewModel` is a deliberate upstream divergence. A rebase that reverts it reintroduces a button that opens nothing.
- Both apps share the `group.ch.healthchat` UserDefaults suite (`AppSettings.swift:56`), so an unqualified `@UserPreference` name lands in a suite the legacy also writes to. Collisions are cheap to introduce.
13. **Rebase divergence** — `fastlane/`, the release workflow, `HEALTHCHAT_CHANGES.md` and this skill are **fork-only**; upstream has no `fastlane/` at all, so the legacy skill's `--ours` guidance does not apply. What does: `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 `project.yml`'s version along.
The `healthchat-` prefix keeps these tags clear of upstream's `release/<version>`
and `nightly/*` namespaces.
- [ ] **Step 3: Verify the skill is discoverable**
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.
- **Xcode 26.4**, selectable via `xcodes` (pinned in `fastlane/Fastfile:before_all`).
**Bumping the pin requires installing the new Xcode on the runner first.**
Otherwise `xcodes` starts a multi-GB authenticated download that no CI secret can
satisfy, and the run hangs in `before_all`.
- **xcodegen** on PATH — installed manually once via `brew install xcodegen`;
deliberately **not** in `Brewfile`, so `brew bundle` will not churn it.
- **Homebrew**, **Ruby** + **bundler**, **Git** on PATH.
- **`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`.
If `brew bundle` installs packages on every run, brew state has drifted from
`Brewfile` — investigate rather than ignore.
## 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`,
`Brewfile`, `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:
```bash
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 -n "xcodes(version:" fastlane/Fastfile # runner section cites the pin
```
Expected: `MARKETING_VERSION` at `project.yml:56`; three `CLASSIC_APP_*` entries in the variant; the Fastfile pin matches the `26.4` the skill documents. 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 4: Commit**
- [ ] **Step 5: Commit**
```bash
git add .agents/skills/releasing-healthchat/SKILL.md