mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
Design for HealthChat App Store release capability
Spec for bringing App Store release capability to element-x-ios, targeting the existing HealthChat record that the legacy element-ios fork ships to today. Both apps share bundle id ch.healthchat.element and team 6G37K72W82, so element-x arrives as an in-place update rather than a new listing. That drives the design: the identity already aligns and 26.07.3 clears the legacy's 1.4.1, but the repo has no release machinery at all, and upstream's Xcode Cloud path is unavailable because it needs the private Enterprise submodule. Records three parts: repointing CLASSIC_APP_* at HealthChat's own identifiers so existing users keep their sessions and encrypted history, a fork-owned fastlane pipeline ported from the legacy fork, and a releasing-healthchat skill. Revised after an adversarial review of every factual claim; 26 issues found, none rejected. Most consequential: the migration fix is not monotonic, because making loadAccounts() succeed exposes a backup-instructions sheet whose only button opens element://open — a scheme this app does not register and whose legacy owner has been replaced in place.
This commit is contained in:
@@ -0,0 +1,445 @@
|
|||||||
|
# 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`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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` | `before_all` (Xcode pin) + public `release_testflight` + private helpers |
|
||||||
|
| `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. |
|
||||||
|
| `Brewfile` | `xcodesorg/made/xcodes`. **Not** `xcodegen` — see runner notes. |
|
||||||
|
| `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.
|
||||||
|
- Keep the explicit `brew bundle` step (legacy `:115`).
|
||||||
|
- `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`).
|
||||||
|
|
||||||
|
**Xcode pin: use `"26.4"`.** The runner has it and the legacy pipeline proves it works.
|
||||||
|
`ci_scripts/ci_common.sh:34` (`sudo xcode-select -s /Applications/Xcode_26.5.0.app`) is an **Xcode Cloud image
|
||||||
|
path, not an `xcodes` version identifier**, and is not evidence the runner has 26.5. If element-x turns out to
|
||||||
|
need 26.5, install it on the runner manually **first** — otherwise `xcodes` attempts a multi-GB authenticated
|
||||||
|
download needing `XCODES_USERNAME`/`XCODES_PASSWORD`, which none of the five secrets supply, and hangs in
|
||||||
|
`before_all`.
|
||||||
|
|
||||||
|
Also required on the runner: `xcodegen` on PATH (installed manually once, deliberately **not** in `Brewfile`
|
||||||
|
so `brew bundle` cannot churn it), Homebrew, Ruby + bundler, and `APPLE_ID` exported as a machine-level shell
|
||||||
|
variable (the `Appfile` reads it; it is not repo-carried).
|
||||||
|
|
||||||
|
### 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 `warn`s, not fails.)
|
||||||
|
|
||||||
|
| PR | Contents | Label |
|
||||||
|
|---|---|---|
|
||||||
|
| PR1 | Part 1 — `CLASSIC_APP_*`, entitlement, `.requiresBackup` edit, regenerated pbxproj | `pr-bugfix` |
|
||||||
|
| PR2 | Part 2 — fastlane, workflow, Gemfile(.lock), Brewfile, .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. |
|
||||||
|
|
||||||
|
**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.
|
||||||
Reference in New Issue
Block a user