Correct the release plan and design for the runner's measured state

Both documents told the reader to pin Xcode 26.4 and to install it via a
Brewfile. A probe of novaloop-mac-studio-001 refutes the premise: it carries
exactly one Xcode, 26.6, globally selected, with no versioned siblings and no
26.4 anywhere. The design justified the pin with "the runner has it and the
legacy pipeline proves it works" — both halves are false, and a pin naming an
absent version does not fail fast: xcodes starts a multi-GB authenticated
download that no CI secret can satisfy, so before_all hangs.

Record the no-pin decision and its cost — whatever Xcode the runner has is what
ships, unrecorded — rather than only deleting the wrong instruction, so nobody
reintroduces the pin from the same reasoning. Drop the Brewfile with it: its only
purpose was installing xcodes for that pin, and brew has no business running on a
shared release machine where it could upgrade xcodegen mid-release.

Add the runner's real constraints (no passwordless sudo, single Xcode, five
missing tools installed by hand) to the design, since they are why upstream's
setup_github_actions_environment cannot run here.

Two new risks. The legacy element-ios pipeline pins the same absent 26.4, so it
is very likely broken right now — not this fork's problem to fix, but the human
needs to know before the next legacy release. And with no pin, an Xcode upgrade
on the runner silently changes the compiler for every subsequent release.

The plan's embedded Fastfile and skill now match the branches that implement
them byte-for-byte, so the plan cannot drift from the artefacts it describes.
This commit is contained in:
2026-07-16 00:00:26 +02:00
parent e4d4541c75
commit 2bd8865d4e
2 changed files with 86 additions and 57 deletions
@@ -28,9 +28,9 @@
| `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 |
| `UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift` | Encodes the redirect behaviour | 1 |
| `fastlane/Fastfile` | `before_all` + `release_testflight` + private build/signing lanes | 2 |
| `fastlane/Fastfile` | `release_testflight` + private build/signing lanes | 2 |
| `fastlane/Appfile`, `fastlane/.env.default`, `fastlane/Pluginfile` | fastlane configuration | 2 |
| `Gemfile`, `Gemfile.lock`, `Brewfile` | Toolchain pinning | 2 |
| `Gemfile`, `Gemfile.lock` | Toolchain pinning | 2 |
| `HEALTHCHAT_CHANGES.md` | Release-notes source the workflow greps | 2 |
| `.github/workflows/release-healthchat.yml` | Tag-triggered build + TestFlight upload | 2 |
| `.gitignore` | Excludes `out/` and `provisioning_profiles/` | 2 |
@@ -279,9 +279,11 @@ Leave the description template for the developer. Exactly one `pr-` label or `Da
### Task 4: Add the toolchain files
**Files:**
- Create: `Gemfile`, `Brewfile`, `fastlane/Pluginfile`, `fastlane/Appfile`
- Create: `Gemfile`, `fastlane/Pluginfile`, `fastlane/Appfile`
- 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**
```bash
@@ -314,15 +316,7 @@ 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.
- [ ] **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`**
- [ ] **Step 4: Create `fastlane/Appfile`**
```ruby
# For more information about the Appfile, see:
@@ -334,7 +328,7 @@ team_id ENV["TEAM_ID"]
`APPLE_ID` is a machine-level shell variable on the runner, not a repo secret.
- [ ] **Step 6: Extend `.gitignore`**
- [ ] **Step 5: Extend `.gitignore`**
Append:
@@ -346,17 +340,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.
- [ ] **Step 7: Resolve gems and commit the lock**
- [ ] **Step 6: Resolve gems and commit the lock**
Run: `bundle install`
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.
- [ ] **Step 8: Commit**
- [ ] **Step 7: Commit**
```bash
git add Gemfile Gemfile.lock Brewfile fastlane/Pluginfile fastlane/Appfile .gitignore
git add Gemfile Gemfile.lock fastlane/Pluginfile fastlane/Appfile .gitignore
git commit -m "Add the fastlane toolchain for HealthChat releases"
```
@@ -437,16 +431,11 @@ git commit -m "Add fastlane configuration for the HealthChat App Store build"
```ruby
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
#### 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 ####
desc "Builds the App Store ipa and uploads it to TestFlight (internal testers only)"
@@ -603,12 +592,10 @@ Expected: lists `ios release_testflight` with its description. A Ruby syntax err
- [ ] **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`
Expected: fails with `'marketing_version' must be 1-3 dot-separated integers`, before any build or signing work.
**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.
**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.
- [ ] **Step 4: Commit**
@@ -776,9 +763,6 @@ jobs:
restore-keys: |
${{ runner.os }}-gems-
- name: Brew bundle
run: brew bundle
- name: Bundle install
run: |
bundle config path vendor/bundle
@@ -876,7 +860,13 @@ 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`.
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.
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):
- **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.
---
@@ -1084,20 +1074,35 @@ invisible to the other account.
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.
- **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`.
If `brew bundle` installs packages on every run, brew state has drifted from
`Brewfile` — investigate rather than ignore.
**A release records no Xcode provenance.** With the pin gone, the runner's current
state is the only fact about which compiler built a shipped build, and
`release-healthchat.yml` does not log it — `unit-tests.yml` runs `xcodebuild
-version`, the release path does not. Nothing in a release run's logs or artifacts
will tell you afterwards; read `xcodebuild -version` on the runner while it still
matches.
## Renewing the Apple Distribution certificate (annual)
@@ -1194,7 +1199,7 @@ place, and these are what stop existing users being silently logged out.
## 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
`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.
@@ -1216,9 +1221,9 @@ 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
grep -rn "xcodes(version:\|before_all" fastlane/Fastfile # runner section says nothing pins Xcode
```
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.
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.
- [ ] **Step 4: Verify the skill is discoverable**