Files
PaperClipAI/server/src/__tests__/docker-build-stamp.test.ts
T
DottaandPaperclip b475b90e9e Reconcile qualified sandbox providers with current master
Preserve verified Pi and Codex ACP profiles alongside current Claude installation handling, provider recovery, and native attachment staging. Keep preview lock integrity checks on native-architecture image builds.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-11 14:13:48 -05:00

105 lines
5.5 KiB
TypeScript

import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* Drift guard for the Docker build-stamp wiring.
*
* The server build runs scripts/write-build-stamp.mjs, which stamps the built
* commit into dist/build-info.json. The build context has no .git, so the
* script reads PAPERCLIP_BUILD_COMMIT instead. Docker exposes an ARG to the
* next RUN as an environment variable, but an ARG goes out of scope at the end
* of its stage. So the build stage must declare `ARG PAPERCLIP_BUILD_COMMIT`
* before the server build; the production ARG alone stamps nothing, because
* the server build already ran in the earlier stage.
*
* This guard fails if a refactor drops the build-stage ARG, moves it after the
* server build, or removes the build-arg the docker workflow passes.
*/
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const dockerfile = readFileSync(path.join(repoRoot, "Dockerfile"), "utf8");
const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker.yml"), "utf8");
const cloudWorkflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker-cloud.yml"), "utf8");
/**
* Return the text of the Dockerfile stage that starts at the named target.
* A stage runs from its `FROM ... AS <name>` line to the next `FROM` line.
*/
function stageBody(source: string, stageName: string): string {
const froms = [...source.matchAll(/^FROM .*$/gm)];
const startIdx = froms.findIndex((m) => new RegExp(`\\bAS ${stageName}\\b`).test(m[0]));
expect(startIdx, `Dockerfile must declare a '${stageName}' stage`).toBeGreaterThanOrEqual(0);
const start = froms[startIdx].index ?? 0;
const end = froms[startIdx + 1]?.index ?? source.length;
return source.slice(start, end);
}
it("keeps per-build runtime metadata out of the weekly CLI-install cache", () => {
const production = stageBody(dockerfile, "production");
const tools = production.search(/^RUN echo "cli-tools-epoch:/m);
const entrypoint = production.search(/^RUN chmod \+x \/usr\/local\/bin\/docker-entrypoint\.sh/m);
const runtime = production.search(/^ENV NODE_ENV=production/m);
const epoch = production.search(/^ARG CLI_TOOLS_CACHE_EPOCH\b/m);
expect(tools).toBeGreaterThanOrEqual(0);
expect(entrypoint).toBeGreaterThan(tools);
expect(epoch).toBeGreaterThanOrEqual(0);
expect(epoch).toBeLessThan(tools);
for (const name of ["PAPERCLIP_BUILD_VERSION", "PAPERCLIP_BUILD_COMMIT"]) {
const declarations = [...production.matchAll(new RegExp(`^ARG ${name}\\b`, "gm"))];
expect(declarations).toHaveLength(1);
expect(declarations[0].index).toBeGreaterThan(entrypoint);
expect(declarations[0].index).toBeLessThan(runtime);
expect(production.slice(runtime)).toContain(`${name}=\${${name}}`);
}
});
describe("docker build-stamp wiring", () => {
it("declares PAPERCLIP_BUILD_COMMIT in the build stage before the server build", () => {
const build = stageBody(dockerfile, "build");
const argIdx = build.search(/^ARG PAPERCLIP_BUILD_COMMIT\b/m);
const serverBuildIdx = build.search(/^RUN pnpm --filter @paperclipai\/server build\b/m);
expect(argIdx, "build stage must declare ARG PAPERCLIP_BUILD_COMMIT").toBeGreaterThanOrEqual(0);
expect(serverBuildIdx, "build stage must run the server build").toBeGreaterThanOrEqual(0);
expect(
argIdx,
"ARG PAPERCLIP_BUILD_COMMIT must precede the server build so the stamp script reads it",
).toBeLessThan(serverBuildIdx);
});
it("passes PAPERCLIP_BUILD_COMMIT as a build-arg for both image targets", () => {
const argLines = [...`${workflow}\n${cloudWorkflow}`.matchAll(/^\s*PAPERCLIP_BUILD_COMMIT=.*$/gm)];
expect(
argLines.length,
"the docker workflow must pass PAPERCLIP_BUILD_COMMIT for the production and cloud builds",
).toBeGreaterThanOrEqual(2);
});
});
describe("staging dependency integrity", () => {
it("verifies the reviewed resolved lock before installing the migrator", () => {
const migrator = workflow.split(" staging-migrator:")[1].split(" build-and-push:")[0];
const verification = migrator.indexOf('echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict');
expect(migrator).toContain("EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }}");
expect(migrator).toContain('[[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]]');
expect(verification).toBeGreaterThan(migrator.indexOf("pnpm install --resolution-only --ignore-scripts"));
expect(verification).toBeLessThan(migrator.indexOf("pnpm install --frozen-lockfile"));
});
it("checks the same lock in both staging app build contexts", () => {
const contexts = workflow.split(" - name: Refresh lockfile for Docker build context").slice(1);
expect(contexts).toHaveLength(2);
for (const context of contexts) {
const refresh = context.split(" - name:")[0];
expect(refresh).toContain("STAGING_ARTIFACT_BASE_URL: ${{ inputs.staging_artifact_base_url }}");
expect(refresh).toContain("EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }}");
expect(refresh).toContain('if [ -n "$STAGING_ARTIFACT_BASE_URL" ]; then');
const verification = refresh.indexOf('echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict');
expect(verification).toBeGreaterThan(refresh.indexOf("pnpm install --resolution-only --ignore-scripts"));
expect(verification).toBeLessThan(refresh.indexOf('changed="$(git status --porcelain)"'));
}
});
});