mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
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>
105 lines
5.5 KiB
TypeScript
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)"'));
|
|
}
|
|
});
|
|
});
|