mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The repository has a supply-chain rule: contributors do not commit lockfiles. A bot and verified Dependabot commits write them. > - The rule checks only the root `pnpm-lock.yaml`. > - The sandbox providers are outside the root workspace, so they have two more pnpm lockfiles. Pull requests edited them by hand, for example #12791, #18839 and #19489. > - This pull request makes the same rule and the same bot cover those two lockfiles. > - The benefit is one lockfile rule for every managed pnpm lockfile, and no lockfile edits in contributor pull requests. ## Linked Issues or Issue Description Refs #19489 **What existing behavior does this improve?** The lockfile policy and the lockfile refresh bot. **Subsystem affected** CI: `.github/scripts/lockfile-trust.mjs`, `.github/workflows/refresh-lockfile.yml`, and the sandbox provider installs. **Current behavior** `lockfile-trust.mjs` blocks contributor edits to `pnpm-lock.yaml` only. A contributor can edit `scripts/typecheck-sandbox-providers/pnpm-lock.yaml` or `packages/plugins/sandbox-providers/daytona/pnpm-lock.yaml`, and CI passes. The refresh bot does not update these files. `typecheck:test-programs` fails on a stale provider lockfile and tells the contributor to commit a new one. **Proposed behavior** The policy blocks contributor edits to all three lockfiles. The refresh bot regenerates all three after merge. CI resolves a stale provider lockfile in place for the run, as it does for the root lockfile. **Reason and benefit** Lockfile content comes only from the bot or from verified Dependabot commits. Contributors do not need to run pnpm in special folders. **Breaking changes** None. A pull request that edits a provider lockfile now fails the policy check. Remove the lockfile change from the pull request. The bot commits it after merge. ## What Changed - `lockfile-trust.mjs` now exports `LOCKFILES`: the root lockfile, the shared sandbox provider lockfile, and the Daytona lockfile. The policy blocks contributor edits to each of them. A Dependabot pull request must match a verified Dependabot commit for each lockfile that it changes. The `merged` command and the reporting-lock proof still use only the root lockfile. - `check-pr-lockfile.mjs` (advisory check) uses the same list. - `refresh-lockfile.yml`: - A job-level `LOCKFILES` env lists the same three files. A test checks that it equals the policy list. - A new step regenerates the two provider lockfiles with `--resolution-only --ignore-scripts --no-frozen-lockfile`. - The unexpected-changes check, the change check, and `git add` cover all three lockfiles. - The release-age gate checks the added versions in every lockfile. - A held refresh saves all three lockfiles at their repository paths, with a `source-locks.sha256` file. The next run reuses them only if every master lockfile is unchanged. An artifact in the old format is not reused. - The PR title is now `chore(lockfile): refresh pnpm lockfiles`. - `check-lockfile-release-age.mjs` accepts more than one `--base`/`--head` pair. - `scripts/typecheck-test-programs.mjs`: if the frozen install of the provider workspace fails, the script resolves the lockfile in place and installs frozen again. - `scripts/build-standalone-public-packages.mjs`: the same fallback applies to a package with a committed lockfile (Daytona). `release.sh` already restores tracked files when it exits. - `doc/DEVELOPING.md` and `doc/RELEASE-AUTOMATION-SETUP.md` describe the managed lockfiles. They also say that the Pi and Kimi distribution lockfiles stay hand-managed, because they hold attested provider pins. ## Verification - `node --test .github/scripts/tests/*.test.mjs scripts/__tests__/build-standalone-concurrency.test.mjs`: 731 tests pass. New tests cover: - the policy for provider lockfiles, including a real git history with a Dependabot commit and a later hand edit - the workflow and policy lists match - the unexpected-changes check, run with bash against a fixture - a held refresh round trip, and its invalidation when a provider lockfile changes on master - the release-age gate across lockfile pairs - the inline fallback in both install scripts - `node --test scripts/typecheck-test-programs.test.mjs`: the install tests pass. Two tests fail locally only because this worktree has no root `node_modules` (no `tsc`). They also fail without this change. - I ran the new bot commands on master with pnpm 9.15.4. No package version changed. pnpm 9.15.4 removes `libc:` fields and adds `deprecated:` notes, so the first refresh is a metadata-only change. The release-age gate passes it because it adds no versions. - I ran the release-age and held-lock steps locally with bash against this commit. The gate reported `eligible=true`, and `sha256sum --check` accepted the saved hashes. - The workflow YAML parses, and `bash -n` accepts every `run` block. - Not testable before merge: the policy job loads `lockfile-trust.mjs` from the base branch, so the new rule applies only to pull requests opened after this merges. The refresh workflow runs only on master. ## Risks - The release build now resolves a stale Daytona lockfile in place, from the registry. The root release install already resolves without a frozen lockfile, so this is the same level of trust. - The paid runner e2e job still installs Daytona with `--frozen-lockfile`. After a Daytona manifest change merges, its Daytona cases fail until the bot's refresh merges. This matches the rule that paid jobs refuse a new root lockfile until its refresh merges. - Dependabot pull requests for the provider entries in #19489 change a provider lockfile. They pass only if each lockfile matches a verified Dependabot commit, the same as for the root lockfile. - Low risk otherwise: the bot keeps its existing token, environment, and merge rules. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edits, and git). ## 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 - [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 <noreply@anthropic.com>
377 lines
20 KiB
JavaScript
377 lines
20 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import {
|
|
classifyPullRequest,
|
|
findDependabotLockCommit,
|
|
findMergedDependabotPull,
|
|
isDependabotCommit,
|
|
isGitHubSignedCommit,
|
|
LOCKFILES,
|
|
PROVIDER_LOCKFILES,
|
|
} from '../lockfile-trust.mjs';
|
|
|
|
const repo = 'paperclipai/paperclip';
|
|
const base = { author: 'someuser', headRepo: repo, baseRepo: repo, headRef: 'fix/bug', dependabotLockCommits: {} };
|
|
|
|
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'], dependabotLockCommits: { 'pnpm-lock.yaml': '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]', dependabotLockCommits: { 'pnpm-lock.yaml': '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',
|
|
dependabotLockCommits: { 'pnpm-lock.yaml': 'abc123' }, changedPaths: ['pnpm-lock.yaml'] });
|
|
assert.equal(result.allowed, false);
|
|
});
|
|
|
|
test('guards the sandbox provider lockfiles as well as the root lockfile', () => {
|
|
assert.deepEqual(LOCKFILES, ['pnpm-lock.yaml', ...PROVIDER_LOCKFILES]);
|
|
for (const file of PROVIDER_LOCKFILES) {
|
|
const result = classifyPullRequest({ ...base, changedPaths: [file] });
|
|
assert.equal(result.allowed, false, file);
|
|
assert.ok(result.reason.includes(file), result.reason);
|
|
}
|
|
});
|
|
|
|
test('allows the refresh workflow to change the provider lockfiles', () => {
|
|
const result = classifyPullRequest({ ...base, author: 'github-actions[bot]', headRef: 'chore/refresh-lockfile',
|
|
changedPaths: LOCKFILES });
|
|
assert.equal(result.allowed, true);
|
|
assert.equal(result.writer, 'refresh-bot');
|
|
});
|
|
|
|
test('allows a Dependabot provider lockfile that matches a verified Dependabot commit', () => {
|
|
const [file] = PROVIDER_LOCKFILES;
|
|
const result = classifyPullRequest({ ...base, author: 'dependabot[bot]',
|
|
changedPaths: ['packages/plugins/sandbox-providers/e2b/package.json', file], dependabotLockCommits: { [file]: 'abc123' } });
|
|
assert.equal(result.allowed, true);
|
|
assert.equal(result.writer, 'dependabot');
|
|
});
|
|
|
|
test('blocks a Dependabot pull request when any changed lockfile is unverified', () => {
|
|
const [file] = PROVIDER_LOCKFILES;
|
|
const result = classifyPullRequest({ ...base, author: 'dependabot[bot]',
|
|
changedPaths: ['pnpm-lock.yaml', file], dependabotLockCommits: { 'pnpm-lock.yaml': 'abc123', [file]: null } });
|
|
assert.equal(result.allowed, false);
|
|
assert.ok(result.reason.startsWith(`${file} does not match`), result.reason);
|
|
});
|
|
|
|
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)) {
|
|
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
|
|
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 checks each lockfile a Dependabot pull request changes', () => {
|
|
const [file] = PROVIDER_LOCKFILES;
|
|
const admitted = runPrCheck((commit) => {
|
|
const sha = commit('dependabot', { [file]: 'lock: dependabot\n', 'package.json': '{"zod":"4.6.6"}\n' });
|
|
return { author: 'dependabot[bot]', commits: [{ sha, login: 'dependabot[bot]', committer: 'web-flow', verified: true }] };
|
|
});
|
|
assert.equal(admitted.status, 0, admitted.stdout);
|
|
assert.match(admitted.output, /^writer=dependabot$/m);
|
|
|
|
const edited = runPrCheck((commit) => {
|
|
const sha = commit('dependabot', { 'pnpm-lock.yaml': 'lock: dependabot\n', [file]: 'lock: dependabot\n' });
|
|
const edit = commit('maintainer', { [file]: '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(edited.status, 1, edited.stdout);
|
|
assert.ok(edited.stdout.includes(`${file} does not match`), edited.stdout);
|
|
});
|
|
|
|
test('pr command blocks a contributor change to a provider lockfile', () => {
|
|
const result = runPrCheck((commit) => {
|
|
commit('someuser', { [PROVIDER_LOCKFILES[1]]: 'lock: contributor\n' });
|
|
return { author: 'someuser', commits: [] };
|
|
});
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.output, /^writer=none$/m);
|
|
});
|
|
|
|
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 "\$trusted_base:\.github\/scripts\/lockfile-trust\.mjs" > "\$policy"/);
|
|
assert.match(pr, /BASE_SHA="\$trusted_base" 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'));
|
|
});
|
|
|
|
// Run the policy job's step script, as GitHub would, against a merge commit.
|
|
// RUNNER_TEMP is a symlink, so the policy must still recognize itself as the entry point.
|
|
function runPolicyStep({ staleBase, githubSha, headSha, author, headRef, root }) {
|
|
const workflow = readFileSync('.github/workflows/pr-trusted.yml', 'utf8');
|
|
const step = workflow.split(' - name: Block untrusted lockfile edits\n')[1];
|
|
const lines = step.split('\n');
|
|
const start = lines.findIndex((line) => line === ' run: |') + 1;
|
|
const end = lines.findIndex((line, index) => index >= start && line.trim() !== '' && !line.startsWith(' '));
|
|
const script = lines.slice(start, end).map((line) => line.slice(10)).join('\n');
|
|
const output = path.join(root, '.output');
|
|
writeFileSync(output, '');
|
|
const runnerTemp = path.join(mkdtempSync(path.join(tmpdir(), 'policy-runner-')), 'temp');
|
|
symlinkSync(mkdtempSync(path.join(tmpdir(), 'policy-runner-real-')), runnerTemp);
|
|
const result = spawnSync('bash', ['-e', '-c', script], {
|
|
cwd: root, encoding: 'utf8',
|
|
env: { ...process.env, RUNNER_TEMP: runnerTemp, GITHUB_OUTPUT: output,
|
|
GITHUB_REPOSITORY: repo, GITHUB_SHA: githubSha, BASE_SHA: staleBase, HEAD_SHA: headSha, PR_NUMBER: '1',
|
|
PR_AUTHOR: author, HEAD_REPO: repo, HEAD_REF: headRef },
|
|
});
|
|
return { status: result.status, stdout: result.stdout + result.stderr, output: readFileSync(output, 'utf8') };
|
|
}
|
|
|
|
test('the policy step loads the policy from the merge commit when the recorded base is stale', () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'lockfile-policy-step-'));
|
|
try {
|
|
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
|
|
const commit = (message) => {
|
|
git('add', '.');
|
|
git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-qm', message);
|
|
return git('rev-parse', 'HEAD');
|
|
};
|
|
git('init', '-q', '-b', 'master');
|
|
writeFileSync(path.join(root, 'pnpm-lock.yaml'), 'lock: base\n');
|
|
const staleBase = commit('base before the policy');
|
|
// A contributor branch named like the refresh branch, from the stale base. The earlier rule exempts it.
|
|
git('checkout', '-q', '-b', 'chore/refresh-lockfile');
|
|
writeFileSync(path.join(root, 'pnpm-lock.yaml'), 'lock: contributor\n');
|
|
const head = commit('contributor lockfile');
|
|
git('checkout', '-q', 'master');
|
|
execFileSync('mkdir', ['-p', path.join(root, '.github/scripts')]);
|
|
writeFileSync(path.join(root, '.github/scripts/lockfile-trust.mjs'), readFileSync('.github/scripts/lockfile-trust.mjs'));
|
|
commit('add the policy');
|
|
git('-c', 'user.name=GitHub', '-c', 'user.email=noreply@github.com', 'merge', '-q', '--no-ff', '-X', 'theirs', '-m', 'merge', head);
|
|
const merge = git('rev-parse', 'HEAD');
|
|
|
|
const blocked = runPolicyStep({ root, staleBase, githubSha: merge, headSha: head, author: 'someuser', headRef: 'chore/refresh-lockfile' });
|
|
assert.equal(blocked.status, 1, blocked.stdout);
|
|
assert.match(blocked.stdout, /Untrusted lockfile change/);
|
|
|
|
// A contributor pull request that does not touch lock content passes through the loaded policy.
|
|
git('checkout', '-q', '-b', 'docs', staleBase);
|
|
writeFileSync(path.join(root, 'notes.md'), 'docs\n');
|
|
const docs = commit('docs only');
|
|
git('checkout', '-q', 'master');
|
|
git('-c', 'user.name=GitHub', '-c', 'user.email=noreply@github.com', 'merge', '-q', '--no-ff', '-m', 'merge docs', docs);
|
|
const allowed = runPolicyStep({ root, staleBase, githubSha: git('rev-parse', 'HEAD'), headSha: docs, author: 'someuser', headRef: 'docs' });
|
|
assert.equal(allowed.status, 0, allowed.stdout);
|
|
assert.match(allowed.stdout, /does not change lock content/);
|
|
assert.match(allowed.output, /^writer=none$/m);
|
|
|
|
// Without GitHub's merge commit, the step keeps the recorded base, which predates the policy.
|
|
const fallback = runPolicyStep({ root, staleBase, githubSha: head, headSha: head, author: 'someuser', headRef: 'chore/refresh-lockfile' });
|
|
assert.equal(fallback.status, 0, fallback.stdout);
|
|
assert.match(fallback.output, /^writer=none$/m);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('the policy step fails when the loaded policy exits without reporting a writer', () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'lockfile-policy-silent-'));
|
|
try {
|
|
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
|
|
const commit = (message) => {
|
|
git('add', '.');
|
|
git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-qm', message);
|
|
return git('rev-parse', 'HEAD');
|
|
};
|
|
git('init', '-q', '-b', 'master');
|
|
execFileSync('mkdir', ['-p', path.join(root, '.github/scripts')]);
|
|
writeFileSync(path.join(root, '.github/scripts/lockfile-trust.mjs'), 'process.exit(0);\n');
|
|
const baseSha = commit('a policy that skips its checks');
|
|
git('checkout', '-q', '-b', 'feature');
|
|
writeFileSync(path.join(root, 'pnpm-lock.yaml'), 'lock: contributor\n');
|
|
const head = commit('contributor lockfile');
|
|
git('checkout', '-q', 'master');
|
|
git('-c', 'user.name=GitHub', '-c', 'user.email=noreply@github.com', 'merge', '-q', '--no-ff', '-m', 'merge', head);
|
|
|
|
const result = runPolicyStep({ root, staleBase: baseSha, githubSha: git('rev-parse', 'HEAD'), headSha: head, author: 'someuser', headRef: 'feature' });
|
|
assert.equal(result.status, 1, result.stdout);
|
|
assert.match(result.stdout, /did not report a lockfile writer/);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|