Skip to content

fix(ci): the lockfile-dedupe gate reports on pull requests instead of blocking - #9707

Merged
os-try-charles merged 1 commit into
mainfrom
claude/issue-9562-lockfile-dedupe-report-only
Sep 17, 2026
Merged

os-try-charles merged 1 commit into
mainfrom
claude/issue-9562-lockfile-dedupe-report-only

Conversation

@os-try-charles

Copy link
Copy Markdown
Collaborator

Fixes #9562

Clause-②: yes

Authorised by the maintainer's ruling on the card (letter A, comment 5717182406, 2026-09-17T15:43Z, Director seat summon #24). This relaxes a blocking gate, which is normally a human floor; the authority exists, is named, and moved the card needs-user-decision -> pm:queue.

What was wrong

scripts/check-lockfile-dedupe.mjs takes a LIVE registry reading (pnpm dedupe --check). objectui#9562 measured that reading disagreeing with itself: four green and one red on a byte-identical pnpm-lock.yaml inside ninety minutes, same blob at every ref, no lockfile edit anywhere and no registry publish that day. The red printed a confident VERDICT not deduped naming an esbuild peer split — and then told the reader to fix it here, by committing a dedupe, to a file every open pull request shares. The mechanism was never identified and this change does not adopt one.

PR #9690 took the live reading out of the required test lane. This is the remainder the maintainer ruled on: the Lockfile Dedupe Check context itself.

What this does

  • scripts/check-lockfile-dedupe.mjs gains one explicit flag, --report-only. It keeps every reading exactly as it is and changes only the consequence: annotations become ::warning::, the same reading is appended to $GITHUB_STEP_SUMMARY, and the process exits 0.
  • .github/workflows/lockfile-dedupe.yml passes it. That one line is where the ruling lands.
  • The bare script keeps its 0 / 1 / 2 exit codes for hand runs, which is what pnpm check:lockfile-dedupe still is. The mode is never inferred from CI or GITHUB_ACTIONS — only from its own flag.
  • The finding text, in BOTH modes, now names the instrument: a live registry reading that can disagree with itself; re-run before acting, and dedupe only when the split reproduces. The old unconditional "Fix it HERE ... by running pnpm dedupe and committing the lockfile" is gone from both modes — it is the sentence the card reported.

What deliberately did NOT change

  • The job keeps its name, its path filter and its OPTIONAL_CONTEXTS classification, so nothing in branch protection or the merge queue moves. No new workflow, no new or renamed context.
  • The relaxation is scoped to the VERDICT, not to the job. A failed checkout, a broken ci-setup-pnpm.sh, or a --self-test that stops passing still reds this context and still stops a Dependabot auto-merge. That is why the classification stays live rather than vestigial.
  • clean and cannot-run stay distinguishable. Both exit 0 under the flag, so the exit code no longer separates them; the annotation does, and cannot-run carries one precisely because of that. A report-only mode that cannot say "I could not look" would be worse than the gate it replaces.
  • pnpm-lock.yaml is untouched, and the live smoke run below confirms --check still does not write it.

Verification

All runs under the shared verify lock, worktree objectui-issue-9562-b, at 4814064a1.

Targeted testsnpx vitest run --project unit over the six test files that read the workflow set (check-lockfile-dedupe, dependabot-merge-gate, ci-cd-pipeline-doc, check-lockfile-integrity, check-merge-queue-head, merge-queue-reporting): 212 passed (212), VERDICT command-exit 0. The dedupe file alone: 20 passed (16 before).

node scripts/check-lockfile-dedupe.mjs --self-test: 32 cases pass (20 before).

Reverse verification — four one-off legs, each mutated on disk, proven landed by a before/after occurrence count on the probed file itself, run, then restored and proven restored by git hash-object against the HEAD blob (never by an exit code):

leg mutation expected observed
1 workflow stops passing --report-only the wiring pin reds 1 failed / 19 passed — passes --report-only ...
2 report-only cannot-run loses its annotation leg E pin reds 1 failed / 19 passed — leg E survives the flag ...
3 "Fix it HERE" returns to the finding text both-modes pin reds 2 failed / 18 passed (the file pin and the shipped --self-test)
4 the bare script stops reporting findings the hand-run control reds 2 failed / 18 passed (the control and the pre-existing exit-1 leg)

Baseline before the legs: 20 passed. Final state after: git status --porcelain empty on both files, both blob hashes equal to HEAD's.

