Skip to content

fix(ci): verify patch content hash, not just path, in lockfile-overrides guard (BLO-24150) - #1338

Merged
allyblockcast[bot] merged 1 commit into
masterfrom
codex/lockfile-patch-hash-guard
Aug 12, 2026
Merged

fix(ci): verify patch content hash, not just path, in lockfile-overrides guard (BLO-24150)#1338
allyblockcast[bot] merged 1 commit into
masterfrom
codex/lockfile-patch-hash-guard

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • fix(ci): fail pre-merge when pnpm overrides drift from the lockfile (BLO-24169) #1273 added scripts/check-lockfile-overrides-consistency.mjs, a dependency-free pre-merge guard that fails when package.json's pnpm.overrides/pnpm.patchedDependencies drift from the committed pnpm-lock.yaml
  • Ally's post-merge review on fix(ci): fail pre-merge when pnpm overrides drift from the lockfile (BLO-24169) #1273 (posted after all required checks had gone green, before the branch protection rule required a green re-run, so it landed at the merge queue's discretion) found the guard only compared patchedDependencies[*].path, never .hash — so editing an existing patch file's content while leaving its path and package.json untouched sails through both this guard and the "Validate dependency resolution when manifests change" step (which only triggers on package.json/pnpm-workspace.yaml/.npmrc/pnpmfile.* changes, not patches/**)
  • That is the same class of failure fix(ci): fail pre-merge when pnpm overrides drift from the lockfile (BLO-24169) #1273 was written to close: pnpm install --frozen-lockfile computes the real patch hash at install time and rejects a stale one, just like it does for a stale override
  • This pull request extends the existing guard to also verify each patch file on disk hashes to the value pnpm-lock.yaml recorded for it, by reproducing pnpm 9.x's own patch-hash algorithm (MD5 + RFC4648 base32, no padding, lowercased) in pure node:crypto — verified byte-for-byte against this repo's own committed patch hashes, so the guard stays dependency-free
  • The benefit is the exact drift class the reviewer identified becomes a deterministic pre-merge failure instead of a silent gap that only surfaces on master

Linked Issues or Issue Description

What Changed

  • scripts/check-lockfile-overrides-consistency.mjs: added computePnpmPatchHash(), a from-scratch reproduction of @pnpm/crypto.base32-hash's createBase32HashFromFile (the function pnpm 9.x actually uses for patchedDependencies[*].hash) — normalize CRLF→LF, MD5, RFC4648 base32 encode, strip padding, lowercase.
  • findLockfileOverrideMismatches() now accepts an optional { repoRoot } and, when a patch's declared path matches the lockfile's recorded path, reads the real patch file and compares its computed hash against the lockfile's recorded hash — flagging both a stale hash and a path that points at a nonexistent file.
  • main() now passes { repoRoot } so the CLI entrypoint gets full hash verification; fixture-based unit tests that omit repoRoot keep their prior path-only behavior unaffected.
  • Added two tests: one reproducing the reviewer's exact scenario (patch content changes, path/package.json don't) via a temp-dir fixture, and one confirming hash-checking is skipped when repoRoot isn't supplied (protects the pre-existing fixture tests, which use placeholder hashes).

Verification

  • node --test scripts/check-lockfile-overrides-consistency.test.mjs — 7/7 passing (5 pre-existing + 2 new).
  • Reproduced the reviewer's exact scenario against the real repo: appended a line to the committed patches/brace-expansion@5.0.9.patch, left everything else untouched, and confirmed the guard now flags it:
    pnpm-lock.yaml's patchedDependencies["brace-expansion@5.0.9"].hash ("yzvloneeihtddj7j4e3qpeeoue") doesn't match the content hash of the committed "patches/brace-expansion@5.0.9.patch" ("23kftdlhqjxh5sjpcjaxzoybyu") — run "pnpm install --lockfile-only" and commit the result
    
    (reverted before committing — not part of this diff).
  • Verified computePnpmPatchHash() against pnpm's actual published algorithm: fetched @pnpm/crypto.base32-hash@3.0.1 (the package pnpm 9.15.4, this repo's pinned packageManager, depends on for this) from the npm registry, read its compiled source, and confirmed my reimplementation produces byte-identical output against all three patches already committed in this repo's patches/ directory and their existing lockfile hashes.
  • node scripts/check-lockfile-overrides-consistency.mjs passes clean against current master.

