ci: verify isolated Docker news journeys before merging to main
CI / check (push) Successful in 4m23s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped
CI / preflight (push) Successful in 1m36s

This commit is contained in:
Simo committed 2026-09-13 21:05:02 +02:00
1 parent 043c763484
commit 33a760ab6b
6 files changed
+274 -7

No files matched your search

+31 -1
View File
@@ -2,7 +2,7 @@ name: CI
on: on:
push: push:
branches: [main, master] branches: [main, master, "codex/**"]
tags: ["v*"] tags: ["v*"]
pull_request: pull_request:
branches: [main, master] branches: [main, master]
@@ -79,6 +79,36 @@ jobs:
test-results/ui/ test-results/ui/
retention-days: 14 retention-days: 14
# Validate branch/PR Docker images before integration into a deployment branch.
preflight:
needs: check
if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/'))
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
- name: Toolchain check
run: node scripts/check-node-toolchain.mjs
- name: Build and verify isolated candidate
shell: bash
run: bash scripts/ci-preflight.sh
- name: Upload preflight news browser results
if: always()
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
with:
name: preflight-news-browser-results
path: |
test-results/news-real/
playwright-report/news-real/
if-no-files-found: warn
retention-days: 14
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# Docker build & deploy # Docker build & deploy
# Draait op de host (self-hosted) zodat Docker # Draait op de host (self-hosted) zodat Docker
+17
View File
@@ -0,0 +1,17 @@
# Docker and news checks before merging
Push work to a `codex/**` branch and open a pull request targeting `main` or `master`. CI runs the existing `check` job first. After it passes, the new `preflight` job builds the production Dockerfile and runs the isolated news browser suite against that exact image. Review both results before merging; repository branch protection can require `check` and `preflight` for pull requests.
Each execution uses `epicnext-cms:preflight-<commit>-<random suffix>`, including retries and separate push/PR runs of the same commit. The full checked-out commit is passed as `NEXT_DEPLOYMENT_ID` and `NEWS_E2E_RELEASE`; `NEWS_E2E_IMAGE` identifies that execution's image. Failures in dependency/browser setup, Docker build, news tests or cleanup fail the job. News browser artifacts are uploaded even when the gate fails.
The script installs dependencies with the frozen lockfile and installs Chromium on the CI runner. The real Docker build uses the existing Dockerfile's fixture build settings. It never copies or sources a deployment `.env`, connects to a VPS, runs live migrations, updates live containers, publishes a registry image or changes release tags. The existing isolated news runner owns its disposable MariaDB, Redis and application containers. Cleanup removes only the preflight tag and its empty private temporary directory; it does not prune Docker resources.
The `deploy` and `publish-container` conditions remain restricted to pushes on `main`/`master`. Deployment still runs its own news gate before live migrations/cutover. A successful branch preflight supplies earlier evidence; the deployed commit is independently checked again.
On a Linux development or CI host with the project toolchain, Docker Engine and normal browser prerequisites, the same gate can be run from a clean checkout:
```sh
bash scripts/ci-preflight.sh
```
Shell orchestration is covered by `pnpm exec vitest run --coverage.enabled=false src/lib/ci-preflight.test.ts`. Those tests execute the real shell script with external command boundaries simulated; they prove ordering, failure propagation, unique tags and cleanup scope. They do not build an image or run the news browser suite. The branch/PR CI job provides that Docker/browser evidence.
+6 -6
View File
@@ -59,6 +59,9 @@ test("staff signs in, saves a draft, previews it and publishes to anonymous read
const rows = async (sql: string, params: string[] = []) => const rows = async (sql: string, params: string[] = []) =>
(await database.query<RowDataPacket[]>(sql, params))[0]; (await database.query<RowDataPacket[]>(sql, params))[0];
const title = "Notizia browser: città e novità 🎉"; const title = "Notizia browser: città e novità 🎉";
const publicTitle = reader.locator(
".content-card:has(.article-body) .content-card-title",
);
const slug = "browser-news-real"; const slug = "browser-news-real";
const summary = const summary =
"Una notizia creata e pubblicata attraverso il pannello reale."; "Una notizia creata e pubblicata attraverso il pannello reale.";
@@ -160,9 +163,7 @@ test("staff signs in, saves a draft, previews it and publishes to anonymous read
expect(response?.status()).toBeLessThan(500); expect(response?.status()).toBeLessThan(500);
// Next can stream not-found markup with HTTP 200; assert the actual 404 screen. // Next can stream not-found markup with HTTP 200; assert the actual 404 screen.
await expect(reader.locator(".error-screen-code")).toHaveText("404"); await expect(reader.locator(".error-screen-code")).toHaveText("404");
await expect( await expect(publicTitle).toHaveCount(0);
reader.getByRole("heading", { name: title, exact: true }),
).toHaveCount(0);
const listing = await anonymous.request const listing = await anonymous.request
.get("/api/articles") .get("/api/articles")
.then((response) => response.json()); .then((response) => response.json());
@@ -257,9 +258,8 @@ test("staff signs in, saves a draft, previews it and publishes to anonymous read
await test.step("anonymous pages and API read the newly published content", async () => { await test.step("anonymous pages and API read the newly published content", async () => {
const response = await reader.reload(); const response = await reader.reload();
expect(response?.status()).toBe(200); expect(response?.status()).toBe(200);
await expect( await expect(publicTitle).toHaveText(title);
reader.getByRole("heading", { name: title, exact: true }), await expect(publicTitle).toBeVisible();
).toBeVisible();
await expect(reader.locator(".article-body")).toContainText(body); await expect(reader.locator(".article-body")).toContainText(body);
await expect(reader.locator(".error-screen-code")).toHaveCount(0); await expect(reader.locator(".error-screen-code")).toHaveCount(0);
const listing = await anonymous.request const listing = await anonymous.request
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Build and browser-test a branch candidate using only disposable services.
set -Eeuo pipefail
umask 077
sha="$(git rev-parse HEAD)"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid preflight commit" >&2; exit 1; }
temporary="$(mktemp -d "${TMPDIR:-/tmp}/cms-preflight.XXXXXXXXXX")"
suffix="${temporary##*.}"
image="epicnext-cms:preflight-$sha-$suffix"
build_attempted=0
finish() {
local status=$?
trap - EXIT
if [ "$build_attempted" -eq 1 ]; then
# Remove this run's tag only. Never prune, force-remove or touch release tags.
if ! docker image rm "$image"; then
if [ "$status" -eq 0 ]; then status=1; fi
fi
fi
# The private directory contains no files; never recursively delete a path.
if ! rmdir -- "$temporary"; then
if [ "$status" -eq 0 ]; then status=1; fi
fi
if [ "$status" -eq 0 ]; then echo "Branch preflight verified: $sha"; fi
exit "$status"
}
trap finish EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
[[ "$temporary" = /* && -d "$temporary" && ! -L "$temporary" && "$suffix" =~ ^[a-zA-Z0-9]{10}$ ]] || {
echo "Invalid private preflight directory" >&2
exit 1
}
pnpm install --frozen-lockfile
pnpm exec playwright install chromium
build_attempted=1
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain \
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
+151
View File
@@ -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));
});
});
+27
View File
@@ -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