Skip to content

ci: the gate compiles on the image the release builder already uses - #8

Merged
noidee-dev merged 8 commits into
developfrom
ci/runner-image-with-a-current-xcode
Oct 30, 2026
Merged

noidee-dev merged 8 commits into
developfrom
ci/runner-image-with-a-current-xcode

Conversation

@noidee-dev

Copy link
Copy Markdown
Contributor

Why

ci.yml was pinned to macos-15, which on 2026-10-29 printed Xcode 16.4 / Build version 16F6 out of /Applications/Xcode_16.4.app — Swift 6.1.2. The development machine is Xcode 27.1 (27A9275) / Swift 6.4, and release.yml's build job has run on macos-latest since that workflow was written (c2453cf7, 2026-07-27). From the repository root this prints nothing, so the release builder was never on the older image:

git log -S'macos-15' -- .github/workflows/release.yml

Measured 2026-10-29, macos-latest is macOS 26 Arm64 with Xcode 26.6 (17F113) as its default. The binary that ships — tests and all — is already compiled there. ci.yml was the odd one out: the only gate on the project, reading a compiler nobody here builds with.

What that cost, in two runs on one day

Run 40913494898 failed the build on one line of Sources/MacSCPAppKit/Comparison.swift, which was green here from a clean scratch path. Run 40928759131, after that fix, got the build through and failed the test targets on five more sites across both of them:

Site Error
Tests/macSCPAppKitTests/BoundedViewFigureReportTests.swift:86 extra argument 'severity' in call
Tests/macSCPAppKitTests/ResizableContainerFloorReportTests.swift:44 same
Tests/macSCPCoreTests/FileDiffCostTests.swift:376 same
Tests/macSCPAppKitTests/BoundedViewLayoutGuardTests.swift:363 converting non-sendable function value to '@MainActor @Sendable () -> SnippetsSheet' may introduce data races
Tests/macSCPAppKitTests/EmptyContainerListTests.swift:131 main actor-isolated static property 'allLines' can not be referenced from a nonisolated context

Plus one macro expansion #expect:1:1: error: the compiler is unable to type-check this expression in reasonable time, which is the older type checker's pace rather than a defect in the expression.

The three severity: sites all write Issue.record(Comment(rawValue: text), severity: .warning) — an argument the swift-testing shipping with Xcode 16.4 does not have. No annotation could have fixed those; only dropping the feature, or fencing it behind #if swift(...) three times.

What this does not claim

  • Whether Xcode 26.6 accepts all five sites is unmeasured. Finding that out is what this PR is for, which is why it arrives as a pull request instead of a push to develop.
  • MAX_WARNINGS=0 was tuned against the warnings 6.1.2 prints. A newer compiler may turn the warning gate red where the build gate now passes. The first run on the new image is a measurement, not a result.

The trade, taken knowingly

macos-latest can move under us. Both workflows now move together, so this gate meets a new image before a release does — the ordering that catches the move instead of shipping it.

Both toolchain steps now print swift --version beside xcodebuild -version, so the Swift figure travels with the run. Reading it off the Xcode version is exactly what went wrong: a comment dated 2026-09-16 recorded Xcode 27 (27A266a) / Swift 6.4 for a machine, and it was read as the runner's from then until 2026-10-29.

🤖 Generated with Claude Code

noidee-dev and others added 8 commits October 29, 2026 19:43
The only gate on this project was reading a compiler nobody here builds
with. `ci.yml` was pinned to `macos-15`, which on 2026-10-29 printed
`Xcode 16.4` / `Build version 16F6` out of `/Applications/Xcode_16.4.app`
-- Swift 6.1.2. The development machine is Xcode 27.1 (`27A9275`) /
Swift 6.4, and release.yml's build job has run on `macos-latest` since
that workflow was written (`c2453cf7`, 2026-07-27); from the repository
root this prints nothing, so it was never on the older image:

    git log -S'macos-15' -- .github/workflows/release.yml

Measured 2026-10-29, `macos-latest` is macOS 26 Arm64 with Xcode 26.6
(`17F113`) as its default. So the binary that ships, tests and all, is
already compiled there, and `ci.yml` was the odd one out.

