Reject stub fixture bundles at the verify gate (fixes #454) - #470
Conversation
|
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 (5)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughThe pull request adds shared validation for XCTest result bundles. CI verifies that fixtures are readable and contain non-zero test data before running tests, generating reports, rendering the site, or saving caches. Documentation and sample fixtures are updated. ChangesFixture verification
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to The change rejects empty or unreadable fixture bundles before downstream use and cache save, preventing invalid fixtures from being accepted; no actionable merge-blocking risk remains after normal checks and review. Sequence Diagram(s)sequenceDiagram
participant GitHubActions
participant prepareTestResults.sh
participant verify_fixtures.sh
participant xcresulttool
participant TestSuite
participant ReportBuilder
participant FixtureCache
GitHubActions->>prepareTestResults.sh: Generate fixtures
GitHubActions->>verify_fixtures.sh: Verify bundles
verify_fixtures.sh->>xcresulttool: Read test-results summary
xcresulttool-->>verify_fixtures.sh: Return totalTestCount
verify_fixtures.sh-->>GitHubActions: Return verification status
GitHubActions->>TestSuite: Run tests after verification
GitHubActions->>ReportBuilder: Generate reports after verification
GitHubActions->>FixtureCache: Save verified fixtures
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
CI-side evidence, Same on |
The gate shared by `test.yml` and `toolchain-drift.yml` asked only whether each
`.xcresult` had an `Info.plist`. When a runner's simulator vanished mid-run
("Unable to find a device matching the provided destination specifier"), the
`|| true` in `prepareTestResults.sh` swallowed the failure and `xcodebuild`
still left a stub bundle behind — `Info.plist`, a `Data` directory, no tests.
That stub passed the gate, failed the suite later with a confusing error
instead of failing fast at generation, and came one step from being SAVED to
the fixture cache under a valid key, where it would have poisoned every
restore until the key rotated. The cache is the dangerous half.
`scripts/verify_fixtures.sh` now asserts real test data per bundle.
`xcresulttool get test-results summary` exits 0 on a stub and reports
`"totalTestCount": 0`, so the count is the assertion, not the exit status; the
command is the modern surface `ModernResultReader` already reads through, so
the gate needs no toolchain the tool does not. Both workflows call the one
script — the two inline copies could drift apart, and one of them is the gate
in front of the cache — and every bundle is checked before it exits, so a run
names every offender rather than the first.
Verified against a real bundle (accepted: `Real.xcresult: 2 tests`), against a
stub reproduced the way the incident produced one, by pointing `xcodebuild
test-without-building` at a device id that does not exist (rejected, exit 1,
naming the bundle), and against a corrupted bundle whose summary command fails
outright (rejected, with xcresulttool's own error echoed).
In `toolchain-drift.yml` verification becomes its own step. Generation is
allowed to fail tests, so folding the check into it conflated "generation blew
up" with "generation produced empty bundles" — distinct faults in the issue
that workflow files. The suite, the render and the cache save now hang off the
verify step instead of off generation. `test.yml` needs no reordering:
`actions/cache` saves post-job under `post-if: success()`, so a failing gate
already blocks the poisoned save.
Also from the #453 review: the `Expected Failure` doc comment is scoped to
`TestResults.xcresult`, since `RetryTests.testInUnknownState` records one too
and lands in `RetryResults.xcresult`; and the programmatic PNG pins its
renderer to scale 1, because `UIGraphicsImageRenderer(size:)` takes points and
would emit a 16×16 or 24×24 attachment on a Retina simulator instead of the
8×8 its comment promises.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The verify gate this PR added covered test.yml and toolchain-drift.yml, but main had four inline copies, not two. The two left behind were the ones that mattered most. pages.yml was the hole: it restores, generates and post-job saves all three bundles under the same key test.yml restores from, on every merge to main, while its inline gate checked only TestResults for an Info.plist. A stub from an incident like #454 on a merge run passed that gate, published an empty demo, and then poisoned the shared cache for every PR run until the key rotated. It now runs the shared script over all three bundles — not just the one it renders, because it writes all three. Failing there also blocks the save, since the cache action's post step runs under `post-if: success()`. pages-release.yml has no cache to poison, but it pushes an immutable per-tag render, so a stub becomes a permanently hosted empty demo under /v/<tag>/. It verifies just TestResults, the only bundle it publishes: failing a release over a stub Sanity or Retry bundle nothing in the job reads would be a false blocker. No version-skew risk — a tag push runs the workflow file as it existed at the tag and that job checks out the tag with no ref: override, so the workflow and the script always travel together. Zero inline copies remain, so the script's own header no longer undercounts its callers. Also corrects the Expected-Failure doc comment, which the rider that rewrote it left wrong in both halves. TestResults.xcresult carries two Expected Failure rows, not one: testExpectedFailure and SwiftTestingSuite.knownIssue, both in the SampleAppUnitTests target, so prepareTestResults.sh's -skip-testing:SampleAppUITests/RetryTests does not exclude the second. Measured with `xcresulttool get test-results tests` — 21 rows, {'Passed': 12, 'Failed': 6, 'Expected Failure': 2, 'Skipped': 1} — which is what CoreTests' `all - skipped - 2` already encoded. The comment's second half was stale besides: #439 landed and gave the status its own Status.expectedFailure and glyph, so it no longer renders as .unknown. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
8cbaa6c to
b038c76
Compare
|
Review follow-up pushed as F1/F2 — the two un-migrated gates. Zero inline copies remain ( No version-skew hazard on the release path: a tag push runs the workflow file as it existed Neither changed workflow runs on a PR ( F3/F4 — the Expected-Failure comment. which is what Gate output from this run. The sample-app edit rotates the fixture cache key, so both Rebase. Clean onto
|
…ation faults, drift artifacts (refs #478) The Xcode 27 beta leg reported eight differential failures and a red suite. None of it was a reader regression. The sample app is pre-scene UIKit, the iOS 27 SDK promotes a missing scene manifest from a runtime warning to a launch trap, and the app died on launch -- taking `SampleAppUnitTests` with it, since the unit tests are hosted in it. Twelve of the fixture's twenty-one rows never existed, and every downstream failure was arithmetic on that hole. Four changes, because they are one story: the beta leg has to measure this project rather than a bundle that was never produced, and nothing in the chain should have let a 10-of-21 bundle through in silence. **The sample app adopts UIScene.** A `SceneDelegate`, a scene manifest naming `Main` through `UISceneStoryboardFile`, and `UIMainStoryboardFile` retired since it is superseded. The five app-level lifecycle stubs go with it: scenes deliver those transitions to the scene delegate, so keeping them would leave five methods nothing calls, and they were empty template comments. Fixtures regenerated on stable and the suite run on both legs: 21/1/4 test counts, byte-for-byte the same shape as before the change, 150 tests green on `auto` and on `modern`, `DifferentialTests` included. **The verify gate learns each bundle's shape.** `totalTestCount > 0` passed a bundle missing 57% of the suite -- ten is a healthy count for a bundle whose expected count is ten, so no global floor can catch it. The floors are now per bundle (21/1/4, with the sources for each documented at the table), a rejection names the bundle and prints observed-versus-expected, and a bundle nobody declared keeps the old `> 0` rule. `pages.yml` and `pages-release.yml` inherit this for free; both already call the shared script. **Two new faults, on both backends by construction.** `xchtmlreport` ran against the truncated beta bundle, printed "Report successfully created", and exited 0. Now `emptyPlannedTestable` fires on a test target present with no test rows beneath it, and `systemFailure` on the `System Failures` group. Both are read off `ParsedResult` -- the model both readers produce -- so there is one implementation and it cannot drift between backends. The shapes were measured, not guessed. Reproducing the crash on Xcode 26.2 by trapping in the host app's `didFinishLaunchingWithOptions` yields a bundle both readers agree on: a group named and identified `System Failures` sitting directly under the testable. They stop agreeing one level down -- modern carries the crash row, XCResultKit drops it (an `ActionTestSummary` is not one of the `ActionTestMetadata` entries `subtests` decodes) -- which is exactly why the bucket is the marker and its rows are not. Against that bundle the tool now exits 3 on both readers, where it exited 0 on both before. The cardinal rule holds: a bundle carrying no testable for a target is structural absence, which is what `-only-testing` produces and what `SanityResults` and `RetryResults` are, and is never a fault. Verified directly -- every fixture, through every backend the toolchain runs, reports neither fault. **The drift workflow uploads its bundles.** This triage was done from job logs because `toolchain-drift.yml` uploaded nothing and `gh run download` found no artifacts, and the one open question -- whether XCTest stopped recording `file:line` in failure titles on 27 or `xcresulttool` stopped emitting it -- cannot be answered without a bundle. Bundles now upload on any failing signal, named per leg per the #470 convention, at five days' retention since they are ~50 MB and this runs twice a week. The filed issue points at them, which matters as much as the upload: nobody knew there was nothing to download. Not touched: the `.auto` preference, since legacy was not at fault and flipping it would have produced the identical 10-row report. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ncation fault and its canary, drift artifacts (refs #478) (#479) * Make beta runs measure reality: UIScene, a partial-bundle gate, truncation faults, drift artifacts (refs #478) The Xcode 27 beta leg reported eight differential failures and a red suite. None of it was a reader regression. The sample app is pre-scene UIKit, the iOS 27 SDK promotes a missing scene manifest from a runtime warning to a launch trap, and the app died on launch -- taking `SampleAppUnitTests` with it, since the unit tests are hosted in it. Twelve of the fixture's twenty-one rows never existed, and every downstream failure was arithmetic on that hole. Four changes, because they are one story: the beta leg has to measure this project rather than a bundle that was never produced, and nothing in the chain should have let a 10-of-21 bundle through in silence. **The sample app adopts UIScene.** A `SceneDelegate`, a scene manifest naming `Main` through `UISceneStoryboardFile`, and `UIMainStoryboardFile` retired since it is superseded. The five app-level lifecycle stubs go with it: scenes deliver those transitions to the scene delegate, so keeping them would leave five methods nothing calls, and they were empty template comments. Fixtures regenerated on stable and the suite run on both legs: 21/1/4 test counts, byte-for-byte the same shape as before the change, 150 tests green on `auto` and on `modern`, `DifferentialTests` included. **The verify gate learns each bundle's shape.** `totalTestCount > 0` passed a bundle missing 57% of the suite -- ten is a healthy count for a bundle whose expected count is ten, so no global floor can catch it. The floors are now per bundle (21/1/4, with the sources for each documented at the table), a rejection names the bundle and prints observed-versus-expected, and a bundle nobody declared keeps the old `> 0` rule. `pages.yml` and `pages-release.yml` inherit this for free; both already call the shared script. **Two new faults, on both backends by construction.** `xchtmlreport` ran against the truncated beta bundle, printed "Report successfully created", and exited 0. Now `emptyPlannedTestable` fires on a test target present with no test rows beneath it, and `systemFailure` on the `System Failures` group. Both are read off `ParsedResult` -- the model both readers produce -- so there is one implementation and it cannot drift between backends. The shapes were measured, not guessed. Reproducing the crash on Xcode 26.2 by trapping in the host app's `didFinishLaunchingWithOptions` yields a bundle both readers agree on: a group named and identified `System Failures` sitting directly under the testable. They stop agreeing one level down -- modern carries the crash row, XCResultKit drops it (an `ActionTestSummary` is not one of the `ActionTestMetadata` entries `subtests` decodes) -- which is exactly why the bucket is the marker and its rows are not. Against that bundle the tool now exits 3 on both readers, where it exited 0 on both before. The cardinal rule holds: a bundle carrying no testable for a target is structural absence, which is what `-only-testing` produces and what `SanityResults` and `RetryResults` are, and is never a fault. Verified directly -- every fixture, through every backend the toolchain runs, reports neither fault. **The drift workflow uploads its bundles.** This triage was done from job logs because `toolchain-drift.yml` uploaded nothing and `gh run download` found no artifacts, and the one open question -- whether XCTest stopped recording `file:line` in failure titles on 27 or `xcresulttool` stopped emitting it -- cannot be answered without a bundle. Bundles now upload on any failing signal, named per leg per the #470 convention, at five days' retention since they are ~50 MB and this runs twice a week. The filed issue points at them, which matters as much as the upload: nobody knew there was nothing to download. Not touched: the `.auto` preference, since legacy was not at fault and flipping it would have produced the identical 10-row report. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> * Withdraw emptyPlannedTestable and give systemFailure a canary (refs #478) Review of #479 produced a counterexample I reproduced here before touching anything: on an unmodified sample app, xcodebuild test-without-building ... \ -skip-testing:SampleAppUITests/RetryTests \ -skip-testing:SampleAppUnitTests/SampleAppUnitTests \ -skip-testing:SampleAppUnitTests/SwiftTestingSuite produces a bundle where every test that was planned to run ran -- and at a59640e `xchtmlreport` exits 3 on it under the legacy reader and 0 under modern. Legacy keeps a zero-row `Selected tests` group for the still-selected unit target; the modern node tree drops the bundle node entirely. One implementation, two inputs, two answers. **`emptyPlannedTestable` is withdrawn.** Being read off the shared model made it one implementation, which is not the same as one behaviour: the structure underneath differs between backends, so the rule was observable on legacy only -- the one-backend field the model doctrine exists to catch -- and on the default reader it faulted ordinary practice. Quarantining a flaky suite and sharding a target across CI jobs both produce that shape, and `--lenient` is all-or-nothing, so a user had no way to suppress just this one. Its residual value was near zero: the #478 partial bundle is caught by the per-bundle floors in `verify_fixtures.sh`, and the launch trap by `systemFailure`, which fires on both readers. Gone with it: the kind, its `validate()` arm, the now-unused `ParsedTestable.allTestCases`, its tests, and its release-note claim. `testSelectedTargetWithNoTestRowsIsNotAFault` pins the counterexample so the rule cannot come back by accident. **`systemFailure` gets the canary the review asked for.** It keys on a display name, and the review confirmed there is no structural alternative on either surface: modern's node is `{"name": "System Failures", "nodeType": "Test Suite"}`, legacy's an ordinary `ActionTestSummaryGroup`. So it fails open -- an Xcode that renames the bucket disarms the fault, healthy fixtures keep passing, and nothing reddens. A recursive search of `Xcode.app/Contents` finds the literal in no binary and no `.strings` table (the crash message `Early unexpected exit` is in `XCTHarness`, so the search does work), which says the string is composed at run time -- weak evidence against localization, none at all against a rename. So the name is re-measured rather than trusted. `prepareTestResults.sh` now generates a fourth fixture, `CrashResults.xcresult`, by setting `TEST_RUNNER_XCHR_TRAP_AT_LAUNCH` -- xcodebuild's own channel to the launched process, which for a hosted unit test is the host app -- and the sample app's `AppDelegate` traps in `didFinishLaunchingWithOptions`. `SampleAppUnitTests` is hosted in it and dies with it: the #478 failure reproduced, not simulated. `SystemFailureCanaryTests` then asserts both readers still find the bucket by name in that bundle and that the tool faults on both, and names every group it did find when it fails, so the failure says what Apple renamed it to. Verified by flipping the constant: three tests, four assertions, red on both readers. The canary reaches a new toolchain before a release does, because `toolchain-drift` regenerates fixtures and runs the suite on the beta leg. **Also from the review.** The release note no longer says the two readers "behave identically"; the rule is implemented once, and the two still word the fault differently because each names the most specific node its own document holds. The `AppDelegate` scene-configuration comment claimed a rename of `UISceneConfigurationName` "stops compiling" -- the opposite of what happens: the name is matched at run time, an unmatched configuration has no delegate class and no storyboard, and the app comes up as a blank window. The drift upload comment now says it reads the `failure` subset of the six signals `Report drift` reads, not the same set. The floor table's comment now admits it keys on basename alone. Re-verified on Xcode 26.2 / iPhone 17 Pro Max (iOS 26.2), fixtures regenerated from scratch by the updated script: - 3 healthy fixtures x 3 readers x 2 rendering modes -> exit 0, zero faults, 18/18. - `CrashResults.xcresult` -> exit 3 on both readers (legacy names the bucket, modern the crash row). - The `-skip-testing` counterexample -> exit 0 on both readers, where legacy gave exit 3 before. - `swift test` on `auto` and on `modern`: 151 tests, 3 skipped, 0 failures. - `verify_fixtures.sh` green on all four bundles; shellcheck, actionlint, zizmor `--min-severity low`, swiftformat `--lint` (0/88) and swiftlint (0 serious, no new warnings) all clean. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Fixes #454.
The hole
The fixture verify gate — duplicated inline in all four workflows that
generate or restore fixtures — asked only whether each
.xcresulthad anInfo.plist. During the #453 review a runner's simulatorvanished mid-
prepareTestResults.sh(Unable to find a device matching the provided destination specifier), the script's|| trueswallowed it, andxcodebuildstill left a stub bundle behind:Info.plist, aDatadirectory,zero tests. It passed the gate. The suite then failed downstream with a
confusing error instead of failing fast at generation — and the stub was one
step from being saved to the fixture cache under a valid key, where it
would have poisoned every hit until the key rotated. That last part is the
reason this is worth a gate rather than a better error message.
The gate
scripts/verify_fixtures.shasserts real test data per bundle. Note the shapeof the failure:
xcresulttool get test-results summaryexits 0 on a stuband reports
"totalTestCount": 0, so the count is the assertion, not the exitstatus. The command is the modern surface
ModernResultReaderalready readsthrough, so the gate needs no toolchain the tool itself does not.
One script, called from all four workflows, because every one of those copies
stands in front of something that outlives its own run:
test.yml,toolchain-drift.ymlandpages.ymlall write the shared fixture cache, andpages-release.ymlpublishes an immutable per-tag render.pages.ymlis theworst of them — it restores, generates and post-job saves all three bundles
under the key
test.ymlrestores from, on every merge tomain, whilechecking only
TestResults. It checks every bundle beforeexiting, so a run names every offender rather than the first, and it takes
bundle paths as arguments so a contributor can run it straight after
prepareTestResults.sh(now documented in CONTRIBUTING).Evidence
Run locally against three bundles built for the purpose:
The old gate accepts every one of those stubs; each has an
Info.plist.Ordering vs the cache save
test.ymlneeds no reordering.actions/cachesaves in its post-job stepunder
post-if: "success()"(checked at the pinned SHA), so a failing gatebefore
swift testalready blocks the poisoned save.toolchain-drift.ymlnow verifies in its own step. Generation is allowedto fail tests, so folding the check into it conflated "generation blew up"
with "generation produced empty bundles" — separate faults in the issue that
workflow files. The suite, the render and
Save fixtures to cacheall hangoff the verify step now instead of off generation, so a stub neither produces
confusing downstream failures nor reaches the cache. The drift report gains a
fixtures contain test datarow and a fault line pointing at toolchain-drift/test fixture verify gate accepts stub bundles — assert non-empty test data #454.pages.ymlverifies all three bundles, not just the one it renders.It post-job saves all three under the key
test.ymlrestores from, sochecking only
TestResultswould let a stubSanity/Retrybundle reach theshared cache on any merge to
mainand fail every PR run until the keyrotates. Same
post-if: success()property astest.yml: failing the gatealso blocks the save, and blocks publishing an empty demo from a cache hit
that restored nothing.
pages-release.ymlverifies justTestResults, the only bundle itpublishes. No cache here to poison, and failing a release over a stub bundle
nothing in the job reads would be a false blocker — but the render is pushed
immutably under
/v/<tag>/, so a stub that got through would be apermanently hosted empty demo. No version-skew hazard: a tag push runs the
workflow file as it existed at the tag, and that job's checkout takes the tag
with no
ref:override, so the workflow andscripts/verify_fixtures.shalways travel together.
Riders from the #453 review
Expected Failuredoc comment now says what the bundle actuallycontains.
TestResults.xcresulthas two such rows, not one —testExpectedFailureandSwiftTestingSuite.knownIssue, both in theSampleAppUnitTeststarget, soprepareTestResults.sh's-skip-testing:SampleAppUITests/RetryTestsdoes not exclude the second.Measured with
xcresulttool get test-results tests(21 rows;{'Passed': 12, 'Failed': 6, 'Expected Failure': 2, 'Skipped': 1}), which is also whatCoreTests'all - skipped - 2has encoded all along. The comment's secondhalf — "renders it as
.unknown(blank icon); the redesign's status-iconwork needs a real case" — was stale: Redesign the report UI #439 landed and gave the status its own
Status.expectedFailureandexpected-failureglyph.UIGraphicsImageRenderer(size:)takes points and defaults to the screenscale, so on a Retina simulator the attachment was 16×16 or 24×24, not the
8×8 its comment promises. (Sample app compiles:
TEST BUILD SUCCEEDED.)That second one changes the sample app, so it rotates the fixture cache key —
which means this PR's
test.ymlrun regenerates from scratch and exercises thenew gate end to end against real bundles rather than restored ones.
shellcheckandzizmor --min-severity loware clean;actionlintreportsnothing in the four workflow files touched here (its two findings are
pre-existing, in
release.ymlandtest-artifacts.yml). SHA pins untouched.Summary by CodeRabbit
Bug Fixes
Documentation
Tests