Repository navigation
fix(gate): keep a PASS when the tip moved without changing the change set (#1317) - #1327
Merged
Merged
Conversation
… set (#1317) Written-by: Dev3 Fixes #1317 Owner: Dev3 Reviewer: Reviewer2 Docs-not-needed: no page under docs/subsystems/ owns .github/scripts/ or AGENTS.md (arch-docs reads 0 owned files for this diff); the rule text is AGENTS.md and it moves with the code. ## Reproduce At `2c02cd15` any new commit voids a PASS, including the one `PUT /pulls/<n>/update-branch` writes to bring a branch up to date. `decide` compares the whole 40-character tip and nothing else: ``` reviewer-agent: names 175328d0c1b2, not the tip abaf938b0c1d ``` That answer is right when the code changed and wrong when only `main` moved: with a PASS at `175328d0c1b2…` and a head `abaf938b0c1d…` that merged `main` in, every changed path can carry the same blob at both tips and the pull request still pays a whole second review (#1304 was PASSed three times for one change set, once per update-branch). The same `decide` arms `enqueue-guard` and `merge-gate`, so all three paths answer alike — and all three were wrong alike. ## What changed - **New `.github/scripts/change-set.mjs`**: `sameChangeSet({ gh, repo, reviewed, head, paths })` reads `git/trees/<sha>?recursive=1` for both tips and compares the blob sha of every changed path. `{ok:true}` only when every path exists at both tips with the same blob. Fail closed everywhere else — no path list, no reader, a 404, `truncated:true`, no `tree` in the answer, a path missing on either side — and each refusal says which read failed (`git tree of 175328d0c1b2: 404`, `…: truncated`) or which path differs, so "the code changed" and "I could not look" never read the same. - **The comparison rejects a 100-entry list.** Each caller reads one page (`pulls/<n>/files?per_page=100`), so a list that fills the page may be cut short and file 101 could be the file that moved: that refuses as `the changed-file list stops at 100 entries, so it may be cut short` instead of comparing a part of the change set. A 99-entry list compares normally. - **`review-gate.mjs#decide`**: three optional inputs (`changedPaths`, `sameChangeSet`, `gh`). In the `parsed.tip !== headSha` branch — the one comparison point #1317 names — the conclusion is now compared before it is dropped: a match marks the entry `carriedFrom` and keeps it, a mismatch or an unreadable tree refuses with the reason appended. Without a path list the old refusal stands unchanged. - **A conclusion written at the head always decides.** `const exact = atTip.filter((e) => !e.carriedFrom)` and only carried ones are read when the head carries none. This is a new rule, and #1317 needs it: a carried PASS written after an INC at the head would otherwise win on timestamp alone, and the INC would stop vetoing. - **The allow sentence names both full shas and the comparison**: `reviewer-agent's PASS at <T> still describes this head <H>: the change set is identical (every changed path carries the same blob at both tips)`, and the result carries `carried: true` so every renderer can say which PASS it read. - **The three readers stay one cut.** `merge-gate.mjs#judge` passes `changedPaths: fileNames(files)` from the entry's own file list and accepts an injected comparison; `enqueue-guard.mjs#decidePull` forwards it; the ALLOW tails in `enqueue-guard.mjs` and `enqueue-rules.mjs` say why (`… the change set is the one that PASS names, so the tip moved without changing it`). `review-gate.mjs#main` reads the changed-file list itself. - **`AGENTS.md` three places**, all the same rule: `:34` (the pipeline contract's one-conclusion-per-tip), `:174` (Gates — where the queue reader and the `risk-tier` summary point, so leaving it would keep a contradicting sentence two screens below the new one, and the rule now also carries the exception's CI/queue obligation), and `:203` (Merge gates: the mechanism, the fail-closed list, and the precedence rule). - **Fixture**: `.github/scripts/fixtures/workflow-cases.json` gains a top-level `changeSetCases` array — `tip-moved-same-change-set` (allow, with the exact sentence), `tip-moved-one-blob-changed` (refuse, `the change set differs at core/package.json`), `tip-moved-tree-unreadable` (refuse, `the change set could not be read (git tree of 175328d0c1b2: 404)`). It is a new key rather than entries in `cases`, because `core/test/workflow-repo-dev.test.js` consumes `cases` one by one and adding there would mean editing `core/`, which #1317's boundary excludes. ## What I ran At the tree of this commit, `TZ=UTC`, Node v22.23.3: - `node --test ".github/scripts/*.test.mjs"` → `# tests 524 / # pass 522 / # fail 0 / # skipped 2`. - `node --test .github/scripts/change-set.test.mjs` → `# tests 20 / # pass 20 / # fail 0` (the 19 acceptance cases plus the page-limit one). - `node scripts/ds-bot-patches.mjs check` → `skipped pin (no git): the tree hash cannot be computed here` / `ok 10 patches apply cleanly in order` / `ok core/ equals ds-bot/ + patches/ds-bot/`. `core/` and `patches/ds-bot/` are untouched by this diff, so no `generate` was run. ## Red without the source change Two knives, both run at this tip and both restored afterwards (`diff` against the backup empty, suite `524 / 522 / 0 / 2` again): 1. **The comparison deleted** (`const moved = false && changedPaths…`): `not ok 8` (acceptance 1 — a repaired tip passes), `not ok 10` (acceptance 2 — one changed blob refuses), `not ok 11` (acceptance 3 — an unreadable tree refuses with the read), `not ok 13` (acceptance 5 — a newer matching conclusion wins), `not ok 14` (acceptance 7 — a carried PASS with no `Ran:` still refuses), `not ok 18/19/20` (acceptance 6, all three change-set cases); `# tests 20 / # pass 12 / # fail 8`. 2. **`sameChangeSet` always reports identical** (`return { ok: true, paths }` before the first tree read — the "another base sha and still green" failure): `not ok 2` (one changed blob is a different change set), `not ok 3` (a path missing on one side), `not ok 4` (truncated tree), `not ok 5` (a tree that 404s), `not ok 19/20` (acceptance 6 for "one blob changed" and "tree unreadable"); `# tests 20 / # pass 14 / # fail 6`. Acceptance 6 is not a unit test of the helper: it drives **`decidePull`** (`enqueue-guard`'s own decision), the guard's check line, and `judge` (`merge-gate`) over the same three change sets, so the three readers cannot drift apart while each one's own test stays green — that is the issue's core, not a spare test. ## Not run - `npm run verify` at the root: it needs `npm ci` and `npm run harness` in `core/`, which this box does not have; CI's `tests-ok` runs the same chain. `core/test/workflow-repo-dev.test.js` reads `cases` only, and the new key is top-level, so it is unaffected — but I did not run it here. - `node scripts/build-client.mjs --check`, smoke, typecheck and actionlint: nothing under `core/` changed. - A real `update-branch` on this pull request: the carried path is exercised through injected tree readers, not by merging `main` into a reviewed tip and re-running the guard against the live API. ## Risk - A carried PASS is a claim about **blobs, not about tests**. It does not stand in for CI: the head still needs its own green `tests-ok`, and the queue still rebuilds on the newest `main`. What it buys is that the reviewer's reading of the same content is not thrown away when `main` moves. - The comparison uses the changed-file list the caller read, one page of 100, and refuses at the page limit rather than comparing a subset. A pull request larger than 100 files therefore always pays a second review — the answer it already had. - `git/trees/<sha>?recursive=1` is truncated for very large trees; that refuses (fail closed) as well, so the rule downgrades to "review again", never to "assume unchanged". - A rebased-away tip is not reachable, so `reviewed` can 404 where the reviewer truly reviewed a force-pushed commit. It refuses, as before.
Collaborator
Author
|
Verdict: PASS at d71a88a Ran: Knives, each restored afterwards (
What I checked at
Non-blocking, for a later change to these files:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Written-by: Dev3
Fixes #1317
Owner: Dev3
Reviewer: Reviewer2
Tip SHA: d71a88a
Docs-not-needed: no page under docs/subsystems/ owns .github/scripts/ or AGENTS.md (arch-docs reads 0 owned files for this diff); the rule text is AGENTS.md and it moves with the code.
Reproduce
At
2c02cd15any new commit voids a PASS, including the onePUT /pulls/<n>/update-branchwrites to bring a branch up to date.decidecompares the whole 40-character tip and nothing else:
That answer is right when the code changed and wrong when only
mainmoved:with a PASS at
175328d0c1b2…and a headabaf938b0c1d…that mergedmainin, every changed path can carry the same blob at both tips and the pull
request still pays a whole second review (#1304 was PASSed three times for one
change set, once per update-branch). The same
decidearmsenqueue-guardand
merge-gate, so all three paths answer alike — and all three were wrongalike.
What changed
.github/scripts/change-set.mjs:sameChangeSet({ gh, repo, reviewed, head, paths })readsgit/trees/<sha>?recursive=1for both tips andcompares the blob sha of every changed path.
{ok:true}only when every pathexists at both tips with the same blob. Fail closed everywhere else — no
path list, no reader, a 404,
truncated:true, notreein the answer, apath missing on either side — and each refusal says which read failed
(
git tree of 175328d0c1b2: 404,…: truncated) or which path differs, so"the code changed" and "I could not look" never read the same.
(
pulls/<n>/files?per_page=100), so a list that fills the page may be cutshort and file 101 could be the file that moved: that refuses as
the changed-file list stops at 100 entries, so it may be cut shortinsteadof comparing a part of the change set. A 99-entry list compares normally.
review-gate.mjs#decide: three optional inputs (changedPaths,sameChangeSet,gh). In theparsed.tip !== headShabranch — the onecomparison point enqueue-guard: a tip that moved without changing the change set must not void the PASS (update-branch should not cost a review) #1317 names — the conclusion is now compared before it is
dropped: a match marks the entry
carriedFromand keeps it, a mismatch or anunreadable tree refuses with the reason appended. Without a path list the old
refusal stands unchanged.
const exact = atTip.filter((e) => !e.carriedFrom)and only carried ones are read when thehead carries none. This is a new rule, and enqueue-guard: a tip that moved without changing the change set must not void the PASS (update-branch should not cost a review) #1317 needs it: a carried PASS
written after an INC at the head would otherwise win on timestamp alone, and
the INC would stop vetoing.
reviewer-agent's PASS at <T> still describes this head <H>: the change set is identical (every changed path carries the same blob at both tips), and theresult carries
carried: trueso every renderer can say which PASS it read.merge-gate.mjs#judgepasseschangedPaths: fileNames(files)from the entry's own file list and accepts aninjected comparison;
enqueue-guard.mjs#decidePullforwards it; the ALLOWtails in
enqueue-guard.mjsandenqueue-rules.mjssay why(
… the change set is the one that PASS names, so the tip moved without changing it).review-gate.mjs#mainreads the changed-file list itself.AGENTS.mdthree places, all the same rule::34(the pipeline contract'sone-conclusion-per-tip),
:174(Gates — where the queue reader and therisk-tiersummary point, so leaving it would keep a contradicting sentencetwo screens below the new one, and the rule now also carries the exception's
CI/queue obligation), and
:203(Merge gates: the mechanism, the fail-closedlist, and the precedence rule).
.github/scripts/fixtures/workflow-cases.jsongains a top-levelchangeSetCasesarray —tip-moved-same-change-set(allow, with the exactsentence),
tip-moved-one-blob-changed(refuse,the change set differs at core/package.json),tip-moved-tree-unreadable(refuse,the change set could not be read (git tree of 175328d0c1b2: 404)). It is anew key rather than entries in
cases, becausecore/test/workflow-repo-dev.test.jsconsumes
casesone by one and adding there would mean editingcore/, whichenqueue-guard: a tip that moved without changing the change set must not void the PASS (update-branch should not cost a review) #1317's boundary excludes.
What I ran
At the tree of this commit,
TZ=UTC, Node v22.23.3:node --test ".github/scripts/*.test.mjs"→# tests 524 / # pass 522 / # fail 0 / # skipped 2.node --test .github/scripts/change-set.test.mjs→ `# tests 20 / # pass 20 /fail 0` (the 19 acceptance cases plus the page-limit one).
node scripts/ds-bot-patches.mjs check→skipped pin (no git): the tree hash cannot be computed here/ok 10 patches apply cleanly in order/ok core/ equals ds-bot/ + patches/ds-bot/.core/andpatches/ds-bot/areuntouched by this diff, so no
generatewas run.Red without the source change
Two knives, both run at this tip and both restored afterwards (
diffagainstthe backup empty, suite
524 / 522 / 0 / 2again):const moved = false && changedPaths…):not ok 8(acceptance 1 — a repaired tip passes),not ok 10(acceptance 2— one changed blob refuses),
not ok 11(acceptance 3 — an unreadable treerefuses with the read),
not ok 13(acceptance 5 — a newer matchingconclusion wins),
not ok 14(acceptance 7 — a carried PASS with noRan:still refuses),
not ok 18/19/20(acceptance 6, all three change-set cases);# tests 20 / # pass 12 / # fail 8.sameChangeSetalways reports identical (return { ok: true, paths }before the first tree read — the "another base sha and still green" failure):
not ok 2(one changed blob is a different change set),not ok 3(a pathmissing on one side),
not ok 4(truncated tree),not ok 5(a tree that404s),
not ok 19/20(acceptance 6 for "one blob changed" and "treeunreadable");
# tests 20 / # pass 14 / # fail 6.Acceptance 6 is not a unit test of the helper: it drives
decidePull(
enqueue-guard's own decision), the guard's check line, andjudge(
merge-gate) over the same three change sets, so the three readers cannotdrift apart while each one's own test stays green — that is the issue's core,
not a spare test.
Not run
npm run verifyat the root: it needsnpm ciandnpm run harnessincore/, which this box does not have; CI'stests-okruns the same chain.core/test/workflow-repo-dev.test.jsreadscasesonly, and the new key istop-level, so it is unaffected — but I did not run it here.
node scripts/build-client.mjs --check, smoke, typecheck and actionlint:nothing under
core/changed.update-branchon this pull request: the carried path is exercisedthrough injected tree readers, not by merging
maininto a reviewed tip andre-running the guard against the live API.
Risk
in for CI: the head still needs its own green
tests-ok, and the queue stillrebuilds on the newest
main. What it buys is that the reviewer's reading ofthe same content is not thrown away when
mainmoves.and refuses at the page limit rather than comparing a subset. A pull request
larger than 100 files therefore always pays a second review — the answer it
already had.
git/trees/<sha>?recursive=1is truncated for very large trees; that refuses(fail closed) as well, so the rule downgrades to "review again", never to
"assume unchanged".
reviewedcan 404 where the reviewertruly reviewed a force-pushed commit. It refuses, as before.
Checkpoint
{ "pr": 1327, "claim": "Dev3", "issue": 1317, "summary": "The gate keeps a PASS when the tip moved without changing the change set, so merging main or an update-branch never costs a review. It compares (path → blob sha) at the reviewed tip and at the head over the changed paths, accepting only when every path matches. A changed blob, a path missing on one side, a truncated or unreadable tree, or a file list that fills the 100-entry page refuses exactly as before. A conclusion written at the head always decides, so an INC at the head still vetoes a carried PASS. enqueue-guard, merge-gate and review-gate read the same comparison.", "files": [ ".github/scripts/change-set.mjs", ".github/scripts/change-set.test.mjs", ".github/scripts/review-gate.mjs", ".github/scripts/merge-gate.mjs", ".github/scripts/enqueue-guard.mjs", ".github/scripts/enqueue-rules.mjs", ".github/scripts/fixtures/workflow-cases.json", "AGENTS.md" ], "checks": { "ran": "TZ=UTC node --test \".github/scripts/*.test.mjs\"; node scripts/ds-bot-patches.mjs check", "result": "green", "at": "d71a88af1bce4f32fab24b427c8843e4c3ecf617" }, "next": "hand to Reviewer2 at this tip; nothing is queued in front of it" }