fix(deploy): verify Docker clone updates against the served release
This commit is contained in:
1 parent
3bac126ace
commit
cbaa115d56
15 files changed
+321
-187
No files matched your search
@@ -22,3 +22,6 @@ bun.lockb
|
||||
# Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml)
|
||||
public/nitro-assets
|
||||
public/swf
|
||||
|
||||
.deploy.lock
|
||||
logs
|
||||
@@ -41,3 +41,6 @@ blob-report/
|
||||
.aider*
|
||||
|
||||
/public/vendor/tinymce/
|
||||
|
||||
# Shared deployment lock (never application source)
|
||||
.deploy.lock
|
||||
+9
-13
@@ -1,19 +1,11 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# node:alpine = latest Node within the supported LTS major (tracks the newest
|
||||
# patch automatically; currently v26.x, which satisfies package.json's
|
||||
# engines ">=26.8.1 <27").
|
||||
FROM node:alpine AS builder
|
||||
# Pin the runtime to the supported engine; update both stages deliberately.
|
||||
FROM node:26.8.1-alpine AS builder
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Real release id supplied by CI (ci-deploy.sh) so next.config.ts skips git
|
||||
# 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.
|
||||
# Keep the bootstrap aligned with package.json packageManager.
|
||||
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
|
||||
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
|
||||
# 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 install --frozen-lockfile --ignore-scripts --offline
|
||||
COPY . .
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
||||
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
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
services:
|
||||
cms:
|
||||
image: epicnext-cms:${CMS_RELEASE:-local}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
||||
# The host disables Docker iptables (daemon.json: "iptables": false), so
|
||||
# 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.
|
||||
|
||||
@@ -74,11 +74,6 @@ pnpm install --frozen-lockfile
|
||||
pnpm exec playwright install chromium
|
||||
|
||||
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 \
|
||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||
check_current
|
||||
@@ -136,6 +131,7 @@ candidate_attempted=1
|
||||
"$image"
|
||||
)
|
||||
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
|
||||
# Publish the latest alias only after health and browser checks pass.
|
||||
docker tag "$image" epicnext-cms:latest
|
||||
|
||||
@@ -124,7 +124,13 @@ check_dep git
|
||||
|
||||
doing "5. Port 3002 state (host CMS clash)"
|
||||
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
|
||||
ok "port $CMS_PORT free"
|
||||
fi
|
||||
|
||||
+54
-108
File diff suppressed because it is too large.
Load diff
@@ -57,6 +57,7 @@ export async function GET() {
|
||||
emulator,
|
||||
resend: resendAvailable,
|
||||
node: process.version,
|
||||
release: process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown",
|
||||
uptime: Math.round(process.uptime()),
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -80,7 +80,12 @@ describe("deployment transaction", () => {
|
||||
"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",
|
||||
(scenario) => {
|
||||
const result = simulate(scenario);
|
||||
|
||||
@@ -29,3 +29,16 @@ describe("Docker build cache", () => {
|
||||
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,15 +1,13 @@
|
||||
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 path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CatalogExportQueue,
|
||||
publishCatalogFiles,
|
||||
recoverCatalogQueue,
|
||||
sqlValue,
|
||||
} from "./catalog-git-core";
|
||||
import { gitProcessEnvironment } from "./git-process-environment";
|
||||
|
||||
describe("catalog export", () => {
|
||||
it("recovers queue entries owned by a terminated local process", async () => {
|
||||
|
||||
@@ -42,3 +42,6 @@ docker() {
|
||||
esac
|
||||
}
|
||||
export -f git flock pnpm curl sleep docker
|
||||
|
||||
node() { echo "node $*" >> "$TEST_DIR/calls"; [ "$SCENARIO" != release-failure ]; }
|
||||
export -f node
|
||||
@@ -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
|
||||
Reference in new issue
Block a user