What that cost, in two runs on the same day. Run 40913494898 failed the
BUILD on one line of Sources/MacSCPAppKit/Comparison.swift, green here
from a clean scratch path. Run 40928759131, after that fix, got the
build through and failed the TEST TARGETS on five more sites across
both of them:

    Tests/macSCPAppKitTests/BoundedViewFigureReportTests.swift:86
    Tests/macSCPAppKitTests/ResizableContainerFloorReportTests.swift:44
    Tests/macSCPCoreTests/FileDiffCostTests.swift:376
      -- extra argument 'severity' in call
    Tests/macSCPAppKitTests/BoundedViewLayoutGuardTests.swift:363
      -- converting non-sendable function value to
         '@mainactor @sendable () -> SnippetsSheet' may introduce data races
    Tests/macSCPAppKitTests/EmptyContainerListTests.swift:131
      -- main actor-isolated static property 'allLines' can not be
         referenced from a nonisolated context

plus one `macro expansion #expect:1:1: error: the compiler is unable to
type-check this expression in reasonable time`, which is the older type
checker's pace and not a defect in the expression. The three `severity:`
sites all write `Issue.record(Comment(rawValue: text), severity:
.warning)`, an argument the swift-testing that ships with Xcode 16.4
does not have -- so no annotation could have fixed those, only dropping
the feature or fencing it behind `#if swift(...)` three times.

Two things this does NOT claim. Whether Xcode 26.6 accepts all five
sites is unmeasured -- that is what this change exists to find out, and
it is why it arrives as a pull request rather than as a push to develop.
And `MAX_WARNINGS=0` was tuned against the warnings 6.1.2 prints, so a
newer compiler may well turn the warning gate red where the build gate
now passes; the first run on the new image is a measurement, not a
result.

The trade, taken knowingly: `macos-latest` can move under us. Both
workflows now move together, so this gate meets a new image before a
release does -- the ordering that catches the move instead of shipping
it. And both toolchain steps now print `swift --version` beside
`xcodebuild -version`, so the Swift figure travels with the run. Reading
it off the Xcode version is exactly what went wrong: a comment dated
2026-09-16 recorded `Xcode 27 (27A266a) / Swift 6.4` for a machine, and
it was read as the runner's from then until 2026-10-29.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All seven polling cases in RemoteBrowserViewModelMetadataMergeTests came
back `Time limit was exceeded: 60.000 seconds` on CI run 40933631584, the
first run of the gate on `macos-latest`. The suite's own comment called
`.timeLimit(.minutes(1))` "the only clock any assertion reads", which it
is -- and that is exactly what made one minute a wall-clock ceiling
rather than a hang bound. On the development machine,

    swift test --filter RemoteBrowserViewModelMetadataMergeTests

reports the whole suite in 0.087 s: the slowest of the seven,
twoHundredEntriesArrivingInReverseOrderMergeUnderSizeSort, is that same
0.087 s, and the other six run in 0.011 s to 0.013 s. Sixty seconds is
690x the slowest case and still lost to a loaded three-core runner. Five
minutes is 3448x it and turns a poll that never completes into a red just
as well, which is all the bound is for. ConnectionDiagnostics already
carries `.minutes(5)` for the same reason.

Nothing about the merge itself is slow: the local figures above are from
this same tree, and the suite passes in 0.089 s with the new bound in
place.

One cross-reference checked while editing the comment, since a comment
that names other code is a claim about it: the twoHundredEntries case
cites `mergePathIndex`'s doc comment for a hang. Read 2026-10-29, that
doc comment records the cost of the `firstIndex(where:)` scan it replaced
-- O(n) per arrival, O(n^2) over a listing, "roughly 12.5 million
compares" for 5000 entries -- and does not mention a hang. The new
paragraph says so rather than repeating the claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run 40933631584 killed the Unit-Tests step at its own
`timeout-minutes: 8`. The budget was not wrong when it was written; the
maximum it rests on expired. Two runs measure that, each read with

    gh run view <id> --repo NoiXdev/macSCP --log

