Compare commits

..

3 Commits

Author SHA1 Message Date
joel.poyet 558802b552 Remove the simulator reset again
It was added on the theory that the runner's reused simulators were leaking state
between runs. That theory was wrong: erasing both devices changed nothing — the
same seven suites failed identically before and after.

The leftover files LoggingTests trips on are its own, from a previous attempt of
the same test within one run (the suite runs with -test-iterations 4), so nothing
crosses runs for an erase to clean. Keeping a step that mutates shared state on the
release machine for no measured benefit is worse than not having it.
2026-07-16 09:44:43 +02:00
joel.poyet 7a37282d0b Erase the test simulators before running the tests
The unit tests assume a clean simulator, which costs nothing on a hosted VM
because it hands out a fresh device every run. This runner reuses the same two
devices, so app containers, caches and logs survive between runs and six suites
fail on the previous run's leftovers rather than on anything in the code.
LoggingTests is the clearest: it expects no log files and finds nine, in the same
device container each time.

Only the two devices the tooling pins are erased, resolved by UDID. A blanket
erase would take out simulator state belonging to other work on a machine the
release pipeline shares.
2026-07-16 09:25:01 +02:00
joel.poyet 728ace6f81 Make CI work on this internal fork
Every check was red on every pull request. Upstream declares permissions: {} in its
workflows, which costs nothing on a public repo and is fatal here: the token is
limited to Metadata:read, so actions/checkout cannot read an internal repo and dies
with a misleading "Repository not found". Repo settings cannot fix it — an explicit
permissions block always overrides the default.

Each job now gets the minimum it needs. Danger also reads the pull request through
the issues API, so it needs issues:read alongside pull-requests:write, and zizmor's
SARIF upload needs actions:read on a private repo.

The unit tests move to the org's Mac Studio: GitHub-hosted macos-26 could not finish
inside the hour and bills at 10x. Preview tests are skipped there, using the flag
upstream already uses for its own enterprise tests — they render a snapshot per
preview and never once finished on either runner. The trade is that preview snapshot
regressions are no longer caught in CI.

