mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +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>
238 lines
11 KiB
JavaScript
238 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* lockfile-trust.mjs
|
|
*
|
|
* Decides who may change the dependency locks (LOCKFILES). Only trusted environments write them:
|
|
* - the lockfile refresh workflow (`chore/refresh-lockfile`, opened by github-actions[bot]),
|
|
* - Dependabot, when the exact bytes of each lockfile it changes 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 current
|
|
* base-branch tip, 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, realpathSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
// The root lockfile. The reporting-lock proof and the `merged` command follow this one only.
|
|
export const LOCKFILE = 'pnpm-lock.yaml';
|
|
// Sandbox providers stay out of the root workspace (see doc/DEVELOPING.md), so they have their own
|
|
// pnpm lockfiles. The refresh workflow regenerates these too, and its LOCKFILES env lists the same
|
|
// paths. Hand-managed pins, such as the Pi and Kimi distributions, are not listed.
|
|
export const PROVIDER_LOCKFILES = [
|
|
'scripts/typecheck-sandbox-providers/pnpm-lock.yaml',
|
|
'packages/plugins/sandbox-providers/daytona/pnpm-lock.yaml',
|
|
];
|
|
export const LOCKFILES = [LOCKFILE, ...PROVIDER_LOCKFILES];
|
|
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.
|
|
* `dependabotLockCommits` maps each changed lockfile to the verified Dependabot commit that holds
|
|
* its exact bytes, or to null.
|
|
* @returns {{ allowed: boolean, writer: 'none' | 'dependabot' | 'refresh-bot', reason: string }}
|
|
*/
|
|
export function classifyPullRequest({ changedPaths, author, headRepo, baseRepo, headRef, dependabotLockCommits = {} }) {
|
|
const changedLockfiles = LOCKFILES.filter((file) => changedPaths.includes(file));
|
|
const proofChanged = changedPaths.some((file) => REPORT_LOCK_FILES.includes(file));
|
|
if (changedLockfiles.length === 0 && !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.` };
|
|
}
|
|
const unverified = changedLockfiles.filter((file) => !dependabotLockCommits[file]);
|
|
if (unverified.length > 0) {
|
|
return { allowed: false, writer: 'dependabot',
|
|
reason: `${unverified.join(', ')} does not match any verified Dependabot commit in this pull request. ${RECREATE_HINT}` };
|
|
}
|
|
return { allowed: true, writer: 'dependabot',
|
|
reason: changedLockfiles.map((file) => `${file} matches verified Dependabot commit ${dependabotLockCommits[file]}.`).join(' ') };
|
|
}
|
|
|
|
const files = [...changedLockfiles, ...REPORT_LOCK_FILES.filter((file) => changedPaths.includes(file))];
|
|
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<any>} 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);
|
|
|
|
const dependabotLockCommits = {};
|
|
const changedLockfiles = LOCKFILES.filter((file) => changedPaths.includes(file));
|
|
if (author === DEPENDABOT_LOGIN && changedLockfiles.length > 0) {
|
|
const commits = pullCommits(repository, required('PR_NUMBER'));
|
|
for (const file of changedLockfiles) {
|
|
dependabotLockCommits[file] = findDependabotLockCommit({
|
|
lockBlob: tryRun('git', ['rev-parse', `${headSha}:${file}`]),
|
|
commits,
|
|
lockBlobAt: (sha) => tryRun('git', ['rev-parse', `${sha}:${file}`]),
|
|
});
|
|
}
|
|
}
|
|
|
|
const result = classifyPullRequest({
|
|
changedPaths, author, headRepo: process.env.HEAD_REPO ?? '', baseRepo: repository,
|
|
headRef: process.env.HEAD_REF ?? '', dependabotLockCommits,
|
|
});
|
|
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) });
|
|
}
|
|
|
|
// Compare real paths: the runner's temporary directory can sit behind a symlink.
|
|
if (process.argv[1] && realpathSync(path.resolve(process.argv[1])) === realpathSync(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');
|
|
}
|