which prints the Build step's `Build complete! (<n>s)`, then `swift
test`'s own compile of the test targets as a second one, and -- on a run
that reaches the end -- `Test run with <n> tests passed after <t>
seconds`:

  38225383495, 2026-10-12, c0762fa, macos-15, GREEN:
    240.40s build, 244.42s test-target compile, 6821 tests in 78.397 s.
    Whole step 329 s, which is 1.46x inside the 480 s budget -- not the
    2.09x the step's comment derived on 2026-09-05 from a 230 s maximum.
  40933631584, 2026-10-29, 9c8d367, macos-latest, killed:
    230.46s build, 392.46s test-target compile. The first test did not
    start until 397 s (6 m 37 s) into the step, leaving 83 s of the 480 s
    for the whole suite; the kill landed 96 s after the first test
    started.

So the step now reads 16 minutes: 960 s is 2.04x of 392 s + 78 s =
470 s. That sum is this image's measured compile plus the OTHER image's
measured run, because this image's run figure does not exist yet -- the
step was killed before the suite finished. The comment says so, and says
that the first green run on this image replaces both the addend and the
number.

The two compile figures are NOT a toolchain comparison, and the comment
forbids reading them as one: 28,721 lines of test code and 17,144 lines
of source landed between those two commits.

    git diff --shortstat c0762fa..dcb5a41 -- Tests/
    git diff --shortstat c0762fa..dcb5a41 -- Sources/

Note which way that cuts on the build half: Sources grew by those 17,144
lines and `swift build` still went 240.40s -> 230.46s.

The job budget goes from 20 to 35 minutes to leave the wider step room.
Its comment claimed "Green runs take three to four minutes, so 20 leaves
ample room for a slow runner" -- the withdrawn wording. Three to four
minutes was the figure for the test STEP in September, never for the job:
the last green run took 9 m 52 s end to end, 08:44:12Z to 08:54:04Z,
from

    gh run view 38225383495 --repo NoiXdev/macSCP --json jobs

reading the job's own startedAt and completedAt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI run 40965600726 (2026-10-29, `79851463`, `macos-latest`) failed
`awaitingTheConnectorDoesNotKeepTheMainActor` on

    liveness: test1 ambient=23.317136667 seconds gap=4.923961375 seconds ceiling=4.0 seconds

read with `gh run view 40965600726 --repo NoiXdev/macSCP --log`. The
ambient control -- the same ticker, same process, moments earlier, with
nothing under test -- measured a 23.317 s main-actor stall, which is
4.74x the 4.924 s gap the assertion called a defect. The ceiling is
capped at 4 s on purpose: test 1's detectable block is 5 s, so a cap
above it would make the assertion unfailable. Both halves of that are
right, and together they describe a run in which the instrument has no
power at all -- a gap under the ceiling is unreachable and a gap over it
is what noise and block both produce.

This file has named that regime since 2026-08-28 and chose a side:
"so a run in that regime is a false red rather than a blind pass -- the
direction this suite prefers, since a false red is visible and a blind
pass is not. No run of the rebuilt assertion has reached it
(2026-09-02)" -- the withdrawn wording, quoted in the comment it was
removed from. A run has now reached it, and a third option is better
than either: a measurement whose control is off the scale has no verdict
to give, so it reports its figures and does not assert.

Both call sites now go through one `stateGapAgainstCeiling` helper --
they held verbatim copies of the same print-and-expect block -- which
prints the same `liveness:` line as before when the control is in range
and asserts the ceiling there, and otherwise prints and records a
warning-severity issue naming the case and both figures. The house
pattern is `FileDiffCostTests.report(_:)`.

Three things keep this from being a check that quietly stopped checking,
the failure mode CLAUDE.md names under "Guards that name what they
watch": the skip is loud, test 1's `ranOnMainThread == false` is
deterministic and still asserts, and test 3 blocks the main actor on
purpose and asserts a FLOOR on the gap, which no load can lift. The
comment also names what goes unproven on such a run, rather than
implying the suite is unaffected.

Sensitivity measured, since a negative check that learns to decline is
exactly the kind this project does not take on trust. Two plants through
`scripts/mutation-probe`, differing only in whether the control reads
out of range:

    scripts/mutation-probe --filter 'awaitingTheConnectorDoesNotKeepTheMainActor' \
      --apply "perl -0pi -e 's/^        _ = await vm\.connect\(\)\$/        blockThisThread(forMilliseconds: 5000)\n        _ = await vm.connect()/m' Tests/macSCPCoreTests/ConnectMainActorLivenessTests.swift"
    → RESULT: RED — caught by
      macSCPCoreTests.ConnectMainActorLivenessTests/awaitingTheConnectorDoesNotKeepTheMainActor()

    scripts/mutation-probe --filter 'awaitingTheConnectorDoesNotKeepTheMainActor' \
      --apply "perl -0pi -e 's/^        _ = await vm\.connect\(\)\$/        blockThisThread(forMilliseconds: 5000)\n        _ = await vm.connect()/m; s/^        try\? await Task\.sleep\(for: window\)\$/        blockThisThread(forMilliseconds: 5000)\n        try? await Task.sleep(for: window)/m' Tests/macSCPCoreTests/ConnectMainActorLivenessTests.swift"
    → RESULT: GREEN

The second carries the first's plant and one more, inside
`ambientGap(over:)`, which lifts the control to ~5 s and so past the
4 s cap. Same defect, same assertion, opposite verdicts: the branch does
what it says and nothing else in the test moved. A third plant -- the
ambient block alone -- is GREEN too and proves nothing either way, since
it never touches the dial window the gap is measured over; it is in the
record as a probe the guard was never asked to see, not as evidence.
Test 2 is gated behind `MACSCP_ITEST=1` and was not probed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run 41030496532 (`a64df8ee`, the third run of the gate on `macos-latest`)
failed with 73 tests red in 11 suites, and every one of them for the same
reason. Read with

    gh run view 41030496532 --repo NoiXdev/macSCP --log

the whole suite finished -- `Test run with 7975 tests in 650 suites failed
after 131.530` -- and the reason tally has exactly one entry:

    grep -o 'Time limit was exceeded: [0-9.]* seconds' <log> | sort | uniq -c
    #  148 Time limit was exceeded: 60.000 seconds

148 issues, no second reason. Confirmed by difference rather than by
reading the list: of the tests that recorded ANY issue, all but one also
recorded a time-limit issue, and the one that did not is
ConnectMainActorLivenessTests' own warning-severity report, which does not
fail its test.

    # tests with any issue: 74; with a time-limit issue: 73
    comm -23 <(grep -o 'issue in [A-Za-z]*\.[A-Za-z]*/[A-Za-z0-9_]*()' <log> \
                 | sed 's/.*\///' | sort -u) \
             <(grep 'Time limit was exceeded' <log> \
                 | grep -o 'issue in [A-Za-z]*\.[A-Za-z]*/[A-Za-z0-9_]*()' \
                 | sed 's/.*\///' | sort -u)
    #  awaitingTheConnectorDoesNotKeepTheMainActor()

This is not a property of any suite. Three named cases, same 60 s bound,
two consecutive runs on the same image:

  cancelAllWithParallelRunners()              70.097 s PASS / 100.325 s FAIL
  cancelAllCancelsQueuedAndRunning()          72.676 s PASS / 100.335 s FAIL
  sendPreservesFIFOOrderUnderVaryingLatency() 89.332 s PASS / 100.360 s FAIL

Left column run 40965600726, right column 41030496532. All three ran past
their own 60 s bound in BOTH runs and reddened only in the second, so the
bound is not a clock these tests lost to -- it is a race.

The mechanism is the one the CLAUDE.md section already records one layer
down: Swift Testing runs the suite in parallel with no concurrency limit,
the cooperative pool is as wide as the machine has cores (three), and all
7975 tests are in flight, so a case's reported duration is the run's wall
clock as seen from that case. In run 41030496532, of 6911 cases with a
duration line, 6843 reported over 10 s, 6335 over 30 s, 1153 over 60 s and
45 under 1 s, against a run total of 131.530 s. Whether a 60 s bound fires
is then down to whether the timer enforcing it gets a thread, which is the
2026-09-19 `DetachedProbe` starvation measured again at the harness level.

So the 110 `.minutes(1)` bounds in `Tests/` become `.minutes(5)`: 2.3x the
131.530 s run, 0.31x the 960 s step budget -- above the suite's wall clock
to be a hang bound at all, below the step budget so a real hang still reds
with the test's name instead of killing the step. The script that made the
change asserted its own reach before writing: 110 occurrences on 95 files,
skipping `///` lines. 127 bounds of this kind now stand in `Tests/`:

    grep -rnE '\.timeLimit\(\.minutes\(5\)\)' Tests/ | grep -vc '///'

