Files
paperclip/scripts/preview-artifacts.test.mjs
T
Devin FoleyandPaperclip 4cc387f907 fix(ci): remove npm propagation from cloud readiness (#13456)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip Cloud needs a verified image and matching database
migrator before it can deploy a merge.
> - New npm package versions can take minutes to become downloadable
after the package build finishes.
> - The direct producer now publishes signed archives and a complete
dependency lockfile for each master commit.
> - This pull request makes readiness verify those artifacts and removes
the duplicate automatic npm migrator run.
> - Deployment still requires all source checks, exact image identity,
migration compatibility, and pinned dependencies.

## Linked Issues or Issue Description

Refs: #13455, #13454, #13192

**What existing behavior does this improve?**
The time from a master merge to the `Cloud deployable v1` signal.

**Current behavior**
Readiness polls npm metadata for the new DB and shared versions. An
automatic dispatcher also starts a separate npm-only migrator workflow.
A measured source built its packages at 06:22:41 UTC on 2026-09-15, but
both npm archives were not downloadable until 06:31:56 UTC.

**Proposed behavior**
Wait for the successful exact-source direct producer, verify its signed
manifest and all pinned downloads, and publish readiness only after the
existing source and image jobs pass. Keep manual npm migrators and
branch previews available.

**Reason and benefit**
Remove new-version npm propagation from merge-to-deployable time. The
gain depends on whether image building or source verification finishes
later; it is not a fixed subtraction from every run.

## What Changed

- Require a successful producer from the canonical repository, exact
commit, master ref, expected workflow, and approved event.
- Verify the manifest's GitHub attestation with the hosted GitHub CLI.
Enforce the exact source SHA, master workflow identity, and hosted
runner.
- Download and validate both archives and the complete dependency
lockfile after publication succeeds. Reject invalid signatures,
inaccessible objects, corrupt bytes, and source mismatches.
- Remove automatic npm-only migrator dispatch. Retain manual release and
branch-preview publication.
- Document the cloud feature-switch prerequisite and coordinated
rollback.

## Verification

- `node --test .github/scripts/tests/*.test.mjs`: 405 pass.
- Focused readiness, routing, preview, and artifact tests: 249 pass.
- Workflow lint and `git diff --check`: pass.
- `pnpm test:release-registry`: 139 pass after installing this
worktree's dependencies.
- All latest-head GitHub CI checks passed. Greptile is 5/5 with no
unresolved comments.
- Application source is unchanged. Common-source local typecheck and
build passed. The full local application suite has the documented macOS
read-only-directory rename limitation from #13454 (13 failures in two
unchanged suites); Linux CI is the final application gate.
- Live readiness verification of master
da77a0c28c passed in 6.52 seconds,
including the real GitHub CLI signature policy and all artifact
downloads.
- Cloud consumer resolution with the certificate encoding fix passed in
5.45 seconds with zero npm metadata requests or npm processes. The
consumer is deployed and enabled in staging and production; their live
resolution APIs passed in 2.34 and 2.38 seconds. Both report the
expected fixed harness commit. A fresh tenant deployment follows this
cutover merge.

## Risks

- `Cloud deployable v1` no longer promises npm preview availability.
Enable the cloud direct-artifact consumer in staging and production
before merging this change.
- Artifact storage and GitHub attestations become required services for
new direct releases. Missing or invalid evidence fails explicitly.
- Restore the old npm dispatcher and readiness gate together before
disabling the consumer switch. Retain artifacts referenced by existing
releases.
- This change does not expand AWS runner access. The producer and
readiness bookkeeping use GitHub-hosted runners. Existing PR allowlists
and source verification gates remain enforced.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, and code
execution. The exact serving model ID and context window are not exposed
by this environment.

