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>
286 lines
8.6 KiB
JavaScript
286 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFile, execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
import { availableParallelism } from "node:os";
|
|
import path, { dirname } from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
|
|
import { linkSdkInto } from "./link-plugin-dev-sdk.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(scriptDir, "..");
|
|
const workspacePath = path.join(repoRoot, "pnpm-workspace.yaml");
|
|
const releasePackageMapPath = path.join(repoRoot, "scripts", "release-package-map.mjs");
|
|
|
|
export function parseWorkspaceEntries(workspaceText) {
|
|
// Keep this aligned with the repo's block-sequence `packages:` format in
|
|
// pnpm-workspace.yaml. If that file moves to a more complex YAML shape,
|
|
// switch this parser to a real YAML parser instead of line matching.
|
|
return workspaceText
|
|
.split("\n")
|
|
.map((line) => line.match(/^\s*-\s+(.+)\s*$/)?.[1]?.trim() ?? null)
|
|
.map((entry) => {
|
|
if (!entry) return entry;
|
|
return entry.replace(/^(['"])(.*)\1$/, "$2");
|
|
})
|
|
.filter(Boolean)
|
|
.map((entry) => ({
|
|
pattern: entry.startsWith("!") ? entry.slice(1) : entry,
|
|
negated: entry.startsWith("!"),
|
|
}));
|
|
}
|
|
|
|
function globToRegExp(pattern) {
|
|
let regex = "";
|
|
|
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
const char = pattern[index];
|
|
const next = pattern[index + 1];
|
|
|
|
if (char === "*" && next === "*") {
|
|
regex += ".*";
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (char === "*") {
|
|
regex += "[^/]*";
|
|
continue;
|
|
}
|
|
if (char === "?") {
|
|
regex += "[^/]";
|
|
continue;
|
|
}
|
|
regex += /[|\\{}()[\]^$+?.]/.test(char) ? `\\${char}` : char;
|
|
}
|
|
|
|
return new RegExp(`^${regex}$`);
|
|
}
|
|
|
|
export function isWorkspacePackage(pkgDir, workspaceEntries) {
|
|
let included = false;
|
|
|
|
for (const entry of workspaceEntries) {
|
|
if (globToRegExp(entry.pattern).test(pkgDir)) {
|
|
included = !entry.negated;
|
|
}
|
|
}
|
|
|
|
return included;
|
|
}
|
|
|
|
function listPublicPackages() {
|
|
const output = execFileSync(
|
|
process.execPath,
|
|
[releasePackageMapPath, "list"],
|
|
{ cwd: repoRoot, encoding: "utf8" },
|
|
);
|
|
|
|
return output
|
|
.trim()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [dir, name] = line.split("\t");
|
|
return { dir, name };
|
|
});
|
|
}
|
|
|
|
function readPackageJson(pkgDir) {
|
|
return JSON.parse(
|
|
readFileSync(path.join(repoRoot, pkgDir, "package.json"), "utf8"),
|
|
);
|
|
}
|
|
|
|
async function runCaptured(command, args, cwd, log) {
|
|
try {
|
|
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
cwd,
|
|
env: {
|
|
...process.env,
|
|
CI: "true",
|
|
},
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
if (stdout?.trim()) log(stdout.trimEnd());
|
|
if (stderr?.trim()) log(stderr.trimEnd());
|
|
} catch (error) {
|
|
if (error.stdout?.toString().trim()) log(error.stdout.toString().trimEnd());
|
|
if (error.stderr?.toString().trim()) log(error.stderr.toString().trimEnd());
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const FROZEN_INSTALL_ARGS = ["install", "--ignore-workspace", "--frozen-lockfile"];
|
|
export const RESOLVE_LOCKFILE_ARGS = ["install", "--ignore-workspace", "--resolution-only", "--ignore-scripts", "--no-frozen-lockfile"];
|
|
|
|
/**
|
|
* Install a standalone package. A package with a committed lockfile installs it frozen. Contributors
|
|
* do not commit that lockfile: the lockfile refresh workflow regenerates it after a manifest change
|
|
* merges. Until then it can be stale, so resolve it in place and install frozen again, like the
|
|
* root install. release.sh restores tracked files when it exits.
|
|
*/
|
|
export async function installStandalonePackage({ hasLockfile, lockfile, run, log }) {
|
|
if (!hasLockfile) {
|
|
// Standalone packages intentionally avoid committed lockfile churn in the repo.
|
|
await run(["install", "--ignore-workspace", "--no-lockfile"]);
|
|
return;
|
|
}
|
|
try {
|
|
await run(FROZEN_INSTALL_ARGS);
|
|
return;
|
|
} catch {
|
|
log(` i ${lockfile} does not match its package.json; resolving it in place`);
|
|
}
|
|
await run(RESOLVE_LOCKFILE_ARGS);
|
|
await run(FROZEN_INSTALL_ARGS);
|
|
}
|
|
|
|
// Each standalone package installs into its own directory (`--ignore-workspace`)
|
|
// and builds into its own `dist`, so there is no shared mutable state between
|
|
// packages beyond pnpm's content-addressable global store, which is safe for
|
|
// concurrent access. Buffer each package's output and flush it as one block so
|
|
// interleaved parallel logs stay readable.
|
|
async function prepareAndBuildPackage(pkg) {
|
|
const logs = [];
|
|
const log = (line) => logs.push(line);
|
|
|
|
const pkgDir = path.join(repoRoot, pkg.dir);
|
|
const pkgJson = readPackageJson(pkg.dir);
|
|
const nodeModulesDir = path.join(pkgDir, "node_modules");
|
|
const packageLockfilePath = path.join(pkgDir, "pnpm-lock.yaml");
|
|
|
|
log(` Preparing standalone package ${pkg.name} (${pkg.dir})`);
|
|
try {
|
|
if (existsSync(nodeModulesDir)) {
|
|
rmSync(nodeModulesDir, { force: true, recursive: true });
|
|
}
|
|
|
|
await installStandalonePackage({
|
|
hasLockfile: existsSync(packageLockfilePath),
|
|
lockfile: path.join(pkg.dir, "pnpm-lock.yaml"),
|
|
run: (args) => runCaptured("pnpm", args, pkgDir, log),
|
|
log,
|
|
});
|
|
|
|
// The fresh install above wipes node_modules and no longer fires a
|
|
// per-plugin postinstall (removed for supply-chain safety), so link the
|
|
// in-repo @paperclipai/plugin-sdk that the build's tsc resolves against.
|
|
linkSdkInto(pkgDir);
|
|
|
|
if (pkgJson.scripts?.build) {
|
|
await runCaptured("pnpm", ["run", "build"], pkgDir, log);
|
|
} else {
|
|
log(" i No build script; skipped build");
|
|
}
|
|
} finally {
|
|
if (logs.length > 0) {
|
|
console.log(logs.join("\n"));
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resolveConcurrency(packageCount) {
|
|
const raw = process.env.STANDALONE_BUILD_CONCURRENCY;
|
|
if (raw !== undefined && raw !== "") {
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (Number.isFinite(parsed) && parsed > 0) {
|
|
return Math.max(1, Math.min(parsed, packageCount));
|
|
}
|
|
}
|
|
|
|
let cpus = 4;
|
|
try {
|
|
cpus = availableParallelism();
|
|
} catch {
|
|
// Fall back to the default when parallelism cannot be determined.
|
|
}
|
|
return Math.max(1, Math.min(cpus, packageCount));
|
|
}
|
|
|
|
// Bounded-concurrency task pool. Workers pull from a shared cursor so no more
|
|
// than `limit` tasks run at once. Every task is awaited even if an earlier one
|
|
// fails, and failures are aggregated (sorted by original index) so a single bad
|
|
// package neither aborts the others mid-flight nor hides which one broke.
|
|
export async function runWithConcurrency(items, limit, worker) {
|
|
const results = new Array(items.length);
|
|
const failures = [];
|
|
let nextIndex = 0;
|
|
|
|
async function runWorker() {
|
|
while (true) {
|
|
const current = nextIndex;
|
|
nextIndex += 1;
|
|
if (current >= items.length) {
|
|
return;
|
|
}
|
|
try {
|
|
results[current] = await worker(items[current], current);
|
|
} catch (error) {
|
|
failures.push({ index: current, error });
|
|
}
|
|
}
|
|
}
|
|
|
|
const poolSize = Math.max(1, Math.min(limit, items.length));
|
|
await Promise.all(Array.from({ length: poolSize }, () => runWorker()));
|
|
|
|
if (failures.length > 0) {
|
|
failures.sort((a, b) => a.index - b.index);
|
|
const aggregate = new Error(
|
|
`${failures.length} standalone package build(s) failed`,
|
|
);
|
|
aggregate.failures = failures;
|
|
throw aggregate;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async function main() {
|
|
const workspaceEntries = parseWorkspaceEntries(readFileSync(workspacePath, "utf8"));
|
|
const standalonePackages = listPublicPackages()
|
|
.filter(({ dir }) => !isWorkspacePackage(dir, workspaceEntries));
|
|
|
|
if (standalonePackages.length === 0) {
|
|
console.log(" i No standalone public packages detected outside the pnpm workspace");
|
|
return;
|
|
}
|
|
|
|
const concurrency = resolveConcurrency(standalonePackages.length);
|
|
console.log(
|
|
` Building ${standalonePackages.length} standalone package(s) with concurrency ${concurrency}`,
|
|
);
|
|
|
|
try {
|
|
await runWithConcurrency(
|
|
standalonePackages,
|
|
concurrency,
|
|
prepareAndBuildPackage,
|
|
);
|
|
} catch (error) {
|
|
if (Array.isArray(error.failures)) {
|
|
const names = error.failures
|
|
.map(({ index }) => standalonePackages[index]?.name)
|
|
.filter(Boolean)
|
|
.join(", ");
|
|
console.error(` ✗ Failed standalone package build(s): ${names}`);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const invokedDirectly =
|
|
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
|
|
if (invokedDirectly) {
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
}
|