A LINE count, deliberately: a bound is one trait on one line, while the
same spelling also sits in nine doc comments, and the escaped pattern plus
`grep -vc '///'` keeps both those and the command's own text out of the
figure. It took three tries to stop that number moving -- 128 matches, then
136 -- each time because writing the sentence added a spelling the previous
command counted. That is the `grep -c` rule in CLAUDE.md arriving from its
other side: here lines ARE the unit, so `-c` is the right tool.

The bulk change skipped `///` lines so that comments quoting a bound would
survive it. That protected the two quotations worth keeping -- this file's
own withdrawal, and ConnectionDiagnosticsJumpTests' record of four per-case
traits it removed in 2026-10-05 -- and left EIGHT comments describing a
bound their suite no longer has, which is the "Comments that describe other
code" failure in this very commit. They are corrected here, and three of
them owed more than a token:

  AgentAuth and SSHPrivateKeyLoader each argued FOR one minute -- "The
  project default is enough" and "nothing here needed the wider limit" --
  on the premise that a minute of reported time is a minute of this
  suite's work. It is not; it is the run's wall clock seen from one case.
  DiagnosticLog's bound was "not a duration this suite is expected to
  approach", which was true of its work and false of what the harness
  reports.

QuitSequence's sentence dated the bound ("since fix round 2 of the
port-forwarding plan's Task 6"), so swapping the value in place would have
claimed five minutes dated from then; it now says when each is from.

