Sandbox the archive paths so a test process cannot reach production #206
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |