Files
PaperClipAI/server/src/__tests__/docker-build-stamp.test.ts
T
DottaandPaperclip 5b12157460 fix(ci): verify staging dependency resolution before installation
Require a reviewed lock digest for staging migrator and app builds so registry drift fails before lifecycle-enabled installation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-07 20:55:25 -05:00

85 lines
4.4 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");
/**
* 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);
}
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.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)"'));
}
});
});