Two claims in that same comment are withdrawn in this commit, quoted
verbatim where they stood. It said "only these tests poll via `pollUntil`
for a background merge Task to catch up, so only they need a stated
ceiling": an uncounted claim about the rest of the tree, and wrong by 126.
And it attributed the first red to this suite -- "One minute was not a hang
bound for them; it was a wall-clock ceiling", with "Sixty seconds is 690x
the slowest case measured here and still lost to a loaded three-core
runner". The direction held; the attribution did not, and the local 0.087 s
figure the ratio rests on is beside the point, because the runner figure it
is divided into is the run's duration rather than the case's work.

CLAUDE.md gets the measurement under "A wall-clock ceiling in a test
measures the runner", and its closing rule now names `.timeLimit` as one of
the ceilings it is about.

Verified locally before committing, on this tree: the merge, transfer-queue
and liveness suites at 105 tests in 3 suites, and the five suites whose
comments are rewritten here -- Polling guard among them, since it is the
scanner that reads test source and could have reacted to the new comment
text -- at 93 tests in 4 suites plus 19 in 1. All green.

Not measured, and deliberately untouched: the 27 `.minutes(2)` bounds. No
run has recorded one firing, but 120 s is 0.91x this run's total, so they
are the next candidates rather than a cleared set. And the fix has a shelf
life by construction -- it ties the bound to the suite's size, so a run
twice as long makes five minutes marginal again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run 41040277938 (`c8041d19`) took the gate from 73 red tests to one, and
the reason tally is now empty:

    gh run view 41040277938 --repo NoiXdev/macSCP --log
    grep -c 'Time limit was exceeded' <log>    # 0, against 148 a run earlier

at a run duration of 129.852 s against the previous run's 131.530 s -- so
the bounds held on a runner that was just as slow, which is what the
previous commit claimed and had not yet shown.

The one test left is this suite's own timing half, and it failed on a run
with nothing blocking: test 2 printed

    liveness: test2 ambient=1.004113042 seconds gap=21.006908125 seconds
              ceiling=3.012339126 seconds