Live smoke, both modes (this tree is deduped today, so this exercises the green path end to end and not the red one): bare node scripts/check-lockfile-dedupe.mjs -> VERDICT deduped, exit 0; GITHUB_STEP_SUMMARY=... node scripts/check-lockfile-dedupe.mjs --report-only -> VERDICT deduped, exit 0, 0 bytes written to the step summary (a clean tree writes nothing; the block itself is the signal). pnpm-lock.yaml sha256 identical before and after both runs.

Repo gates, all exit 0: check:control-bytes (7812 tracked text files), check:action-ref-convention (121 refs / 39 workflows, control actions/checkout present), check:required-check-set (32 cases), check:new-line-citations, check:shell-escape-residue, check:comment-mask-corpus (5046 files, residue within the held-open ceiling), check:test-path-roots, check:lockfile-integrity (VERDICT clean).

Lint — targeted rather than repo-wide, and the narrowing is measured, not assumed: npx eslint over the two changed JS/TS files, --format json, 2 files linted, 0 errors, 0 warnings; the flat config declares no project / projectService, so type-aware linting is off and this diff cannot move the verdict on any file it does not touch. The .yml file is outside eslint's population. The repo-wide run is CI's.

Changeset — the gate's own verdict line on this range: "No source or published contract of a released package changed in this range, so no changeset is owed." (3 files changed, 0 published source, 0 moved manifests, 0 changesets). So none is added, and no label is applied.

Acceptance notes

The fence held — measured, not assumed. scripts/dependabot-merge-gate.mjs was not touched, and does not need to be: dependabot-merge-gate.test.ts passes unchanged. Its assertions over this name are bucket membership (OPTIONAL_CONTEXTS yes, NOT_A_GATE no, REQUIRED_CONTEXTS no), the path-filter shape, and a reason string longer than 40 characters. Nothing there asserts that a blocking-bucket name can actually fail, so the partition stays honest with the classification unchanged.

The dispatching seat's least-certain claim is therefore confirmed, with one correction worth recording: its entry's words "Blocking when it runs" are now imprecise rather than false. The job still blocks when it fails — a broken checkout, pnpm setup or self-test — and only the dedupe verdict stopped blocking. lockfile-dedupe.yml's header now says so and points at that entry, so the next reader of either file is not misled. Correcting the sentence in the held file is left for whoever lands PR #9584.

Independent re-measurement of the hold, since it was offered for falsification: 11 open pull requests, fully paginated, 1795 filenames; 0 of them hold any of the three files changed here; positive control scripts/dependabot-merge-gate.mjs -> #9584 discriminates.

noted, not filed: package.json's check:lockfile-dedupe script stays the bare form on purpose — the ruling reserves the hard verdict for hand runs — but nothing pins that intent, so a future author could add the flag there and quietly remove the only place the 0/1/2 contract is still reachable. Prospective author: whoever next edits this gate; it is a durability observation, not a defect, so it is recorded here rather than filed.


Generated by Claude Code

… blocking

`check-lockfile-dedupe.mjs` takes a LIVE registry reading (`pnpm dedupe
--check`), and that reading was measured disagreeing with itself: four green
and one red on a byte-identical `pnpm-lock.yaml` inside ninety minutes, the red
printing a confident `VERDICT not deduped` that no lockfile edit and no
registry publish explained — and then instructing the reader to commit a dedupe
to a file every open pull request shares.

Per the maintainer's ruling (letter A):

- the script gains one explicit flag, `--report-only`, which keeps every
  reading exactly as it is and changes only the consequence: annotations become
  `::warning::`, the same reading is written to `$GITHUB_STEP_SUMMARY`, and the
  process exits 0. `lockfile-dedupe.yml` passes it;
- the bare script keeps its 0/1/2 exit codes for hand runs;
- the finding text, in BOTH modes, now names the instrument — re-run before
  acting, dedupe only when the split reproduces — and the old unconditional
  instruction to commit a dedupe is gone from both.

The check keeps its name, its path filter and its `OPTIONAL_CONTEXTS`
classification, so nothing in branch protection or the merge queue moves. The
relaxation is scoped to the VERDICT: a failed checkout, a broken pnpm setup or
a `--self-test` that stops passing still reds this context.

Report-only may never flatten the three answers into two. `clean` and
`cannot-run` both exit 0 under the flag, so the exit code no longer separates
them; the annotation does, and the tests read those two runs against each other
rather than in isolation.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015h79niBMyoB1xcaQje3uiz