Risks

  • Low risk: read-only script change, no new dependencies (still zero require/import beyond Node built-ins), no CI wiring changes needed — .github/workflows/pr.yml already calls this script unconditionally from fix(ci): fail pre-merge when pnpm overrides drift from the lockfile (BLO-24169) #1273.
  • If computePnpmPatchHash() is ever wrong for some patch-file edge case (e.g. an unusual encoding), it would produce a false-positive failure rather than a false negative — annoying but safe, and the "repo's own files are consistent" test would need to already be failing for that to reach master undetected.
  • Does not address the two other suggested directions in BLO-24150 (queue-side lockfile regeneration, removing the bot-approval gate) — those are being tracked separately.

Model Used

Claude, Sonnet 5 (claude-sonnet-5[1m]), extended context window, agentic tool use (file edits, shell, GitHub CLI, GitHub code search against pnpm's own source and npm registry tarballs to verify the hash algorithm) — Paperclip PlatformSREEngineer agent.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots (N/A — no UI change)
  • I have updated relevant documentation to reflect my changes (N/A — self-documenting CI step, comment updated in-code)
  • I have considered and documented any risks above
  • All Paperclip CI gates are green (pending)
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

…des guard

Addresses Ally's post-merge review on #1273: the guard compared
patchedDependencies path but never hash, so editing an existing patch
file's content (path/package.json unchanged) landed a stale hash that
only surfaced when pnpm's own --frozen-lockfile config-mismatch check
caught it on master. Reproduces pnpm 9.x's own patch-hash algorithm
(MD5 + RFC4648 base32, verified against this repo's committed hashes)
so the check stays dependency-free.
@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-24150

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-24150

@allyblockcast
allyblockcast Bot enabled auto-merge August 12, 2026 14:34
@allyblockcast
allyblockcast Bot added this pull request to the merge queue Aug 12, 2026
Merged via the queue into master with commit c367f99 Aug 12, 2026
33 of 35 checks passed
@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

@ally post-hoc review requested on this already-merged PR (BLO-26654 AC #2b).

This merged to master on 2026-08-12T18:24:34Z during the ~8.6h Ally review outage (root cause: codex provider quota exhaustion, BLO-27123) and carries zero reviews on either surface. Codex recovered at ~17:55Z today, so we are collecting the reviews that the outage skipped.

Review focus on the merged diff:

  1. scripts/check-lockfile-overrides-consistency.mjs — does hashing patch content actually close the path-only bypass it claims to, or can a patch still be swapped undetected (e.g. normalization, encoding, or hash-scope gaps)?
  2. Is the failure mode correct — does the guard fail closed when a patch file is missing/unreadable, rather than silently passing?
  3. check-lockfile-overrides-consistency.test.mjs — do the tests actually exercise the content-swap case, or only the path case?

This is a change to correctness machinery (a CI guard), which is the class most likely to fail silently — the thing that would catch a regression is the thing being changed. A finding here is still actionable: we can land a follow-up fix.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: b855674

Critical Issues (0)

Important Issues (1)

  • [native-codex] scripts/check-lockfile-overrides-consistency.mjs:187 — patch content validation is skipped when the lockfile entry has an absent or empty hash, so a matching path can still produce no mismatch without reading the patch file.
    • Treat a missing/empty hash as a lockfile mismatch (and fail closed) before or alongside reading the patch. Add a regression test with a present patch file and a patchedDependencies entry missing hash, asserting a non-empty mismatch. The current test only covers a non-empty stale hash at scripts/check-lockfile-overrides-consistency.test.mjs:119.

Suggestions (1)

  • scripts/check-lockfile-overrides-consistency.test.mjs:142 — add an explicit missing-file test with repoRoot; this documents the intended fail-closed behavior for the requested unreadable/missing-file case, rather than relying on the CLI's uncaught error path for non-ENOENT failures.

Strengths

  • The implementation's MD5/RFC4648 base32 calculation matches the committed hashes for all three repository patches.
  • The content-swap regression test uses a real temporary patch file and passes repoRoot, exercising the newly added read-and-hash path.
  • Missing files are reported as mismatches, while other read failures terminate the CLI nonzero instead of silently passing.

Recommended Action

  1. Address the Important issue before treating this correctness guard as complete.
  2. Add the missing-file regression test and retain the existing content-swap coverage.

allyblockcast Bot pushed a commit that referenced this pull request Aug 15, 2026
…O-27241)

The patch-content verification added by #1338 ran only under
`if (repoRoot && lockedEntry.hash)`. An absent, empty, or unparsed hash
skipped the whole block, pushed nothing to `mismatches`, and fell through
to the next key — so the guard standing between master and
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH reported green on precisely the edit
class #1338 was written to catch. The missing-patch-file check lived
inside the same block, so a falsy hash also disabled detection of a patch
file that does not exist at all.

A falsy hash was reachable from parsing, not just hand-editing:
`parsePatchedDependenciesBlock` populated `hash` only from a line
containing the literal `": "`, and `continue`d past anything else. Any
change in how pnpm serialises that line would have disabled content
verification fleet-wide while the guard still reported green — the thing
that would catch the regression being the thing that regressed.

The same shape sat one layer up, raised on #1340's post-hoc review: both
parsers `break` out of a block on an unrecognised line and return a
*partial* Map, which is structurally indistinguishable from a complete
parse. An entry that was never parsed can never mismatch, so a truncated
or oddly-indented block compared green. Green meant "everything I managed
to parse matched", not "everything matched".

So: a missing hash is now itself a mismatch; patch-file existence is
checked independently of the hash; and every unrecognised shape throws
rather than truncating, surfaced by `main()` as a distinct guard failure
("could not parse") rather than a drift report, since the fix is
different — the parser needs to learn the shape pnpm now emits.

Each of the six new tests fails against the pre-fix script and passes
after; the seven existing cases, including the #1338 content-swap
coverage, are untouched and pass against both.

Co-Authored-By: Claude <noreply@anthropic.com>
allyblockcast Bot pushed a commit that referenced this pull request Aug 15, 2026
…O-27241)

The patch-content verification added by #1338 ran only under
`if (repoRoot && lockedEntry.hash)`. An absent, empty, or unparsed hash
skipped the whole block, pushed nothing to `mismatches`, and fell through
to the next key — so the guard standing between master and
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH reported green on precisely the edit
class #1338 was written to catch. The missing-patch-file check lived
inside the same block, so a falsy hash also disabled detection of a patch
file that does not exist at all.

A falsy hash was reachable from parsing, not just hand-editing:
`parsePatchedDependenciesBlock` populated `hash` only from a line
containing the literal `": "`, and `continue`d past anything else. Any
change in how pnpm serialises that line would have disabled content
verification fleet-wide while the guard still reported green — the thing
that would catch the regression being the thing that regressed.

The same shape sat one layer up, raised on #1340's post-hoc review: both
parsers `break` out of a block on an unrecognised line and return a
*partial* Map, which is structurally indistinguishable from a complete
parse. An entry that was never parsed can never mismatch, so a truncated
or oddly-indented block compared green. Green meant "everything I managed
to parse matched", not "everything matched".

So: a missing hash is now itself a mismatch; patch-file existence is
checked independently of the hash; and every unrecognised shape throws
rather than truncating, surfaced by `main()` as a distinct guard failure
("could not parse") rather than a drift report, since the fix is
different — the parser needs to learn the shape pnpm now emits.

Each of the six new tests fails against the pre-fix script and passes
after; the seven existing cases, including the #1338 content-swap
coverage, are untouched and pass against both.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant