ci: cache generated test fixtures keyed on toolchain and inputs - #436
Merged
Merged
Conversation
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]>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThe test workflow now restores cached ChangesFixture workflow
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
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]>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #412.
What
Caches the three generated
.xcresultfixture bundles in thetestjob withactions/cache, keyed on:xcodebuild -version)prepareTestResults.shselects)hashFiles('prepareTestResults.sh', 'XCTestHTMLReportSampleApp/**')On an exact key hit, the
Generate test fixturesstep is skipped entirely. There are norestore-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 fixturesstep asserts all three bundles exist (via theirInfo.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
latest-stablesilently moving under us — changes the resolved Xcode build and runtime in the key and forces regeneration. The scheduled drift detector (Add a scheduled Xcode/Swift drift detector #392) and the legacy-vs-modern differential test both get fixtures that reflect the current toolchain.ci.yml's build matrix (macos-15/Xcode 16 andmacos-latest/latest-stable) is untouched; iftest.ymlever gains the same matrix, the resolved-toolchain key already keeps the legs' caches separate.prepareTestResults.shuntouched,./prepareTestResults.sh && swift testworks exactly as before.Measurements
All numbers are measured
test-job wall clock from real CI runs (jobstarted_at→completed_atvia the Actions API), not estimates.Before — the last 8 successful
testjobs onmain(all cache-free, identical code path to this branch's miss):This branch (run 31642383154, three attempts):
Verify fixturespassed on all three attempts — the hits restored real bundles, andswift testran 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.ymlruns the same generation on pushes tomain; the same three steps could be copied there to hit the cache seeded bymain'stestjob. Left out to keep this change to the required check.🤖 Generated with Claude Code
Summary by CodeRabbit