## 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 (focused checks; full
application host limitation documented above)
- [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-15 01:14:43 -07:00

357 lines
21 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 { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
import { execFileSync, spawnSync } from "node:child_process";
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`));
});
test("cloud builds start per commit and preserve tag promotion dependencies", () => {
const docker = readFileSync(new URL("../.github/workflows/docker.yml", import.meta.url), "utf8");
const cloud = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
const readiness = readFileSync(new URL("../.github/workflows/cloud-readiness.yml", import.meta.url), "utf8");
assert.match(readiness, /branches: \[master\]/);
assert.match(readiness, /uses: \.\/\.github\/workflows\/docker-cloud.yml/);
assert.doesNotMatch(cloud, /^ push:/m);
assert.match(cloud, /workflow_call:/);
assert.match(cloud, /group: docker-cloud-\$\{\{ github.sha \}\}/);
assert.match(cloud, /cancel-in-progress: false/);
assert.doesNotMatch(cloud, /uses: .*@v\d\b/);
assert.match(cloud, /cache-to: type=registry,ref=ghcr.io\/\$\{\{ github.repository \}\}:buildcache-cloud-\$\{\{ github.sha \}\},mode=max/);
const caller = docker.split(" build-and-push-cloud:")[1].split(" promote_canary_channel:")[0];
assert.match(caller, /if: github.event_name != 'push' \|\| github.ref != 'refs\/heads\/master'/);
assert.match(caller, /uses: .\/.github\/workflows\/docker-cloud.yml/);
assert.match(docker.split(" promote_canary_channel:")[1], /needs: \[merge-and-push, build-and-push-cloud\]/);
const reaping = cloud.indexOf(" - name: Verify cloud PID 1 reaps orphaned processes");
assert.ok(reaping > cloud.indexOf(" - name: Verify the pushed image resolves the declared Sentry version"));
assert.ok(reaping < cloud.indexOf(" - name: Publish verified full-SHA cloud tag"));
});
test("cloud builds bake the managed runtime identity and verify it before publication", () => {
const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
const build = workflow.split(" - name: Build and push (cloud)")[1].split(" - name:")[0];
assert.match(build, /build-args: \|\n\s+USER_UID=1001\n\s+USER_GID=1001\n/);
const verify = workflow.indexOf(" - name: Verify cloud runtime user");
assert.ok(verify > workflow.indexOf(" - name: Verify the pushed image resolves the declared Sentry version"));
assert.ok(verify < workflow.indexOf(" - name: Publish verified full-SHA cloud tag"));
const step = workflow.slice(verify).split("\n - name:")[0];
assert.match(step, /IMAGE: ghcr.io\/\$\{\{ github.repository \}\}@\$\{\{ steps.build-cloud.outputs.digest \}\}/);
assert.doesNotMatch(step, /continue-on-error:|if:/);
assert.ok(step.indexOf('--entrypoint sh "$IMAGE"') < step.indexOf('-e USER_UID=1001 -e USER_GID=1001'));
for (const flag of ["u", "g"]) {
assert.ok(step.includes(`test "$(id -${flag} node)" = 1001`));
assert.ok(step.includes(`test "$(id -${flag})" = 1001`));
}
assert.ok(step.includes('test -w "$PAPERCLIP_HOME"'));
});
test("cloud cache imports are bounded, follow master ancestry, and retain the legacy fallback", () => {
const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
const selector = workflow.indexOf(" - name: Select cloud cache ancestry");
assert.ok(selector > workflow.indexOf(" - name: Login to GitHub Container Registry"));
assert.ok(selector > workflow.indexOf(" - name: Set up Docker Buildx"));
assert.ok(selector < workflow.indexOf(" - name: Build and push (cloud)"));
assert.match(workflow, /run: node scripts\/select-cloud-cache.mjs/);
assert.match(workflow, /cache-from: \$\{\{ steps.cloud-cache.outputs.source \}\}/);
const script = fileURLToPath(new URL("./select-cloud-cache.mjs", import.meta.url));
const dir = mkdtempSync(path.join(tmpdir(), "cloud-cache-test-"));
const output = path.join(dir, "output");
const env = { ...process.env, GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.test", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.test" };
const git = (...args) => execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", ...args], { cwd: dir, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
try {
git("init", "--initial-branch=master");
const commits = [];
for (let i = 0; i < 12; i++) {
git("commit", "--allow-empty", "-m", `main ${i}`);
commits.unshift(git("rev-parse", "HEAD"));
}
git("checkout", "-b", "topic", "HEAD~1");
git("commit", "--allow-empty", "-m", "topic");
git("checkout", "master");
git("merge", "--no-ff", "topic", "-m", "merge topic");
commits.unshift(git("rev-parse", "HEAD"));
const available = `ghcr.io/paperclipai/paperclip:buildcache-cloud-${commits[2]}`;
const inspections = path.join(dir, "inspections");
writeFileSync(path.join(dir, "docker"), `#!/usr/bin/env node
const fs = require("node:fs");
fs.appendFileSync(process.env.CACHE_INSPECTIONS, process.argv.at(-1) + "\\n");
if (process.argv.at(-1) !== process.env.AVAILABLE_CACHE) {
process.stderr.write("manifest unknown");
process.exit(1);
}
`, { mode: 0o755 });
const result = spawnSync(process.execPath, [script], {
cwd: dir, encoding: "utf8", env: {
...env, PATH: `${dir}${path.delimiter}${env.PATH}`, CACHE_IMAGE: "ghcr.io/paperclipai/paperclip",
GITHUB_OUTPUT: output, AVAILABLE_CACHE: available, CACHE_INSPECTIONS: inspections,
},
});
assert.equal(result.status, 0, result.stderr);
assert.equal(readFileSync(output, "utf8"), `source=type=registry,ref=${available}\n`);
assert.deepEqual(readFileSync(inspections, "utf8").trim().split("\n"), commits.slice(0, 3).map((commit) => `ghcr.io/paperclipai/paperclip:buildcache-cloud-${commit}`));
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("normal cloud builds publish the checked digest only when source and platform match", () => {
const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
const cloud = workflow.split(" build-and-push-cloud:")[1];
const verify = cloud.indexOf(" - name: Verify the pushed image resolves the declared Sentry version");
const publish = cloud.indexOf(" - name: Publish verified full-SHA cloud tag");
assert.ok(verify >= 0 && publish > verify);
const verification = cloud.slice(verify, publish);
assert.match(verification, /IMAGE: ghcr.io\/\$\{\{ github.repository \}\}@\$\{\{ steps.build-cloud.outputs.digest \}\}/);
assert.doesNotMatch(verification, /continue-on-error:|if: always\(/);
const step = cloud.slice(publish).split(/\n(?: #| - name:)/)[0];
assert.doesNotMatch(step, /continue-on-error:|if:/);
assert.match(step, /FULL_SHA_TAG: ghcr.io\/\$\{\{ github.repository \}\}:sha-\$\{\{ github.sha \}\}-cloud/);
const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
const dir = mkdtempSync(path.join(tmpdir(), "cloud-tag-test-"));
const image = `ghcr.io/paperclipai/paperclip@sha256:${"b".repeat(64)}`;
const tag = `ghcr.io/paperclipai/paperclip:sha-${sha}-cloud`;
try {
writeFileSync(path.join(dir, "docker"), `#!/bin/sh
case "$1 $2" in
'image inspect')
case "$5" in
*revision*) printf '%s\\n' "$TEST_REVISION" ;;
*) printf '%s\\n' "$TEST_PLATFORM" ;;
esac ;;
'buildx imagetools') printf '%s\\n' "$@" > "$TEST_CALLS" ;;
*) exit 99 ;;
esac
`, { mode: 0o755 });
for (const [revision, platform, succeeds] of [[sha, "linux/amd64", true], ["c".repeat(40), "linux/amd64", false], [sha, "linux/arm64", false]]) {
const calls = path.join(dir, "calls");
rmSync(calls, { force: true });
const result = spawnSync("bash", ["-c", script], { encoding: "utf8", env: {
...process.env, PATH: `${dir}${path.delimiter}${process.env.PATH}`, GITHUB_SHA: sha,
IMAGE: image, FULL_SHA_TAG: tag, TEST_REVISION: revision, TEST_PLATFORM: platform, TEST_CALLS: calls,
} });
if (succeeds) {
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(readFileSync(calls, "utf8").trim().split("\n"), ["buildx", "imagetools", "create", "--prefer-index=false", "--tag", tag, image]);
} else {
assert.notEqual(result.status, 0);
assert.throws(() => readFileSync(calls), { code: "ENOENT" });
}
}
} finally { rmSync(dir, { recursive: true, force: true }); }
});