fix(release): reuse publisher lockfile for canary smoke (#15712)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Every master push publishes an exact canary and tests its onboarding
flow.
> - The publisher resolves dependencies before building, while the smoke
job checks out the committed lockfile again.
> - Patch or manifest changes can land before the separate lockfile
refresh, so the smoke fails before testing the published app.
> - This pull request passes the publisher's resolved lockfile to the
same-source smoke job.
> - The smoke keeps frozen dependency validation and tests the exact
published canary version.

## Linked Issues or Issue Description

Refs #10393, #13689. Related work: #13690 documents lockfile drift;
#12115 addresses refresh-PR check triggering. This change addresses the
canary smoke handoff. #15678 only refreshed the opencode importer.
#15164 incidentally corrected the earlier patch hash on master.

**What happened?**

Canary publication succeeded, but the fresh onboarding smoke install
failed with `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` on sources `0b80ea17ef`
and `ab8dcab809`. The committed `acpx@0.13.1` hash no longer matched its
patch. A later run on `6080daf262` failed with
`ERR_PNPM_OUTDATED_LOCKFILE` after an `opencode-ai` manifest addition.
The current master lockfile has since caught up; the workflow still
permits this race on future changes.

**Expected behavior**

The exact published canary reaches the onboarding smoke with test
dependencies resolved for its source. A mismatched or missing lockfile
must fail validation.

**Steps to reproduce**

Check out either affected source and run `pnpm install
--frozen-lockfile` with pnpm 9.15.4. The smoke fails before a browser
starts, although the publisher already installed that source with a
resolved lockfile.

## What Changed

- Pin both canary publisher and onboarding smoke checkouts to the
triggering SHA.
- Save the publisher's resolved lockfile before restoring tracked files
or rewriting release versions.
- Restore that same-run, source-named artifact before the unchanged
frozen smoke install.
- Add offline real-pnpm regressions for patch-hash and manifest drift,
plus wrong-source rejection and workflow binding checks.
- Document artifact retention and job reruns. No manifest or committed
lockfile changes.

## Verification

- Focused release workflow, exact-source verification and image-contract
tests: 45 passed.
- Negative control: both new real-pnpm fixture cases fail against the
original workflow with their respective original lockfile errors; both
pass with the handoff.
- `pnpm install --frozen-lockfile` passed on the current source.
- `pnpm -r typecheck` and `pnpm build` passed after reclaiming
disposable build-cache space.
- The browser onboarding smoke passed against exact published
`2026.1009.0-canary.7`: 1 test passed using a fresh temporary instance.
- Full local `pnpm test:run` was attempted twice. The first hit disk
exhaustion; the second reported one chat integration failure and then a
Vitest worker `ENOTCONN` crash before completing. This is not a
full-suite pass. The named chat case passed in isolation without
changes. All full sharded PR CI checks passed on the final head.
- Independent source review and a clean-checkout run also passed all 45
focused tests, with separate real-pnpm negative controls.
- The supply-chain scan is neutral because managed intelligence could
not scan the pinned official `download-artifact` action. It reported no
defect; independent review inspected the action source, same-run scope
and v4/v4 compatibility.
- Reviewed the diff for secrets, personal data and generated artifacts;
none found.

## Risks

- The smoke now depends on a same-run GitHub artifact retained for 14
days. A missing artifact intentionally fails instead of resolving a
different dependency set.
- A publisher rerun replaces its source-named artifact; a smoke-only
rerun reuses the retained artifact.
- Frozen validation and the exact published version remain required. The
source app and publication logic do not change.

## Model Used

OpenAI GPT-6 through Codex, with tool execution and parallel independent
review. The exact serving variant and context-window size are not
exposed in this session.

## 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

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-10-09 17:14:32 -07:00
1 parent ce024457f3
commit 23b9aaa8e6
3 files changed
+130 -1

No files matched your search

@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
@@ -170,6 +171,99 @@ test("published canaries are gated by the exact-version onboarding browser smoke
assert.match(releaseWorkflow, /tests\/canary-onboarding\/playwright-report/);
});
test("canary smoke consumes its publisher's source-bound lockfile before frozen installation", () => {
const workflow = readWorkflow("release.yml");
const publish = workflow.split(" publish_canary:\n")[1].split(" smoke_canary_onboarding:\n")[0];
const smoke = workflow.split(" smoke_canary_onboarding:\n")[1].split(" # ----- Nightly lane")[0];
for (const job of [publish, smoke]) {
assert.match(job, /name: Checkout repository\n\s+uses: actions\/checkout@[^\n]+\n\s+with:\n\s+ref: \$\{\{ github\.sha \}\}/);
assert.match(job, /name: canary-smoke-lockfile-\$\{\{ github\.sha \}\}/);
assert.match(job, /version: 9\.15\.4/);
}
assert.match(publish, /name: Save canary smoke lockfile\n\s+uses: actions\/upload-artifact@[0-9a-f]{40} # v4\n\s+with:\n\s+name: canary-smoke-lockfile-\$\{\{ github\.sha \}\}\n\s+path: pnpm-lock\.yaml\n\s+if-no-files-found: error\n\s+overwrite: true\n\s+retention-days: 14/);
assert.ok(publish.indexOf("name: Install dependencies") < publish.indexOf("name: Save canary smoke lockfile"));
assert.ok(publish.indexOf("name: Save canary smoke lockfile") < publish.indexOf("name: Restore tracked install-time changes"));
assert.ok(publish.indexOf("name: Restore tracked install-time changes") < publish.indexOf("name: Publish canary"));
assert.match(smoke, /name: Restore canary smoke lockfile\n\s+uses: actions\/download-artifact@[0-9a-f]{40} # v4\n\s+with:\n\s+name: canary-smoke-lockfile-\$\{\{ github\.sha \}\}/);
assert.ok(smoke.indexOf("name: Restore canary smoke lockfile") < smoke.indexOf("name: Install test dependencies"));
assert.doesNotMatch(smoke, /run-id:|github-token:|repository:|continue-on-error|--no-frozen-lockfile/);
});
for (const drift of ["patch", "manifest"]) {
test(`a publisher lockfile lets a fresh smoke install retain validation after ${drift} drift`, (t) => {
const root = mkdtempSync(path.join(os.tmpdir(), "canary-smoke-lockfile-test-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const source = path.join(root, "package");
mkdirSync(source);
writeFileSync(path.join(source, "package.json"), JSON.stringify({ name: "smoke-fixture-dependency", version: "1.0.0" }));
writeFileSync(path.join(source, "index.js"), 'module.exports = "original";\n');
const pack = spawnSync("npm", ["pack", "--ignore-scripts", "--pack-destination", root], { cwd: source, encoding: "utf8" });
assert.equal(pack.status, 0, pack.stderr);
const manifest = {
name: "canary-smoke-fixture", private: true,
packageManager: "pnpm@9.15.4",
dependencies: { "smoke-fixture-dependency": "file:smoke-fixture-dependency-1.0.0.tgz" },
pnpm: { patchedDependencies: { "smoke-fixture-dependency@1.0.0": "fixture.patch" } },
};
writeFileSync(path.join(root, "package.json"), JSON.stringify(manifest));
writeFileSync(path.join(root, "pnpm-workspace.yaml"), "packages: []\n");
const patch = (value) => writeFileSync(path.join(root, "fixture.patch"), [
"diff --git a/index.js b/index.js", "index 1111111..2222222 100644",
"--- a/index.js", "+++ b/index.js", "@@ -1 +1 @@",
'-module.exports = "original";', `+module.exports = "${value}";`, "",
].join("\n"));
const workflow = readWorkflow("release.yml");
const publish = workflow.split(" publish_canary:\n")[1].split(" smoke_canary_onboarding:\n")[0];
const smoke = workflow.split(" smoke_canary_onboarding:\n")[1].split(" # ----- Nightly lane")[0];
const installArgs = (name) => {
const job = name === "Install dependencies" ? publish : smoke;
const command = job.match(new RegExp(`name: ${name}\\n\\s+run: (pnpm install[^\\n]+)`))?.[1];
assert.ok(command, `missing ${name}`);
return [...command.split(" ").slice(1), "--offline", "--ignore-scripts"];
};
const install = (args) => spawnSync("pnpm", args, {
cwd: root, encoding: "utf8", env: { ...process.env, CI: "true" },
});
const success = (result) => assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
patch(drift === "patch" ? "old-patch" : "published-patch");
success(install(installArgs("Install dependencies")));
const staleLock = readFileSync(path.join(root, "pnpm-lock.yaml"));
if (drift === "patch") {
patch("published-patch");
} else {
copyFileSync(path.join(root, "smoke-fixture-dependency-1.0.0.tgz"), path.join(root, "updated-dependency.tgz"));
manifest.dependencies["smoke-fixture-dependency"] = "file:updated-dependency.tgz";
writeFileSync(path.join(root, "package.json"), JSON.stringify(manifest));
}
const before = install(installArgs("Install test dependencies"));
assert.notEqual(before.status, 0);
assert.match(before.stdout + before.stderr, drift === "patch" ? /ERR_PNPM_LOCKFILE_CONFIG_MISMATCH/ : /ERR_PNPM_OUTDATED_LOCKFILE/);
// Capture the publisher's resolution, then reproduce its tracked-file restore
// and a fresh smoke runner with no installed workspace dependencies.
success(install(installArgs("Install dependencies")));
const artifact = path.join(root, "publisher-lock.yaml");
if (publish.includes("name: Save canary smoke lockfile")) {
copyFileSync(path.join(root, "pnpm-lock.yaml"), artifact);
}
writeFileSync(path.join(root, "pnpm-lock.yaml"), staleLock);
rmSync(path.join(root, "node_modules"), { recursive: true, force: true });
if (smoke.includes("name: Restore canary smoke lockfile")) {
copyFileSync(artifact, path.join(root, "pnpm-lock.yaml"));
}
success(install(installArgs("Install test dependencies")));
assert.equal(readFileSync(path.join(root, "node_modules/smoke-fixture-dependency/index.js"), "utf8"), 'module.exports = "published-patch";\n');
assert.deepEqual(readFileSync(path.join(root, "pnpm-lock.yaml")), readFileSync(artifact));
// A lockfile from the wrong source still fails closed; restoring an artifact
// must not disable hash validation or resolve another version in the smoke job.
patch("different-source");
const wrongSource = install(installArgs("Install test dependencies"));
assert.notEqual(wrongSource.status, 0);
assert.match(wrongSource.stdout + wrongSource.stderr, /ERR_PNPM_LOCKFILE_CONFIG_MISMATCH/);
});
}
test("every lane's tag push degrades to recovery instructions when rejected", () => {
const releaseWorkflow = readWorkflow("release.yml");