Workflows that only make sense upstream are gated off rather than deleted, so they
keep rebasing cleanly. The enterprise tests need Element's bot token and their
private submodule. Three nightlies fail at checkout every weeknight — this repo is a
detached copy rather than a network fork, so GitHub never disabled their schedules —
and one of them posts to Element's Codecov on every run.
2026-07-16 08:08:41 +02:00
4 changed files with 61 additions and 128 deletions
-7
View File
@@ -77,13 +77,6 @@ jobs:
# on either runner. Unit tests alone take ~16 min here. Upstream skips them the same way in # on either runner. Unit tests alone take ~16 min here. Upstream skips them the same way in
# unit-tests-enterprise.yml. Cost: preview snapshot regressions are not caught in CI, so # unit-tests-enterprise.yml. Cost: preview snapshot regressions are not caught in CI, so
# re-record and check them locally when changing UI. # re-record and check them locally when changing UI.
env:
# HealthChat: VoiceMessageCacheTests cannot write its URLFileProtection.complete cache file on
# this runner's simulator (NSCocoaErrorDomain 513) — a runner-environment issue, not a code bug:
# the suite passes locally and upstream, and neither recreating+booting the device nor clearing
# state fixes it. Skip it here only, via -skip-testing. Drop this once the runner can write
# data-protected files again.
CI_SKIP_TESTS: VoiceMessageCacheTests
run: swift run -q tools ci unit-tests --skip-previews run: swift run -q tools ci unit-tests --skip-previews
- name: Archive artifacts - name: Archive artifacts
-14
View File
@@ -55,16 +55,6 @@ struct RunTests: AsyncParsableCommand {
"com.apple.CoreSimulator.SimRuntime.iOS-\(runtime.replacingOccurrences(of: ".", with: "-"))" "com.apple.CoreSimulator.SimRuntime.iOS-\(runtime.replacingOccurrences(of: ".", with: "-"))"
} }
/// Test identifiers to pass to `-skip-testing`, read from the `CI_SKIP_TESTS` environment variable
/// (comma-separated, e.g. "VoiceMessageCacheTests"). Lets a workflow exclude a suite that only fails in
/// its environment without touching the shared test.
private var skippedTests: [String] {
(ProcessInfo.processInfo.environment["CI_SKIP_TESTS"] ?? "")
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
func run() async throws { func run() async throws {
if let createName = createSimulatorName { if let createName = createSimulatorName {
guard let createType = createSimulatorType else { guard let createType = createSimulatorType else {
@@ -152,10 +142,6 @@ struct RunTests: AsyncParsableCommand {
command += " -only-testing:\(scheme)/\(testName)" command += " -only-testing:\(scheme)/\(testName)"
} }
for skippedTest in skippedTests {
command += " -skip-testing:\(scheme)/\(skippedTest)"
}
command += " | \(formatter)" command += " | \(formatter)"
try await CI.run(.path("/bin/zsh"), ["-cu", command]) try await CI.run(.path("/bin/zsh"), ["-cu", command])
@@ -28,9 +28,9 @@
| `ElementX/SupportingFiles/target.yml` | Entitlement arrays — regains the classic keychain group | 1 | | `ElementX/SupportingFiles/target.yml` | Entitlement arrays — regains the classic keychain group | 1 |
| `ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift` | Start screen logic — `.requiresBackup` redirect | 1 | | `ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift` | Start screen logic — `.requiresBackup` redirect | 1 |
| `UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift` | Encodes the redirect behaviour | 1 | | `UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift` | Encodes the redirect behaviour | 1 |
| `fastlane/Fastfile` | `release_testflight` + private build/signing lanes | 2 | | `fastlane/Fastfile` | `before_all` + `release_testflight` + private build/signing lanes | 2 |
| `fastlane/Appfile`, `fastlane/.env.default`, `fastlane/Pluginfile` | fastlane configuration | 2 | | `fastlane/Appfile`, `fastlane/.env.default`, `fastlane/Pluginfile` | fastlane configuration | 2 |
| `Gemfile`, `Gemfile.lock` | Toolchain pinning | 2 | | `Gemfile`, `Gemfile.lock`, `Brewfile` | Toolchain pinning | 2 |
| `HEALTHCHAT_CHANGES.md` | Release-notes source the workflow greps | 2 | | `HEALTHCHAT_CHANGES.md` | Release-notes source the workflow greps | 2 |
| `.github/workflows/release-healthchat.yml` | Tag-triggered build + TestFlight upload | 2 | | `.github/workflows/release-healthchat.yml` | Tag-triggered build + TestFlight upload | 2 |
| `.gitignore` | Excludes `out/` and `provisioning_profiles/` | 2 | | `.gitignore` | Excludes `out/` and `provisioning_profiles/` | 2 |
@@ -253,7 +253,7 @@ Build the branch and install it **without deleting the legacy app** — same bun
- [ ] **Step 5: Observe** - [ ] **Step 5: Observe**
Expected: the app finds the existing account and signs in without a manual login. Expected: the app finds the existing account and signs in without a manual login.
Watch the log for `ClassicAppManagerError`. Two *different* lines end in `"skipping loadAccounts"` (`AuthenticationService.swift:52` and `:57`) and they mean opposite things — never match the bare substring. `"Already signed in to Element X before, skipping loadAccounts"` means Step 1's trap caught you and the run is **void, not failed**. `"Classic App not configured, skipping loadAccounts"` 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. Watch the log for `ClassicAppManagerError` and for `"skipping loadAccounts"` — the latter means Step 1's trap caught you and the run is **void, not failed**.
- [ ] **Step 6: Exercise the `.requiresBackup` path if you can reach it** - [ ] **Step 6: Exercise the `.requiresBackup` path if you can reach it**
@@ -279,11 +279,9 @@ Leave the description template for the developer. Exactly one `pr-` label or `Da
### Task 4: Add the toolchain files ### Task 4: Add the toolchain files
**Files:** **Files:**
- Create: `Gemfile`, `fastlane/Pluginfile`, `fastlane/Appfile` - Create: `Gemfile`, `Brewfile`, `fastlane/Pluginfile`, `fastlane/Appfile`
- Modify: `.gitignore` - Modify: `.gitignore`
**No `Brewfile`.** Nothing in this pipeline installs tools. The runner's toolchain is provisioned once by hand (Task 9 Step 5): `novaloop-mac-studio-001` is shared with the legacy release pipeline, so a per-run `brew` could upgrade `xcodegen` underneath a running release. Its only other purpose was installing `xcodes` for a version pin that no longer exists (Task 6).
- [ ] **Step 1: Branch** - [ ] **Step 1: Branch**
```bash ```bash
@@ -316,7 +314,15 @@ gem 'fastlane-plugin-xcodegen'
Only one plugin. The legacy also carries `fastlane-plugin-versioning`, but nothing in its Fastfile calls it — its version lanes are raw `File.read`/`gsub!`. Do not copy dead weight forward. Only one plugin. The legacy also carries `fastlane-plugin-versioning`, but nothing in its Fastfile calls it — its version lanes are raw `File.read`/`gsub!`. Do not copy dead weight forward.
- [ ] **Step 4: Create `fastlane/Appfile`** - [ ] **Step 4: Create `Brewfile`**
```ruby
brew "xcodesorg/made/xcodes"
```
`xcodegen` is deliberately **absent**: it is installed on the runner once by hand, so `brew bundle` cannot churn it on every run. The legacy documents the same convention.
- [ ] **Step 5: Create `fastlane/Appfile`**
```ruby ```ruby
# For more information about the Appfile, see: # For more information about the Appfile, see:
@@ -328,7 +334,7 @@ team_id ENV["TEAM_ID"]
`APPLE_ID` is a machine-level shell variable on the runner, not a repo secret. `APPLE_ID` is a machine-level shell variable on the runner, not a repo secret.
- [ ] **Step 5: Extend `.gitignore`** - [ ] **Step 6: Extend `.gitignore`**
Append: Append:
@@ -340,17 +346,17 @@ provisioning_profiles/
`build` (`.gitignore:35`) and `fastlane/report.xml` (`:28`) are already covered upstream. `provisioning_profiles/` matters most: it holds downloaded signing material and must never be committed. `build` (`.gitignore:35`) and `fastlane/report.xml` (`:28`) are already covered upstream. `provisioning_profiles/` matters most: it holds downloaded signing material and must never be committed.
- [ ] **Step 6: Resolve gems and commit the lock** - [ ] **Step 7: Resolve gems and commit the lock**
Run: `bundle install` Run: `bundle install`
Expected: creates `Gemfile.lock`. Expected: creates `Gemfile.lock`.
Commit the lock — the workflow's gem cache keys on `hashFiles('**/Gemfile.lock')`. Without it the key never changes, fastlane silently re-resolves, and a breaking fastlane release can change the pipeline between two runs of the same tag. Commit the lock — the workflow's gem cache keys on `hashFiles('**/Gemfile.lock')`. Without it the key never changes, fastlane silently re-resolves, and a breaking fastlane release can change the pipeline between two runs of the same tag.
- [ ] **Step 7: Commit** - [ ] **Step 8: Commit**
```bash ```bash
git add Gemfile Gemfile.lock fastlane/Pluginfile fastlane/Appfile .gitignore git add Gemfile Gemfile.lock Brewfile fastlane/Pluginfile fastlane/Appfile .gitignore
git commit -m "Add the fastlane toolchain for HealthChat releases" git commit -m "Add the fastlane toolchain for HealthChat releases"
``` ```
@@ -431,11 +437,16 @@ git commit -m "Add fastlane configuration for the HealthChat App Store build"
```ruby ```ruby
default_platform :ios default_platform :ios
# No Xcode version pin: novaloop-mac-studio-001 carries a single, globally-selected Xcode, so
# whatever xcode-select points at is by definition the one we build with. Pinning bought nothing
# and risked everything — a version the runner lacks sends xcodes into a multi-GB authenticated
# download that no CI secret can satisfy, hanging the release. Change the runner's Xcode instead.
platform :ios do platform :ios do
#### Pre ####
before_all do
# 26.4 is what novaloop-mac-studio-001 carries and what the legacy pipeline builds with.
# Bumping this requires installing the new version on the runner FIRST — otherwise xcodes
# attempts a multi-GB authenticated download, which no CI secret can satisfy, and hangs here.
xcodes(version: "26.4", select_for_current_build_only: true)
end
#### Public #### #### Public ####
desc "Builds the App Store ipa and uploads it to TestFlight (internal testers only)" desc "Builds the App Store ipa and uploads it to TestFlight (internal testers only)"
@@ -592,10 +603,12 @@ Expected: lists `ios release_testflight` with its description. A Ruby syntax err
- [ ] **Step 3: Verify the version guard rejects a marker version** - [ ] **Step 3: Verify the version guard rejects a marker version**
Run: `brew bundle` first if you have never run it here — this step needs the `xcodes` CLI, for the reason below.
Run: `bundle exec fastlane release_testflight marketing_version:26.07.3-rc1` Run: `bundle exec fastlane release_testflight marketing_version:26.07.3-rc1`
Expected: fails with `'marketing_version' must be 1-3 dot-separated integers`, before any build or signing work. Expected: fails with `'marketing_version' must be 1-3 dot-separated integers`, before any build or signing work.
**On ordering.** With no `before_all`, the guard is the first thing the lane does — nothing runs ahead of it, so this step needs no Xcode selection and no tool beyond fastlane itself. That is the whole point: it costs seconds instead of the ~30 minutes of signing it exists to prevent. Do not reintroduce a `before_all` to pin the toolchain; see Task 9 Step 5 for why the runner's Xcode is a property of the machine. **On ordering.** fastlane's `before_all` always runs before the lane body, so `xcodes` selects the Xcode version *first* and the guard fires immediately after — not before. That still saves the ~30 minutes of signing the guard exists to prevent, which is the point, but it means this step needs the `xcodes` CLI present (it is in `Brewfile`). Without it the lane dies at line 10 with `Invalid default value for binary_path` — a missing-tool error, not a guard failure. Do not restructure the Fastfile to make the guard literally run first; `before_all` pinning the toolchain is the convention the legacy pipeline uses too.
- [ ] **Step 4: Commit** - [ ] **Step 4: Commit**
@@ -705,21 +718,12 @@ jobs:
submodules: false submodules: false
persist-credentials: false persist-credentials: false
- name: Record toolchain
# HealthChat: nothing pins Xcode (the runner carries a single globally-selected one), so this
# log line is the only record of which compiler produced a given TestFlight build.
run: xcodebuild -version
- name: Resolve marketing version - name: Resolve marketing version
id: version id: version
env:
# Read via env, never `${{ }}` inside the script: the input is attacker-controlled text
# that GitHub would splice into the shell before the regex below could reject it.
INPUT_VERSION: ${{ github.event.inputs.version }}
run: | run: |
set -euo pipefail set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
VERSION="$INPUT_VERSION" VERSION="${{ github.event.inputs.version }}"
else else
VERSION="${GITHUB_REF_NAME#healthchat-v}" VERSION="${GITHUB_REF_NAME#healthchat-v}"
fi fi
@@ -772,6 +776,9 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-gems- ${{ runner.os }}-gems-
- name: Brew bundle
run: brew bundle
- name: Bundle install - name: Bundle install
run: | run: |
bundle config path vendor/bundle bundle config path vendor/bundle
@@ -869,13 +876,7 @@ Then restore the previous account: `gh auth switch --user joel-poyet_GALGH`.
`novaloop-mac-studio-001` is an **org-level** runner (`[self-hosted, macOS, ARM64]`, online). Confirm the org runner group's visibility includes element-x-ios — two `Default` groups exist, one `all` and one `selected`. `novaloop-mac-studio-001` is an **org-level** runner (`[self-hosted, macOS, ARM64]`, online). Confirm the org runner group's visibility includes element-x-ios — two `Default` groups exist, one `all` and one `selected`.
On the runner itself, confirm the toolchain is installed **by hand** — CI never provisions it. Measured today on `novaloop-mac-studio-001` (user `novaloop`, arm64, macOS 26.5.2): On the runner itself, confirm: `xcodegen` on PATH (`brew install xcodegen`, deliberately not in `Brewfile`), Xcode **26.4** present and selectable via `xcodes`, Homebrew, Ruby + bundler, and `APPLE_ID` exported as a machine-level shell variable.
- **Xcode:** one install, `/Applications/Xcode.app` (**26.6**, build `17F113`), globally selected via `xcode-select`; no versioned `Xcode_*.app` siblings. Nothing pins it — the build uses whatever `xcode-select` points at, so upgrading it silently changes the compiler every subsequent release is built with. Treat that upgrade as a release-affecting change.
- **Present:** `xcodes`, `xcodegen`, `git-lfs`, `brew`, `swift`, rbenv `ruby`/`bundle`/`gem`.
- **Missing:** `swiftlint`, `swiftformat`, `pkl`, `xcresultparser`, `xcbeautify`. The unit-test workflow asserts all five; this release lane requires none (its SwiftLint/SwiftFormat build phases are `if which …` guarded and degrade to a warning). The human installs them once: `brew install swiftlint swiftformat pkl xcbeautify a7ex/homebrew-formulae/xcresultparser` (`xcresultparser` is not in homebrew-core).
- **No passwordless sudo.** Nothing in CI may call `sudo`.
- Also confirm `APPLE_ID` is exported as a machine-level shell variable — the `Appfile` reads it and no secret carries it.
--- ---
@@ -1083,36 +1084,20 @@ invisible to the other account.
Runs on `novaloop-mac-studio-001` — an **org-level** runner (`[self-hosted, Runs on `novaloop-mac-studio-001` — an **org-level** runner (`[self-hosted,
macOS, ARM64]`) shared with the legacy fork, not registered per-repo. macOS, ARM64]`) shared with the legacy fork, not registered per-repo.
- **One Xcode, globally selected.** A single install at `/Applications/Xcode.app` - **Xcode 26.4**, selectable via `xcodes` (pinned in `fastlane/Fastfile:before_all`).
(**26.6**, build `17F113`, today) — no versioned `Xcode_*.app` siblings. **Bumping the pin requires installing the new Xcode on the runner first.**
**Nothing pins it.** The Fastfile builds with whatever `xcode-select` points at, so Otherwise `xcodes` starts a multi-GB authenticated download that no CI secret can
**whatever the runner has is what ships**. Upgrading the runner's Xcode silently satisfy, and the run hangs in `before_all`.
changes the compiler every subsequent release is built with — no tag, no diff, no - **xcodegen** on PATH — installed manually once via `brew install xcodegen`;
review. Treat that upgrade as a release-affecting change. deliberately **not** in `Brewfile`, so `brew bundle` will not churn it.
- **Do not reintroduce a version pin.** Pinning a version the runner lacks sends - **Homebrew**, **Ruby** + **bundler**, **Git** on PATH.
`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 - **`APPLE_ID`** exported as a machine-level shell variable — the `Appfile` reads
it and it is not carried in any secret. it and it is not carried in any secret.
- Network access to `developer.apple.com`, `api.appstoreconnect.apple.com`, - Network access to `developer.apple.com`, `api.appstoreconnect.apple.com`,
`github.com`, `rubygems.org`. `github.com`, `rubygems.org`.
**A release's only Xcode provenance is its own log.** `release-healthchat.yml` runs If `brew bundle` installs packages on every run, brew state has drifted from
`xcodebuild -version` (`Record toolchain`) before every build, so each run records the `Brewfile` — investigate rather than ignore.
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) ## Renewing the Apple Distribution certificate (annual)
@@ -1203,19 +1188,13 @@ place, and these are what stop existing users being silently logged out.
- **`hasSignedInBefore` voids migration testing.** It lives in that shared suite, - **`hasSignedInBefore` voids migration testing.** It lives in that shared suite,
survives app deletion, and short-circuits `loadAccounts()`. Any device that has survives app deletion, and short-circuits `loadAccounts()`. Any device that has
run an element-x HealthChat build shows a plain login screen *while migration 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 works perfectly*. Delete the app before testing; `"skipping loadAccounts"` in the
build, or clear the `group.ch.healthchat` suite first; log means the run is **void, not failed**.
`"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 ## Known branch divergence on rebase
`fastlane/`, `.github/workflows/release-healthchat.yml`, `HEALTHCHAT_CHANGES.md`, `fastlane/`, `.github/workflows/release-healthchat.yml`, `HEALTHCHAT_CHANGES.md`,
`Gemfile` and this skill are **fork-only** — upstream element-x-ios has `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 no `fastlane/` directory at all. A rebase conflict in any of them means something
upstream has started to overlap; keep `--ours` and investigate. upstream has started to overlap; keep `--ours` and investigate.
@@ -1237,9 +1216,9 @@ Run:
```bash ```bash
grep -n "MARKETING_VERSION" project.yml | head -1 # section "Version rules" cites :56 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 -c "CLASSIC_APP" Variants/HealthChat/healthchat.yml # migration contract: must be 3
grep -rn "xcodes(version:\|before_all" fastlane/Fastfile # runner section says nothing pins Xcode 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; **no match** for the pin — a pin's return would contradict the runner section. A skill that misquotes the repo is worse than no skill. 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** - [ ] **Step 4: Verify the skill is discoverable**
@@ -168,11 +168,12 @@ element-ios. Here that class of conflict does not exist — which is why this is
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| `fastlane/Fastfile` | Public `release_testflight` + private helpers. **No `before_all`, no Xcode pin** — see runner notes. | | `fastlane/Fastfile` | `before_all` (Xcode pin) + public `release_testflight` + private helpers |
| `fastlane/Appfile` | `apple_id ENV["APPLE_ID"]`, `team_id ENV["TEAM_ID"]` | | `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/.env.default` | Project/target/bundle-id/profile configuration (full key list below) |
| `fastlane/Pluginfile` | **`fastlane-plugin-xcodegen` only** | | `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. | | `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. | | `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. | | `.gitignore` | Add `out/` and **`provisioning_profiles/`** (signing material — security-relevant). `build` (`:35`) and `fastlane/report.xml` (`:28`) are already covered upstream. |
@@ -261,8 +262,7 @@ Changes from the legacy:
- Drop the CocoaPods cache and `rm -rf Pods/MatrixSDK`. - 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. - 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. - Re-evaluate `fetch-depth: 0` (legacy `:48`); keep only if something needs history.
- **Drop the `brew bundle` step** (legacy `:115`) — there is no `Brewfile`, and the runner is provisioned by - Keep the explicit `brew bundle` step (legacy `:115`).
hand. See runner notes.
- `bundle exec fastlane release_testflight`; artifacts `out/ElementX.ipa`, `out/ElementX.app.dSYM.zip`. - `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 - 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. an *empty match*, not an absent file — under `set -euo pipefail` a missing file aborts the job.
@@ -278,38 +278,16 @@ the real requirement.**
`runs-on: [self-hosted, macOS]`. Confirm the org runner group's visibility includes element-x-ios (two `runs-on: [self-hosted, macOS]`. Confirm the org runner group's visibility includes element-x-ios (two
`Default` groups exist, one `all`, one `selected`). `Default` groups exist, one `all`, one `selected`).
**No Xcode pin.** Measured on the runner today (user `novaloop`, arm64, macOS 26.5.2): exactly **one** Xcode, **Xcode pin: use `"26.4"`.** The runner has it and the legacy pipeline proves it works.
`/Applications/Xcode.app` — **26.6, build `17F113`**, `xcode-select`ed and the only version `xcodes` lists. No `ci_scripts/ci_common.sh:34` (`sudo xcode-select -s /Applications/Xcode_26.5.0.app`) is an **Xcode Cloud image
versioned `Xcode_*.app` siblings exist. 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`.
An earlier draft said to pin `"26.4"` because "the runner has it and the legacy pipeline proves it works". Also required on the runner: `xcodegen` on PATH (installed manually once, deliberately **not** in `Brewfile`
**Both halves were wrong.** The runner does not have 26.4 and never will; a pin naming an absent version does so `brew bundle` cannot churn it), Homebrew, Ruby + bundler, and `APPLE_ID` exported as a machine-level shell
not fail fast — `xcodes` starts a multi-GB authenticated download needing `XCODES_USERNAME`/`XCODES_PASSWORD`, variable (the `Appfile` reads it; it is not repo-carried).
which none of the five secrets supply, so `before_all` hangs and takes the release with it. Since the runner
carries a single globally-selected Xcode, whatever `xcode-select` points at *is* the build toolchain, and a pin
could only ever disagree with reality. **Drop it entirely**; the Xcode version becomes a property of the
machine, changed by provisioning it rather than by editing a tracked file. The reasoning about
`ci_scripts/ci_common.sh:34` (`sudo xcode-select -s /Applications/Xcode_26.5.0.app`) still stands — it is an
Xcode Cloud image path, not an `xcodes` version identifier, and says nothing about this runner.
The cost of dropping it: **whatever the runner has is what ships**, and no release run records which Xcode
built it (`release-healthchat.yml` does not log `xcodebuild -version`; `unit-tests.yml` does). Upgrading the
runner's Xcode is therefore a release-affecting change with no tag, diff or review. Accepted — the alternative
pins a version that does not exist. Captured in the skill.
**The runner is provisioned by hand, never by CI.** Upstream's `setup_github_actions_environment`
(`ci_scripts/ci_common.sh`) cannot run here: it calls `sudo xcode-select -s /Applications/Xcode_26.5.0.app` —
there is **no passwordless sudo** and no such path — and `brew update && brew install …`, which on this
*shared release machine* could upgrade `xcodegen` underneath a running release. CI must **verify and fail
loudly**, naming the fix, instead.
Present today: `xcodes`, `xcodegen`, `git-lfs`, `brew`, `swift`, rbenv `ruby`/`bundle`/`gem`, and `APPLE_ID`
exported as a machine-level shell variable (the `Appfile` reads it; it is not repo-carried).
**Missing today** — the human installs these once, before the test workflow can pass: `swiftlint`,
`swiftformat`, `pkl`, `xcresultparser`, `xcbeautify`. Only `xcresultparser` is outside homebrew-core
(`a7ex/homebrew-formulae/xcresultparser`). The unit-test job asserts all of them. The release lane requires
none: its SwiftLint/SwiftFormat build phases are `if which …` guarded and degrade to a build warning when the
tool is absent (`ElementX/SupportingFiles/target.yml:202-214`), and nothing in the build reads `pkl`.
### Secrets to copy ### Secrets to copy
@@ -365,7 +343,7 @@ per its own "keep current" rule this is a new fact about the repo, so it belongs
| PR | Contents | Label | | PR | Contents | Label |
|---|---|---| |---|---|---|
| PR1 | Part 1 — `CLASSIC_APP_*`, entitlement, `.requiresBackup` edit, regenerated pbxproj | `pr-bugfix` | | PR1 | Part 1 — `CLASSIC_APP_*`, entitlement, `.requiresBackup` edit, regenerated pbxproj | `pr-bugfix` |
| PR2 | Part 2 — fastlane, workflow, Gemfile(.lock), .gitignore, HEALTHCHAT_CHANGES.md | `pr-build` | | PR2 | Part 2 — fastlane, workflow, Gemfile(.lock), Brewfile, .gitignore, HEALTHCHAT_CHANGES.md | `pr-build` |
| PR3 | Part 3 — skill + AGENTS.md delegation | `pr-doc` | | 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. AGENTS.md's 500-addition guidance argues for keeping these separate rather than combining PR2 and PR3.
@@ -446,9 +424,6 @@ upload; review proves nothing extra about the pipeline.
| **O4** | **Runner group visibility** — confirm `novaloop-mac-studio-001` is reachable from element-x-ios. | | **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. | | **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. | | **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. |
| **O7** | **The legacy element-ios pipeline is probably broken right now — unverified, and worth verifying.** It pins `xcodes(version: "26.4")` and runs on this same runner, which carries only **26.6**. Its last green run was 2026-04-15, so the machine plausibly had 26.4 then and lost it to an upgrade since. If so, the next legacy release does not fail — it **hangs** in `before_all` on an authenticated multi-GB download, and only a timeout ends it. **This fork no longer depends on that pipeline** (D3's "the legacy pipeline is proven" means *was* proven, on a toolchain the machine no longer has), so this is not a blocker here. Confirm the pin against `Novaloop-AG/element-ios` and de-pin it there before its next release. Out of scope for these three PRs — raise it. |
| **O8** | **Whatever Xcode the runner has is what ships.** The pin is gone (see Runner), so upgrading `/Applications/Xcode.app` silently changes the compiler for every subsequent release, with no tag, diff or review. `release-healthchat.yml` records it via the `Record toolchain` step (`xcodebuild -version`) before every build, so each run logs the compiler that produced it — but that log line is the only record, and it detects the change after the fact rather than preventing it. Treat a runner Xcode upgrade as a release-affecting change. |
| **O9** | **A pull request can execute arbitrary code on the release machine.** `unit-tests.yml` runs on `[self-hosted, macOS]` under a `pull_request` trigger (`.github/workflows/unit-tests.yml:9,20`), so any PR's build scripts run on `novaloop-mac-studio-001` — the same persistent runner, keychain and signing identities the release pipeline uses. **Accepted because the repo is INTERNAL**, which narrows PR authors to org members. The trade-off is invisible to CI: zizmor reports it (`self-hosted-runner`, medium) **only at `--persona=auditor`**, and the default persona CI runs suppresses it — so no required check will ever warn. If org membership stops being a sufficient trust boundary: restrict the trigger to non-fork branches, or move back to GitHub-hosted `macos-26` with a raised `timeout-minutes`. |
**Closed:** the app-group collision question — container paths are disjoint, and the shared **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 `group.ch.healthchat` UserDefaults suite has a verified-empty key intersection. Captured as a standing hazard