Skip to content

Sandbox the archive paths so a test process cannot reach production #206

Sandbox the archive paths so a test process cannot reach production

Sandbox the archive paths so a test process cannot reach production #206

Workflow file for this run

name: CI
on:
pull_request:
branches:
- main
paths-ignore:
- '**/*.md'
push:
branches:
- main
paths-ignore:
- '**/*.md'
# Cancel in-progress runs for the same PR/branch — prevents queuing
# multiple expensive macOS builds when commits are pushed rapidly.
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# Pin the Xcode version so CI matches Xcode Cloud and local dev. Bump in
# lockstep with Xcode Cloud's "Xcode Version" setting (App Store Connect →
# Xcode Cloud → Workflow). To find which versions are available on the
# runner, see https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md
env:
XCODE_VERSION: "26.4.1"
# Pinned so the XcodeGen Drift job compares against a fixed generator — see the
# comment on that job. Keep in lockstep with the version used locally
# (`xcodegen --version`) and with the committed LumiVault.xcodeproj.
XCODEGEN_VERSION: "2.46.0"
XCODEGEN_SHA256: "4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806"
permissions:
contents: read
jobs:
build:
name: Build (macOS)
runs-on: macos-26
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Cache SwiftPM and SourcePackages
uses: actions/cache@v5
with:
path: |
~/.swiftpm
~/Library/Caches/org.swift.swiftpm
~/Library/Developer/Xcode/DerivedData/*/SourcePackages
key: ${{ runner.os }}-swiftpm-${{ hashFiles('Package.resolved', 'Package.swift') }}
restore-keys: |
${{ runner.os }}-swiftpm-
- name: Select Xcode ${{ env.XCODE_VERSION }}
run: sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app"
# `xcode-select -s` succeeding is not proof the pinned version is active —
# a runner-image change can leave us silently on a different toolchain,
# which is how the Xcode Cloud data-race errors in #25 and #43 reached
# main while local Xcode and GH CI both stayed green.
- name: Verify Xcode version
run: |
actual="$(xcodebuild -version | head -1 | awk '{print $2}')"
echo "xcodebuild reports: $actual (pinned: $XCODE_VERSION)"
if [ "$actual" != "$XCODE_VERSION" ]; then
echo "::error::Expected Xcode $XCODE_VERSION, got $actual"
exit 1
fi
- name: Build macOS target
run: |
set -o pipefail
xcodebuild \
-project LumiVault.xcodeproj \
-scheme LumiVault \
-destination "generic/platform=macOS" \
-configuration Debug \
CODE_SIGNING_ALLOWED=NO \
build | tee build.log
# Guards the root cause of #54: this toolchain silently ignores the
# SWIFT_DEFAULT_ISOLATION build setting — it never reaches the compiler as
# `-default-isolation` — so the app built with a *nonisolated* default and
# ran SwiftData mutations off the main actor (EXC_BAD_ACCESS in @Query).
# SwiftPM applies `.defaultIsolation(MainActor)` independently, so
# `swift test` stayed green and hid the divergence.
#
# Grep the build log, NOT `-showBuildSettings`: the whole point is that the
# setting was present while the compiler flag was absent.
- name: Assert MainActor default isolation reached the compiler
run: |
if ! grep -q -- '-default-isolation MainActor' build.log; then
echo "::error::The app target compiled WITHOUT -default-isolation MainActor."
echo "SWIFT_DEFAULT_ISOLATION alone is silently ignored by this toolchain."
echo "project.yml must pass it via OTHER_SWIFT_FLAGS for the app target."
exit 1
fi
echo "OK: -default-isolation MainActor present in the compile invocation."
test:
name: Unit Tests
runs-on: macos-26
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Cache SwiftPM
uses: actions/cache@v5
with:
path: |
~/.swiftpm
~/Library/Caches/org.swift.swiftpm
key: ${{ runner.os }}-swiftpm-test-${{ hashFiles('Package.resolved', 'Package.swift') }}
restore-keys: |
${{ runner.os }}-swiftpm-test-
${{ runner.os }}-swiftpm-
- name: Select Xcode ${{ env.XCODE_VERSION }}
run: sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app"
- name: Verify Xcode version
run: |
actual="$(xcodebuild -version | head -1 | awk '{print $2}')"
echo "xcodebuild reports: $actual (pinned: $XCODE_VERSION)"
if [ "$actual" != "$XCODE_VERSION" ]; then
echo "::error::Expected Xcode $XCODE_VERSION, got $actual"
exit 1
fi
- name: Run tests (SwiftPM)
run: swift test
# `swift test` and `xcodebuild test` compile the app target under different
# isolation defaults (SwiftPM applies Package.swift's `.defaultIsolation`;
# Xcode needs the explicit OTHER_SWIFT_FLAGS in project.yml). Running only the
# former is what let #54 reach users. This job exercises the suite through the
# same toolchain that builds the shipped app.
xcode-test:
name: Unit Tests (Xcode)
runs-on: macos-26
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Select Xcode ${{ env.XCODE_VERSION }}
run: sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app"
- name: Verify Xcode version
run: |
actual="$(xcodebuild -version | head -1 | awk '{print $2}')"
if [ "$actual" != "$XCODE_VERSION" ]; then
echo "::error::Expected Xcode $XCODE_VERSION, got $actual"
exit 1
fi
# UI tests need a real app launch and are unreliable headless (see
# TEST-PLAN.md), so `-only-testing` runs just the unit bundle. It does not
# narrow the *build*: the LumiVault scheme's test action includes
# LumiVaultUITests (project.yml), so this step still compiles that target
# and catches the Sendable/toolchain drift that broke #25 and #43 — which
# only ever showed up as a compile error. A separate `build-for-testing`
# step for the same scheme would rebuild everything for no extra coverage.
- name: Run unit tests
run: |
xcodebuild test \
-project LumiVault.xcodeproj \
-scheme LumiVault \
-destination 'platform=macOS' \
-only-testing:LumiVaultTests \
CODE_SIGNING_ALLOWED=NO
# Debug-only builds let `-O`-specific diagnostics reach Xcode Cloud first.
# The Makefile already archives Release locally; mirror it here.
release-archive:
name: Release Archive
runs-on: macos-26
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Select Xcode ${{ env.XCODE_VERSION }}
run: sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app"
# This job exists to match the toolchain Xcode Cloud ships with, so an
# unnoticed toolchain swap here defeats its entire purpose.
- name: Verify Xcode version
run: |
actual="$(xcodebuild -version | head -1 | awk '{print $2}')"
echo "xcodebuild reports: $actual (pinned: $XCODE_VERSION)"
if [ "$actual" != "$XCODE_VERSION" ]; then
echo "::error::Expected Xcode $XCODE_VERSION, got $actual"
exit 1
fi
- name: Archive (Release)
run: |
xcodebuild archive \
-project LumiVault.xcodeproj \
-scheme LumiVault \
-destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/LumiVault-ci.xcarchive" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
COMPILER_INDEX_STORE_ENABLE=NO
# The .xcodeproj is committed because Xcode Cloud builds from it — it clones the
# repo and has no XcodeGen step. That makes drift possible: a corrected
# project.yml can sit next to a stale project.pbxproj, and the fix never reaches
# either the App Store build or CI. That is exactly how a build-setting fix
# silently fails to ship (see the -default-isolation history in #54).
#
# So: keep the artifact, and verify on every run that it still matches its source.
project-drift:
name: XcodeGen Drift
runs-on: macos-26
steps:
- name: Checkout
uses: actions/checkout@v5
# Toolchain-independent guard: even if the regeneration below has to be
# skipped for an XcodeGen upgrade, the setting that caused #54 stays fenced.
- name: Assert committed project carries the isolation flag
run: |
if ! grep -q -- '-default-isolation MainActor' LumiVault.xcodeproj/project.pbxproj; then
echo "::error::Committed project.pbxproj is missing -default-isolation MainActor."
echo "Run 'xcodegen generate' and commit the result."
exit 1
fi
# Pinned rather than `brew install xcodegen`, because this job compares
# generated output byte-for-byte. On an unpinned install, any XcodeGen
# release that changes that output turns every unrelated PR red — and the
# failure tells the author to commit a regenerated project, which would then
# break everyone still on the old version. With the version fixed, a drift
# failure means what it says. The checksum makes the pin tamper-evident:
# this tool generates the project the App Store build is compiled from.
#
# Bump XCODEGEN_VERSION and XCODEGEN_SHA256 together, in the same commit as
# the project regenerated with that version.
- name: Install XcodeGen ${{ env.XCODEGEN_VERSION }}
run: |
curl -fsSL -o "$RUNNER_TEMP/xcodegen.zip" \
"https://github.com/yonaskolb/XcodeGen/releases/download/${XCODEGEN_VERSION}/xcodegen.zip"
echo "${XCODEGEN_SHA256} $RUNNER_TEMP/xcodegen.zip" | shasum -a 256 -c -
unzip -q "$RUNNER_TEMP/xcodegen.zip" -d "$RUNNER_TEMP"
echo "$RUNNER_TEMP/xcodegen/bin" >> "$GITHUB_PATH"
- name: Verify XcodeGen version
run: |
actual="$(xcodegen --version | awk '{print $NF}')"
echo "xcodegen reports: $actual (pinned: $XCODEGEN_VERSION)"
if [ "$actual" != "$XCODEGEN_VERSION" ]; then
echo "::error::Expected XcodeGen $XCODEGEN_VERSION, got $actual"
exit 1
fi
- name: Regenerate project from project.yml
run: xcodegen generate
# `git status --porcelain`, not `git diff`: regenerating can *add* files
# (a new shared scheme under xcshareddata/xcschemes/ when a target or
# scheme is added to project.yml), and `git diff` is blind to untracked
# paths — so the drift this job exists to catch would pass unnoticed and
# Xcode Cloud would build from a project missing that scheme.
- name: Fail if the committed project drifted
run: |
drift="$(git status --porcelain -- LumiVault.xcodeproj)"
if [ -n "$drift" ]; then
echo "::error::LumiVault.xcodeproj is out of date with project.yml."
echo "Run 'xcodegen generate' (XcodeGen ${XCODEGEN_VERSION}) and commit the result."
echo "$drift"
git diff -- LumiVault.xcodeproj
exit 1
fi
echo "OK: committed project matches project.yml."
# The headline coverage number is dominated by ~16,000 lines of SwiftUI view
# code that unit tests do not reach, so it moves for reasons unrelated to test
# quality — adding a settings screen lowers it. This job gates the part unit
# tests are responsible for instead. Floor is set a couple of points below the
# current figure so ordinary churn does not redden unrelated PRs, while a real
# regression (a deleted suite, a stubbed-out test) still fails the build.
coverage:
name: Coverage (non-view floor)
runs-on: macos-26
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Select Xcode ${{ env.XCODE_VERSION }}
run: sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app"
- name: Verify Xcode version
run: |
actual="$(xcodebuild -version | head -1 | awk '{print $2}')"
echo "xcodebuild reports: $actual (pinned: $XCODE_VERSION)"
if [ "$actual" != "$XCODE_VERSION" ]; then
echo "::error::Expected Xcode $XCODE_VERSION, got $actual"
exit 1
fi
# SwiftPM rather than xcodebuild on purpose: xcodebuild launches the app as
# a test host, so view code registers as partly covered by the launch alone
# and the figure drifts between runs. SwiftPM links the library with no
# host and reports only what the tests actually drive.
- name: Run tests with coverage
run: swift test --enable-code-coverage
- name: Enforce non-view coverage floor
run: ./Scripts/coverage-gate.sh 58
# UI tests gate. They were informational while five of them were red for reasons
# that had nothing to do with the app — assertions that depended on the
# developer's own UserDefaults, and window lookups by a title macOS does not use.
# A job nobody has to fix is a job nobody fixes, so it blocks now.
#
# `-retry-tests-on-failure -test-iterations 2` keeps a genuine regression failing
# while absorbing a single XCUIAutomation flake. Note the runner needs
# automation/accessibility permission; without it the run dies with "Timed out
# while enabling automation mode" before a single assertion — see TEST-PLAN.md.
ui-test:
name: UI Tests
runs-on: macos-26
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Select Xcode ${{ env.XCODE_VERSION }}
run: sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app"
- name: Run UI tests
run: |
set -o pipefail
xcodebuild test \
-project LumiVault.xcodeproj \
-scheme LumiVault \
-destination 'platform=macOS' \
-only-testing:LumiVaultUITests \
-resultBundlePath UITests.xcresult \
-retry-tests-on-failure \
-test-iterations 2 \
CODE_SIGNING_ALLOWED=NO
# The accessibility hierarchy that explains a UI failure lives in the result
# bundle, and discarding it is what turned a one-run diagnosis into a week of
# guessing from timing lines.
- name: Upload result bundle
if: failure()
uses: actions/upload-artifact@v4
with:
name: ui-test-results
path: UITests.xcresult
retention-days: 7