1e7bd1f9d2
Retrospective of 22.06-10.07 merges showed one recurring miss class: local logic verified, integration property never checked (#361, #353, #452, #172, #435). This lands four gates so each of those classes fails BEFORE the :develop image is pushed: 1. Image boot-smoke in the publish job (develop.yml + scripts/ci/image-smoke.sh): the exact image watchtower pulls is booted against postgres/redis services before the push — /api/health (startup migrator, #361-boot/#353), auth/setup, client dist served, hashed assets immutable + brotli (#452). 2. migration-order gate now also runs on push (test.yml): direct pushes used to bypass the PR-only gate; base = event.before, zero-SHA skips, force-push fails closed. 3. External-MCP tool allowlist fails closed (#172 class): corrupt stored value now reads as [] (deny-all) with an error log instead of null (allow-all); [] round-trips as jsonb [] via jsonbBind({preserveEmpty}) and means deny-all in the toolset filter. The settings form sends null for an empty tag field so existing "unrestricted" servers are not silently narrowed. 4. Property tests for the silent-degradation classes: converter fixpoint through the live server path (mcp e2e), and CollabSession cache-key stability under per-call fresh tokens (#435/#439 lesson) incl. a negative control with the token cache disabled. Closes #476 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
80 lines
4.2 KiB
Bash
Executable File
80 lines
4.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Boot-smoke for the exact Docker image that is about to be pushed (issue #476).
|
|
#
|
|
# Retrospective class "local logic is right, the integration property was never
|
|
# checked" (#353/#452/#361): every other CI job builds and tests code from the
|
|
# working tree, but the IMAGE watchtower pulls was never actually started
|
|
# anywhere before this gate. This script boots the built image against the
|
|
# publish job's postgres/redis services and asserts four integration
|
|
# properties end-to-end:
|
|
# S1 the app boots and /api/health answers (startup migrator + boot)
|
|
# S2 the first-run workspace setup endpoint works (API + DB writes)
|
|
# S3 the client dist is inside the image and served
|
|
# S4 hashed assets are served immutable (#452) with the precompressed
|
|
# brotli copy shipped in the image
|
|
set -euo pipefail
|
|
|
|
IMAGE="${1:?usage: image-smoke.sh <image>}"
|
|
|
|
fail() { echo "FAIL: $*"; exit 1; }
|
|
|
|
# Boot the exact image that will be pushed, wired to the job services via host
|
|
# network (postgres on localhost:5432, redis on localhost:6379). The container
|
|
# is deliberately NOT removed on failure so the workflow's dump-on-failure step
|
|
# can read `docker logs gitmost-smoke`.
|
|
docker run -d --name gitmost-smoke --network host \
|
|
-e DATABASE_URL=postgresql://docmost:docmost@localhost:5432/docmost \
|
|
-e REDIS_URL=redis://localhost:6379 \
|
|
-e APP_SECRET=ci-smoke-secret-change-me-min-32-characters \
|
|
-e APP_URL=http://localhost:3000 \
|
|
"$IMAGE"
|
|
|
|
# S1: wait for /api/health — covers the startup migrator + boot inside the
|
|
# shipped image (#361-boot, #353 runtime class): a migration the Kysely startup
|
|
# migrator rejects, or a runtime module missing from the image, dies right here.
|
|
healthy=0
|
|
for _ in $(seq 1 60); do
|
|
if curl -fsS http://localhost:3000/api/health > /dev/null 2>&1; then
|
|
healthy=1
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
[ "$healthy" -eq 1 ] || fail "S1: /api/health did not answer within 120s (boot or startup migration failed)"
|
|
echo "OK S1: image booted and /api/health answers"
|
|
|
|
# S2: the first-run workspace setup works end-to-end (controller -> service ->
|
|
# DB write chain inside the shipped image, not just a static health probe).
|
|
curl -fsS -X POST http://localhost:3000/api/auth/setup \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"name":"Smoke","email":"smoke@example.com","password":"SmokePassword123","workspaceName":"Smoke"}' \
|
|
> /dev/null || fail "S2: POST /api/auth/setup failed"
|
|
echo "OK S2: workspace setup succeeded"
|
|
|
|
# S3: the client dist is actually inside the image and served — the SPA HTML
|
|
# must reference hashed /assets/ bundles (a broken client COPY in the
|
|
# Dockerfile would serve an empty shell that every other job stays green on).
|
|
HTML=$(curl -fsS http://localhost:3000/) || fail "S3: fetching / failed"
|
|
grep -q '/assets/' <<<"$HTML" || fail "S3: served HTML references no /assets/ bundle (client dist missing from the image?)"
|
|
echo "OK S3: client dist served (HTML references /assets/)"
|
|
|
|
# S4: hashed /assets/ files must be served with an immutable cache-control
|
|
# (#452 class: static.module.ts resolveStaticAssetHeaders owns the header) AND
|
|
# with the precompressed brotli neighbour. Both checks are mandatory — verified
|
|
# against the code: resolveStaticAssetHeaders marks every /assets/ path
|
|
# immutable, and the client build (vite-plugin-compression2, include covers
|
|
# .js) emits a .br copy next to every bundle that the Dockerfile ships and
|
|
# @fastify/static serves via preCompressed:true.
|
|
ASSET=$(grep -oE '/assets/[A-Za-z0-9._@/-]+\.js' <<<"$HTML" | head -1 || true)
|
|
[ -n "$ASSET" ] || fail "S4: no /assets/*.js path found in the served HTML"
|
|
HDRS=$(curl -fsSI -H 'Accept-Encoding: br' "http://localhost:3000$ASSET") || fail "S4: HEAD $ASSET failed"
|
|
grep -qi '^cache-control:.*immutable' <<<"$HDRS" || fail "S4: $ASSET served without an immutable cache-control (#452)"
|
|
echo "OK S4: hashed asset served with immutable cache-control"
|
|
grep -qi '^content-encoding:.*br' <<<"$HDRS" || fail "S4: $ASSET not served brotli-precompressed (content-encoding: br missing)"
|
|
echo "OK S4: hashed asset served with the precompressed brotli copy"
|
|
|
|
# Remove the container ONLY on success, so the failure path keeps it around for
|
|
# the workflow's "Dump smoke container log on failure" step.
|
|
docker rm -f gitmost-smoke > /dev/null
|
|
echo "OK image smoke passed"
|