ci(#476): гейты наблюдаемых свойств перед publish — image-smoke, migration-order на push, allowlist fail-closed, property-тесты
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>
This commit is contained in:
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user