ci: fleet review lanes show up as commit statuses on the PR head - #114
Conversation
…in the bot rule A deleted `## Verification at` comment or an edited review's SECOND READ line changes the lanes, but neither event re-ran the workflow, so a green status could outlive what it stood for. issue_comment now includes `deleted` and pull_request_review includes `edited`. isBotPr is unchanged on purpose: it is the dispatcher's rule. review-dispatch.sh's `gate` field and needsVerification() in platform's public-automerge-sweep.ts both exempt a bot-shaped branch only when askalf or github-actions opened it, because anyone can name a branch `release-x`. The doc comment now says so, and five tests pin it (a person on bot/ or release/ is still verified).
The concurrency group cancelled an in-flight run whenever a review, comment or CI completion landed close behind another event. GitHub rolls a cancelled check run up as a failure, so the PR's checks read red with nothing wrong (cordon#82, truecopy-action#32 and checkout-with-retry#20 showed it within minutes). The group goes. Ordering moves into the script: a run notes GitHub's clock (the Date header) when it reads the PR, and before posting each context skips it if a status for that context was posted after that moment, since that run read fresher data. postedSince() is pure and has five tests (83/83).
The Second Read on amnesia#83 and redstamp#162: once fleet/verify, fleet/review and fleet/second-read are required checks (the step this PR plans next), the branch rules list them, and requiredCiState counted them as CI the head waits on. fleet/verify pending made requiredCi pending, which kept fleet/verify pending, so every code PR would have stayed blocked for good. requiredCiState drops the three contexts before it reads anything else. Five tests pin it, including the Second Read's reproduction: three rounds of feeding each run's statuses back in as the next run's checks now end all green (88/88).
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).
Verdict: request changes — dismissed Second Read verdicts are still treated as active. rule:none
Blocking — incorrect status after a dismissed Second Read review
scripts/fleet-status.mjs:235-243
if (r.login !== SECOND_READ_LOGIN || r.commitId !== facts.head) continue;
...
out = last.startsWith('NOT READY')
? { state: 'NOT READY', reason: last.replace(/^NOT READY\W*/, '').trim() }
: { state: 'READY', reason: '' };
A Second Read can post SECOND READ: NOT READY - x at the current head and then have that review dismissed. The workflow explicitly reruns on pull_request_review dismissed, but this parser does not exclude DISMISSED reviews, so it continues to publish fleet/second-read as failure from the dismissed body. That leaves a PR blocked even after the only negative verdict was dismissed. Filter Second Read reviews to the active state(s) that constitute a verdict and add a regression case for a dismissed NOT READY review.
if (r.login !== SECOND_READ_LOGIN || r.commitId !== facts.head || r.state !== 'COMMENTED') continue;
Required CI is green at the live head. I reviewed the workflow event handling, status computation, and the accompanying unit-test coverage; I did not run the test suite locally.
…0 files Redline on browser-bridge#114, truecopy#212 and plumbline#52: - A dismissed Second Read review is not a verdict. secondReadAtHead skips DISMISSED reviews, so a dismissed NOT READY no longer keeps the lane red. - Ordering by the Date header and created_at cannot tell a same-second newer post from an older one. postedSince is gone. Each run now posts only what differs from the head's newest status per context, then re-reads everything and corrects what differs, up to three passes. The run that acts last re-reads after its own writes, so what stays on the head matches data at least as new as anything posted. latestByContext and statusesToPost are the pure parts, with tests for a stale overwrite being corrected. - The file-count rule matches the live dispatcher: forge's readPrFacts reads the first 100 files and fails closed when there are more, so more than 100 (not exactly 100) is code. A large docs-only PR still verifies once its required CI passes. 94/94.
Three test lines described where a case came from instead of what it checks: a section title naming an old PR, and two comments referring to a review and to the planned rollout step. They now describe the behavior only. The workflow comment on ordering matches the post-then-verify loop. No logic change (94/94).
sprayberry-secondread
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the Claude second-opinion lane (independent second read; the gating review is posted separately).
Verdict: The lane logic is correct on every boundary I rebuilt except one. The one test meant to pin "the bot exemption beats the >100-file fail-closed rule" passes with or without the bot exemption, and nothing else pins that ordering.
Read at live head 779cd55 (the ticket named d97817f; five commits have landed since). I read the full diff: workflow, script and tests. I ran node scripts/fleet-status.test.mjs at this head (94 pass, 0 fail) plus two mutants, described below. Required CI is green (docker-build, actionlint, analyze, unit-tests, plus self-test and status).
Finding 1 (medium): test that cannot fail, and an unpinned boundary on the bot exemption vs. the 100-file limit
scripts/fleet-status.mjs:52-55
if (isBotPr(facts.author, facts.headRef)) return false;
// More than 100 files: the dispatcher reads the first 100 and fails closed on the rest
// (readPrFacts in platform's review-events.ts), so the lanes do the same.
return facts.files.length > 100 || facts.files.some(isCodePath);scripts/fleet-status.test.mjs:197
check('a bot PR with 100 files is not code', by(laneStatuses(base({ headRef: 'bot/cc-drift-v2.1.281', files: docs(100) })))[CONTEXTS.verify].state === 'success');The rows for this predicate:
| input | head does | pinned by |
|---|---|---|
| non-bot, 100 docs files | not code | exactly 100 docs files is not code |
| non-bot, 101 docs files | code | more than 100 files is code whatever they are |
| bot, code files | not code | bot branch: verify not required, approval counts |
| bot, 100 docs files | not code | line 197, but this holds without the bot rule, because 100 docs files are not code either way |
| bot, 101 files | not code (the bot check returns first) | nothing |
I checked both mutants against this head:
- Delete line 52 (drop the bot exemption). Line 197 still passes. Only
bot branch: verify not requiredfails, so line 197 tests nothing about bots. - Keep the exemption but move it after the size check (
files.length > 100 || (!isBotPr(...) && files.some(isCodePath))). All 94 tests pass. For a bot branch with 101 files,fleet/verifyflips fromsuccesstopending.
This input is reachable. Release, receipts and dependabot branches from askalf/github-actions can touch more than 100 files. After the follow-up in the body makes fleet/verify a required check, that case either merges or waits on a Breaker that the dispatcher never sends for bot PRs. The comment at line 53 says this rule mirrors the dispatcher, but no test fixes which rule wins. The name of line 197 says it covers that case, and it does not.
Suggested fix: test one past the limit, with code paths, so both the ordering and the exemption are pinned. Swap the assertion if the dispatcher actually fails closed before the bot check.
check('a bot PR with more than 100 files is not code',
by(laneStatuses(base({ headRef: 'bot/cc-drift-v2.1.281', files: Array.from({ length: 101 }, (_, i) => `src/f${i}.ts`) })))[CONTEXTS.verify].state === 'success');What I checked and found sound
requiredCiState: empty required list, own contexts excluded (sofleet/verifycannot wait on itself), unreported check becomes pending, failed beats pending, last result per name wins for reruns, skipped and neutral pass. Each row has a test, including the three-round self-reference loop.secondReadAtHead: reviews at an older head, dismissed reviews, a line-less follow-up review,READYwith trailing text, the last line in a body winning, and the dash-agnosticNOT READY\b.*parse. All are pinned. The em dash in this seat's own verdict line parses (test at line 70 uses\u2014).redlineVerdict: deterministic approval ignored on code and counted on docs, COMMENTED/DISMISSED skipped, a later CHANGES_REQUESTED at the same head wins. All pinned.fit: tested at exactly 140 characters and at 141.- Workflow:
status/backfillsparse-checkout the default branch's script withpersist-credentials: false.self-testruns PR code withcontents: readonly. The fork guard is in the jobifand again inreadFacts.permissions: {}is set at the top level. The body's claims (94 tests, no required checks named in code, plain ASCII, no concurrency group, post-then-reverify up to three passes) all match the diff. I found no non-ASCII bytes in any of the three files. - Not verified: parity with askalf/dario#1419 and with the dispatcher source (
review-events.ts,review-dispatch.sh). I could not read them within budget.
SECOND READ: NOT READY — test at scripts/fleet-status.test.mjs:197 passes without the bot exemption; bot PR with >100 files (bot rule vs fail-closed ordering) is unpinned
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).
Verdict: changes requested — generated-writing tells remain in public test text. rule:reads-as-generated
Blocking — generated-writing tell
scripts/fleet-status.test.mjs:70
review(SECOND_READ_LOGIN, 'COMMENTED', OLD, 'text\nSECOND READ: NOT READY — stale stack'),
scripts/fleet-status.test.mjs:96
review(SECOND_READ_LOGIN, 'COMMENTED', HEAD, 'SECOND READ: NOT READY — commit subject has an em dash'),
The added test fixtures retain typographic em dashes. First-party public-repo strict mode treats an em dash anywhere in added public code or tests as a blocking generated-writing tell. These strings are merely fixture prose; replace the punctuation with the file's ordinary ASCII delimiter without changing the verdict cases.
review(SECOND_READ_LOGIN, 'COMMENTED', OLD, 'text\nSECOND READ: NOT READY - stale stack'),
// ...
review(SECOND_READ_LOGIN, 'COMMENTED', HEAD, 'SECOND READ: NOT READY - commit subject has an em dash'),
The required checks are green at 779cd55. I reviewed the workflow, status-state implementation, and its unit-test coverage; I did not run the local suite.
…losed rules - The two NOT READY test fixtures use an ASCII hyphen instead of a dash escape. - The reason strip drops the one separator after NOT READY (hyphen, colon, or the Second Read's own dash) and keeps a leading backtick, quote or bracket, so a reason that names a symbol keeps its code span. Four tests. - Unreadable branch rules count as pending, as the dispatcher waits on them, instead of falling back to the label-and-comment rule. - The bot-rule test uses 101 files, so it fails if the bot rule is removed, and a counterpart pins that a person's 101-file docs PR is code. 99/99.
… that it does The workflow_run list named only the workflows behind today's required checks, so a required check added later from another workflow would leave fleet/verify pending until the next PR event. The list now names every workflow in this repository that runs on pull requests, and a test reads .github/workflows and fails if one is missing from it, so adding a workflow without listing it fails CI instead of stalling the lanes.
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).
Verdict: approve — no blocking issues found.
Required CI is green at live head 431eb50ab85827f7bcc77d9cec98ac68722d860a. I reviewed the full workflow, lane-status implementation, and unit-test diff, with particular attention to the new workflow_run coverage list and its test that enumerates pull-request workflows. The status workflow is triggered after each listed PR workflow completes, while the test makes adding an unlisted PR workflow fail CI instead of silently leaving the lane statuses stale. The required-status logic continues to exclude the three fleet contexts, avoiding self-dependency. I did not run the local test suite.
…et cannot move a required check A pull_request_target workflow runs against the base branch's latest commit, so its checks land on that commit and never on the PR head, and the status job drops its workflow_run events anyway. Listing one (PR triage) could never refresh the lanes, and the list test passed regardless. The list now names only pull_request workflows, and the test checks both directions: every pull_request workflow is listed, and a pull_request_target-only workflow is not.
master gained hygiene.yml (a pull_request workflow) after this branch was cut, and the list test caught it on the merge. Merged master and listed it.
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).
Verdict: approve — no blocking issues found.
Required CI is green at live head 4498951c53c9fc30d0eb7b3fab116f96d024dc5b. I reviewed the workflow, lane-status implementation, and its unit-test coverage. In particular, I checked the current-head fixes for dismissed Second Read reviews, status self-dependency exclusion, bot PRs over the 100-file threshold, and the workflow list assertion that refreshes lanes after every pull-request workflow. The workflow uses the default branch script for status writes, keeps pull-request test execution read-only, and filters pull_request_target workflows whose checks cannot appear on the PR head. I did not run the local suite; required CI is the test signal.
What's good: the bounded post-then-re-read loop avoids leaving a stale lane status after concurrent webhook runs, while the tests pin the relevant head, dismissal, file-count, and required-check boundaries.
… own separator - backfill read one page of 100 open PRs, so a larger repository would leave some PRs without lanes. It now lists up to 1000 (gh pages past 100) and fails at that cap instead of skipping PRs. Two tests pin the limit and the guard. - The Second Read writes its verdict line with its own separator character, and after the ASCII fixtures nothing tested that form. The tests now build every separator form at run time with String.fromCharCode, the Second Read's included, so the real line is pinned with no dash in the source.
sprayberry-secondread
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the Claude second-opinion lane (independent second read; the gating review is posted separately).
Verdict: no blocking issues at c146493. The lane rules, the fail-closed paths and the workflow wiring all match the diff. Every reachable boundary I rebuilt has a test that can fail.
This ticket named head b9c8b7d, and the head moved twice while I read (4498951, then c146493). This review is of c146493.
What I checked
-
The Second Read's own verdict line.
secondReadAtHead(scripts/fleet-status.mjs:114,:118) is new at this head's tests, and they now drive the separator it really posts.scripts/fleet-status.test.mjs:364-374builds-,:, U+2013 and U+2014 at run time and pins the fulllaneStatusesresult for a U+2014 line. Both can fail. Tightening the separator class to[-:]leaves\u2014 stale stackas the reason, which failsseparator U+2014 is dropped. Tightening the line regex to ASCII separators turns the lanepending, which fails thestate === 'failure'check. I also ran the code againstSECOND READ: NOT READY \u2014 \x` is nulland got{"state":"NOT READY","reason":"`x` is null"}.self-testat this head logs all five checksok,114 pass, 0 fail`. -
Boundaries rebuilt from the diff, with the tests that pin them. Required CI:
- no required checks gives
none, and so does only own lanes required; - own lanes pending while CI is green gives
passed, and the three-round self-feed stays green; - a required check that has not reported, or is still running, gives
pending; - failed while another is pending gives
failed; for reruns, the last result wins; skipped and neutral count as passed.
Files: 99, 100 and 101 docs files; a bot PR with 101 files stays exempt. Descriptions: exactly 140 and 141 characters. Verification SHA:
- 7-character prefix, older comment, latest comment wins;
- wrong login,
findingsandblockedheadings.
Second Read: another login carrying the line, an older head, a lineless follow-up, dismissed,
READY, mostly, no separator. Redline: deterministic approval on code (ignored) and on docs (counted); CHANGES_REQUESTED after APPROVED at the same head. - no required checks gives
-
Fail-closed paths in the CLI (
fleet-status.mjs:236-252).- Unreadable rules give
required = null, which givespending. - A check read that throws gives
pending. - Check runs are read one page of 100. A required check past that reads as unreported, so
pending, never green. - A completed run with a null conclusion maps to
'', sopending.
- Unreadable rules give
-
Backfill at this head.
--limit 1000, and at 1000 or more PRs the job fails with::error::rather than dropping PRs silently.jqis onubuntu-latest, andset -euo pipefailcovers the pipe. The new test only matches the workflow text, but a limit of 100 or a missing cap guard would fail it. -
Workflow.
permissions: {}at the top.statusandbackfillsparse-checkout the default branch's script withpersist-credentials: false;hashFilesskips the step until this merges.self-testruns PR code withcontents: readonly.- The fork guard is in the job
ifand again inreadFacts. - The
workflow_runlist covers everypull_requestworkflow on master (all 12name:values checked) and leaves outPR triage(pull_request_target). The test at:333-361enforces this against the directory.
-
Body claims.
- Master's branch rules require
docker-build,actionlint,analyze (javascript-typescript)andunit-tests, as the body says, and no check is named in code. - The count of 114 matches the
self-testlog. - The body's line about the separator is built with
String.fromCharCodeand matches the diff. - No non-ASCII bytes in the diff.
scripts/fleet-status.mjsis blob-identical to askalf/dario#1419's (aab0a0e).- Required CI passes at this head.
- Master's branch rules require
What's good: the post-then-reread loop is a sound replacement for a concurrency group, since a cancelled run would otherwise show up red. The test that checks the workflow_run list against the workflow directory stops the lanes from quietly going stale when a later required check is added.
SECOND READ: READY
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).
Verdict: request changes — the lane classifier treats a documented exempt asset type as code, so docs-only .txt PRs are sent through verification and gating contrary to the stated policy. rule:none
Blocking — correctness: scripts/fleet-status.mjs:33-35
if (/\.(md|svg|png|jpe?g|webp|gif)$/i.test(path)) return false;
if (/^docs\/.*\.txt$/i.test(path)) return false;
if (path === '.gitattributes') return false;
The documented exemption is for .txt assets, not only .txt files below docs/. For example, a same-repository PR changing only NOTICE.txt or RELEASE-NOTES.txt at the repository root reaches isCodePath() with neither condition true. needsVerify() consequently returns true, and laneStatuses() puts it through required-CI verification plus Redline and Second Read gating. That is the wrong lane state for an exempt docs/assets-only PR and differs from the policy this change is meant to publish.
if (/\.(md|txt|svg|png|jpe?g|webp|gif)$/i.test(path)) return false;
Please add a root-level .txt fixture alongside the existing path-classification coverage.
I reviewed the new workflow, lane-state computation, API reads/writes, and regression tests. Required CI is green at c146493129c32ba14ad38f8690fb0442ec533842; I did not run the suite locally.
Dismissed for a re-read: this verdict came from Redline's pre-398 rule text (.txt exempt everywhere). Migration 398 (live since 04:54Z) states the dispatcher's rule: .txt is exempt only under docs/, which is what this script implements.
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).
Verdict: APPROVED — no blocking issues found in the current-head implementation.
What's good: Required CI is green at c146493. I reviewed the added workflow, status derivation, API/error paths, and the 114 unit-test cases. The workflow runs the default-branch script with minimally scoped read/status permissions, treats unreadable required-check metadata as pending, excludes its own lane contexts from CI gating, and tests the dispatcher-aligned code/verification and review-head rules. I did not run the suite locally, per review policy; CI is the test signal.
sprayberry-secondread
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the Claude second-opinion lane (independent second read; the gating review is posted separately).
Verdict: no blocking issues at f252571. The diff reads correctly, and each boundary I rebuilt from it has a test that would fail without the change.
Change since my last read (c146493): one merge commit from master (93f2d30, #115) touches .github/labels.json. That file is not in this PR's diff. .github/workflows/fleet-status.yml, scripts/fleet-status.mjs and scripts/fleet-status.test.mjs are byte-identical to the head I last read. I re-read all three for this gating read anyway.
Boundaries rebuilt from the diff
| Predicate (file:line) | Input | Behaviour | Pinned by |
|---|---|---|---|
needsVerify: isBotPr before files.length > 100 (scripts/fleet-status.mjs:52-56) |
bot branch + 101 files | exempt | a bot PR with more than 100 files is still not code (fails if the order is swapped) |
same, > 100 |
person, 100 / 101 / 99 docs files | not code / code / not code | exactly 100 docs files is not code, a person with more than 100 docs files is code, 99 docs files are not code |
isBotPr identity AND branch |
person on bot/, release/1.2; askalf on feature branch; dependabot on any branch |
not bot / not bot / bot | the isBotPr checks in both blocks |
requiredCiState OWN_CONTEXTS filter |
fleet/* listed as required, pending | ignored; only own lanes → none |
own lanes required, three rounds: all three green (without the filter the lanes wait on themselves forever, so this test fails) |
requiredCiState empty / missing / running / failed / rerun / skipped-neutral |
each | none / pending / pending / failed / last wins / passed | the eight requiredCiState checks |
rules or checks unreadable (:236-242, :251) |
API error | pending; never green |
fails closed by construction (CLI path, untested; acceptable for I/O) |
verifiedAtHead label AND last askalf comment prefix |
label only, comment only, older comment last, other login, findings/blocked headings, 7-char prefix |
each handled | the verifiedAtHead blocks |
ci === 'none' vs pending with Breaker label |
pending CI + label + comment | still pending | CI pending: an old Breaker label and comment do not count |
redlineVerdict deterministic marker on code vs docs |
both | skipped on code, counted on docs | deterministic approvals block |
secondReadAtHead commit, login, DISMISSED, last line, READY, mostly, bare NOT READY |
each | handled | the Second Read, review by review + dismissed block + no reason ... without a trailing colon |
reason separator strip (:118) |
-, :, U+2013, U+2014, leading backtick / paren / bracket |
separator dropped, first char kept | the separator loop and the reasonOf block |
fit at 140 |
140 / 141 chars | kept / 137 + ... |
the 140-character edge |
statusesToPost |
identical / state differs / description differs / missing | skip / post / post / post | post-then-verify block |
I checked the new tests for assertions that would pass whether or not the change is there, and found none. The ordering test and the three-round own-lanes test are the two that previously could not pin their rule, and both now fail if their rule is removed. The workflow_run list test reads .github/workflows/*.yml at the head, so a later pull_request workflow left out of the list turns it red.
PR body claims vs the diff
- The status job runs the default branch's script:
ref: ${{ github.event.repository.default_branch }}with a sparse checkout, and it is skipped until then byhashFiles(...) != ''. Holds. - The token is limited to
checks: readandstatuses: write, with reads only otherwise:permissions: {}at top level, plus job-level grants. Holds. - Fork PRs are skipped: they are dropped both in the job's
if:and inreadFacts()via thehead.repo.full_namecheck. Holds. - There is no concurrency group, and the script re-reads and corrects for up to three passes:
for (let pass = 1; pass <= 3; pass++), which breaks whentodois empty. Holds. - Backfill pages past 100 PRs and fails at 1000:
--limit 1000,-ge 1000, and::error::, which a test pins. Holds. pull_request_targetworkflows are left out ofworkflow_run: the test asserts they are absent. Holds.
Also checked
- CI at
f252571is green:docker-build,actionlint,analyze (javascript-typescript),unit-tests,self-test,hygieneand CodeQL all pass. I did not run the suite locally. - Security: nothing an attacker controls reaches a shell.
PRis numeric, validated by/^\d+$/, and it only goes into API paths. Review and comment bodies are only regex-matched and then written into a status description. The backfill loop iterates overjq-extracted numbers.
What's good: the lane rules are pure functions with the I/O kept in the CLI block, so the table above can be pinned without network mocks. Wherever something is ambiguous (unreadable rules, a missing check, no verdict line), the result is pending rather than green.
SECOND READ: READY
What does this PR do?
The fleet's review lanes (verification, Redline's gating review, the Second Read) run as tickets on the fleet's box. On GitHub, a PR waiting on one of them looked the same as a PR nobody had picked up. This posts one commit status per lane on the PR head, next to the other checks.
fleet/verifyfleet/reviewfleet/second-readHere the base branch requires
docker-build,actionlint,analyze (javascript-typescript),unit-tests. The script reads that list from the branch rules at run time, so nothing here names a check.workflow_runre-runs it when anypull_requestworkflow finishes, and a test fails if one is missing from that list, so a required check added later from any workflow still refreshes the lanes.pull_request_targetworkflows (PR triage) are left out: they run against the base branch's commit, so their checks never land on the PR head.The three
fleet/*contexts are never counted as CI themselves, so they can become required checks withoutfleet/verifywaiting on itself.The rules are the fleet dispatcher's: a verdict counts only at the head; on code, Redline's deterministic low-risk approval is not a verdict and the Second Read gates too.
statusjob runs the default branch's copy ofscripts/fleet-status.mjs, never the PR's code. It is skipped until this merges, so this PR's own statuses are not the first live check; the next PR's are.checks: read,statuses: write, reads only otherwise). It does not use the fleet's GitHub quota.self-testruns the script's 114 unit tests on the PR's code, read-only.backfill(manual,workflow_dispatch) posts the lanes on every open same-repo PR, paging past 100 and failing loudly at 1000.Next step, after this merges: add
fleet/verify,fleet/reviewandfleet/second-readto the default branch's required status checks, then runbackfill, so PRs opened earlier report too. With them required, GitHub refuses a merge (by hand or by native auto-merge) until every lane agrees at the head; without them, branch protection knows only CI plus one approval, so a merge by hand or by auto-merge can skip the Second Read.How to test
node scripts/fleet-status.test.mjs: 114 pass, 0 fail.String.fromCharCode, so its real verdict line is covered with no dash in the source.