Files
paperclip/scripts/preview-artifacts.test.mjs
Devin FoleyandPaperclip 8ee8f1fd6e ci: retire recurring public cloud image builds (#13827)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Core publishes standard images and source verification for
downstream services.
> - Managed services can now compose private images from the signed
standard image.
> - Core still builds a second public cloud image on every master push
and release.
> - That duplicate producer consumes build capacity and retains an
obsolete readiness contract.
> - This pull request retires recurring cloud publication while
preserving the standard producer and rollback artifacts.

## Linked Issues or Issue Description

Refs #13797 and #13789. Related: #12856 changes image dependency
packaging; it does not retire this producer.

**What existing behavior does this improve?**

Core's recurring Docker publication and Cloud readiness workflow.

**Current behavior**

Master pushes call the legacy cloud publisher from Cloud readiness.
Release tags and manual Docker runs call it too. Canary promotion also
requires the legacy image.

**Proposed behavior**

Publish standard Core images and retain `Cloud source verified v1`. Let
downstream services build their managed image. Keep explicit commit
previews and existing images available.

## What Changed

- Remove `docker-cloud.yml`, its master and release callers, and its
unused cache selector.
- Remove the legacy image/migrator wait and `Cloud deployable v1` job.
Keep the full source verification workflow and exact source-proof name.
- Make canary promotion inspect and promote the standard image only.
- Preserve signed standard-image publication, direct migrator
publication, and explicit `release.yml` previews. The preview path still
uses the Dockerfile `cloud` target.
- Update workflow, preview, build-stamp, and packaging tests. Exercise
the promotion shell with mocked registry commands, including
missing-image and missing-tag cases.
- Document frozen legacy aliases, consumer requirements, preview
compatibility, and rollback retention.

## Verification

- All 377 workflow tests pass: `node --test
.github/scripts/tests/*.test.mjs`.
- All 129 release-registry tests pass: `pnpm test:release-registry`.
- Focused source-proof, standard-image, preview, and workflow tests
pass: 256 tests.
- Focused image packaging/build-stamp tests pass: 16 tests.
- Actionlint passes on all three changed workflow files. `git diff
--check` passes.
- Full local `pnpm build` and `pnpm -r typecheck` pass.
- The policy follow-up updates an old assertion that required the
removed readiness job. All 37 source-proof/release-workflow tests pass
locally.
- Full local `pnpm test:run` did not complete successfully while the Mac
ran out of disk space. No full-suite pass is claimed. Removed 1.2 GiB of
generated Cargo output from this isolated worktree with `cargo clean`.
GitHub CI passed on the final head: 52 successful checks and 2 optional
skips.
- Fresh Greptile review for `4f5fe1951f0bd7f7739cf6655d395ff78f1ed944`:
**5/5**, successful current-head check, zero review threads.
- September 23 refresh: the unchanged PR head merges cleanly with
current master `db8f8fe5b73a2697684a30261b0d306a9c631aba`. In an
isolated temporary worktree, all 377 workflow tests and 29
release/preview tests pass on the combined tree. `git diff --cached
--check` passes.
- Refreshed Actionlint workflow validation passes with ShellCheck
disabled. Full Actionlint reports the same 10 existing ShellCheck
diagnostics as master, with no added diagnostics. No source changes or
new PR commits were needed.
- The full local build/typecheck and current-head Linux CI results above
remain the verification for the unchanged PR head. They were not rerun
for this metadata-only refresh. No image publication or tenant
deployment was initiated for this refresh.

## Risks

**Deployment prerequisite satisfied (September 23):** The combined
cleanup release is deployed to staging and production, and production
Support is verified. Active managed-fleet automation uses standard-image
composition. Explicit immutable previews remain supported by the
retained preview publisher. This PR is ready for maintainer review; keep
auto-merge disabled and wait for explicit merge authorization.

- A consumer still selecting `Cloud deployable v1` will stop advancing
at the last legacy-ready commit. Confirm active automatic consumers use
the standard-image composition contract before merge.
- Legacy cloud release-channel aliases stop advancing. Standard
self-hosted aliases continue.
- This PR deletes no registry images, cache tags, migrators,
credentials, or runner infrastructure. Existing immutable releases
remain usable for rollback.
- Explicit legacy previews remain for commit-specific operator
deployments. Retiring that compatibility path requires a separate
consumer migration.
- These changes affect CI publication, not database schema or
application behavior.

## Model Used

OpenAI Codex, GPT-6. The runtime does not expose a more specific model
identifier or context-window size. Used repository inspection,
reasoning, code editing, shell tools, and test 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-23 19:35:19 -07:00

223 lines
13 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { planArtifacts } from "./preview-artifacts.mjs";
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { gzipSync } from "node:zlib";
import { previewManifest, assertMetadata, validateRequest, versionFor, tarManifest, packageExists, imageExists, publishPreview, publishImage } from "./preview-artifacts.mjs";
const sha = "a".repeat(40);
const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const manifest = (name) => previewManifest({ name, version: "0.0.0", dependencies: name.endsWith("/db") ? { "@paperclipai/shared": "workspace:*" } : {}, publishConfig: { exports: { ".": "./dist/index.js" } } }, sha);
function pack(pkg) {
const b = Buffer.from(JSON.stringify(pkg)); const h = Buffer.alloc(512);
h.write("package/package.json"); h.write(b.length.toString(8).padStart(11, "0"), 124, 11); h[156] = 48;
const padded = Buffer.alloc(Math.ceil(b.length / 512) * 512); b.copy(padded);
return gzipSync(Buffer.concat([h, padded, Buffer.alloc(1024)]));
}
const json = (body, status = 200) => new Response(JSON.stringify(body), { status });
test("preview request requires immutable SHA and correlation UUID", () => {
validateRequest(sha, id);
for (const ref of ["master", "origin/master", "a".repeat(7), "$(unsafe)", "A".repeat(40)]) assert.throws(() => versionFor(ref));
assert.throws(() => validateRequest(sha, "not-a-request"));
});
test("migrator-only planning never waits for GHCR and reuses complete exact-source packages", async () => {
for (const available of [[], ["@paperclipai/shared"], ["@paperclipai/shared", "@paperclipai/db"]]) {
const calls = [];
const result = await planArtifacts(sha, { image: false, migrator: true, fetchImpl: async (url) => {
assert.equal(new URL(url).hostname, "registry.npmjs.org");
const name = decodeURIComponent(new URL(url).pathname.split("/")[1]);
calls.push(name);
return available.includes(name) ? json({ ...manifest(name), dist: { integrity: "test-integrity", tarball: "https://registry.npmjs.org/package.tgz" } }) : json({}, 404);
} });
assert.deepEqual(result, { image: false, packages: available.length !== 2 });
assert.ok(calls.includes("@paperclipai/shared"));
if (available.length) assert.ok(calls.includes("@paperclipai/db"));
}
});
test("migrator-only planning rejects registry outages and mismatched source identity", async () => {
for (const response of [json({}, 403), json({}, 503), json({ ...manifest("@paperclipai/shared"), gitHead: "b".repeat(40) })]) {
await assert.rejects(planArtifacts(sha, { image: false, migrator: true, fetchImpl: async () => response }));
}
});
test("ordinary preview planning still requests a missing image without publishing unsolicited packages", async () => {
const result = await planArtifacts(sha, { fetchImpl: async (url) => {
assert.equal(new URL(url).hostname, "ghcr.io");
return url.includes("/token?") ? json({ token: "test-pull-token" }) : json({}, 404);
} });
assert.deepEqual(result, { image: true, packages: false });
});
test("preview manifests carry exact source, isolated versions and shared dependency", () => {
const pkg = manifest("@paperclipai/db");
assert.equal(pkg.version, `0.0.0-preview.g${sha}`);
assert.equal(pkg.dependencies["@paperclipai/shared"], pkg.version);
assert.deepEqual(pkg.exports, { ".": "./dist/index.js" });
assertMetadata(pkg, "@paperclipai/db", sha);
assert.throws(() => assertMetadata({ ...pkg, gitHead: "b".repeat(40) }, pkg.name, sha));
assert.throws(() => assertMetadata({ ...pkg, dependencies: { "@paperclipai/shared": "latest" } }, pkg.name, sha));
assert.deepEqual(tarManifest(pack(pkg)), pkg);
});
test("only 404 means an artifact is missing; auth and outages are fatal", async () => {
assert.equal(await packageExists("@paperclipai/db", sha, async () => json({}, 404)), false);
await assert.rejects(packageExists("@paperclipai/db", sha, async () => json({}, 403)));
await assert.rejects(packageExists("@paperclipai/db", sha, async () => json({}, 503)));
await assert.rejects(imageExists(sha, async () => json({}, 503)));
assert.equal(await imageExists(sha, async (url) => url.includes("/token?") ? json({ token: "test-pull-token" }) : json({}, 404)), false);
await assert.rejects(packageExists("@paperclipai/db", sha, async () => json({ ...manifest("@paperclipai/db"), gitHead: "b".repeat(40) })));
});
test("publishing reuses existing previews and never executes package lifecycle hooks", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "preview-publish-test-"));
const published = new Set(["@paperclipai/shared"]);
const calls = [];
try {
for (const short of ["shared", "db"]) writeFileSync(path.join(dir, `${short}.tgz`), pack({ ...manifest(`@paperclipai/${short}`), scripts: { prepublishOnly: "do-not-run" } }));
await publishPreview(dir, sha, {
fetchImpl: async (url) => {
const name = decodeURIComponent(new URL(url).pathname.split("/")[1]);
return published.has(name) ? json({ ...manifest(name), dist: { integrity: "test-integrity", tarball: "https://registry.npmjs.org/package.tgz" } }) : json({}, 404);
},
exec: (command, args) => { calls.push({ command, args }); published.add("@paperclipai/db"); },
sleep: async () => {},
});
assert.equal(calls.length, 1);
assert.equal(calls[0].command, "npm");
assert.ok(calls[0].args.includes("--ignore-scripts"));
assert.equal(calls[0].args[calls[0].args.indexOf("--tag") + 1], "preview");
assert.ok(!calls[0].args.includes("canary"));
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("publishing submits both packages before waiting for either to propagate", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "preview-publish-overlap-"));
const submitted = [];
let polls = 0;
try {
for (const short of ["shared", "db"]) writeFileSync(path.join(dir, `${short}.tgz`), pack(manifest(`@paperclipai/${short}`)));
await publishPreview(dir, sha, {
exec: (_command, args) => submitted.push(path.basename(args[1], ".tgz")),
fetchImpl: async (url) => {
const name = decodeURIComponent(new URL(url).pathname.split("/")[1]);
// Both packages become visible after the first shared visibility wait.
return submitted.length === 2 && polls > 0
? json({ ...manifest(name), dist: { integrity: "test-integrity", tarball: "https://registry.npmjs.org/package.tgz" } })
: json({}, 404);
},
sleep: async () => { assert.deepEqual(submitted, ["shared", "db"]); polls++; },
});
assert.deepEqual(submitted, ["shared", "db"]);
assert.equal(polls, 1);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("a visibility timeout identifies the missing package after both were submitted", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "preview-publish-timeout-"));
const submitted = [];
try {
for (const short of ["shared", "db"]) writeFileSync(path.join(dir, `${short}.tgz`), pack(manifest(`@paperclipai/${short}`)));
await assert.rejects(publishPreview(dir, sha, {
exec: (_command, args) => submitted.push(path.basename(args[1], ".tgz")),
fetchImpl: async (url) => {
const name = decodeURIComponent(new URL(url).pathname.split("/")[1]);
return name === "@paperclipai/db" && submitted.includes("db")
? json({ ...manifest(name), dist: { integrity: "test-integrity", tarball: "https://registry.npmjs.org/package.tgz" } })
: json({}, 404);
},
sleep: async () => {},
}), /not yet visible: @paperclipai\/shared\./);
assert.deepEqual(submitted, ["shared", "db"]);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("invalid DB package metadata prevents publication of either package", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "preview-publish-invalid-"));
try {
writeFileSync(path.join(dir, "shared.tgz"), pack(manifest("@paperclipai/shared")));
writeFileSync(path.join(dir, "db.tgz"), pack({ ...manifest("@paperclipai/db"), gitHead: "b".repeat(40) }));
await assert.rejects(publishPreview(dir, sha, {
exec: () => assert.fail("Invalid package pairs must not be published"),
fetchImpl: async () => assert.fail("Validate the pair before registry requests"),
}), /identity or dependency pin mismatch/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("preview workflow separates branch compilation from trusted publishing", () => {
const workflow = readFileSync(new URL("../.github/workflows/release.yml", import.meta.url), "utf8");
const builder = workflow.split(" package_preview:")[1].split(" publish_preview:")[0];
const publisher = workflow.split(" publish_preview:")[1].split(" image_preview:")[0];
const image = workflow.split(" image_preview:")[1].split(" publish_image_preview:")[0];
const imagePublisher = workflow.split(" publish_image_preview:")[1].split(" result_preview:")[0];
assert.doesNotMatch(builder, /id-token: write|packages: write|secrets\./);
assert.doesNotMatch(publisher, /ref: \$\{\{ inputs.source_ref|working-directory: source|pnpm install/);
assert.match(publisher, /environment: npm-canary/);
assert.match(image, /PAPERCLIP_BUILD_COMMIT=\$\{\{ inputs.source_ref \}\}/);
assert.doesNotMatch(image, /cache-(?:to|from):|canary-cloud|latest-cloud|packages: write|secrets\./);
assert.doesNotMatch(imagePublisher, /ref: \$\{\{ inputs.source_ref|docker\/build-push-action|pnpm install/);
assert.match(imagePublisher, /publish-image/);
assert.match(imagePublisher, /environment: npm-canary/);
assert.match(imagePublisher, /github.ref == 'refs\/heads\/master'/);
assert.match(publisher, /github.ref == 'refs\/heads\/master'/);
assert.doesNotMatch(workflow.split(" verify_canary:")[0], /uses: [^\n]+@v\d/);
assert.match(workflow, /Stack deploy \{0\} build/);
});
test("manual migrator and branch preview retain their npm publisher and concurrency", () => {
const release = readFileSync(new URL("../.github/workflows/release.yml", import.meta.url), "utf8");
assert.match(release, /\(inputs.channel == 'preview' \|\| inputs.channel == 'cloud-migrator'\) && format\('\{0\}-\{1\}', inputs.channel, inputs.source_ref\)/);
const publisher = release.split(" publish_preview:")[1].split(" image_preview:")[0];
assert.match(publisher, /group: preview-package-publish-\$\{\{ inputs.source_ref \}\}/);
assert.match(publisher, /cancel-in-progress: false/);
assert.match(release, /PLAN_COMMAND: \$\{\{ inputs.channel == 'cloud-migrator' && 'plan-migrator' \|\| 'plan' \}\}/);
const result = release.split(" result_preview:")[1].split(" verify_canary:")[0];
assert.match(result, /always\(\) && inputs.channel == 'preview'/);
});
test("existing image reuse verifies the full revision behind the immutable tag", async () => {
const digest = "sha256:" + "b".repeat(64);
for (const revision of [sha, "c".repeat(40)]) {
const fetchImpl = async (url) => url.includes("/token?") ? json({ token: "test-pull-token" }) :
url.includes("/blobs/") ? json({ config: { Labels: { "org.opencontainers.image.revision": revision } } }) :
url.endsWith(digest) ? json({ config: { digest } }) : json({ manifests: [{ digest, platform: { os: "linux", architecture: "amd64" } }] });
if (revision === sha) assert.equal(await imageExists(sha, fetchImpl), true);
else await assert.rejects(imageExists(sha, fetchImpl), /full commit/);
}
});
test("image publisher verifies source and platform before pushing exactly one immutable tag", async () => {
for (const revision of [sha, "c".repeat(40)]) {
const calls = [];
const operation = publishImage("preview-image.tar", sha, {
fetchImpl: async (url) => url.includes("/token?") ? json({ token: "test-pull-token" }) : json({}, 404),
exec: (command, args) => {
calls.push({ command, args });
if (args[0] === "image") return JSON.stringify([{ Id: "sha256:" + "b".repeat(64), Os: "linux", Architecture: "amd64", Config: { Labels: { "org.opencontainers.image.revision": revision } } }]);
return "";
},
});
if (revision === sha) {
await operation;
assert.deepEqual(calls.filter((call) => call.args[0] === "push").map((call) => call.args), [["push", `ghcr.io/paperclipai/paperclip:sha-${sha}-cloud`]]);
} else { await assert.rejects(operation, /identity/); assert.ok(!calls.some((call) => call.args[0] === "push")); }
assert.ok(!calls.some((call) => ["run", "build"].includes(call.args[0])));
}
});
test("commits sharing a short prefix use separate full-SHA image addresses", async () => {
const urls = [];
const fetchImpl = async (url) => { urls.push(url); return url.includes("/token?") ? json({ token: "test-pull-token" }) : json({}, 404); };
const other = sha.slice(0, 7) + "b".repeat(33);
await imageExists(sha, fetchImpl);
await imageExists(other, fetchImpl);
assert.deepEqual(urls.filter((url) => url.includes("/manifests/")), [sha, other].map((commit) => `https://ghcr.io/v2/paperclipai/paperclip/manifests/sha-${commit}-cloud`));
});