and asserted on the gap, because the gate `a64df8ee` added was
`ambient <= bar`. `bar` is derived FROM `ambient`
(`min(max(ambient * 3, 2 s), 4 s)`), so that condition reduces to
`ambient > 4 s` and nothing else -- a control of one second passed it and
was treated as adequate resolution for a three-second bar. It is not. The
control and the gap are sampled at different times, this runner's noise
arrives in bursts, and one quiet 600 ms window says nothing about the next
six seconds.

Six healthy samples now exist, three runs, no block present in any of them:

  40965600726  test 1  ambient 23.317  gap  4.924  ceiling 4.0
  40965600726  test 2  ambient 13.707  gap  0.449  ceiling 4.0
  41030496532  test 1  ambient  5.952  gap 17.047  ceiling 4.0
  41030496532  test 2  ambient  0.910  gap  0.180  ceiling 2.730
  41040277938  test 1  ambient  5.697  gap  8.925  ceiling 4.0
  41040277938  test 2  ambient  1.004  gap 21.007  ceiling 3.012

The gaps span 0.180 s to 21.007 s with nothing under test, a factor of 117,
against a signal of 5 s in test 1 and 6 s in test 2. No bound both detects
a 6 s block and survives 21 s of noise. Test 1's control was over the 4 s
cap in all three runs, so its timing half has never once been in a position
to give a verdict on this image; the single run where the old gate did
assert produced a false red.

So the gate becomes a resolution floor in absolute terms:
`ambient <= .milliseconds(500)`, and the gap is reported rather than
asserted above it. The figure is the gap between two measured populations
rather than a knob -- 0.030269209 s and 0.030088625 s on this development
machine, against the six CI samples above whose smallest is 0.909875959 s.
Half a second is an order of magnitude above the first population, below
all of the second, and leaves the smallest block this suite plants ten
times the control it must be told apart from.

What this does NOT do is recover coverage of a caller-side blocking bridge
on CI. That shape produces a gap of the dial's whole duration and the
timing half is the only thing that catches it; above the floor it is
unproven, and this file already said so where it describes what the skip
leaves out. Nothing here makes that better -- the measurement says the
instrument has no resolution on that runner, and a bound asserted without
resolution buys a false red, not coverage.

One alternative considered and rejected: bracket the window with a second
ambient sample afterwards and take the larger of the two. It needs evidence
that a trailing sample catches a burst the leading one missed, and run
41040277938 took no trailing sample, so adopting it would be a guess
wearing a measurement's clothes.

The gate is only worth changing if it still catches what it is for, so the
plant that proves the assertion fires was re-run against it rather than
reasoned about:

    scripts/mutation-probe \
      --filter awaitingTheConnectorDoesNotKeepTheMainActor \
      --apply <5 s blockThisThread before the awaited connect>

RESULT: RED -- 1 test ran, caught by
awaitingTheConnectorDoesNotKeepTheMainActor(). The control on this machine
is 0.03 s, an order of magnitude under the new floor, so the assertion is
reached here exactly as before. Plant D, which lifts the control to ~5 s,
was NOT re-run: ~5 s clears the old 4 s cap and the new 500 ms floor alike,
so no measurement there separates the two gates, and a re-run would have
been a green with nothing to attribute it to.

Also corrected, a claim about other code that was wrong in `a64df8ee`'s
probe record: it said "test 2 is gated behind `MACSCP_ITEST=1`". It is not
-- test 2 carries `.enabled(if: StalledLoopbackEndpoint.isAvailable)`,
which is why it skips here and runs on CI, and why the probes could only
ever have covered test 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run 41045404989 (`7b9ec334`) is the first run of the gate on
`macos-latest` whose test step passed: `Test run with 7975 tests in 650
suites passed after 137.078 seconds`, the step taking 581 s of its 960 s
budget. That let the Warning gate execute for the first time -- it had come
back `skipped` on four consecutive runs because the tests were red -- and
it failed on its first outing:

    Unique warning locations: 1 (budget: 0)
    ----- warnings -----
    …/Tests/macSCPAppKitTests/EmptyContainerListTests.swift:131:50: warning:
    main actor-isolated static property 'allLines' can not be referenced
    from a nonisolated context

