ci: verify isolated Docker news journeys before merging to main
This commit is contained in:
1 parent
043c763484
commit
33a760ab6b
6 files changed
+274
-7
No files matched your search
@@ -0,0 +1,151 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, dirname, join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const bash =
|
||||
process.platform === "win32"
|
||||
? ((process.env.PATH ?? "")
|
||||
.split(delimiter)
|
||||
.flatMap((directory) => [
|
||||
join(directory, "bash.exe"),
|
||||
join(dirname(directory), "bin", "bash.exe"),
|
||||
join(dirname(dirname(directory)), "bin", "bash.exe"),
|
||||
])
|
||||
.find(existsSync) ?? "bash")
|
||||
: "bash";
|
||||
const sha = "d".repeat(40);
|
||||
function simulate(scenario: string) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "cms-preflight-test-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(directory, ".env"),
|
||||
'echo unexpected-env-read > "$TEST_DIR/env-read"\n',
|
||||
);
|
||||
const shellDirectory = directory
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^([a-zA-Z]):/, (_, drive: string) => `/${drive.toLowerCase()}`);
|
||||
const result = spawnSync(bash, [resolve(root, "scripts/ci-preflight.sh")], {
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
env: {
|
||||
...process.env,
|
||||
BASH_ENV: resolve(root, "src/test/ci-preflight-harness.sh"),
|
||||
TEST_DIR: directory.replaceAll("\\", "/"),
|
||||
TMPDIR: shellDirectory,
|
||||
TEST_SHA: sha,
|
||||
SCENARIO: scenario,
|
||||
CMS_DEPLOY_DIR: "/must-not-read-production",
|
||||
DATABASE_URL: "must-not-use-production",
|
||||
},
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
return {
|
||||
status: result.status,
|
||||
output: result.stdout + result.stderr,
|
||||
calls: existsSync(join(directory, "calls"))
|
||||
? readFileSync(join(directory, "calls"), "utf8")
|
||||
: "",
|
||||
remaining: readdirSync(directory).filter(
|
||||
(name) => name !== "calls" && name !== ".env",
|
||||
),
|
||||
};
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function imageFrom(calls: string) {
|
||||
const image = calls.match(
|
||||
/-t (epicnext-cms:preflight-[a-f0-9]{40}-[a-zA-Z0-9]{10}) /,
|
||||
)?.[1];
|
||||
expect(image).toBeDefined();
|
||||
return image;
|
||||
}
|
||||
|
||||
describe("isolated branch preflight", () => {
|
||||
it("builds the production Dockerfile before testing that exact image and release", () => {
|
||||
const result = simulate("success");
|
||||
expect(result.status, result.output).toBe(0);
|
||||
const image = imageFrom(result.calls);
|
||||
expect(result.calls).toContain(`--build-arg NEXT_DEPLOYMENT_ID=${sha}`);
|
||||
expect(result.calls).toContain("pnpm install --frozen-lockfile");
|
||||
expect(result.calls).toContain("pnpm exec playwright install chromium");
|
||||
expect(result.calls).toContain(
|
||||
`news-image=${image} news-release=${sha} node --import tsx e2e/news-real/run.ts`,
|
||||
);
|
||||
expect(result.calls.indexOf("docker build")).toBeLessThan(
|
||||
result.calls.indexOf("e2e/news-real/run.ts"),
|
||||
);
|
||||
expect(result.calls.indexOf("e2e/news-real/run.ts")).toBeLessThan(
|
||||
result.calls.indexOf("docker image rm"),
|
||||
);
|
||||
expect(
|
||||
result.calls.split("\n").filter((line) => line.startsWith("docker ")),
|
||||
).toEqual([
|
||||
expect.stringContaining("docker build --network=host"),
|
||||
`docker image rm ${image}`,
|
||||
]);
|
||||
expect(result.calls).not.toMatch(
|
||||
/UNEXPECTED|db:migrate|deploy|registry|prune|must-not/,
|
||||
);
|
||||
expect(result.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it("stops after a failed build and cleans only its own attempted image", () => {
|
||||
const result = simulate("build-failure");
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.calls).not.toContain("e2e/news-real/run.ts");
|
||||
expect(result.calls).toContain(
|
||||
`docker image rm ${imageFrom(result.calls)}`,
|
||||
);
|
||||
expect(result.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["news-failure", "cleanup-failure"])(
|
||||
"fails and removes its private temporary directory after %s",
|
||||
(scenario) => {
|
||||
const result = simulate(scenario);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.calls).toContain("e2e/news-real/run.ts");
|
||||
expect(result.calls).toContain(
|
||||
`docker image rm ${imageFrom(result.calls)}`,
|
||||
);
|
||||
expect(result.calls).not.toMatch(
|
||||
/prune|epicnext-cms:latest|epicnext-cms:previous/,
|
||||
);
|
||||
expect(result.remaining).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["install-failure", "invalid-sha"])(
|
||||
"does not build or remove images after %s",
|
||||
(scenario) => {
|
||||
const result = simulate(scenario);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.calls).toContain("git rev-parse HEAD");
|
||||
if (scenario === "install-failure")
|
||||
expect(result.calls).toContain("pnpm install --frozen-lockfile");
|
||||
expect(result.calls).not.toContain("docker ");
|
||||
expect(result.remaining).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses a distinct owned tag for separate runs of the same commit", () => {
|
||||
const first = simulate("success");
|
||||
const second = simulate("success");
|
||||
expect(first.status, first.output).toBe(0);
|
||||
expect(second.status, second.output).toBe(0);
|
||||
expect(imageFrom(first.calls)).not.toBe(imageFrom(second.calls));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
# Test-only command boundaries; the real preflight shell and filesystem cleanup run.
|
||||
git() {
|
||||
echo "git $*" >> "$TEST_DIR/calls"
|
||||
[ "$*" = "rev-parse HEAD" ] || return 91
|
||||
if [ "$SCENARIO" = invalid-sha ]; then echo invalid; else echo "$TEST_SHA"; fi
|
||||
}
|
||||
pnpm() {
|
||||
echo "pnpm $*" >> "$TEST_DIR/calls"
|
||||
[ "$SCENARIO" != install-failure ]
|
||||
}
|
||||
docker() {
|
||||
echo "docker $*" >> "$TEST_DIR/calls"
|
||||
case "$1 $2" in
|
||||
'build --network=host') [ "$SCENARIO" != build-failure ] ;;
|
||||
'image rm') [ "$SCENARIO" != cleanup-failure ] ;;
|
||||
*) return 92 ;;
|
||||
esac
|
||||
}
|
||||
node() {
|
||||
echo "news-image=$NEWS_E2E_IMAGE news-release=$NEWS_E2E_RELEASE node $*" >> "$TEST_DIR/calls"
|
||||
[ "$*" = "--import tsx e2e/news-real/run.ts" ] || return 93
|
||||
[ "$SCENARIO" != news-failure ]
|
||||
}
|
||||
cp() { echo "UNEXPECTED cp $*" >> "$TEST_DIR/calls"; return 94; }
|
||||
curl() { echo "UNEXPECTED curl $*" >> "$TEST_DIR/calls"; return 95; }
|
||||
cat() { echo "UNEXPECTED cat $*" >> "$TEST_DIR/calls"; return 96; }
|
||||
export -f git pnpm docker node cp curl cat
|
||||
Reference in new issue
Block a user