diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index ce83cba6..08c9ff68 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, master] + branches: [main, master, "codex/**"] tags: ["v*"] pull_request: branches: [main, master] @@ -79,6 +79,36 @@ jobs: test-results/ui/ 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 # Draait op de host (self-hosted) zodat Docker diff --git a/docs/operations/ci-preflight.md b/docs/operations/ci-preflight.md new file mode 100644 index 00000000..18fc5263 --- /dev/null +++ b/docs/operations/ci-preflight.md @@ -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--`, 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. diff --git a/e2e/news-real/news.spec.ts b/e2e/news-real/news.spec.ts index 43e25d1e..6c9d1073 100644 --- a/e2e/news-real/news.spec.ts +++ b/e2e/news-real/news.spec.ts @@ -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[] = []) => (await database.query(sql, params))[0]; 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 summary = "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); // 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.getByRole("heading", { name: title, exact: true }), - ).toHaveCount(0); + await expect(publicTitle).toHaveCount(0); const listing = await anonymous.request .get("/api/articles") .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 () => { const response = await reader.reload(); expect(response?.status()).toBe(200); - await expect( - reader.getByRole("heading", { name: title, exact: true }), - ).toBeVisible(); + await expect(publicTitle).toHaveText(title); + await expect(publicTitle).toBeVisible(); await expect(reader.locator(".article-body")).toContainText(body); await expect(reader.locator(".error-screen-code")).toHaveCount(0); const listing = await anonymous.request diff --git a/scripts/ci-preflight.sh b/scripts/ci-preflight.sh new file mode 100644 index 00000000..b4166d93 --- /dev/null +++ b/scripts/ci-preflight.sh @@ -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 diff --git a/src/lib/ci-preflight.test.ts b/src/lib/ci-preflight.test.ts new file mode 100644 index 00000000..a3da7c71 --- /dev/null +++ b/src/lib/ci-preflight.test.ts @@ -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)); + }); +}); diff --git a/src/test/ci-preflight-harness.sh b/src/test/ci-preflight-harness.sh new file mode 100644 index 00000000..e317e860 --- /dev/null +++ b/src/test/ci-preflight-harness.sh @@ -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