diff --git a/.agents/skills/releasing-healthchat/SKILL.md b/.agents/skills/releasing-healthchat/SKILL.md new file mode 100644 index 000000000..e9df096df --- /dev/null +++ b/.agents/skills/releasing-healthchat/SKILL.md @@ -0,0 +1,298 @@ +--- +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` 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 ()` + 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.. +git push origin healthchat-v.. +``` + +Example: `git tag healthchat-v26.7.4` + +The `healthchat-` prefix keeps these tags clear of upstream's `release/` +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 — automated build from .` 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'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: + ```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*. 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. +- **`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`. diff --git a/AGENTS.md b/AGENTS.md index 74d68545b..8f92e514f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,14 @@ PRs must follow rules. Prefer Xcode MCP tools over terminal commands. --- +## 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. + +--- + ## Project Structure ### Build System