ci: a red nightly opens an issue, and a green one closes it (#973) - #978
Conversation
|
Reviewed at You asked me to attack the drill-skips-the-gates trade, so I will answer that first, because the finding falls out of it. The drill proves delivery completely and the decision not at allEvery gate carries So the drill's That is the right trade for what it buys and it is worth saying exactly what it buys. The drill proves the half that did not exist before: the job runs, What it does not buy is any evidence about whether the reporter fires for the right reason. The decision logic lives in a The finding, and why it is the same failure as #973 one level downThere are two lists, and they must agree: Part 450 asserts set equality between So a contributor adding a gate job is compelled by a green-or-red signal to update Driven, not argued. I added a fifth gate job to a copy of your branch, registered it in and then the verdict on a night that job fails: A fully green tree, every guard passing, and a nightly gate whose failure the reporter cannot see. Red night, no issue, no comment, nothing. That is precisely #973, reintroduced inside the fix for #973. Worth noting what did catch it on my first attempt: part 240's docs guard reddened with The fix that also answers your design questionMove the verdict derivation out of the YAML and into
And it makes the two lists one list, which is the real fix: nothing to keep in agreement. If you would rather keep the verdict in the YAML, the minimum is an arm asserting the env block's names match One smaller thingexisting="$(gh issue list --state open --limit 100 --json number,title ...)"Exact-title matching is right and your comment about There are 12 open issues today, so this is not urgent. It is worth a line because the failure is silent and converts the mechanism into the thing it replaces. What I checked and am not disputing
My own error in producing thisMy first attempt at the docs half of the probe used an unquoted heredoc, so the shell evaluated the backticks in Not approving yet, on the two-lists finding. It is not a correctness defect in what you wrote — everything present works — it is a gap that the guard you added actively steers people into, and you are one argument-passing change from closing it and making your drill prove the whole thing. |
|
Head is That is the case for #973's acceptance criterion, made by the thing itself rather than argued.
Four of the five would have reached a genuine red night. Every one was in the instrument, not the product. Defect 5, because it is a fix that made things worse
Then drill 5 closed #980 and the search still reported it open, catching up seconds later. GitHub's search index is eventually consistent. A lag the other way misses a freshly-opened issue and opens a duplicate — the same failure by a different route — and
The whole lifecycle, exercised live rather than describedAll four issues closed, each carrying what it found. The update path and the close path are the two an alert usually lacks, and both ran.
|
The deep gate ran red two nights and nothing surfaced it; before that,
25 consecutive nights. Three properties compound, each reasonable alone:
a scheduled run has no pull request to be red on, the job that failed is
named like a reporting step rather than a gate, and GitHub notifies the
ACTOR of a schedule event -- whoever last touched the workflow file, not
whoever broke it.
So the workflow carries a `red-nightly` job that opens ONE issue on the
first red, updates it on each red after, and CLOSES IT on the next green.
`if: always()`, not `if: failure()`. The green path is half the
mechanism: an alert that must be closed by hand becomes one nobody
closes, and then a stale one nobody believes.
ONE ISSUE, NOT ONE PER RUN, so the title carries nothing variable. A
notifier that files nightly is one people filter, and a filtered notifier
is the state this replaces.
THE VERDICT COMES FROM needs.*.result, which cannot be late. The failing
job NAMES come from the run's own API, because `needs` keys say
`coverage` where a reader needs `coverage report (PG 18)`. If that call
fails the verdict still stands and the body says no name was reported,
rather than reporting green.
AND THE JOB IS NAMED red-nightly BECAUSE `report` WOULD HAVE BEEN
VACUOUS. selftest 240 asserts every nightly job key appears in
docs/testing.md, and that paragraph already says "coverage report" -- so
a job keyed `report` satisfies an existing guard by accident. Verified
both ways: the guard reddens on the new key before the doc names it.
A FIRE DRILL SKIPS THE HEAVY GATES. The drill proves the reporter fires,
and a proof costing a full deep gate is one nobody re-runs -- which is
how a notifier goes back to never having been seen to fire. It takes the
same code path and differs only in where the verdict came from.
Removal proofs, six:
a job added and not wired into needs the set-equality arm reddens
if: always() -> if: failure() 1 -> 0
issues: write removed yes -> empty
the title carries the run id 0 digits -> 1
an unknown verdict defaults to green rc 2 -> 0
a nameless failure prints nothing 1 -> 0
Verified: harness_selftest 817/817 on PG16 with the new part's 13 checks,
shellcheck clean, docs_style 9/9.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
The first fire drill caught this rather than passing cleanly, which is
what the drill is for.
The job-name filter was NEGATIVE -- `conclusion != "success" and
!= "skipped" and != null` -- and this job is still RUNNING when it asks
the API, so it matched its own in-progress row and the issue read:
Jobs that did not succeed:
- report a red nightly
On a genuine red that names the reporter beside the real failures, which
is the noise that teaches a reader to stop believing an alert.
Selecting POSITIVELY (failure, cancelled, timed_out) cannot admit a state
nobody thought of. Same lesson as every other instrument fault this week,
and the first one caught by the mechanism under test exercising itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
@OffgridwithJD found #973 reintroduced inside the fix for #973. There were TWO lists: needs: [suites, sanitizer, coverage, upgrade-guard] guarded by the set-equality arm SUITES/SANITIZER/COVERAGE/UPGRADE env + the for-loop guarded by nothing So the arm I added COMPELLED a contributor to update the guarded list while saying nothing about the one the verdict was computed from. They proved it: a fifth gate job, registered in needs as arm 450 demands and named in testing.md as 240 demands, gives a fully green tree and a nightly whose failure the reporter cannot see. They proposed passing the four results as arguments. That still leaves a hand-maintained list. `toJSON(needs)` carries every entry, so the verdict is derived from ONE list that is already guarded -- nothing to keep in agreement beats a guard that two lists agree. AND IT MAKES THE DECISION TESTABLE, which answers their other point: the drill proved delivery completely and the decision not at all. It no longer overrides the verdict; it feeds a synthetic failing context through the same derivation every real night uses. selftest 450 drives that derivation with tuples, including a gate the part has never heard of, which the old shape could not express. THE REAL CONTEXT IS PRINTED ON EVERY RUN, including drills. The fix rests on toJSON(needs) carrying every entry, and that was an assumption about the platform rather than something seen. A drill now shows all four gates as `skipped`, so a gate missing from that line is visible without waiting for a real red. Also theirs: `gh issue list --limit 100` goes blind past a hundred open issues, the lookup returns empty, and every red opens a NEW issue -- which is the "notifier people filter" this design rests on not being. It now narrows server-side with `in:title` and still matches exactly here, because --search is full-text and would find the issues we have filed ABOUT this mechanism. AND A ZERO-COUNT ARM NOW CARRIES ITS OWN POSITIVE CASE. Probing one of these arms with an UNESCAPED `${{` read 0 against a line that plainly contains it, because `$` before `{` is not literal to GNU grep. That one reddened. The same slip in an arm wanting zero passes vacuously, so the zero-count arm reintroduces the old shape into a copy and requires the pattern to see it. Verified: harness_selftest 828/828 on PG16 with 24 checks in the new part, shellcheck clean, docs_style 9/9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
…crash (#973) Two findings, one from the third fire drill and one from @OffgridwithJD's review. Both are about a reader's first glance, which is the only thing this mechanism has. A DRILL MUST IDENTIFY ITSELF. Rewriting the step to derive the verdict from toJSON(needs) dropped the drill marker. Drill 3's issue read "No job name was reported with this failure" and nothing said it was a drill -- a reader finding it would conclude the nightly was genuinely red. An alert that cries wolf teaches a reader to discount it, which is the state #973 exists to leave. Two arms now pin the marker and the fact that it reaches the printed job list. A REFUSAL MUST NOT READ AS A CRASH. An unparseable context dumped a Python traceback before the script's own message. Behaviour was right -- rc 3, refused, never green -- but a traceback in a CI log sends the reader after a bug in the reporter instead of after the job list changing shape. with the traceback 3 matching lines the arm reddens without it 0 and the message survives THE REMOVAL PROOF FOR THAT ALMOST WENT IN WRONG. My first attempt used a sed whose delimiter clashed with the / in the pattern; it errored, the file was never mutated, and the "0 traceback lines" came from the unmutated script -- which reads exactly like a passing proof. Redone with the mutation asserted and the file compared. That is this repository's own recorded trap and it nearly reached a PR body as evidence. AND toJSON(needs) IS NOW OBSERVED RATHER THAN ASSUMED. Drill 3 printed all four gates as `skipped`, so the platform behaviour the whole fix rests on is visible on every run, drills included. Verified: harness_selftest 832/832 on PG16 with 28 checks in the new part, shellcheck clean, docs_style 9/9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
The fifth fire drill found this, in a fix @OffgridwithJD suggested and I implemented. `--limit 100` alone silently misses the target once more than a hundred issues are open: the lookup returns empty and every red opens a NEW issue, which is the "notifier people filter" this design rests on not being. `--search "<title>" in:title` fixes that and introduces a worse one. MEASURED: immediately after closing #980 the search still reported it open, and caught up seconds later. GitHub's search index is eventually consistent. A lag in the other direction misses a freshly-opened issue and opens a DUPLICATE -- the same failure the single-issue design exists to prevent, by a different route -- and `concurrency: nightly` queues rather than cancels, so two runs can land close together. `gh issue list` without `--search` reads the REST collection, which is strongly consistent, and gh paginates past 100 on its own. A high limit therefore has neither problem. The exact match stays client-side because `--search` is full-text and would also match the issues we have filed ABOUT this mechanism. Two arms: the lookup reads past the first hundred, and it does not use the search index. Fifth defect found by the drills, none of which the 29 selftest arms could see, and four of the five would have reached a real red night. Verified: harness_selftest 833/833 on PG16 with 29 checks in the new part, shellcheck clean, docs_style 9/9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
@OffgridwithJD noticed that `--search` appears FOUR times in nightly-report.sh and ZERO times as a call: the comments record why the obvious fix is wrong, which is the most valuable kind of comment and also the kind that satisfies a grep wanting the call. That is the trap they recorded two hours earlier, demonstrated in the same file that taught it. The arm asserting no search-index query survives counted `in:title` over the WHOLE file and got 0. Verified it reddens on a reintroduced call -- both halves do -- but the 0 was LUCK: the prose says `--search` and never says `in:title`. A later comment mentioning the token would have reddened it for a prose reason. Now counted over code, with the strip's own premise, because a grep over an emptied file reports the same 0 as a grep that matched nothing: over the whole file --search = 4 the trap over code only --search|in:title = 0 premise the lookup survived the strip = 1 Verified: harness_selftest 834/834 on PG16 with 30 checks in part 450, shellcheck clean, docs_style 9/9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
…ommandprompt#961) Eleven new check names in harness_selftest, which the ledger covers. THIRD TIME TODAY, and the second time this PR has paid it: commandprompt#975 merged while this was open, so the census I had derived against 847 was stale before anyone reviewed it. Dropped that commit, rebased onto f69ed1e, re-ran the suite on the REBASED tree, guarded that log, merged it, and DERIVED the census from the file. rows 859 -> 870 checks_never_observed_red 859 -> 870 awk -F'\t' '$4=="never"' | wc -l The derived value equals main's 859 plus eleven and is not set that way. Writing it by addition would have been right today and wrong the moment two ledger PRs land in an order nobody predicted -- which is exactly what happened to the first version of this commit. commandprompt#978 is still open and also moves this line. If it lands first, this rebases again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
#975 landed first, so this PR pays the tax second. Done in the order @OffgridwithJD established, because the earlier census was derived against 847 and was stale before anyone reviewed it: rebase onto f69ed1e (6 commits replayed, no conflict) FRESH log on the REBASED tree, not the pre-rebase one GUARD that log before it goes near the ledger merge it DERIVE the census from the resulting file The guard, because a log is an input and an input is a claim: verdict=PASSED fails=0 checks=846 unrunnable=0 records=846 records == checks run, and the total is above a floor DERIVED, NOT ADDED. The merge reports rows=889 and the census is counted from the file: 889 rows, 889 never, 0 ever red, and the partition closes. It happens to equal 859 + 30. Writing that by addition would have been right today and wrong the first time a merge order surprises me, which has now happened to the other PR in this queue twice in one afternoon. Thirty rows added, zero removed, all in 450-a-red-nightly-must-be-findable. Ceiling unchanged at 250: these checks are in a suite already covered, so nothing about coverage moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
4a13642 to
04147d0
Compare
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approving at 04147d06, 13/13. This PR moved six times and I verified something at every head I relied on, because a table driven at one head is not evidence about another.
What I drove, and where
a85f8023 the full verdict table, after toJSON(needs) closed my two-lists finding
ac5b8f81 the table AGAIN, because you told me the push landed INSIDE
verdict_from_needs (a stderr redirect) -- which is exactly when a
peer's table does not carry forward
4a136425 the new lookup arms, and their teeth
04147d06 that .github/ is untouched since 4a136425, so the table still holds,
and that the regeneration derives rather than adds
You telling me "yes, it touched the derivation, do not take it as read" is the reason the second drive happened. That is the most useful thing either of us said about process today: the honest answer to "was that push cosmetic" is the one that costs the other person a re-run.
The verdict derivation, at ac5b8f81 and unchanged since
all four green / all skipped (a drill) success
failure / cancelled / timed_out failure
a 5th gate in needs that failed failure <- my removal proof, closed
neutral / action_required / stale failure <- fail-closed, covers the unnamed
a null needs entry / result key absent failure
{} / [] / unparseable rc=3, refused, never green
toJSON(needs) is a better fix than the argument-passing I suggested: one list, nothing to keep in agreement, rather than a guard asserting two lists agree. And the default branch failing closed covers states neither of us can enumerate, which enumeration never would — that is the half I asked for and did not expect.
The lookup, and my wrong fix
My --search suggestion was the wrong fix and your fire drill killed it: GitHub's search index is eventually consistent, so a lag misses a freshly-opened issue and opens a duplicate — the same failure I had just found, by another route. I reasoned about the bound and not about the source, and --search versus the plain list is a change of which system answers, not a narrowing.
I verified the replacement myself rather than taking the pagination on trust, since it was my finding that prompted it:
gh issue list --state closed --limit 150 -> 150 rows
gh issue list --state closed --limit 250 -> 250 rows
gh issue list --state all --limit 400 -> 274 rows (every issue in the repo)
So gh paginates the REST collection past 100 without --search, and #974 was findable at a depth requiring it.
The arm about that lookup, and its teeth
Your own catch — that in:title counting 0 over the whole file was luck, since the prose says --search and never in:title. Computed here:
whole file --search=4 in:title=0
code only --search=0 in:title=0
Teeth driven: reintroducing the query in code reddens both halves.
FAIL the issue lookup reads past the first hundred open issues: got [0] want [1]
FAIL and no search-index query survives in the code: got [1] want [0]
And the premise "removing comments left the lookup itself in place" is what makes the pair trustworthy rather than clever, because a grep over an emptied file reports the same 0 as a grep that matched nothing. You did not skip it.
The regeneration, verified not taken
.github/ changed between 4a136425 and 04147d06: nothing
budget 889 rows 889 never-rows 889 the partition closes
behind main: 0
Five drills, five defects, and the shape of them
The reporter listed itself; the verdict came from a second unguarded list; a drill was indistinguishable from a real red; a refusal read as a crash; the lookup used an eventually-consistent index. Four of the five would have reached a real red night, and every one was in the instrument rather than the product. Two were mine, and the drills found them faster than I did.
That is the sentence #973 should close on, and it is the argument for the fire drill existing at all — not that it proved the mechanism works, but that nothing else was ever going to find those five.
One thing I left alone
The 250-... arms in this repo now count over code with comments stripped in three places, because a comment carrying a token satisfies a grep that wants the call. Third instance today, in three files, by both of us. Not a request — an observation that the pattern is now established and worth reaching for first rather than after a guard fires on prose.
Approved. Merge is yours, and #981 is green and approved too whenever you want it.
…ommandprompt#961) THIRD pass for this PR. commandprompt#975 moved the census while it was open, then commandprompt#978 moved it again, so each earlier derivation was stale before anyone could review it. That is not a defect in the PRs -- it is what a committed census costs when more than one change is in flight, and it is the measurement commandprompt#432 needed. Done in the agreed order: drop the stale ledger commit, rebase onto main, FRESH log from the rebased tree, guard it, merge, then DERIVE from the file. The guard now also asserts RESULT records == checks run, which catches a truncated log -- @linuxhikerpm's addition, and the one my earlier guards missed. No expected census value was written down before the count ran. Knowing the target does not bias the count; it biases whether a DISAGREEMENT is investigated or dismissed as a mis-run, and that is where an expected answer wins. The selftest part is byte-identical to the approved revision: a rebase that altered it would be an approval riding through a change to the mechanism it was granted for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
Closes #973.
What was missing
The deep gate ran red two nights and nothing surfaced it; before that, 25 consecutive nights. Three properties compound, and each is reasonable alone:
coverage report) rather than a gate;scheduleevent — whoever last touched the workflow file, not whoever broke it.So the workflow now carries a
red-nightlyjob: one issue on the first red, updated on each red after, closed by the next green.The fire drill found a defect. That is the whole argument for the acceptance criterion
#973 asks for the mechanism to be seen to fire, on the grounds that a notifier never seen to fire is indistinguishable from none. It was worth more than that.
Drill 1 fired and the issue read:
The reporter listed itself. Its filter was negative —
conclusion != "success" and != "skipped" and != null— and this job is still running when it asks the API, so it matched its own in-progress row. On a genuine red the issue would have named the reporter beside the real failures, which is the noise that teaches a reader to stop believing an alert.All thirteen selftest arms were green at that moment. Nothing but the live fire would have caught it.
Fixed by selecting positively (
failure,cancelled,timed_out): a positive list cannot admit a state nobody thought of.The whole lifecycle, demonstrated live
Both drill issues are closed. The green path is half the mechanism — an alert that must be closed by hand becomes one nobody closes, and then a stale one nobody believes — so it is exercised rather than described.
Honest limit on the last two lines: they ran the same script with my token rather than the workflow's. The code path is identical; the actor differs.
A fire drill skips the heavy gates, deliberately
The drill exists to prove the reporter fires, and a proof that costs a full deep gate is one nobody re-runs — which is how a notifier goes back to never having been seen to fire. It takes the same code path and differs only in where the verdict came from; a drill that posted by a separate route would prove the route, not the mechanism.
The job is named
red-nightlybecausereportwould have been vacuousI named it
reportfirst.selftest/240asserts every nightly job key appears indocs/testing.md, and that paragraph already says "coverage report" — so a job keyedreportsatisfies an existing guard by accident, and 240's own comment records the identical trap with the word "upgrade".Verified both ways: the guard reddens on
red-nightlybefore the doc names it, and passes after.What the selftest can and cannot see, stated in the part itself
It drives the reporter's composition through a dry run and asserts the wiring structurally — including that
needsequals every other job key, derived from the workflow, so a job added later and not wired in reddens. It cannot see an issue being created; that is what the drill is for, and the part says so rather than implying coverage it does not have.Removal proofs
The title arm is the one that keeps this to one issue rather than one per run: a notifier that files nightly is one people filter, and a filtered notifier is the state this replaces.
Gate
docs_stylealso caught a 48-word sentence of mine indocs/testing.mdbefore it landed.Ledger
This adds 13 checks to
harness_selftest, which is one of the two covered suites, so it pays the regeneration tax — the same one #972 paid an hour ago. The ledger and census are not regenerated in this PR, deliberately: #975 is in flight and also toucheschecks_never_observed_redon the same line. Whichever lands second regenerates against the tree the first one produced, in the order @OffgridwithJD established: rebase, fresh log from the rebased tree, guard the log, merge, then derive the census from the file rather than doing arithmetic on it.Two PRs adding twenty-five checks between them, two full regeneration cycles. That is the live price tag #432 needs.
🤖 Generated with Claude Code
https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw