Files
PaperClipAI/.github/scripts/tests/lockfile-refresh-merge.test.mjs
T
DottaandClaude Opus 5.5 e2cf9a670f fix(runner-e2e): resync SDK public lock pins and make the lockfile bot keep them current (#20623)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Runner support suite proves the plugin SDK public types from
pinned sources in `tests/runner-e2e/source-sdk-public-inputs.json`
> - That registry pins the committed `pnpm-lock.yaml` blob and a
`lockSha256` that must equal the `outputSha256` in
`tests/runner-e2e/report-lock.json`
> - The lockfile bot refresh #20615 changed the lock and
`report-lock.json`, but the bot does not write the SDK registry
> - The bot merges without CI, so the drift guard from #20579 could not
block it, and every PR now fails `ci / Runner support suite`
> - The guard also checked only the source blobs, so it did not name the
`lockSha256` drift, and the sync script could not update `lockSha256`
> - This pull request regenerates the registry, derives `lockSha256`
from the reviewed policy, and makes the bot resync and check the
registry in its own commit
> - The benefit is that master CI is green again, and the next lockfile
refresh cannot leave the SDK proof stale

## Linked Issues or Issue Description

Refs #20615. Refs #20579. Refs #20550. Refs #20613. Overlaps #20618,
which refreshes only the registry data. This PR has the same data change
and also closes the gap that let the drift land.

**What happened?**

After #20615 merged, `ci / Runner support suite` failed on master and on
every PR (for example runs 42877750315 and 42877750957, and PR #20613).
`source-sdk-public-type.test.ts` and `source-sdk-evidence.test.ts`
failed with "Reviewed SDK reference policy does not match public proof".
`source-sdk-public-inputs.test.mjs` failed with "pnpm-lock.yaml
(changed)".

**Expected behavior**

A lockfile refresh keeps the SDK public proof registry in step with the
lock and the reviewed report-lock policy. If the registry drifts, the
failure names the stale pin and the fix command.

**Steps to reproduce**

1. Check out master at `3bf7af6c77`.
2. Run `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
source-sdk`.
3. See the "Reviewed SDK reference policy does not match public proof"
failure.

## What Changed

- Root cause: #20615 changed `report-lock.json` from output `2360d5e9…`
to `b3208306…` and changed the `pnpm-lock.yaml` blob from `851e65af…` to
`6d825f97…`. The registry kept the old values. `refresh-lockfile.yml`
commits only the lockfiles and the report-lock proof, and it merges
without CI.
- `source-sdk-public-inputs.json`: regenerated with `node
tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --write`. Only
`inputs["pnpm-lock.yaml"]` and `lockSha256` change. The compiler, parser
and Zod pins do not change.
- `source-sdk-public-inputs.mjs`: the sync now copies `lockSha256` from
the committed `report-lock.json` `outputSha256`. It refuses when the
committed lock is not that policy's reviewed input or output. This is
the same rule that `prepareReviewedSdkReference` applies. Only the
lockfile bot can write that policy, so a contributor cannot choose
`lockSha256`.
- `staleSdkPublicInputs()`: also reports a `lockSha256` that differs
from the committed report-lock output. The guard test then names both
stale pins and the `--write` command.
- `source-sdk-reviewed-fixture.ts`: the "Reviewed SDK reference policy
does not match public proof" error now names the two values and the fix
command. The check itself is unchanged.
- `refresh-lockfile.yml`: after the lock commit, the bot runs the SDK
sync with `--write`, amends the commit, and runs `--check` before it
pushes. `head_sha` is still read after the amend, so the bot merges the
exact commit it checked.
- Tests: new sync tests for a lockfile refresh and for an unreviewed
lock, and a workflow-order test in `lockfile-refresh-merge.test.mjs`.
- Docs: `doc/RELEASE-AUTOMATION-SETUP.md` §8.1 and
`tests/runner-e2e/OPERATIONS.md` describe the resync.

## Verification

- Reproduced on master: `source-sdk-public-type.test.ts` and
`source-sdk-evidence.test.ts` failed with the policy mismatch.
- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
source-sdk prepare-report-lock sdk-canary workflow-security`: 9 files,
135 tests pass.
- Before the registry change, the extended guard reported
`pnpm-lock.yaml (changed), tests/runner-e2e/report-lock.json
outputSha256 (lockSha256 changed)` with the fix command.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs $(git rev-parse
HEAD) --check` reports `changed: false`.
- `node --test .github/scripts/tests/lockfile-refresh-merge.test.mjs
.github/scripts/tests/lockfile-refresh-workflows.test.mjs
.github/scripts/tests/lockfile-trust.test.mjs`: 39 tests pass.
- `pnpm test:e2e:runner:unit` on a host with load near 28: 6 files timed
out, and none of them are the SDK proof files. All 15 of those files
pass when run alone. CI runs the full suite.

## Risks

- The bot step now runs one more repository script. If that script
refuses (for example, an unreviewed committed lock), the refresh PR is
not pushed. Before this change, the bot merged a refresh that made
master red.
- `lockSha256` now comes from the reviewed policy, not from the old
registry value. The proof check is unchanged. It still needs the lock
bytes to hash to `lockSha256`.
- If #20618 merges first, this branch rebases cleanly, because the
registry change is identical.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edit) and code execution.

## 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>
2026-11-11 06:57:51 -08:00

188 lines
11 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { LOCKFILES } from "../lockfile-trust.mjs";
const workflow = readFileSync(new URL("../../workflows/refresh-lockfile.yml", import.meta.url), "utf8");
const stepNames = [...workflow.matchAll(/^ - name: (.+)$/gm)].map((match) => match[1]);
const step = (name) => {
const body = workflow.split(` - name: ${name}\n`)[1]?.split(" - name:")[0];
assert.ok(body, `the refresh workflow must have a "${name}" step`);
return body;
};
const condition = (name) => step(name).match(/^ if: (.+)$/m)?.[1].trim();
test("the bot key is only reachable from the master-only lockfile-refresh environment", () => {
assert.match(workflow, /^ environment: lockfile-refresh$/m);
const keyUses = workflow.match(/secrets\.LOCKFILE_BOT_PRIVATE_KEY/g) ?? [];
assert.equal(keyUses.length, 1, "only the token step may read the bot key");
assert.match(step("Create lockfile bot token"), /private-key: \$\{\{ secrets\.LOCKFILE_BOT_PRIVATE_KEY \}\}/);
});
test("the bot token is minted only after resolution and the release-age gate", () => {
const order = (name) => {
const index = stepNames.indexOf(name);
assert.notEqual(index, -1, `missing step "${name}"`);
return index;
};
assert.ok(order("Refresh pnpm lockfile") < order("Check release age of added package versions"));
assert.ok(order("Check release age of added package versions") < order("Create lockfile bot token"));
assert.ok(order("Create lockfile bot token") < order("Merge lockfile PR"));
});
test("the gate runs only for an open PR, and the bot acts only when the gate passes", () => {
assert.equal(condition("Check release age of added package versions"), "steps.upsert-pr.outputs.pr_url != ''");
assert.equal(condition("Hold lockfile PR for review"), "steps.release-age.outputs.eligible == 'false'");
assert.equal(condition("Create lockfile bot token"), "steps.release-age.outputs.eligible == 'true'");
assert.equal(condition("Merge lockfile PR"), "steps.release-age.outputs.eligible == 'true'");
});
test("a held refresh keeps its versions through the next master run's artifact", () => {
assert.ok(stepNames.indexOf("Reuse versions from a held refresh") < stepNames.indexOf("Refresh pnpm lockfile"));
const reuse = step("Reuse versions from a held refresh");
assert.match(reuse, /gh run list --workflow refresh-lockfile\.yml --branch master --status success/);
assert.match(reuse, /gh run download "\$run_id" --name held-pnpm-lock/);
assert.match(
reuse,
/if ! sha256sum --check --status "\$held\/source-locks\.sha256"; then[\s\S]*?exit 0\n fi\n for lock in \$LOCKFILES; do\n cp "\$held\/\$lock" "\$lock"/,
"held locks must only be reused while master still has every lock they were resolved from",
);
assert.match(reuse, /if \[ ! -f "\$held\/source-locks\.sha256" \]; then[\s\S]*?exit 0/, "an older held artifact must not be reused");
assert.doesNotMatch(reuse, /chore\/refresh-lockfile/, "the PR branch is writable by others and must not seed resolution");
for (const name of ["Stage held lockfile", "Save held lockfile for the next refresh"]) {
assert.equal(condition(name), "steps.release-age.outputs.eligible == 'false'");
}
const stage = step("Stage held lockfile");
assert.match(stage, /for lock in \$LOCKFILES; do[\s\S]*?cp "\$lock" "\$held\/\$lock"/);
assert.match(stage, /\(cd "\$RUNNER_TEMP\/base-locks" && sha256sum \$LOCKFILES\) > "\$held\/source-locks\.sha256"/);
const save = step("Save held lockfile for the next refresh");
assert.match(save, /name: held-pnpm-lock/);
assert.match(save, /path: \$\{\{ runner\.temp \}\}\/held-upload\//);
});
test("the bot merges only the commit this job pushed", () => {
assert.match(step("Create or update pull request"), /echo "head_sha=\$\(git rev-parse HEAD\)" >> "\$GITHUB_OUTPUT"/);
const merge = step("Merge lockfile PR");
assert.match(merge, /HEAD_SHA: \$\{\{ steps\.upsert-pr\.outputs\.head_sha \}\}/);
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 });
}
});
test("the workflow owns the same lockfiles that the trust policy guards", () => {
const env = workflow.match(/^ LOCKFILES: (.+)$/m)?.[1];
assert.ok(env, "the refresh job must define LOCKFILES");
assert.deepEqual(env.trim().split(/\s+/), LOCKFILES);
assert.match(step("Refresh sandbox provider lockfiles"),
/pnpm --dir scripts\/typecheck-sandbox-providers install --resolution-only --ignore-scripts --no-frozen-lockfile/);
assert.match(step("Refresh sandbox provider lockfiles"),
/pnpm --dir packages\/plugins\/sandbox-providers\/daytona install --ignore-workspace --resolution-only --ignore-scripts --no-frozen-lockfile/);
assert.ok(stepNames.indexOf("Refresh sandbox provider lockfiles") < stepNames.indexOf("Fail on unexpected file changes"));
assert.match(step("Check release age of added package versions"), /for lock in \$LOCKFILES; do[\s\S]*?pairs\+=\(--base "\$base\/\$lock" --head "\$lock"\)/);
});
// Run a workflow step's script with bash, as GitHub would.
const runStep = (name, cwd, env) => {
const lines = step(name).split("\n");
const start = lines.findIndex((line) => line === " run: |") + 1;
const script = lines.slice(start).map((line) => line.slice(10)).join("\n");
return spawnSync("bash", ["-e", "-c", script], { cwd, encoding: "utf8", env: { ...process.env, LOCKFILES: LOCKFILES.join(" "), ...env } });
};
test("unexpected-change check admits every managed lockfile and nothing else", () => {
const root = mkdtempSync(path.join(tmpdir(), "refresh-unexpected-"));
try {
const git = (...args) => execFileSync("git", args, { cwd: root });
for (const file of [...LOCKFILES, "notes.md"]) {
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
writeFileSync(path.join(root, file), "old\n");
}
git("init", "-q");
git("add", ".");
git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "fixture");
for (const file of LOCKFILES) writeFileSync(path.join(root, file), "new\n");
const allowed = runStep("Fail on unexpected file changes", root, {});
assert.equal(allowed.status, 0, allowed.stdout + allowed.stderr);
writeFileSync(path.join(root, "notes.md"), "new\n");
const blocked = runStep("Fail on unexpected file changes", root, {});
assert.equal(blocked.status, 1, blocked.stdout + blocked.stderr);
assert.match(blocked.stdout, /notes\.md/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a held refresh round-trips every managed lockfile", () => {
const root = mkdtempSync(path.join(tmpdir(), "refresh-held-"));
const temp = mkdtempSync(path.join(tmpdir(), "refresh-held-temp-"));
try {
for (const file of LOCKFILES) {
for (const dir of [root, path.join(temp, "base-locks")]) {
mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
writeFileSync(path.join(dir, file), `master ${file}\n`);
}
writeFileSync(path.join(root, file), `held ${file}\n`);
}
const staged = runStep("Stage held lockfile", root, { RUNNER_TEMP: temp });
assert.equal(staged.status, 0, staged.stderr);
const held = path.join(temp, "held-upload");
for (const file of LOCKFILES) assert.equal(readFileSync(path.join(held, file), "utf8"), `held ${file}\n`);
// The next run checks the hashes against master's lockfiles, then copies the held ones in.
for (const file of LOCKFILES) writeFileSync(path.join(root, file), `master ${file}\n`);
const check = `sha256sum --check --status "${held}/source-locks.sha256"`;
assert.equal(spawnSync("bash", ["-c", check], { cwd: root }).status, 0, "unchanged master locks must match the held source hashes");
writeFileSync(path.join(root, LOCKFILES[1]), "changed on master\n");
assert.notEqual(spawnSync("bash", ["-c", check], { cwd: root }).status, 0, "a changed provider lock must invalidate the held refresh");
} finally {
rmSync(root, { recursive: true, force: true });
rmSync(temp, { recursive: true, force: true });
}
});
// The SDK public source proof pins the lock too. The bot merges without CI, so
// a refresh that left those pins stale turned master red (#20615).
test("the refresh commit resyncs and checks the SDK public source pins before pushing", () => {
const upsert = step("Create or update pull request");
const order = [
'git commit -m "chore(lockfile): refresh pnpm lockfiles"',
'node tests/runner-e2e/source-sdk-public-inputs.mjs "$(git rev-parse HEAD)" --write',
"git add tests/runner-e2e/source-sdk-public-inputs.json",
"git commit --amend --no-edit",
'node tests/runner-e2e/source-sdk-public-inputs.mjs "$(git rev-parse HEAD)" --check',
'git push --force origin "$BRANCH"',
'echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"',
].map((line) => upsert.indexOf(line));
assert.ok(order.every((index) => index !== -1), `missing resync command: ${order}`);
assert.deepEqual([...order].sort((a, b) => a - b), order, "resync must follow the lock commit and precede the push and head_sha");
});