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>
231 lines
9.3 KiB
JavaScript
231 lines
9.3 KiB
JavaScript
#!/usr/bin/env node
|
|
// Typechecks the test files that no build or package typecheck reads.
|
|
//
|
|
// Most package tsconfigs exclude tests so that `tsc` does not emit them, and
|
|
// Vitest strips types, so a type error in those tests fails nothing. Each program
|
|
// in PROGRAMS reads them without emitting. Sandbox providers are outside the
|
|
// workspace, so the root install has none of their dependencies. They install from
|
|
// typecheck-sandbox-providers/pnpm-lock.yaml here, because their SDK types are part
|
|
// of what a test checks.
|
|
//
|
|
// The script then fails if a tracked test file is in no program. tsc decides that,
|
|
// not a glob: `include` silently drops foo.test.tsx when foo.test.ts exists.
|
|
//
|
|
// Usage: node scripts/typecheck-test-programs.mjs [--low-memory] [tsconfig ...]
|
|
// Naming a tsconfig runs only that program and skips the coverage check.
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { linkExcludedPlugins } from "./link-plugin-dev-sdk.mjs";
|
|
import { LOW_MEMORY_VARIABLE, runTsc } from "./run-tsc.mjs";
|
|
import { PROJECTS } from "./typecheck-test-projects.mjs";
|
|
|
|
export const PROVIDER_WORKSPACE = "scripts/typecheck-sandbox-providers";
|
|
|
|
export const SANDBOX_PROVIDERS = [
|
|
"packages/plugins/sandbox-providers/boxd",
|
|
"packages/plugins/sandbox-providers/cloudflare",
|
|
"packages/plugins/sandbox-providers/cloudflare/bridge-template",
|
|
"packages/plugins/sandbox-providers/createos",
|
|
"packages/plugins/sandbox-providers/daytona",
|
|
"packages/plugins/sandbox-providers/e2b",
|
|
"packages/plugins/sandbox-providers/exe-dev",
|
|
"packages/plugins/sandbox-providers/kubernetes",
|
|
"packages/plugins/sandbox-providers/modal",
|
|
"packages/plugins/sandbox-providers/novita",
|
|
];
|
|
|
|
export const PROGRAMS = [
|
|
"packages/db/tsconfig.test.json",
|
|
"packages/kv-demo-mcp-server/tsconfig.test.json",
|
|
"packages/paperclip-runner/tsconfig.browser.test.json",
|
|
"packages/paperclip-runner/tsconfig.surfaces.test.json",
|
|
"packages/paperclip-runner/tsconfig.test.json",
|
|
"packages/plugins/examples/plugin-orchestration-smoke-example/tsconfig.test.json",
|
|
"packages/plugins/plugin-workspace-diff/tsconfig.test.json",
|
|
"packages/plugins/sdk/tsconfig.test.json",
|
|
...SANDBOX_PROVIDERS.map((directory) => `${directory}/tsconfig.test.json`),
|
|
"tests/runner-acceptance/tsconfig.json",
|
|
"tests/tsconfig.playwright.json",
|
|
];
|
|
|
|
// server/scripts/typecheck-layers.mjs reads this tree. Everything else must be in a program.
|
|
export const CHECKED_ELSEWHERE = ["server/src/"];
|
|
|
|
const PREPARE_STEPS = [
|
|
["--filter", "@paperclipai/plugin-sdk", "ensure-build-deps"],
|
|
["--filter", "@paperclipai/paperclip-runner", "run", "ensure:eval-build-deps"],
|
|
];
|
|
// Replacing a provider's standalone node_modules would otherwise ask for confirmation.
|
|
const INSTALL_ARGS = ["install", "--frozen-lockfile", "--ignore-scripts", "--prefer-offline", "--config.confirmModulesPurge=false"];
|
|
// Contributors do not commit provider lockfiles: the lockfile refresh workflow regenerates them after
|
|
// merge, as it does the root pnpm-lock.yaml. Until then a changed manifest leaves the lockfile stale,
|
|
// so resolve it in place, like the root install in CI.
|
|
export const RESOLVE_ARGS = ["install", "--resolution-only", "--ignore-scripts", "--no-frozen-lockfile"];
|
|
|
|
export function findUncheckedTests({ trackedFiles, checkedFiles, checkedElsewhere = CHECKED_ELSEWHERE }) {
|
|
const checked = new Set(checkedFiles);
|
|
return trackedFiles.filter(
|
|
(file) =>
|
|
/\.(test|spec)\.tsx?$/.test(file) &&
|
|
!checked.has(file) &&
|
|
!checkedElsewhere.some((prefix) => file.startsWith(prefix)),
|
|
);
|
|
}
|
|
|
|
function runPnpm(args, { cwd, env }) {
|
|
const result = spawnSync("pnpm", args, {
|
|
cwd,
|
|
env,
|
|
stdio: "inherit",
|
|
// A bare `pnpm` on Windows is pnpm.cmd, which needs the shell to resolve.
|
|
shell: process.platform === "win32",
|
|
});
|
|
if (result.error) throw result.error;
|
|
return result.status ?? 1;
|
|
}
|
|
|
|
function listProgramFiles(config, { cwd, env }) {
|
|
const tsc = path.join(cwd, "node_modules", "typescript", "bin", "tsc");
|
|
const result = spawnSync(process.execPath, [tsc, "-p", config, "--listFilesOnly"], {
|
|
cwd,
|
|
env,
|
|
encoding: "utf8",
|
|
maxBuffer: 1 << 28,
|
|
});
|
|
if (result.error) throw result.error;
|
|
return {
|
|
status: result.status ?? 1,
|
|
output: result.stdout,
|
|
files: result.stdout
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((file) => path.relative(cwd, file).split(path.sep).join("/")),
|
|
};
|
|
}
|
|
|
|
function listWorkspaceConfigs(root) {
|
|
const result = spawnSync("pnpm", ["ls", "-r", "--depth", "-1", "--json"], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
maxBuffer: 1 << 26,
|
|
shell: process.platform === "win32",
|
|
});
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) throw new Error("Unable to list pnpm workspace packages.");
|
|
return JSON.parse(result.stdout)
|
|
.map((workspace) => path.relative(root, path.join(workspace.path, "tsconfig.json")).split(path.sep).join("/"))
|
|
.filter((config) => config !== "server/tsconfig.json" && existsSync(path.join(root, config)));
|
|
}
|
|
|
|
function listTrackedFiles(root) {
|
|
const result = spawnSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) throw new Error("Unable to list tracked files.");
|
|
return result.stdout.split("\0").filter(Boolean);
|
|
}
|
|
|
|
/** Returns the test files in no program, or null if a program could not be listed. */
|
|
export function checkCoverage({
|
|
root,
|
|
env = process.env,
|
|
programs = PROGRAMS,
|
|
workspaceConfigs = listWorkspaceConfigs,
|
|
trackedFiles = listTrackedFiles,
|
|
listFiles = listProgramFiles,
|
|
logError = console.error,
|
|
}) {
|
|
const checkedFiles = [];
|
|
// `pnpm build` and `typecheck:build-gaps` check each workspace's tsconfig.json.
|
|
// scripts/typecheck-test-projects.mjs checks these same files through tsconfig.layers.json.
|
|
const projectConfigs = PROJECTS.map((project) => `${project}/tsconfig.json`);
|
|
for (const config of [...workspaceConfigs(root), ...projectConfigs, ...programs]) {
|
|
const listing = listFiles(config, { cwd: root, env });
|
|
if (listing.status !== 0) {
|
|
logError(`typecheck:test-programs: could not list the files of ${config}\n${listing.output ?? ""}`);
|
|
return null;
|
|
}
|
|
checkedFiles.push(...listing.files);
|
|
}
|
|
return findUncheckedTests({ trackedFiles: trackedFiles(root), checkedFiles });
|
|
}
|
|
|
|
export function typecheckTestPrograms({
|
|
root,
|
|
env = process.env,
|
|
programs = PROGRAMS,
|
|
coverage = true,
|
|
pnpm = runPnpm,
|
|
linkPlugins = linkExcludedPlugins,
|
|
run = runTsc,
|
|
checkFiles = checkCoverage,
|
|
log = console.log,
|
|
logError = console.error,
|
|
}) {
|
|
for (const args of PREPARE_STEPS) {
|
|
const status = pnpm(args, { cwd: root, env });
|
|
if (status !== 0) return status;
|
|
}
|
|
const workspace = path.join(root, PROVIDER_WORKSPACE);
|
|
let installed = pnpm(INSTALL_ARGS, { cwd: workspace, env });
|
|
if (installed !== 0) {
|
|
log(
|
|
`typecheck:test-programs: ${PROVIDER_WORKSPACE}/pnpm-lock.yaml does not match the provider manifests. ` +
|
|
`Resolving it in place. Do not commit it: the lockfile refresh workflow updates it after merge.`,
|
|
);
|
|
installed = pnpm(RESOLVE_ARGS, { cwd: workspace, env });
|
|
if (installed === 0) installed = pnpm(INSTALL_ARGS, { cwd: workspace, env });
|
|
}
|
|
if (installed !== 0) {
|
|
logError(`typecheck:test-programs: could not install the sandbox providers in ${PROVIDER_WORKSPACE}`);
|
|
return installed;
|
|
}
|
|
// The install removes the SDK link from each provider's node_modules.
|
|
linkPlugins();
|
|
|
|
let exitCode = 0;
|
|
for (const config of programs) {
|
|
log(`typecheck:test-programs: ${config}`);
|
|
const { status, signal } = run(["-p", config], { cwd: root, env });
|
|
if (signal) return status || 1;
|
|
if (status !== 0) exitCode = status;
|
|
}
|
|
if (!coverage) return exitCode;
|
|
|
|
const unchecked = checkFiles({ root, env, logError });
|
|
if (unchecked === null) return exitCode || 1;
|
|
if (unchecked.length > 0) {
|
|
logError(
|
|
`typecheck:test-programs: ${unchecked.length} test files are in no typechecked program. ` +
|
|
`Add them to a program in scripts/typecheck-test-programs.mjs. A foo.test.tsx beside a foo.test.ts needs a new name.\n` +
|
|
unchecked.map((file) => ` ${file}`).join("\n"),
|
|
);
|
|
return exitCode || 1;
|
|
}
|
|
return exitCode;
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const args = process.argv.slice(2);
|
|
const flags = args.filter((arg) => arg.startsWith("--"));
|
|
const programs = args.filter((arg) => !arg.startsWith("--"));
|
|
const unknownFlags = flags.filter((flag) => flag !== "--low-memory");
|
|
const unknown = programs.filter((program) => !PROGRAMS.includes(program));
|
|
if (unknownFlags.length > 0) {
|
|
console.error(`Unknown option: ${unknownFlags.join(", ")}`);
|
|
process.exitCode = 2;
|
|
} else if (unknown.length > 0) {
|
|
console.error(`Unknown program: ${unknown.join(", ")}. Known programs:\n ${PROGRAMS.join("\n ")}`);
|
|
process.exitCode = 2;
|
|
} else {
|
|
process.exitCode = typecheckTestPrograms({
|
|
root: path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."),
|
|
env: flags.includes("--low-memory") ? { ...process.env, [LOW_MEMORY_VARIABLE]: "1" } : process.env,
|
|
programs: programs.length > 0 ? programs : PROGRAMS,
|
|
coverage: programs.length === 0,
|
|
});
|
|
}
|
|
}
|