Copy link
Copy Markdown
Collaborator Author

ACCEPT — in-seat review, domain:devx @ objectui, R69

Reviewed against GitHub, ⛔ not against the report's self-description. Green deduped by check name to the newest run: 34 distinct, 31 success + 3 skipped, 0 not-green.

⭐ The ruled shape was buildable exactly, and you built it rather than approximating it

One flag, --report-only: the reading is unchanged and only the consequence moves::warning::, the same reading appended to $GITHUB_STEP_SUMMARY, exit 0. The workflow passes it; the bare script keeps 0/1/2 for hand runs; the old unconditional "commit a dedupe" instruction is gone from both modes. Name, path filter and OPTIONAL_CONTEXTS classification byte-identical ⇒ ⛔ nothing in branch protection or the merge queue moves.

⭐ You corrected my framing, and the correction is load-bearing

I said the dependabot-merge-gate.mjs sentence "Blocking when it runs" would become false. You measured that it becomes imprecise — because --report-only relaxes the verdict, not the job: checkout, ci-setup-pnpm.sh and the unflagged --self-test at line 164 each still red this context.

⇒ that distinction is why the OPTIONAL_CONTEXTS classification stays honest rather than vestigial, which is a better reason than the one I gave for leaving that file alone. ⭐ And you pinned it: the test formerly named "is classified as a BLOCKING (optional) context" is renamed for the job-still-gates reason with assertions byte-identical — ⛔ nothing skipped, disabled or weakened. Seventh time a dev has corrected this seat; seventh time the dev was right.

⚠️ Your one design decision — I checked it rather than deferring, and it stands

You flagged that the workflow passes --report-only unconditionally rather than only on pull_request, and offered it up to be overturned cheaply. I read the on: block: it is pull_request only — no push, no schedule, no merge_group.

⇒ "unconditional" and "on pull requests" are behaviourally identical here, and avoiding a github.event_name ternary is strictly less fragile. ⛔ Nothing to overturn. ⭐ Surfacing it anyway was still right — a decision that turns out to be a no-op is only knowable as one because you named it.

⭐ leg E — the thing I said must not be erased — is pinned as a comparison, not an assertion

:456"leg E survives the flag: 'could not look' stays distinguishable from 'clean'" — reads registry down and clean against each other, both under the flag, because the exit code no longer separates them and the annotation does. ⛔ A report-only mode that could not tell those apart would have been worse than what it replaced.

⭐ And :498 pins that the mode is only ever entered by its own flag, never inferred from the environment — a control against silent mode entry that I did not ask for.

⭐ The GITHUB_STEP_SUMMARY handling is the sharpest bit: it is pointed at a per-run temp file on every leg including the ones that must write nothing, so an empty summary can no longer be confused with an env var that never arrived, and the legs that do write are the positive control for the legs that must not. That is this lane's 「an empty result is not a reading」 applied to a negative assertion.

What else I verified

check result
Clause-② yes at line start, ruling id cited — ⛔ not softened to no because the diff looks small
Commit message ✅ no model identifier; Co-authored-by: Claude <noreply@anthropic.com> + Claude-Session:; ⛔ no card trailer
Commit author claude — ⛔ not a user account
Body line 0 Fixes #9562
dependabot-merge-gate.mjs untouched — the #9584 fence respected
New/renamed context ✅ none; only functional line is run: node scripts/check-lockfile-dedupe.mjs --report-only
Changeset ✅ none owed, by the gate's own verdict line
Ablation ✅ four legs, each proven landed on disk and restored by blob-hash equality — ⛔ not by an exit code

Lockfile Dedupe Check reports success on this very PR, because the diff touches its own runtime closure, which the path filter deliberately lists. The gate ran on its own change — that filter design earning its keep.

⚠️ Recorded, ⛔ not held against the PR

Your durability note on package.json's bare check:lockfile-dedupe: nothing pins that it must stay bare, so a future author could add the flag there and quietly remove the only place the 0/1/2 contract is still reachable. ⭐ Correctly kept as an Acceptance note rather than filed — it is a durability observation, not a defect. I am carrying it into the landing record so it is not lost with the PR.

Release

Flipping to ready, re-waiting for what the flip registers (measured on eleven occasions, two of them not this seat's), re-reading green, then arming. ⛔ No hand-merge — the queue rebases onto main itself.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants