fix(deploy): verify Docker clone updates against the served release
CI / check (push) Successful in 59s
CI / deploy (push) Failing after 1m21s

This commit is contained in:
Simo committed 2026-09-07 21:17:34 +02:00
1 parent 3bac126ace
commit cbaa115d56
15 files changed
+321 -187

No files matched your search

+3
View File
@@ -22,3 +22,6 @@ bun.lockb
# Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml) # Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml)
public/nitro-assets public/nitro-assets
public/swf public/swf
.deploy.lock
logs
+3
View File
@@ -41,3 +41,6 @@ blob-report/
.aider* .aider*
/public/vendor/tinymce/ /public/vendor/tinymce/
# Shared deployment lock (never application source)
.deploy.lock
+9 -13
View File
@@ -1,19 +1,11 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# node:alpine = latest Node within the supported LTS major (tracks the newest # Pin the runtime to the supported engine; update both stages deliberately.
# patch automatically; currently v26.x, which satisfies package.json's FROM node:26.8.1-alpine AS builder
# engines ">=26.8.1 <27").
FROM node:alpine AS builder
WORKDIR /app WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Real release id supplied by CI (ci-deploy.sh) so next.config.ts skips git # Keep the bootstrap aligned with package.json packageManager.
# entirely (there is no .git in the build context). Defaults to "unknown".
ARG NEXT_DEPLOYMENT_ID="unknown"
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
# pnpm install is always pinned to the version in package.json's
# `packageManager` field (pnpm auto-selects it on install), so this global
# install only needs to exist as a bootstrap and follows the active major.
RUN apk add --no-cache git \ RUN apk add --no-cache git \
&& npm install -g pnpm@latest && npm install -g pnpm@11.25.0
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the # pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build # overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH). # where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
@@ -23,9 +15,13 @@ COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
RUN pnpm fetch --ignore-scripts RUN pnpm fetch --ignore-scripts
RUN pnpm install --frozen-lockfile --ignore-scripts --offline RUN pnpm install --frozen-lockfile --ignore-scripts --offline
COPY . . COPY . .
ARG NEXT_DEPLOYMENT_ID="unknown"
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
RUN pnpm run build RUN pnpm run build
FROM node:alpine AS runner FROM node:26.8.1-alpine AS runner
ARG NEXT_DEPLOYMENT_ID="unknown"
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \ NEXT_TELEMETRY_DISABLED=1 \
+67 -56
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -1,8 +1,11 @@
services: services:
cms: cms:
image: epicnext-cms:${CMS_RELEASE:-local}
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
# The host disables Docker iptables (daemon.json: "iptables": false), so # The host disables Docker iptables (daemon.json: "iptables": false), so
# build containers on the bridge network have no outbound NAT/DNS. Build on # build containers on the bridge network have no outbound NAT/DNS. Build on
# the host network instead so pnpm/npm/yarn can reach the registry. # the host network instead so pnpm/npm/yarn can reach the registry.
+1 -5
View File
@@ -74,11 +74,6 @@ pnpm install --frozen-lockfile
pnpm exec playwright install chromium pnpm exec playwright install chromium
echo "Building $image" echo "Building $image"
# Throw away the previous build cache before each build so disk usage doesn't
# grow unbounded across deployments. The current release image (`epicnext-cms`)
# is still reused as a base layer via `--cache-from`; only the accumulated
# BuildKit intermediate cache is discarded.
docker builder prune -af --filter "until=1h" --keep-storage=0 2>/dev/null || true
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \ DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" . --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
check_current check_current
@@ -136,6 +131,7 @@ candidate_attempted=1
"$image" "$image"
) )
healthy healthy
node --input-type=module -e 'const r=await fetch("http://127.0.0.1:3002/api/health",{cache:"no-store",signal:AbortSignal.timeout(5000)});const d=await r.json();if(!r.ok||d.release!==process.argv[1]){console.error("Release mismatch",d.release,process.argv[1]);process.exit(1)}' "$sha"
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
# Publish the latest alias only after health and browser checks pass. # Publish the latest alias only after health and browser checks pass.
docker tag "$image" epicnext-cms:latest docker tag "$image" epicnext-cms:latest
+7 -1
View File
@@ -124,7 +124,13 @@ check_dep git
doing "5. Port 3002 state (host CMS clash)" doing "5. Port 3002 state (host CMS clash)"
if (echo >/dev/tcp/127.0.0.1/"$CMS_PORT") >/dev/null 2>&1; then if (echo >/dev/tcp/127.0.0.1/"$CMS_PORT") >/dev/null 2>&1; then
err "port $CMS_PORT already in use on host — stop the host-side CMS (pm2 stop next) before starting the container" if [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms 2>/dev/null || true)" = true ]; then
ok "existing Compose CMS is running; use docker-update.sh to recreate and verify its release"
elif [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" = true ]; then
wrn "CI manages the running CMS; update through CI instead of starting Compose"
else
err "port $CMS_PORT is occupied by another process; identify it before starting the CMS"
fi
else else
ok "port $CMS_PORT free" ok "port $CMS_PORT free"
fi fi
+54 -108
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -57,6 +57,7 @@ export async function GET() {
emulator, emulator,
resend: resendAvailable, resend: resendAvailable,
node: process.version, node: process.version,
release: process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown",
uptime: Math.round(process.uptime()), uptime: Math.round(process.uptime()),
time: new Date().toISOString(), time: new Date().toISOString(),
}); });
+6 -1
View File
@@ -80,7 +80,12 @@ describe("deployment transaction", () => {
"docker tag sha256:old epicnext-cms:previous", "docker tag sha256:old epicnext-cms:previous",
); );
}); });
it.each(["run-failure", "health-failure", "smoke-failure"])( it.each([
"run-failure",
"health-failure",
"smoke-failure",
"release-failure",
])(
"restores the exact previous container after %s", "restores the exact previous container after %s",
(scenario) => { (scenario) => {
const result = simulate(scenario); const result = simulate(scenario);
+13
View File
@@ -29,3 +29,16 @@ describe("Docker build cache", () => {
expect(dockerfile).not.toContain("yarn install --production"); expect(dockerfile).not.toContain("yarn install --production");
}); });
}); });
it("passes a compiled release to both the application build and final image", () => {
expect(dockerfile.indexOf("ARG NEXT_DEPLOYMENT_ID")).toBeGreaterThan(
dockerfile.indexOf("COPY . ."),
);
expect(dockerfile).toContain(
'LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"',
);
expect(dockerfile.match(/FROM node:26\.8\.1-alpine/g)).toHaveLength(2);
const compose = readFileSync("docker-compose.yml", "utf8");
// biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose interpolation, not JavaScript.
expect(compose).toContain("NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}");
});
File diff suppressed because it is too large. Load diff
+1 -3
View File
@@ -1,15 +1,13 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
CatalogExportQueue, CatalogExportQueue,
publishCatalogFiles,
recoverCatalogQueue, recoverCatalogQueue,
sqlValue, sqlValue,
} from "./catalog-git-core"; } from "./catalog-git-core";
import { gitProcessEnvironment } from "./git-process-environment";
describe("catalog export", () => { describe("catalog export", () => {
it("recovers queue entries owned by a terminated local process", async () => { it("recovers queue entries owned by a terminated local process", async () => {
+3
View File
@@ -42,3 +42,6 @@ docker() {
esac esac
} }
export -f git flock pnpm curl sleep docker export -f git flock pnpm curl sleep docker
node() { echo "node $*" >> "$TEST_DIR/calls"; [ "$SCENARIO" != release-failure ]; }
export -f node
+32
View File
@@ -0,0 +1,32 @@
# Test doubles; never calls real Docker, Git remotes or databases.
git() {
echo "git $*" >> "$TEST_DIR/calls"
case "$1" in
status) if [ "$SCENARIO" = dirty ]; then echo ' M local.ts'; fi ;;
hash-object) echo unchanged ;;
rev-parse) if [ "${2:-}" = HEAD ]; then echo "$TEST_SHA"; else echo origin/main; fi ;;
pull) [ "$SCENARIO" != pull-failure ] ;;
esac
}
flock() { :; }
sleep() { :; }
docker() {
echo "docker $*" >> "$TEST_DIR/calls"
case "$1 ${2:-}" in
'inspect --format')
if [ "${@: -1}" = epicnext-cms-app ]; then [ "$SCENARIO" = ci-active ] && echo true; return 0; fi
if [ "$SCENARIO" = wrong-image ]; then echo sha256:old; else echo sha256:new; fi ;;
'image inspect')
if [[ "$*" = *org.opencontainers* ]]; then echo "$TEST_SHA"; else echo sha256:new; fi ;;
'compose config') return 0 ;;
'compose build') [ "$SCENARIO" != build-failure ] ;;
'compose up') [ "$SCENARIO" != recreate-failure ] ;;
'compose ps') echo container123 ;;
'run --rm') [ "$SCENARIO" != migration-failure ] ;;
'exec container123')
if [ "$SCENARIO" = wrong-release ]; then return 1; fi
if [ "$SCENARIO" = wrong-public ] && [[ "$*" = *example.test* ]]; then return 1; fi ;;
*) return 0 ;;
esac
}
export -f git flock sleep docker