Skip to content

fix(ci): stop the ci-failure self-test minting a failure annotation on every green lint run - #12018

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-11886-ci-failure-selftest-annotation
Aug 25, 2026
Merged

fix(ci): stop the ci-failure self-test minting a failure annotation on every green lint run#12018
os-steve merged 1 commit into
mainfrom
claude/issue-11886-ci-failure-selftest-annotation

Conversation

@claude

@claudeclaudeBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes#11886

What was wrong

scripts/pm/ci-failure.mjs --self-test printed a line of prose that named a workflow-command token literally, inside backticks, as documentation of what the tool anchors on. The Actions runner does not know a token is being quoted. It parsed it and minted a real annotation on a check run whose conclusion was success:

check-run 97678882948 · `Lint & Repo Gates` on `d63b01436` · conclusion=success
GET /repos/{owner}/{repo}/check-runs/97611285494/annotations
annotation_level = failure path = .github line = 32 title = ""
message = "` in it is labelled a window rather than an anchor. And a `fix`"

The card's premise held in full, and one part of it got worse on re-measure: the card measured this as 1 of 5 annotations on one PR head. On today's main head (d63b01436, both runs) it is 1 of 1 — the two sibling Release section coverage warnings have since been fixed, so the only annotation Lint & Repo Gates now carries is this artifact. Verified in the workflow rather than inherited: lint.yml triggers on pull_request with no paths: filter, the lint job carries no if:, and neither does the step.

The two measurements that shaped the fix

The card named one site and one token and said outright that the sweep had not been done. It has been done now, and it decided both halves of the design.

1. The legacy ##[...] form is parsed ANYWHERE in a printed line. The source line begins tail with no and the token sits at column 18; the runner still consumed it, dropped everything before it, and took the remainder of the line as the message — which is why the annotation above starts mid-sentence on a stray backtick. So burying the token in prose or indenting it does not make it safe.

2. The ::...:: form is parsed only at LINE START.scripts/check-prerelease-pin-watch.mjs --self-test prints two lines carrying ::error:: and ::warning:: mid-sentence on every PR, and its check run carries zero annotations.

⚠️ Both facts are invisible to every local run — the transformation happens on a runner and nowhere else.

The sweep

Every one of the 128 scripts CI runs that ship a --self-test was executed and its real stdout+stderr scanned (~4 min):

formsites
##[cmd] anywhere in a line — parsed1 (scripts/pm/ci-failure.mjs)
::cmd:: at line start — parsed0
::cmd:: mid-line — measured inert2 (scripts/check-prerelease-pin-watch.mjs)

failure is the only level minted, and the reason is mechanical rather than lucky: error is the only registered command name that appears in a printed token anywhere in the population.

The two inert sites are not touched here. They are one re-indentation away from being real, and the gate reddens at the moment they move — which is the moment they start lying. Flagging them where they sit would be a false positive the next author would be right to delete.

The fix

Because the sweep found one live site, the site itself gets the cheap fix: the prose is reworded so the printed bytes carry no token. ⚠️ Note for the next author, and recorded in the code: splitting the JS string would have changed nothing — the runner parses the printed line, not the source. The file's own comments spell the token freely, because comments are never printed.

The class is closed by a gate rather than by a convention: scripts/check-self-test-workflow-commands.mjs runs the self-tests CI runs and scans their real output, so "is this token printed?" is answered by printing it, never by a rule about source — the failure mode this repo keeps meeting (a matching rule that silently stops matching, with the empty finding set as its fixed point) has no purchase on the verdict. A static prefilter only selects which to run; over-selection costs a subprocess and nothing else. Membership is imported from check-self-test-wired.mjs rather than re-derived — one definition, two gates.

It carries no registered-command list on purpose: that set is GitHub's to change and a stale copy would under-match in silence. The detectors match the command shape, which over-matches in the safe direction — measured cost on this tree: zero false positives across the whole population.

Cost: ~14 s (14 of 128 selected), against the ~4 min the whole population costs. The measured reason the prefilter's :: arm is not simply /::[A-Za-z]/: that spelling selected os-verify-lock.sh — a 65-second self-test — on Time::HiRes::time(), a Perl namespace inside a shell string.