`everyKeyTheViewUsesIsOneTheCatalogueChecksCover` reads
`EmptyContainerListView.allLines` from a nonisolated test. Both siblings
that touch the same property -- `everyLineResolvesInAllFourLocales` and
`theEnglishCatalogueSaysTheDefault` -- carry `@MainActor @Test`, so the fix
is the file's own idiom and not a new convention: this one test was missing
it.

The warning is the TOOLCHAIN's, not this branch's. Counted across the saved
job logs with

    grep -c 'EmptyContainerListTests.swift:131' <log>

it appears 25 times in each of runs 40965600726, 41030496532 and
41040277938 -- every run on `macos-latest` -- and 26 in 41045404989, the
extra line being the gate quoting it. In run 38225383495, the last green
run on `macos-15` (Xcode 16.4, Swift 6.1.2), it appears 0 times. Swift
6.3.3 diagnoses what 6.1.2 did not, which is the kind of thing moving the
image was meant to surface; nothing in `Tests/` was edited at that site by
any commit on this branch.

Why the gate's first red was a surprise rather than a prediction, which is
worth one line because the lesson is about the check and not the code: this
warning was reported earlier in the session as absent, from a search that
stripped the `gh run view --log` prefix with `sed -E
's/^[a-z]*\t[^[:space:]]*...'` written as `[^\t]*` for the step-name field.
Outside a bracket expression `\t` is a tab; INSIDE one it is a backslash
and the letter `t`, so the class excluded `t` and could not match a step
named `Unit-Tests`. Nothing was stripped, the following `^`-anchored grep
matched nothing, and nothing-matched read as no-warnings. A negative check
with no positive control beside it, which CLAUDE.md has a section about.

Verified with the gate's own pattern, this time with a positive check beside
the negative one. After the fix, `swift build --build-tests` with that file
touched so it recompiles:

    grep -cE '^[^[:space:]]+:[0-9]+:[0-9]+: warning:' <build log>   # 0

and the same pattern against the CI warning line, stripped of its log
prefix, returns 1 -- so the pattern does match this class of line rather
than nothing at all. Run over the whole of run 41040277938's log with the
corrected strip it reports 1 unique location, which is the figure the gate
itself printed on run 41045404989. The method now reproduces the authority
it was meant to predict.

The local build does emit other warnings, all of them from `macro expansion
#expect:` under this machine's Swift 6.4 and absent on CI's 6.3.3. None are
counted by the gate: its pattern anchors a path at the line start, and
`macro expansion #expect:1:46: warning:` has a space inside the first
field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rule under "Guards that name what they watch" -- a negative check needs
a positive check beside it -- was written about checks that live in the
suite. On 2026-10-30 it was broken by a check run once at a keyboard, and
the consequence was a wrong figure reported as measured, so the rule now
says it binds both.

The case: the CI warning gate's own pattern was run against a saved
`gh run view --log` to predict whether `MAX_WARNINGS=0` would pass. That
needs the log's `<step>\t<stream>\t<timestamp> ` prefix stripped, and the
strip was written `[^\t]*` for the step-name field. Outside a bracket
expression `\t` is a tab; inside one it is a backslash and the letter `t`,
so the class excluded `t` and could not cross `Unit-Tests`. Nothing was
stripped, the `^`-anchored grep that followed matched nothing, and
nothing-matched was read as no-warnings -- while the log held the warning
25 times, and the gate went on to fail on it on run 41045404989.

The two commands in the new paragraph differ in that one bracket
expression; the first leaves the line untouched, the second prints
`STRIPPED`. Both were run in the form they are committed, after the
paragraph was written, and print what it says they print.

Same family as the `grep`/ugrep `$` case already recorded under point 3,
and the same remedy, which is why this sits beside point 1 rather than
starting a section: before believing a zero, feed the pattern something it
MUST match and watch it match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noidee-dev
noidee-dev merged commit 1ce3e9f into develop Oct 30, 2026
1 check passed
@noidee-dev
noidee-dev deleted the ci/runner-image-with-a-current-xcode branch October 30, 2026 12:18
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.

1 participant