Skip to content

ci: cache generated test fixtures keyed on toolchain and inputs - #436

Merged
tylervick merged 1 commit into
mainfrom
tylervick/ci-fixture-time-412
Aug 12, 2026
Merged

tylervick merged 1 commit into
mainfrom
tylervick/ci-fixture-time-412

Conversation

@tylervick

@tylervick tylervick commented Aug 12, 2026 •

Copy link
Copy Markdown
Member

Fixes #412.

What

Caches the three generated .xcresult fixture bundles in the test job with actions/cache, keyed on:

  • the exact Xcode version and build number (xcodebuild -version)
  • the newest installed iOS simulator runtime (version + build — what prepareTestResults.sh selects)
  • hashFiles('prepareTestResults.sh', 'XCTestHTMLReportSampleApp/**')

On an exact key hit, the Generate test fixtures step is skipped entirely. There are no restore-keys, deliberately: a partial match would serve fixtures produced by a different toolchain or sample app. It's exact hit or full regeneration.

A Verify fixtures step asserts all three bundles exist (via their Info.plist) whether restored or freshly generated — per the guard called out in #412, a cache hit that restores nothing must fail, not pass as a suspiciously fast green run.

Why this is safe

Measurements

All numbers are measured test-job wall clock from real CI runs (job started_at → completed_at via the Actions API), not estimates.