The pin is non-vacuous — shown red, in both directions

Three legs, scripted under trap restore EXIT INT TERM, each mutation confirmed on disk by grepping both the injected and the removed text before any reading was taken. No build leg applies: both files are read directly off disk by node <path> and by a relative source import, so nothing resolves through a dist/.

legon-disk stategate productiongate --self-test
A fix reverted, detector intactfixed=0 defect=1 detector=1exit=1, 1 finding(s)exit=1, FAILED
C defect present, detector ablatedfixed=0 defect=1 detector=0, marker=1exit=0 (green)
B both restoredfixed=1 defect=0 detector=1, marker=0exit=0exit=0

Leg C is the one that makes A mean something: with the defect still on disk and only the detector broken, the gate goes green — so the red in leg A came from the detector matching, not from a refusal or an unrelated path.

Verification

Union re-run on the final commit e018b0024, tree clean:

check-self-test-workflow-commands --self-test exit=0
check-self-test-workflow-commands (production) exit=0
scope: 128 script(s) CI runs ship a `--self-test`; 14 mention a workflow-command
token in code (comments masked) and were RUN, and their real stdout+stderr was scanned.
ci-failure.mjs --self-test exit=0
check-self-test-wired (+ --self-test) exit=0

Plus the derived families for this diff (dispatch-gates.mjs --repo objectstack-ai/objectstack, 3 paths, 23 families): step-collectors, required-contexts, workflow-status-functions, shard-attestation, aggregator-roster, ci-filter-parity, entry-guard, parse-guard, nul-bytes, cross-package-test-inputs, pnpm-filter-targets, agent-test-spelling, node-version, pnpm-acquisition, whole-set-label-write, drift-comment — all exit=0.

Ratchet family and lint at the same sha, after building the closure (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) because the ratchet refuses to measure an unbuilt tree:

check-type-check-coverage: OK — 65/78 workspace packages type-checked, 13 in the DEBT ledger
check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured, none above its recorded number
pnpm lint (eslint . --no-inline-config) exit=0 [full repo, not narrowed]

Exit codes captured before any pipe throughout. No changeset: this changes scripts/ and one workflow, and publishes nothing — skip-changeset.


Generated by Claude Code

…n green runs
`scripts/pm/ci-failure.mjs --self-test` printed a line of prose that named a
workflow-command token literally, inside backticks, documenting what the tool
anchors on. The Actions runner does not know a token is being quoted: it parsed
it and minted `annotation_level: failure` on a check run whose conclusion was
SUCCESS. Measured on `d63b01436` (check-run 97678882948, `Lint & Repo Gates`),
that artifact was the ONLY annotation the run carried, and the step has no `if:`
and the workflow no `paths:` filter, so it fired on every pull request.
Two facts measured on real runs shape the fix and the gate:
* the legacy `##[...]` form is parsed ANYWHERE in a printed line -- the token
sat at column 18 and was still consumed, with everything before it dropped
and the rest of the line taken as the message;
* the `::...::` form is parsed only at LINE START -- two mid-sentence ones
print on every PR from `check-prerelease-pin-watch` and mint nothing.
The prose is reworded so the printed bytes carry no token; splitting the JS
string would have changed nothing, because the runner reads the printed line.
`scripts/check-self-test-workflow-commands.mjs` closes the class rather than the
instance: it runs the self-tests CI runs and scans their real stdout, so the
question "is this token printed?" is answered by printing it rather than by a
rule about source. A static prefilter only selects which to run, which keeps the
step at ~14s against the ~4min the whole population costs. A sweep of all 128
found exactly one live site; the two inert ones redden the moment they move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 25, 2026
@os-steve
os-steve marked this pull request as ready for review August 25, 2026 05:12
@os-steve
os-steve added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 607c870Aug 25, 2026
32 checks passed
@os-steve
os-steve deleted the claude/issue-11886-ci-failure-selftest-annotation branch August 25, 2026 05:26
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] ci-failure.mjs's self-test prose is parsed as a workflow command — every PR's green lint check carries a real failure annotation

2 participants

@os-steve@claude