From 3a8f95d928054987d644a6808b553ec656426be9 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 5 Nov 2026 16:42:26 -0800 Subject: [PATCH] fix(ci): let verified Dependabot lockfiles merge through the reporting-lock gate (#19356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Only trusted environments may change `pnpm-lock.yaml`: the lockfile refresh workflow and Dependabot. Contributors never commit it. > - Paid evaluation jobs also install a lock only when the reporting-lock proof (`tests/runner-e2e/report-lock.json` and `.patch`) admits it. > - #19283 added a CI test that checks the committed lock against that proof. Only the refresh workflow writes the proof, so every Dependabot lockfile pull request now fails CI. Examples are #17364, #17172, and #17911. > - A merged Dependabot lockfile also breaks the refresh workflow. `prepare-report-lock.mjs --refresh` refuses to renew the proof for a lock that the proof does not already admit. > - This pull request lets a verified Dependabot lockfile pass, and it keeps both lockfile rules strict. It also closes a gap in the contributor rule. > - The benefit is that Dependabot security updates can merge again, and only trusted environments write lock content. ## Linked Issues or Issue Description Refs #19283, #17364, #17172, #17911, #19303. **What happened?** `server/scripts/reporting-lock-policy.test.mjs` fails on each Dependabot pull request that changes `pnpm-lock.yaml`. The error is "Reporting lock changed; review the dependency correction before install". A Dependabot pull request cannot change the proof files, so it can never pass. After a merge, the lockfile refresh also stops at "Committed report lock is not reviewed; refusing to refresh its proof". **Expected behavior** - Contributors cannot change `pnpm-lock.yaml` or the reporting-lock proof. - Dependabot can change `pnpm-lock.yaml`. - The lockfile refresh workflow can change `pnpm-lock.yaml` and the proof. - After a Dependabot lockfile merges, the refresh workflow admits it and renews the proof. **Steps to reproduce** 1. Open a Dependabot pull request that changes `pnpm-lock.yaml`, such as #17364. 2. Let CI run. The `server (9/12)` shard fails on the reporting-lock policy test. **Security gap found during this work** The old rule skipped the lockfile check when `github.head_ref` was `chore/refresh-lockfile`. A contributor could open a pull request from a fork branch with that name and commit a lockfile change. The old rule also exempted any pull request that Dependabot opened, including commits that other people pushed to its branch. ## What Changed - Add `.github/scripts/lockfile-trust.mjs`. It decides which writers may change lock content: - The refresh workflow: the head is `chore/refresh-lockfile` in this repository, and `github-actions[bot]` opened the pull request. - Dependabot: Dependabot opened the pull request from this repository, and the head `pnpm-lock.yaml` matches a Dependabot commit. That commit must have `dependabot[bot]` as its author, GitHub (`web-flow`) as its committer, and a verified signature. The script reads these fields from the GitHub API. - The reporting-lock proof files count as lock content. Only the refresh workflow may change them. - `pr-trusted.yml` policy job: replace the inline `head_ref` and author exemption with `lockfile-trust.mjs pr`. The job runs the script copy from the base commit, so a pull request cannot change the check that judges it. If the base predates the script, the job uses the earlier rule. - `refresh-lockfile.yml`: add `lockfile-trust.mjs merged`. It checks that the last lockfile change on `master` is a GitHub-signed merge of a Dependabot pull request, and that the lockfile at the pull request head matches a verified Dependabot commit. If both checks pass, it gives the lockfile SHA-256 to `prepare-report-lock.mjs --refresh --trusted-lock-sha256`. - `refresh-lockfile.yml` PR step: check `pnpm-lock.yaml` and both proof files before it exits as unchanged. A refresh that renews only the proof now opens a pull request. - `prepare-report-lock.mjs` and its `.d.mts` declaration: `refreshReportLock` accepts a trusted lock hash. If the trusted lock already resolves, the proof admits it exactly with an empty patch. If not, the proof records the resolution as the reviewed correction, as before. - `reporting-lock-policy.test.mjs`: skip on Dependabot pull requests. The policy job verifies those lockfiles. The general test job gets `PAPERCLIP_CI_PR_AUTHOR` for this purpose. This keeps the test, so it still merges cleanly with #19303. - `check-pr-lockfile.mjs` (advisory gate): allow Dependabot lockfile changes, and flag changes to the proof files. - `CODEOWNERS`: add the two proof files. - `doc/RELEASE-AUTOMATION-SETUP.md`: describe the writers, the `@dependabot recreate` path, and the proof renewal after merge. ## Verification - `node --test '.github/scripts/tests/*.test.mjs'`: 707 passed. This includes 17 new tests in `lockfile-trust.test.mjs` and 3 new tests in `check-pr-lockfile.test.mjs`. The new tests run the `pr` command against real git histories with a stub `gh`. - `tests/runner-e2e/prepare-report-lock.test.ts`: 10 passed, including 3 new trusted-lock refresh tests. I ran it with a minimal Vitest config, because the runner-e2e config builds a provider bundle first. - `server/scripts/reporting-lock-policy.test.mjs`: passes normally, and skips with `PAPERCLIP_CI_PR_AUTHOR=dependabot[bot]`. - Live check of the `pr` mode against #17364 with the real GitHub API: - With the real author, it reports "pnpm-lock.yaml matches verified Dependabot commit 83c328c15c…" and `writer=dependabot`. - With a contributor as the author and `chore/refresh-lockfile` as the head ref, it fails. - Live check of the `merged` mode at the real merge commits of #16018, #16020, and #15361. All three report a trusted lock hash. For #16018 and #15361, `master` changed after Dependabot's commit, so GitHub's merge produced different lockfile bytes. An exact-bytes rule would refuse them. That is why the rule trusts GitHub-signed merges of a verified Dependabot head. - Greptile reviewed `513ca2def8` at 4/5 with one P1: a proof-only refresh was never committed, because the PR step checked only `pnpm-lock.yaml`. `edbeb1ea0d` fixes it. A new test in `lockfile-refresh-merge.test.mjs` runs the step's change check on a proof-only change. The test fails without the fix. - CI `Typecheck + Release Registry` failed at `edbeb1ea0d`: `prepare-report-lock.d.mts` did not declare the new options argument (TS2554 twice in `prepare-report-lock.test.ts`). `9139aaad85` updates the declaration. A local `tsc -p tests/runner-e2e/tsconfig.json` shows the 2 errors with the old declaration and none with the new one. - CI at `9139aaad85` failed in six general test shards. None of them run code that this pull request changes, and PR CI loads `pr-trusted.yml` from `master`. The failures: - `heartbeat-plugin-environment.test.ts`: "expected 'running' to be 'succeeded'" - `status-card-evaluation-work.test.ts`: a deadline comparison - `isolated-repository-rehome.test.ts`: timed out after 15 seconds - `chat-channels.integration.test.ts`: GitHub webhook recovery scheduling - `company-import-export-e2e.test.ts`: child server start timeout, which also fails on unrelated branches - `workspaces-b`: every test passed, but an `EPIPE` error came after `acpx-engine/execute.test.ts` I started a rerun of the failed jobs. - Superagent found a P1 at `9139aaad85`: a verified signature proves who committed, not who authored. A collaborator could sign a commit with their own key and set Dependabot as the author. `4fe180d6c1` also requires GitHub (`web-flow`) as the committer. New tests cover the spoofed-author case. #17364, #16018, #16020 and #15361 still verify against the GitHub API. - `git merge-file` merges this test change with #19303 without conflicts. - Not run: the full repository test suite and typecheck. These changes touch only CI scripts, workflows, one helper, and tests. GitHub CI covers full regression. ## Risks - `pr-trusted.yml` loads from `master`, so the new policy takes effect only after merge. This pull request's own CI uses the earlier rule. - A Dependabot pull request that is behind `master` now fails if someone merges `master` into it and the lockfile changes. Use `@dependabot recreate`. The policy job error says this. - Paid evaluation jobs refuse a merged Dependabot lockfile until the refresh pull request merges. The refresh merges without review when it adds no new package versions. If the release-age gate holds it, the wait lasts until a maintainer merges it or the daily refresh runs. During that time, the reporting-lock policy test also fails on other pull requests, which is the current behavior for any unrenewed lock. - The rule trusts GitHub as the signer of Dependabot commits and of merge commits (`web-flow`). Superagent uses the same trust root. - People with write access can still push to `chore/refresh-lockfile`. This was already true. The bot merges only its own head commit (`--match-head-commit`), and code-owner review covers anything else. - A future manual proof renewal, such as #19283, now needs the refresh workflow or an admin merge. A contributor pull request that changes the proof files fails. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution, in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused tests; see Verification) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/CODEOWNERS | 2 + .github/scripts/check-pr-lockfile.mjs | 21 +- .github/scripts/lockfile-trust.mjs | 219 ++++++++++++++++++ .../scripts/tests/check-pr-lockfile.test.mjs | 16 ++ .../tests/lockfile-refresh-merge.test.mjs | 31 ++- .github/scripts/tests/lockfile-trust.test.mjs | 213 +++++++++++++++++ .github/workflows/pr-trusted.yml | 35 ++- .github/workflows/refresh-lockfile.yml | 23 +- doc/RELEASE-AUTOMATION-SETUP.md | 11 +- server/scripts/reporting-lock-policy.test.mjs | 7 +- tests/runner-e2e/prepare-report-lock.d.mts | 2 +- tests/runner-e2e/prepare-report-lock.mjs | 22 +- tests/runner-e2e/prepare-report-lock.test.ts | 57 +++++ 13 files changed, 633 insertions(+), 26 deletions(-) create mode 100644 .github/scripts/lockfile-trust.mjs create mode 100644 .github/scripts/tests/lockfile-trust.test.mjs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 721b2ae9bc5..2dc62f53c75 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -19,6 +19,8 @@ skills/** @cryppadotta @devinfoley @nickyleach @forgottendev # package.json matches recursively at all depths (covers root + all workspaces) package.json @cryppadotta @devinfoley @nickyleach @forgottendev pnpm-lock.yaml @cryppadotta @devinfoley @nickyleach @forgottendev +/tests/runner-e2e/report-lock.json @cryppadotta @devinfoley @nickyleach @forgottendev +/tests/runner-e2e/report-lock.patch @cryppadotta @devinfoley @nickyleach @forgottendev pnpm-workspace.yaml @cryppadotta @devinfoley @nickyleach @forgottendev .npmrc @cryppadotta @devinfoley @nickyleach @forgottendev diff --git a/.github/scripts/check-pr-lockfile.mjs b/.github/scripts/check-pr-lockfile.mjs index e8352006667..ea3e80ea0c6 100644 --- a/.github/scripts/check-pr-lockfile.mjs +++ b/.github/scripts/check-pr-lockfile.mjs @@ -1,22 +1,27 @@ #!/usr/bin/env node /** * check-pr-lockfile.mjs - * Checks that pnpm-lock.yaml was not manually edited. + * Advisory check that lock content was not edited by hand. `pr-trusted.yml` enforces the + * same rule with `lockfile-trust.mjs`, which also verifies Dependabot commit signatures. * Export: checkLockfile(files, prAuthor, prBranch) → { passed, failures } */ import { fileURLToPath } from 'node:url'; +import { DEPENDABOT_LOGIN, LOCKFILE, REFRESH_BRANCH, REFRESH_PR_AUTHOR, REPORT_LOCK_FILES } from './lockfile-trust.mjs'; export function checkLockfile(files, prAuthor, prBranch) { - const lockfileChanged = files.some(f => f.filename === 'pnpm-lock.yaml'); - if (!lockfileChanged) return { passed: true, failures: [] }; + const changed = files.map(f => f.filename); + const lockfileChanged = changed.includes(LOCKFILE); + const proofChanged = changed.filter(f => REPORT_LOCK_FILES.includes(f)); + if (!lockfileChanged && proofChanged.length === 0) return { passed: true, failures: [] }; - const isRefreshBot = - prAuthor === 'github-actions[bot]' && prBranch === 'chore/refresh-lockfile'; + if (prAuthor === REFRESH_PR_AUTHOR && prBranch === REFRESH_BRANCH) return { passed: true, failures: [] }; + if (prAuthor === DEPENDABOT_LOGIN && proofChanged.length === 0) return { passed: true, failures: [] }; + const edited = [lockfileChanged && LOCKFILE, ...proofChanged].filter(Boolean).map(f => `\`${f}\``).join(', '); return { - passed: isRefreshBot, - failures: isRefreshBot ? [] : [ - 'You have changes to `pnpm-lock.yaml` — `pr.yml` will hard-fail this PR with a confusing message about lockfile edits. ' + + passed: false, + failures: [ + `You have changes to ${edited} — \`pr.yml\` will hard-fail this PR because only the lockfile refresh workflow and Dependabot may change lock content. ` + 'To fix: run `pnpm install` locally, exclude the lockfile from your commit, push again. ' + 'The lockfile is regenerated automatically by the refresh bot on a schedule.', ], diff --git a/.github/scripts/lockfile-trust.mjs b/.github/scripts/lockfile-trust.mjs new file mode 100644 index 00000000000..c710f58ebbe --- /dev/null +++ b/.github/scripts/lockfile-trust.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +/** + * lockfile-trust.mjs + * + * Decides who may change the dependency lock. Only trusted environments write it: + * - the lockfile refresh workflow (`chore/refresh-lockfile`, opened by github-actions[bot]), + * - Dependabot, when the exact lockfile bytes come from a verified Dependabot commit. + * + * The reporting-lock proof (`tests/runner-e2e/report-lock.json` and `.patch`) can rewrite the + * lock that paid evaluation jobs install, so it counts as lock content too. Only the refresh + * workflow writes it. After a verified Dependabot lockfile merges, the refresh workflow renews + * the proof for it. + * + * Keep this file free of imports outside node:*. The PR policy job runs the copy from the base + * commit, so a pull request cannot rewrite the check that judges it. + * + * Usage: + * node .github/scripts/lockfile-trust.mjs pr # PR policy job + * node .github/scripts/lockfile-trust.mjs merged # refresh workflow on master + */ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const LOCKFILE = 'pnpm-lock.yaml'; +export const REPORT_LOCK_FILES = ['tests/runner-e2e/report-lock.json', 'tests/runner-e2e/report-lock.patch']; +export const DEPENDABOT_LOGIN = 'dependabot[bot]'; +export const REFRESH_BRANCH = 'chore/refresh-lockfile'; +export const REFRESH_PR_AUTHOR = 'github-actions[bot]'; +// GitHub commits and signs Dependabot commits as web-flow. A verified signature proves only who +// committed, and anyone can set the author field, so require GitHub as the committer as well. +export const GITHUB_COMMITTER_LOGIN = 'web-flow'; + +const RECREATE_HINT = 'Comment `@dependabot recreate` so Dependabot rebuilds the lockfile, instead of merging or editing it by hand.'; + +/** + * True for a commit that Dependabot authored and GitHub committed and signed. + * @param {{ login: string | null, committer: string | null, verified: boolean }} commit + */ +export function isDependabotCommit(commit) { + return commit.login === DEPENDABOT_LOGIN && commit.committer === GITHUB_COMMITTER_LOGIN && commit.verified === true; +} + +/** + * Find a Dependabot commit (see isDependabotCommit) in the pull request that holds exactly the + * lockfile under review. + * @param {{ lockBlob: string | null, + * commits: Array<{ sha: string, login: string | null, committer: string | null, verified: boolean }>, + * lockBlobAt: (sha: string) => string | null }} input + */ +export function findDependabotLockCommit({ lockBlob, commits, lockBlobAt }) { + if (!lockBlob) return null; + for (const commit of [...commits].reverse()) { + if (!isDependabotCommit(commit)) continue; + if (lockBlobAt(commit.sha) === lockBlob) return commit.sha; + } + return null; +} + +/** + * Classify the lock-content change in a pull request. + * @returns {{ allowed: boolean, writer: 'none' | 'dependabot' | 'refresh-bot', reason: string }} + */ +export function classifyPullRequest({ changedPaths, author, headRepo, baseRepo, headRef, dependabotLockCommit }) { + const lockfileChanged = changedPaths.includes(LOCKFILE); + const proofChanged = changedPaths.some((file) => REPORT_LOCK_FILES.includes(file)); + if (!lockfileChanged && !proofChanged) { + return { allowed: true, writer: 'none', reason: 'The pull request does not change lock content.' }; + } + + const sameRepo = Boolean(headRepo) && headRepo === baseRepo; + if (sameRepo && author === REFRESH_PR_AUTHOR && headRef === REFRESH_BRANCH) { + return { allowed: true, writer: 'refresh-bot', reason: 'The lockfile refresh workflow opened this pull request.' }; + } + + if (sameRepo && author === DEPENDABOT_LOGIN) { + if (proofChanged) { + return { allowed: false, writer: 'dependabot', + reason: `Dependabot pull requests must not change ${REPORT_LOCK_FILES.join(' or ')}. The lockfile refresh renews the reporting-lock proof after merge.` }; + } + if (!dependabotLockCommit) { + return { allowed: false, writer: 'dependabot', + reason: `${LOCKFILE} does not match any verified Dependabot commit in this pull request. ${RECREATE_HINT}` }; + } + return { allowed: true, writer: 'dependabot', reason: `${LOCKFILE} matches verified Dependabot commit ${dependabotLockCommit}.` }; + } + + const files = [lockfileChanged && LOCKFILE, ...REPORT_LOCK_FILES.filter((file) => changedPaths.includes(file))].filter(Boolean); + return { allowed: false, writer: 'none', + reason: `Do not commit ${files.join(', ')} in pull requests. CI owns lockfile updates.` }; +} + +/** + * True when GitHub created and signed the commit, as it does when it merges a pull request. + * @param {any} commit Response of GET /repos/{repo}/commits/{sha}. + */ +export function isGitHubSignedCommit(commit) { + return commit?.committer?.login === GITHUB_COMMITTER_LOGIN && commit?.commit?.verification?.verified === true; +} + +/** + * Pick the merged Dependabot pull request whose squash or merge commit is `commitSha`. + * @param {Array} pulls Response of GET /repos/{repo}/commits/{sha}/pulls. + */ +export function findMergedDependabotPull(pulls, commitSha, defaultBranch) { + return pulls.find((pull) => pull?.user?.login === DEPENDABOT_LOGIN && Boolean(pull.merged_at) && + pull.merge_commit_sha === commitSha && pull?.base?.ref === defaultBranch) ?? null; +} + +function run(command, args, options = {}) { + return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }).trim(); +} + +function tryRun(command, args) { + try { return run(command, args); } catch { return null; } +} + +function ghJsonLines(endpoint, jq) { + const output = run('gh', ['api', '--paginate', endpoint, '--jq', jq]); + return output ? output.split('\n').map((line) => JSON.parse(line)) : []; +} + +function pullCommits(repository, number) { + return ghJsonLines(`repos/${repository}/pulls/${number}/commits?per_page=100`, + '.[] | {sha, login: .author.login, committer: .committer.login, verified: .commit.verification.verified}'); +} + +function writeOutputs(values) { + const lines = Object.entries(values).map(([key, value]) => `${key}=${value}\n`).join(''); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, lines); +} + +function required(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required`); + return value; +} + +function checkPullRequest() { + const repository = required('GITHUB_REPOSITORY'); + const baseSha = required('BASE_SHA'); + const headSha = required('HEAD_SHA'); + const author = required('PR_AUTHOR'); + // Diff against the merge base so recent base-branch commits are not counted as PR changes. + const changedPaths = run('git', ['diff', '--name-only', `${baseSha}...${headSha}`]).split('\n').filter(Boolean); + + let dependabotLockCommit = null; + if (author === DEPENDABOT_LOGIN && changedPaths.includes(LOCKFILE)) { + dependabotLockCommit = findDependabotLockCommit({ + lockBlob: tryRun('git', ['rev-parse', `${headSha}:${LOCKFILE}`]), + commits: pullCommits(repository, required('PR_NUMBER')), + lockBlobAt: (sha) => tryRun('git', ['rev-parse', `${sha}:${LOCKFILE}`]), + }); + } + + const result = classifyPullRequest({ + changedPaths, author, headRepo: process.env.HEAD_REPO ?? '', baseRepo: repository, + headRef: process.env.HEAD_REF ?? '', dependabotLockCommit, + }); + console.log(result.reason); + writeOutputs({ writer: result.writer }); + if (!result.allowed) { + console.log(`::error title=Untrusted lockfile change::${result.reason}`); + process.exitCode = 1; + } +} + +/** + * On master: report the committed lockfile's SHA-256 when its last change is a GitHub-signed merge + * of a Dependabot pull request whose head lockfile bytes come from a verified Dependabot commit. + * When master's lockfile moved after Dependabot's commit, GitHub's merge combines the two trusted + * lockfiles, so the merged bytes can differ from Dependabot's. The refresh then records the + * resolution of that merged lock as the reviewed correction. Otherwise report nothing. + */ +function checkMerged() { + const repository = required('GITHUB_REPOSITORY'); + const head = required('GITHUB_SHA'); + const defaultBranch = process.env.DEFAULT_BRANCH || 'master'; + const lockCommit = run('gh', ['api', `repos/${repository}/commits?sha=${head}&path=${LOCKFILE}&per_page=1`, '--jq', '.[0].sha // empty']); + const pull = lockCommit + ? findMergedDependabotPull(JSON.parse(run('gh', ['api', `repos/${repository}/commits/${lockCommit}/pulls`])), lockCommit, defaultBranch) + : null; + if (pull && !isGitHubSignedCommit(JSON.parse(run('gh', ['api', `repos/${repository}/commits/${lockCommit}`])))) { + console.log(`::warning title=Unverified Dependabot merge::${lockCommit} is not a GitHub-signed merge of #${pull.number}. The reporting-lock proof is not renewed.`); + writeOutputs({ trusted_lock_sha256: '', dependabot_pr: String(pull.number) }); + return; + } + if (!pull) { + console.log(`The last ${LOCKFILE} change on ${defaultBranch} is not a merged Dependabot pull request.`); + writeOutputs({ trusted_lock_sha256: '', dependabot_pr: '' }); + return; + } + + // The checkout is shallow, so read each candidate's lockfile blob hash from the API. + const lockBlobAt = (sha) => tryRun('gh', ['api', `repos/${repository}/git/trees/${sha}`, + '--jq', `.tree[] | select(.path == "${LOCKFILE}") | .sha`]) || null; + const verified = findDependabotLockCommit({ + lockBlob: lockBlobAt(pull.head.sha), + commits: pullCommits(repository, pull.number), + lockBlobAt, + }); + if (!verified) { + console.log(`::warning title=Unverified Dependabot lockfile::The ${LOCKFILE} at the head of #${pull.number} matches no verified Dependabot commit. The reporting-lock proof is not renewed.`); + writeOutputs({ trusted_lock_sha256: '', dependabot_pr: String(pull.number) }); + return; + } + const lockSha256 = createHash('sha256').update(execFileSync('git', ['show', `HEAD:${LOCKFILE}`])).digest('hex'); + console.log(`${LOCKFILE} on ${defaultBranch} is GitHub's merge of verified Dependabot commit ${verified} from #${pull.number}.`); + writeOutputs({ trusted_lock_sha256: lockSha256, dependabot_pr: String(pull.number) }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const mode = process.argv[2]; + if (mode === 'pr') checkPullRequest(); + else if (mode === 'merged') checkMerged(); + else throw new Error('Usage: lockfile-trust.mjs pr|merged'); +} diff --git a/.github/scripts/tests/check-pr-lockfile.test.mjs b/.github/scripts/tests/check-pr-lockfile.test.mjs index 03d897f2154..4436ebd176d 100644 --- a/.github/scripts/tests/check-pr-lockfile.test.mjs +++ b/.github/scripts/tests/check-pr-lockfile.test.mjs @@ -31,3 +31,19 @@ test('fails when lockfile changed by bot on wrong branch', () => { ); assert.equal(result.passed, false); }); + +test('passes when Dependabot changes the lockfile', () => { + assert.equal(checkLockfile(makeFiles(['pnpm-lock.yaml', 'package.json']), 'dependabot[bot]', 'dependabot/npm_and_yarn/zod-4.6.6').passed, true); +}); + +test('fails when Dependabot changes the reporting-lock proof', () => { + const result = checkLockfile(makeFiles(['pnpm-lock.yaml', 'tests/runner-e2e/report-lock.json']), 'dependabot[bot]', 'dependabot/npm_and_yarn/zod-4.6.6'); + assert.equal(result.passed, false); + assert.ok(result.failures[0].includes('report-lock.json')); +}); + +test('fails when a regular user changes the reporting-lock proof', () => { + const result = checkLockfile(makeFiles(['tests/runner-e2e/report-lock.patch']), 'someuser', 'fix/bug'); + assert.equal(result.passed, false); + assert.ok(result.failures[0].includes('report-lock.patch')); +}); diff --git a/.github/scripts/tests/lockfile-refresh-merge.test.mjs b/.github/scripts/tests/lockfile-refresh-merge.test.mjs index 94a60c167de..54406e450b6 100644 --- a/.github/scripts/tests/lockfile-refresh-merge.test.mjs +++ b/.github/scripts/tests/lockfile-refresh-merge.test.mjs @@ -1,6 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; const workflow = readFileSync(new URL("../../workflows/refresh-lockfile.yml", import.meta.url), "utf8"); const stepNames = [...workflow.matchAll(/^ - name: (.+)$/gm)].map((match) => match[1]); @@ -66,3 +69,29 @@ test("the bot merges only the commit this job pushed", () => { assert.match(merge, /GH_TOKEN: \$\{\{ steps\.bot-token\.outputs\.token \}\}/); assert.match(merge, /gh pr merge "\$PR_URL" .*--match-head-commit "\$HEAD_SHA"/); }); + +test("a refresh that renews only the reporting-lock proof still opens a pull request", () => { + const upsert = step("Create or update pull request"); + const check = upsert.match(/^ if (git diff --quiet -- [^;]+); then$/m)?.[1]; + assert.ok(check, "the PR step must check for changes with git diff --quiet"); + const added = upsert.match(/^ git add (.+)$/m)?.[1].split(" "); + assert.deepEqual(check.replace("git diff --quiet -- ", "").split(" "), added, "the change check must cover every file the PR step commits"); + + const root = mkdtempSync(path.join(tmpdir(), "refresh-proof-only-")); + try { + const git = (...args) => execFileSync("git", args, { cwd: root }); + execFileSync("mkdir", ["-p", path.join(root, "tests/runner-e2e")]); + const files = { "pnpm-lock.yaml": "lock\n", "tests/runner-e2e/report-lock.json": "{}\n", "tests/runner-e2e/report-lock.patch": "old\n" }; + for (const [file, contents] of Object.entries(files)) writeFileSync(path.join(root, file), contents); + git("init", "-q"); + git("add", "."); + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "fixture"); + const unchanged = () => spawnSync("bash", ["-c", check], { cwd: root }).status === 0; + assert.equal(unchanged(), true); + writeFileSync(path.join(root, "tests/runner-e2e/report-lock.json"), '{"outputSha256":"new"}\n'); + writeFileSync(path.join(root, "tests/runner-e2e/report-lock.patch"), ""); + assert.equal(unchanged(), false, "a proof-only change must not exit as unchanged"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/.github/scripts/tests/lockfile-trust.test.mjs b/.github/scripts/tests/lockfile-trust.test.mjs new file mode 100644 index 00000000000..a669ce9c880 --- /dev/null +++ b/.github/scripts/tests/lockfile-trust.test.mjs @@ -0,0 +1,213 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + classifyPullRequest, + findDependabotLockCommit, + findMergedDependabotPull, + isDependabotCommit, + isGitHubSignedCommit, +} from '../lockfile-trust.mjs'; + +const repo = 'paperclipai/paperclip'; +const base = { author: 'someuser', headRepo: repo, baseRepo: repo, headRef: 'fix/bug', dependabotLockCommit: null }; + +test('allows a pull request that does not change lock content', () => { + assert.deepEqual( + { ...classifyPullRequest({ ...base, changedPaths: ['server/src/index.ts'] }), reason: undefined }, + { allowed: true, writer: 'none', reason: undefined }, + ); +}); + +test('blocks a contributor lockfile change', () => { + const result = classifyPullRequest({ ...base, changedPaths: ['pnpm-lock.yaml'] }); + assert.equal(result.allowed, false); + assert.match(result.reason, /pnpm-lock\.yaml/); +}); + +test('blocks a contributor change to the reporting-lock proof', () => { + const result = classifyPullRequest({ ...base, changedPaths: ['tests/runner-e2e/report-lock.patch'] }); + assert.equal(result.allowed, false); + assert.match(result.reason, /report-lock\.patch/); +}); + +test('blocks a contributor branch named like the refresh branch', () => { + for (const headRepo of [repo, 'someone/paperclip']) { + const result = classifyPullRequest({ ...base, headRepo, headRef: 'chore/refresh-lockfile', changedPaths: ['pnpm-lock.yaml'] }); + assert.equal(result.allowed, false, headRepo); + } +}); + +test('blocks the refresh author and branch from a fork', () => { + const result = classifyPullRequest({ ...base, author: 'github-actions[bot]', headRepo: 'someone/paperclip', + headRef: 'chore/refresh-lockfile', changedPaths: ['pnpm-lock.yaml'] }); + assert.equal(result.allowed, false); +}); + +test('allows the refresh workflow to change the lockfile and its proof', () => { + const result = classifyPullRequest({ ...base, author: 'github-actions[bot]', headRef: 'chore/refresh-lockfile', + changedPaths: ['pnpm-lock.yaml', 'tests/runner-e2e/report-lock.json', 'tests/runner-e2e/report-lock.patch'] }); + assert.equal(result.allowed, true); + assert.equal(result.writer, 'refresh-bot'); +}); + +test('allows a Dependabot lockfile that matches a verified Dependabot commit', () => { + const result = classifyPullRequest({ ...base, author: 'dependabot[bot]', headRef: 'dependabot/npm_and_yarn/zod-4.6.6', + changedPaths: ['package.json', 'pnpm-lock.yaml'], dependabotLockCommit: 'abc123' }); + assert.equal(result.allowed, true); + assert.equal(result.writer, 'dependabot'); +}); + +test('blocks a Dependabot lockfile that no verified Dependabot commit produced', () => { + const result = classifyPullRequest({ ...base, author: 'dependabot[bot]', changedPaths: ['pnpm-lock.yaml'] }); + assert.equal(result.allowed, false); + assert.match(result.reason, /@dependabot recreate/); +}); + +test('blocks a Dependabot pull request that changes the reporting-lock proof', () => { + const result = classifyPullRequest({ ...base, author: 'dependabot[bot]', dependabotLockCommit: 'abc123', + changedPaths: ['pnpm-lock.yaml', 'tests/runner-e2e/report-lock.json'] }); + assert.equal(result.allowed, false); +}); + +test('blocks a Dependabot author from a fork', () => { + const result = classifyPullRequest({ ...base, author: 'dependabot[bot]', headRepo: 'someone/paperclip', + dependabotLockCommit: 'abc123', changedPaths: ['pnpm-lock.yaml'] }); + assert.equal(result.allowed, false); +}); + +test('finds only verified Dependabot commits that hold the exact lockfile', () => { + const blobs = { a: 'lock-1', b: 'lock-2', c: 'lock-2', d: 'lock-2' }; + const lockBlobAt = (sha) => blobs[sha] ?? null; + const commits = [ + { sha: 'a', login: 'dependabot[bot]', committer: 'web-flow', verified: true }, + { sha: 'b', login: 'dependabot[bot]', committer: 'web-flow', verified: false }, + { sha: 'c', login: 'someuser', committer: 'web-flow', verified: true }, + ]; + assert.equal(findDependabotLockCommit({ lockBlob: 'lock-2', commits, lockBlobAt }), null); + assert.equal(findDependabotLockCommit({ lockBlob: 'lock-1', commits, lockBlobAt }), 'a'); + assert.equal(findDependabotLockCommit({ lockBlob: 'lock-2', lockBlobAt, + commits: [...commits, { sha: 'd', login: 'dependabot[bot]', committer: 'web-flow', verified: true }] }), 'd'); + assert.equal(findDependabotLockCommit({ lockBlob: null, commits, lockBlobAt }), null); +}); + +test('requires a Dependabot author with GitHub as the verified committer', () => { + assert.equal(isDependabotCommit({ login: 'dependabot[bot]', committer: 'web-flow', verified: true }), true); + // A collaborator can sign with their own key and set Dependabot as the author. + assert.equal(isDependabotCommit({ login: 'dependabot[bot]', committer: 'someuser', verified: true }), false); + assert.equal(isDependabotCommit({ login: 'dependabot[bot]', committer: null, verified: true }), false); + assert.equal(isDependabotCommit({ login: 'dependabot[bot]', committer: 'web-flow', verified: false }), false); + assert.equal(isDependabotCommit({ login: 'someuser', committer: 'web-flow', verified: true }), false); +}); + +test('ignores a lockfile commit with a spoofed Dependabot author', () => { + const lockBlobAt = () => 'lock-1'; + const spoofed = [{ sha: 'x', login: 'dependabot[bot]', committer: 'someuser', verified: true }]; + assert.equal(findDependabotLockCommit({ lockBlob: 'lock-1', commits: spoofed, lockBlobAt }), null); +}); + +test('finds the merged Dependabot pull request for a master commit', () => { + const pull = (overrides) => ({ number: 1, user: { login: 'dependabot[bot]' }, merged_at: '2026-11-05T00:00:00Z', + merge_commit_sha: 'm', base: { ref: 'master' }, ...overrides }); + assert.equal(findMergedDependabotPull([pull({})], 'm', 'master')?.number, 1); + assert.equal(findMergedDependabotPull([pull({ merged_at: null })], 'm', 'master'), null); + assert.equal(findMergedDependabotPull([pull({ user: { login: 'someuser' } })], 'm', 'master'), null); + assert.equal(findMergedDependabotPull([pull({ merge_commit_sha: 'other' })], 'm', 'master'), null); + assert.equal(findMergedDependabotPull([pull({ base: { ref: 'release' } })], 'm', 'master'), null); +}); + +test('trusts only merge commits that GitHub created and signed', () => { + assert.equal(isGitHubSignedCommit({ committer: { login: 'web-flow' }, commit: { verification: { committer: 'web-flow', verified: true } } }), true); + assert.equal(isGitHubSignedCommit({ committer: { login: 'web-flow' }, commit: { verification: { committer: 'web-flow', verified: false } } }), false); + assert.equal(isGitHubSignedCommit({ committer: { login: 'someuser' }, commit: { verification: { committer: 'web-flow', verified: true } } }), false); + assert.equal(isGitHubSignedCommit(null), false); +}); + +// Run the `pr` command against a real git history, with a stub `gh` that lists the PR commits. +function runPrCheck(build) { + const root = mkdtempSync(path.join(tmpdir(), 'lockfile-trust-')); + try { + const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); + const commit = (author, files) => { + for (const [file, contents] of Object.entries(files)) writeFileSync(path.join(root, file), contents); + git('add', '.'); + git('-c', `user.name=${author}`, '-c', `user.email=${author}@example.invalid`, 'commit', '-qm', author); + return git('rev-parse', 'HEAD'); + }; + git('init', '-q'); + const baseSha = commit('base', { 'pnpm-lock.yaml': 'lock: base\n', 'package.json': '{}\n' }); + const { author, commits } = build(commit); + const bin = path.join(root, '.bin'); + execFileSync('mkdir', [bin]); + writeFileSync(path.join(bin, 'gh'), '#!/bin/sh\nprintf "%s\\n" "$GH_STUB_OUTPUT"\n'); + chmodSync(path.join(bin, 'gh'), 0o755); + const output = path.join(root, '.output'); + writeFileSync(output, ''); + const result = spawnSync(process.execPath, [path.resolve('.github/scripts/lockfile-trust.mjs'), 'pr'], { + cwd: root, encoding: 'utf8', + env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, GITHUB_OUTPUT: output, GITHUB_REPOSITORY: repo, + PR_NUMBER: '7', BASE_SHA: baseSha, HEAD_SHA: git('rev-parse', 'HEAD'), PR_AUTHOR: author, HEAD_REPO: repo, + HEAD_REF: 'dependabot/npm_and_yarn/zod', GH_STUB_OUTPUT: commits.map((entry) => JSON.stringify(entry)).join('\n') }, + }); + return { status: result.status, stdout: result.stdout, output: readFileSync(output, 'utf8') }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +test('pr command admits a Dependabot lockfile after a later non-lockfile commit', () => { + const result = runPrCheck((commit) => { + const sha = commit('dependabot', { 'pnpm-lock.yaml': 'lock: dependabot\n', 'package.json': '{"zod":"4.6.6"}\n' }); + commit('maintainer', { 'notes.md': 'fix\n' }); + return { author: 'dependabot[bot]', commits: [{ sha, login: 'dependabot[bot]', committer: 'web-flow', verified: true }] }; + }); + assert.equal(result.status, 0, result.stdout); + assert.match(result.output, /^writer=dependabot$/m); +}); + +test('pr command blocks a lockfile edited after the Dependabot commit', () => { + const result = runPrCheck((commit) => { + const sha = commit('dependabot', { 'pnpm-lock.yaml': 'lock: dependabot\n' }); + const edit = commit('maintainer', { 'pnpm-lock.yaml': 'lock: edited\n' }); + return { author: 'dependabot[bot]', commits: [ + { sha, login: 'dependabot[bot]', committer: 'web-flow', verified: true }, + { sha: edit, login: 'maintainer', committer: 'web-flow', verified: true }, + ] }; + }); + assert.equal(result.status, 1); + assert.match(result.stdout, /Untrusted lockfile change/); +}); + +test('pr command blocks a lockfile commit whose Dependabot author another committer signed', () => { + const result = runPrCheck((commit) => { + const sha = commit('maintainer', { 'pnpm-lock.yaml': 'lock: spoofed\n' }); + return { author: 'dependabot[bot]', commits: [{ sha, login: 'dependabot[bot]', committer: 'maintainer', verified: true }] }; + }); + assert.equal(result.status, 1); + assert.match(result.stdout, /Untrusted lockfile change/); +}); + +test('pr command blocks a contributor lockfile change', () => { + const result = runPrCheck((commit) => { + commit('someuser', { 'pnpm-lock.yaml': 'lock: contributor\n' }); + return { author: 'someuser', commits: [] }; + }); + assert.equal(result.status, 1); + assert.match(result.output, /^writer=none$/m); +}); + +test('workflows run the trust policy from trusted code and pass the verified hash to the proof refresh', () => { + const pr = readFileSync('.github/workflows/pr-trusted.yml', 'utf8'); + assert.match(pr, /git show "\$BASE_SHA:\.github\/scripts\/lockfile-trust\.mjs" > "\$policy"/); + assert.match(pr, /node "\$policy" pr/); + assert.match(pr, /PAPERCLIP_CI_PR_AUTHOR: \$\{\{ github\.event\.pull_request\.user\.login \}\}/); + assert.doesNotMatch(pr, /github\.head_ref != 'chore\/refresh-lockfile'/); + + const refresh = readFileSync('.github/workflows/refresh-lockfile.yml', 'utf8'); + assert.match(refresh, /node \.github\/scripts\/lockfile-trust\.mjs merged/); + assert.match(refresh, /--refresh --trusted-lock-sha256 "\$TRUSTED_LOCK_SHA256"/); + assert.ok(refresh.indexOf('lockfile-trust.mjs merged') < refresh.indexOf('--trusted-lock-sha256')); +}); diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index 9d83f5ac945..7a151a783c7 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -131,14 +131,32 @@ jobs: echo 'CI_NODE_TEST=--test' >> "$GITHUB_ENV" fi - - name: Block manual lockfile edits - if: >- - github.head_ref != 'chore/refresh-lockfile' && - github.event.pull_request.user.login != 'dependabot[bot]' + # Only trusted environments write lock content: the lockfile refresh + # workflow, and Dependabot when the lockfile bytes come from a verified + # Dependabot commit. Run the policy from the base commit so a pull request + # cannot rewrite the check that judges it. + - name: Block untrusted lockfile edits + id: lockfile_writer + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_REF: ${{ github.head_ref }} run: | - # Diff the PR branch against its merge base so recent base-branch commits - # do not masquerade as changes made by the PR itself. - changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")" + policy="$RUNNER_TEMP/lockfile-trust.mjs" + if git show "$BASE_SHA:.github/scripts/lockfile-trust.mjs" > "$policy" 2>/dev/null; then + node "$policy" pr + exit 0 + fi + # The base predates lockfile-trust.mjs: keep the earlier rule. + echo "writer=none" >> "$GITHUB_OUTPUT" + if [ "$HEAD_REF" = 'chore/refresh-lockfile' ] || [ "$PR_AUTHOR" = 'dependabot[bot]' ]; then + exit 0 + fi + changed="$(git diff --name-only "$BASE_SHA...$HEAD_SHA")" if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates." exit 1 @@ -455,6 +473,9 @@ jobs: PAPERCLIP_CI_SELECTION_HASH: ${{ matrix.selection && fromJSON(needs.test_selection.outputs.plan).testSelection.hash || '' }} PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }} PAPERCLIP_CI_PROFILE_JOB: General tests (${{ matrix.group_label }}) + # The reporting-lock policy test skips a Dependabot lockfile, which the + # policy job verifies and the lockfile refresh admits after merge. + PAPERCLIP_CI_PR_AUTHOR: ${{ github.event.pull_request.user.login }} steps: - name: Checkout repository diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index 2f057fa0ae8..4160536d3a3 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -74,8 +74,23 @@ jobs: - name: Refresh pnpm lockfile run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + # A verified Dependabot lockfile merged to master is trusted lock content. + # Report its hash so the proof refresh can admit it. + - name: Identify a verified Dependabot lockfile on master + id: trusted_lock + env: + GH_TOKEN: ${{ github.token }} + run: node .github/scripts/lockfile-trust.mjs merged + - name: Refresh reviewed eval report dependency proof - run: node tests/runner-e2e/prepare-report-lock.mjs --refresh + env: + TRUSTED_LOCK_SHA256: ${{ steps.trusted_lock.outputs.trusted_lock_sha256 }} + run: | + if [ -n "$TRUSTED_LOCK_SHA256" ]; then + node tests/runner-e2e/prepare-report-lock.mjs --refresh --trusted-lock-sha256 "$TRUSTED_LOCK_SHA256" + else + node tests/runner-e2e/prepare-report-lock.mjs --refresh + fi - name: Fail on unexpected file changes run: | @@ -97,8 +112,10 @@ jobs: GH_TOKEN: ${{ github.token }} REPO_OWNER: ${{ github.repository_owner }} run: | - if git diff --quiet -- pnpm-lock.yaml; then - echo "Lockfile unchanged, nothing to do." + # A verified Dependabot lockfile that already resolves changes only + # the reporting-lock proof. Commit that proof too. + if git diff --quiet -- pnpm-lock.yaml tests/runner-e2e/report-lock.json tests/runner-e2e/report-lock.patch; then + echo "Lockfile and reporting-lock proof unchanged, nothing to do." echo "pr_url=" >> "$GITHUB_OUTPUT" echo "head_sha=" >> "$GITHUB_OUTPUT" exit 0 diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md index 021188f2e53..d5541c23e6a 100644 --- a/doc/RELEASE-AUTOMATION-SETUP.md +++ b/doc/RELEASE-AUTOMATION-SETUP.md @@ -221,7 +221,16 @@ If `@cryppadotta` is not the right reviewer identity in the public repo, change ## 8.1. Let the Lockfile Bot Merge Lockfile Refreshes -`.github/workflows/refresh-lockfile.yml` regenerates `pnpm-lock.yaml` from the manifests on `master` and merges the result without CI or review. Contributors never commit the lockfile, so the bot is the only path for lockfile changes. +`.github/workflows/refresh-lockfile.yml` regenerates `pnpm-lock.yaml` from the manifests on `master` and merges the result without CI or review. Only trusted environments write lock content, and contributors never commit it. Two writers can change `pnpm-lock.yaml`: + +- this workflow, on its `chore/refresh-lockfile` branch +- Dependabot, but only when the lockfile in its pull request matches a verified Dependabot commit + +The reporting-lock proof (`tests/runner-e2e/report-lock.json` and `report-lock.patch`) controls the lock that paid evaluation jobs install, so it counts as lock content too. Only this workflow writes it. + +`.github/scripts/lockfile-trust.mjs` enforces these rules in the `pr-trusted.yml` policy job. The job runs the copy of the script from the base commit, so a pull request cannot change the check that judges it. To update a Dependabot pull request that is behind `master`, comment `@dependabot recreate`. Do not merge `master` into its branch, because a merged lockfile no longer matches Dependabot's commit. + +After a Dependabot lockfile merges, this workflow renews the proof. It first confirms three things: GitHub signed the merge commit, Dependabot opened the pull request, and the lockfile at the pull request head matches a verified Dependabot commit. It then records the merged lockfile in the proof and merges the refresh like any other. Paid evaluation jobs refuse the new lockfile until that refresh merges. Resolution keeps locked versions, but it picks the newest matching version for an added or widened dependency. A release-age gate (`.github/scripts/check-lockfile-release-age.mjs`) therefore checks every package version that the refresh adds. The bot merges only when each one has been on npm for at least 3 days. If any version is newer, or the gate cannot verify it, the bot leaves a comment on the PR and does not merge. A maintainer can review and merge it, or the daily scheduled refresh merges it after the versions are old enough. A held run saves its lockfile, and the hash of the `master` lockfile it started from, as an artifact. The next run resolves from that lockfile only while `master` still has the same lockfile. Held versions therefore stay put while their manifests are unchanged, even when newer releases appear, and a lockfile change on `master` replaces them. The gate also holds any added entry whose resolution is not a plain npm registry tarball. Change the age with `--min-age-days` in the workflow. diff --git a/server/scripts/reporting-lock-policy.test.mjs b/server/scripts/reporting-lock-policy.test.mjs index 144db19dcf4..55ff534d5b3 100644 --- a/server/scripts/reporting-lock-policy.test.mjs +++ b/server/scripts/reporting-lock-policy.test.mjs @@ -7,7 +7,12 @@ import { fileURLToPath } from 'node:url'; import { expect, it } from 'vitest'; import { prepareReportLock } from '../../tests/runner-e2e/prepare-report-lock.mjs'; -it('admits the committed controller lock through the reviewed reporting policy before paid execution', async () => { +// A Dependabot lockfile is not in the proof yet. The pr-trusted policy job verifies that it comes from +// a verified Dependabot commit, and the lockfile refresh renews the proof after it merges. +// See .github/scripts/lockfile-trust.mjs. +const pendingDependabotLock = process.env.PAPERCLIP_CI_PR_AUTHOR === 'dependabot[bot]'; + +it.skipIf(pendingDependabotLock)('admits the committed controller lock through the reviewed reporting policy before paid execution', async () => { const repository = fileURLToPath(new URL('../../', import.meta.url)); const root = await mkdtemp(path.join(tmpdir(), 'controller-report-policy-')); const relative = 'tests/runner-e2e'; diff --git a/tests/runner-e2e/prepare-report-lock.d.mts b/tests/runner-e2e/prepare-report-lock.d.mts index c69a6f0e43a..1aa68bacd63 100644 --- a/tests/runner-e2e/prepare-report-lock.d.mts +++ b/tests/runner-e2e/prepare-report-lock.d.mts @@ -1,2 +1,2 @@ export function prepareReportLock(root?: string): Promise<'already-reviewed' | 'reviewed-correction'>; -export function refreshReportLock(root?: string): Promise<'unchanged' | 'refreshed'>; +export function refreshReportLock(root?: string, options?: { trustedLockSha256?: string }): Promise<'unchanged' | 'refreshed'>; diff --git a/tests/runner-e2e/prepare-report-lock.mjs b/tests/runner-e2e/prepare-report-lock.mjs index 2916e1d4b52..f713cca39b0 100644 --- a/tests/runner-e2e/prepare-report-lock.mjs +++ b/tests/runner-e2e/prepare-report-lock.mjs @@ -26,17 +26,28 @@ export async function prepareReportLock(root = path.resolve(import.meta.dirname, return 'reviewed-correction'; } finally { await rm(temporary, { recursive: true, force: true }); } } -export async function refreshReportLock(root = path.resolve(import.meta.dirname, '../..')) { +// trustedLockSha256 admits a committed lock that the caller verified came from a trusted writer, +// such as a verified Dependabot commit merged to master (.github/scripts/lockfile-trust.mjs). +export async function refreshReportLock(root = path.resolve(import.meta.dirname, '../..'), { trustedLockSha256 } = {}) { const directory = path.join(root, 'tests/runner-e2e'); const policyPath = path.join(directory, 'report-lock.json'); const policy = JSON.parse(await readFile(policyPath, 'utf8')); const committed = execFileSync('git', ['show', 'HEAD:pnpm-lock.yaml'], { cwd: root }); const inputSha256 = sha256(committed); - if (![policy.inputSha256, policy.outputSha256].includes(inputSha256)) { + const reviewed = [policy.inputSha256, policy.outputSha256].includes(inputSha256); + const trusted = typeof trustedLockSha256 === 'string' && trustedLockSha256 === inputSha256; + if (!reviewed && !trusted) { throw new Error('Committed report lock is not reviewed; refusing to refresh its proof'); } const resolved = await readFile(path.join(root, 'pnpm-lock.yaml')); - if (resolved.equals(committed)) return 'unchanged'; + if (resolved.equals(committed)) { + if (reviewed) return 'unchanged'; + // The trusted lock already resolves, so admit it exactly. No correction is needed. + await writeFile(path.join(directory, 'report-lock.patch'), ''); + await writeFile(policyPath, JSON.stringify({ inputSha256, outputSha256: inputSha256, patchSha256: sha256(''), + basis: 'Admitted by the trusted master lockfile refresh after a verified Dependabot lockfile merged. No correction is needed.' }, null, 2) + '\n'); + return 'refreshed'; + } const patch = execFileSync('git', ['diff', '--no-ext-diff', '--no-textconv', '--no-color', 'HEAD', '--', 'pnpm-lock.yaml'], { cwd: root }); const changed = execFileSync('git', ['apply', '--numstat', '-'], { cwd: root, input: patch, encoding: 'utf8' }); if (!/^\d+\s+\d+\s+pnpm-lock\.yaml\n$/.test(changed)) throw new Error('Report correction must change only the lockfile'); @@ -48,7 +59,10 @@ export async function refreshReportLock(root = path.resolve(import.meta.dirname, if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { const args = process.argv.slice(2); + const usage = 'Usage: prepare-report-lock.mjs [--refresh [--trusted-lock-sha256 ]]'; if (args.length === 0) console.log(await prepareReportLock()); else if (args.length === 1 && args[0] === '--refresh') console.log(await refreshReportLock()); - else throw new Error('Usage: prepare-report-lock.mjs [--refresh]'); + else if (args.length === 3 && args[0] === '--refresh' && args[1] === '--trusted-lock-sha256' && /^[0-9a-f]{64}$/.test(args[2])) { + console.log(await refreshReportLock(undefined, { trustedLockSha256: args[2] })); + } else throw new Error(usage); } diff --git a/tests/runner-e2e/prepare-report-lock.test.ts b/tests/runner-e2e/prepare-report-lock.test.ts index 463a841bf26..6098e9d393f 100644 --- a/tests/runner-e2e/prepare-report-lock.test.ts +++ b/tests/runner-e2e/prepare-report-lock.test.ts @@ -100,3 +100,60 @@ it.each(['input', 'output', 'unknown'])('refreshes dependency proof only from a expect(await readFile(path.join(relative, 'report-lock.patch'), 'utf8')).toBe(patch); } finally { await rm(root, { recursive: true, force: true }); } }); + +async function trustedLockFixture(variant: string, callback: (root: string, relative: string, hash: (value: string) => string, + original: string, resolved: string, initial: object) => Promise) { + const root = await realpath(await mkdtemp(path.join(tmpdir(), `eval-report-lock-${variant}-`))); + const relative = path.join(root, 'tests/runner-e2e'); + const original = "lockfileVersion: '9.0'\nimporters: {}\npackages:\n dependabot@2.0.0: {resolution: {integrity: fixture-bot}}\n"; + const resolved = "lockfileVersion: '9.0'\nimporters:\n ui: {}\npackages:\n dependabot@2.0.0: {resolution: {integrity: fixture-bot}}\n"; + const hash = (value: string) => createHash('sha256').update(value).digest('hex'); + const initial = { inputSha256: '0'.repeat(64), outputSha256: '1'.repeat(64), patchSha256: hash('historical') }; + try { + await mkdir(relative, { recursive: true }); + await writeFile(path.join(root, 'pnpm-lock.yaml'), original); + await writeFile(path.join(relative, 'report-lock.json'), JSON.stringify(initial)); + await writeFile(path.join(relative, 'report-lock.patch'), 'historical'); + execFileSync('git', ['init', '-q'], { cwd: root }); + execFileSync('git', ['add', '.'], { cwd: root }); + execFileSync('git', ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-qm', 'fixture'], { cwd: root }); + await callback(root, relative, hash, original, resolved, initial); + } finally { await rm(root, { recursive: true, force: true }); } +} + +it('admits a verified trusted lock that already resolves without a correction', async () => { + await trustedLockFixture('trusted-resolved', async (root, relative, hash, original) => { + expect(await refreshReportLock(root, { trustedLockSha256: hash(original) })).toBe('refreshed'); + const policy = JSON.parse(await readFile(path.join(relative, 'report-lock.json'), 'utf8')); + expect(policy).toMatchObject({ inputSha256: hash(original), outputSha256: hash(original), patchSha256: hash('') }); + expect(await readFile(path.join(relative, 'report-lock.patch'), 'utf8')).toBe(''); + expect(await prepareReportLock(root)).toBe('already-reviewed'); + }); +}); + +it('records the correction for a verified trusted lock that resolves differently', async () => { + await trustedLockFixture('trusted-corrected', async (root, relative, hash, original, resolved) => { + await writeFile(path.join(root, 'pnpm-lock.yaml'), resolved); + const cli = path.join(relative, 'prepare-report-lock.mjs'); + await copyFile(path.join(import.meta.dirname, 'prepare-report-lock.mjs'), cli); + expect(execFileSync(process.execPath, [cli, '--refresh', '--trusted-lock-sha256', hash(original)], { cwd: root, encoding: 'utf8' }).trim()) + .toBe('refreshed'); + const policy = JSON.parse(await readFile(path.join(relative, 'report-lock.json'), 'utf8')); + expect(policy).toMatchObject({ inputSha256: hash(original), outputSha256: hash(resolved) }); + await writeFile(path.join(root, 'pnpm-lock.yaml'), original); + expect(await prepareReportLock(root)).toBe('reviewed-correction'); + expect(await readFile(path.join(root, 'pnpm-lock.yaml'), 'utf8')).toBe(resolved); + }); +}); + +it('refuses a trusted hash that does not name the committed lock', async () => { + await trustedLockFixture('trusted-mismatch', async (root, relative, hash, original, resolved, initial) => { + await expect(refreshReportLock(root, { trustedLockSha256: hash(resolved) })).rejects.toThrow('not reviewed'); + expect(JSON.parse(await readFile(path.join(relative, 'report-lock.json'), 'utf8'))).toEqual(initial); + expect(await readFile(path.join(relative, 'report-lock.patch'), 'utf8')).toBe('historical'); + const cli = path.join(relative, 'prepare-report-lock.mjs'); + await copyFile(path.join(import.meta.dirname, 'prepare-report-lock.mjs'), cli); + expect(() => execFileSync(process.execPath, [cli, '--refresh', '--trusted-lock-sha256', 'not-a-hash'], { cwd: root, stdio: 'pipe' })) + .toThrow(); + }); +});