Before — the last 8 successful test jobs on main (all cache-free, identical code path to this branch's miss):

540s  543s  721s  736s  815s  870s  910s  981s     mean 764s (12m44s)

This branch (run 31642383154, three attempts):

attempt cache test job Generate step Restore step
1 (seed) miss → saved 97.56 MiB 643s (10m43s) 464s 1s
2 hit 127s (2m07s) skipped 5s
3 hit 141s (2m21s) skipped 5s

Verify fixtures passed on all three attempts — the hits restored real bundles, and swift test ran green against them.

Steady state: ~630s saved per run (~83%), test job down from ~12.7 min to ~2.2 min.

#412 established that cross-run wall clock here is noisy (identical code has varied 1.8× between runners), so the honest comparison is distributional: both cache-hit runs (127s, 141s) sit ~4× below the fastest of nine cache-miss runs (540s). No runner-speed effect of the observed magnitude can produce that separation; only skipping generation can. The miss path (attempt 1, 643s) lands inside main's 540–981s range, i.e. seeding costs nothing beyond the ~8s cache save.

Cache misses recur only when the key changes (toolchain bump, script or sample-app edit) or the cache is evicted — those runs pay exactly what every run pays today.

Follow-up

codecov.yml runs the same generation on pushes to main; the same three steps could be copied there to hit the cache seeded by main's test job. Left out to keep this change to the required check.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Tests
    • Improved automated test setup by caching generated fixtures for faster repeat runs.
    • Added validation to ensure expected result bundles are complete before tests execute.
    • Automatically regenerates fixtures when relevant build or source inputs change.

Fixture generation boots a simulator and runs the sample app's UI tests
on every CI run; even after #425 it dominates the test job's ~10-minute
wall time (#412). The bundles are deterministic for a given toolchain
and sample app (#423/#430), and every assertion in the suite compares
within one run, so identical bytes are safe to serve across runs.

Cache the three .xcresult bundles keyed on the exact Xcode build, the
newest installed iOS simulator runtime, prepareTestResults.sh itself,
and the sample-app sources. No restore-keys: an inexact match would
serve fixtures from a different toolchain, which the drift detector
(#392) and the legacy-vs-modern differential test cannot tolerate. A
verification step asserts all three bundles exist whether restored or
generated, so a cache hit that restores nothing fails instead of
passing as a suspiciously fast green run.

Local flow is untouched: prepareTestResults.sh is unchanged and swift
test still reads the same paths.

Fixes #412

Co-Authored-By: Claude Fable 5 <[email protected]>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 526b010a-2942-48b5-85fd-6c164693d0b8

📥 Commits

Reviewing files that changed from the base of the PR and between e53ce0a and e3cb93a.

📒 Files selected for processing (1)
  • .github/workflows/test.yml

📝 Walkthrough

Walkthrough

The test workflow now restores cached .xcresult fixtures using deterministic inputs, generates fixtures only on cache misses, and verifies each result bundle contains Info.plist before tests run.

Changes

Fixture workflow

Layer / File(s) Summary
Cache restoration and conditional generation
.github/workflows/test.yml
The workflow keys fixture caches on the Xcode toolchain, selected iOS runtime, fixture-generation script, and sample-app sources. It generates fixtures only when restoration misses.
Fixture bundle validation
.github/workflows/test.yml
The workflow verifies that each expected .xcresult bundle contains Info.plist and exits on incomplete fixtures.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The workflow caches fixtures with toolchain and source-based keys, regenerates on misses, and verifies restored or generated bundles, meeting issue #412.
Out of Scope Changes check ✅ Passed The changes are limited to CI fixture caching and verification, which directly supports the requirements in issue #412.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main CI change: caching generated test fixtures with input-based cache keys.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tylervick/ci-fixture-time-412

Comment @coderabbitai help to get the list of available commands.

@tylervick
tylervick merged commit d99af52 into main Aug 12, 2026
10 checks passed
@tylervick
tylervick deleted the tylervick/ci-fixture-time-412 branch August 12, 2026 21:50
tylervick added a commit that referenced this pull request Aug 12, 2026
The fixture-cache step merged in #436 landed after this branch was cut
and tripped the new zizmor gate on the PR's merge commit.

Co-Authored-By: Claude Fable 5 <[email protected]>
tylervick added a commit that referenced this pull request Aug 12, 2026
* Replace SonarCloud with an in-repo zizmor gate

SonarCloud had degraded to an untriaged dashboard: a red quality gate
nobody was watching, a violations badge advertising it, and no PR
integration. Its only findings that SwiftLint/SwiftFormat/ShellCheck
could not have made were about the workflows themselves, so cover that
gap in-repo instead:

- Add a zizmor job to lint.yml, gated at low severity and above so it
  starts green; informational findings (tag-derived template expansions
  in release.yml) are deliberately below the floor.
- Pin the official actions (checkout, upload-artifact, download-artifact)
  to commit SHAs, which was SonarCloud's one open gate-worthy finding
  and is required by zizmor's blanket pinning policy.
- Drop the SonarCloud badge from the README.

The bump_version checkout keeps its credentials on purpose, so it
carries an inline artipacked ignore rather than a fix.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Pin actions/cache, newly arrived from main

The fixture-cache step merged in #436 landed after this branch was cut
and tripped the new zizmor gate on the PR's merge commit.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
tylervick added a commit that referenced this pull request Aug 12, 2026
A scheduled workflow (Mon/Thu + workflow_dispatch) that exercises the
newest Xcode on the runners end to end: checks that xcresulttool still
advertises legacy command support (the early-warning signal for the #391
migration), builds the package, regenerates fixtures from scratch via
prepareTestResults.sh, runs the test suite, and asserts xchtmlreport
exits clean against a bundle produced by that exact toolchain. Any fault
files or updates a single open `drift`-labelled issue, so a break cannot
rot silently in the Actions tab.

Fixtures are deliberately regenerated (never cache-restored) because the
job's purpose is to exercise the current toolchain — but a successful
run saves them under the #436 cache key, pre-warming PR jobs after a
toolchain bump.

Also folds in the #436 fast-follow: the fixture cache key derived its
runtime from `simctl list runtimes` (newest installed) while
prepareTestResults.sh selects the newest runtime that offers an iPhone.
The selection now lives in scripts/select_simulator.py and both consume
it, so the key can never disagree with the runtime the script boots.

Closes #392

Co-Authored-By: Claude Fable 5 <[email protected]>
tylervick added a commit that referenced this pull request Aug 12, 2026
* ci: add scheduled toolchain drift detector (#392)

A scheduled workflow (Mon/Thu + workflow_dispatch) that exercises the
newest Xcode on the runners end to end: checks that xcresulttool still
advertises legacy command support (the early-warning signal for the #391
migration), builds the package, regenerates fixtures from scratch via
prepareTestResults.sh, runs the test suite, and asserts xchtmlreport
exits clean against a bundle produced by that exact toolchain. Any fault
files or updates a single open `drift`-labelled issue, so a break cannot
rot silently in the Actions tab.

Fixtures are deliberately regenerated (never cache-restored) because the
job's purpose is to exercise the current toolchain — but a successful
run saves them under the #436 cache key, pre-warming PR jobs after a
toolchain bump.

Also folds in the #436 fast-follow: the fixture cache key derived its
runtime from `simctl list runtimes` (newest installed) while
prepareTestResults.sh selects the newest runtime that offers an iPhone.
The selection now lives in scripts/select_simulator.py and both consume
it, so the key can never disagree with the runtime the script boots.

Closes #392

Co-Authored-By: Claude Fable 5 <[email protected]>

* ci: keep the legacy alarm reachable when xcresulttool itself vanishes

Review follow-up (CodeRabbit): the toolchain recording step invoked
`xcrun xcresulttool version` bare, so the tool disappearing entirely
would fail recording, skip the legacy probe, and file a generic setup
failure instead of the one alarm the workflow exists to raise. Recording
now tolerates the failure and the probe swallows the command error so
the missing marker fires the explicit ::error either way.

Co-Authored-By: Claude Fable 5 <[email protected]>

* ci: compute the fixture cache key before generation via a shared action

Review of #442 found the drift job's pre-warm dead on arrival: its save
key rendered hashFiles('XCTestHTMLReportSampleApp/**') at save time,
after prepareTestResults.sh had left .derivedData inside the hashed
tree (actions/glob matches dot-dirs), so the saved key could never
equal test.yml's clean-tree restore key — ~95 MB of unreachable cache
churned per run.

Both workflows now derive the key from one composite action
(.github/actions/fixture-cache-key) that runs before generation. The
hash inputs and key format are unchanged, so existing cache entries
still hit; sharing the action also removes the duplicated key logic
the two workflows could silently de-sync on.

Also from the review:
- run the legacy probe under !cancelled() so a hard-failed record step
  (xcodebuild/swift dying — the alarm's most important case) cannot
  skip it, and have the report call out a probe that somehow still
  did not run
- make the record step's xcresulttool tolerance survive pipefail
  (|| true inside the pipeline) instead of relying on the default
  shell leaving pipefail off

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
tylervick added a commit that referenced this pull request Aug 13, 2026
…t + forced-modern CI leg (#391, Tasks 12–13) (#450)

* Parity rulings from the first differential run; fixes the #449 export race

Running both readers over freshly generated fixtures surfaced divergences
the spec's tables did not anticipate. Each lands here as a rule, recorded
in the spec's "Task 12 execution rules" section; none adds an allow-list
entry.

- Content-addressed attachment exports: both backends name every exported
  payload <payloadId>.<ext> — the one attachment identifier the two
  formats share. Xcode 26.2 gives every auto screen recording in a session
  one shared display name, so name-keyed exports collapsed distinct
  payloads onto one path and raced concurrent copies (#449, reproduced
  with a live EEXIST trace and a 1-in-12 test flake). The export is now
  idempotent: an existing destination is the payload, never removed.
- Symbol-annotation rows (no startTime) and attachment-shadow rows
  (leaf, non-failure, startTime == sibling attachment timestamp) are
  dropped by the modern reader; both are 26.2 bookkeeping the legacy tree
  never had. The shadow join's guards are load-bearing: a genuine failure
  row shares the attachment's millisecond on testWithSpecialChars().
- Swift Testing names come from the identifier's function form on both
  backends; the @test display name is a field only one backend can fill.
- Legacy merges parameterized argument executions (duplicate siblings
  with no repetitionPolicySummary) into one iteration; true retries keep
  their numbers.
- Expected failures are non-events on both backends: messages claim and
  remove their exact-title activity rows instead of joining.
- Skip notices were a legacy reader gap: the reason was always on
  skipNoticeSummary; it now renders the same appended row modern emits.
- Failure-row placement goes through one shared total-ordered interleave
  (ParsedActivity.interleavingFailureRows) fed by both readers; the
  modern reader hoists failure tips (isFailure now means "is the
  assertion row" — tip of the flagged chain). Re-nesting legacy rows by
  time window was tested and rejected: windows collide at millisecond
  granularity and misplace rows.
- Run logs export as <run-identifier-digest>.log on both backends instead
  of backend-internal reference names.

Refs #391. Fixes #449.

Co-Authored-By: Claude Fable 5 <[email protected]>

* test: the differential — legacy/modern parity held to a declared allow-list

Renders each fixture through both readers, forced explicitly, and asserts
(Task 12): per-run counts, the identifier→status map, and exported
attachment filenames AND bytes match exactly; after normalizing identifier
digests (#430's [0-9a-f]{32}, .linking renders only) and masking exactly
the four declared losses, the two renders are byte-identical. Skips loudly
when the toolchain has no legacy commands, and proves the forced backend
actually resolved rather than assuming it.

Anti-rot runs in both directions: a diff outside the allow-list fails the
build, and an entry that masks nothing real also fails — "fires" means
omitting the rule (others still applied) leaves the renders unequal, so an
entry cannot rot silently once Apple fills the gap. All four entries fire:
durations on all three fixtures, wrapperGroups on all three,
failureTitlePrefix and attachmentDisplayNames on TestResults and
RetryResults.

The masker diverged from the plan's snippets in four evidence-forced ways,
recorded in the plan's Task 12 execution note: wrapperGroups is a
structural SwiftSoup unwrap (line-filtering left the wrapper's div
skeleton behind), the display-name anchor spans the icon line the plan's
regex tripped on, line joins are canonicalized before comparing (template
concatenation breaks lines differently across nesting depths), and
XCTest-case duration coverage lost to the over-broad durations mask is
restored by a model-level assertion.

Refs #391.

Co-Authored-By: Claude Fable 5 <[email protected]>

* ci: forced-modern test leg via XCHR_RESULT_READER (Task 13)

One matrix leg runs the whole suite with the modern reader forced, so the
path that becomes the only path once Apple removes the legacy commands is
exercised end to end on every PR — not only through the differential.

The env var is the CI override, not a new control surface: it defaults
both Summary.init's backend and the CLI's --result-reader (so CLI-driven
tests pick it up through the spawned binary), the flag still wins when
passed, and an unrecognised value degrades to auto. Verified locally:
92 tests green under both XCHR_RESULT_READER=modern and =auto. Fixture
cache (#436) is shared across legs unchanged; no new action steps.

Refs #391.

Co-Authored-By: Claude Fable 5 <[email protected]>

* docs: record the Task 12 rulings as rules, and the 4.0 release-note items

Spec: new "Task 12 execution rules (2026-08-12)" section — content-addressed
exports (fixes #449), symbol-annotation and attachment-shadow drops, Swift
Testing names from the identifier, parameterized-execution merge, expected
failures as non-events, skip-notice fix, the shared failure-row interleave
with the reversible hoist, and run-log naming — each with the evidence that
forced it. The attachment-filename table row is superseded, the Tree-shape
display-name paragraph becomes a model rule, and attachmentDisplayNames'
Exercised-by cell gains RetryResults (measured).

Plan: Task 12 execution note (where the shipped harness supersedes the
snippets, and why); Task 15 gains a release-notes checklist so the 4.0
output changes accepted here cannot be missed by the docs task.

Refs #391.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI test job takes >10 minutes; 85% is fixture generation

1 participant