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(); + }); +});