On push-to-main publish, a static assertion fails the publish unless .dockerignore exists, covers the forbidden patterns, and publish.yml still pins --target production
A boot-smoke runs the freshly-built production image with NODE_ENV=production + DEV_AUTH_BYPASS=true and fails the publish unless the image refuses to boot (non-zero exit, neither 0 nor a 124 timeout)
Both assertions run AFTER docker build but BEFORE docker push, so a hygiene failure can never publish the image
path
provides
contains
.gitea/workflows/publish.yml
static image-hygiene assertion + boot-smoke steps, ordered before docker push
boot-smoke
from
to
via
pattern
.gitea/workflows/publish.yml boot-smoke
apps/api/src/lib/bootGuards.ts (via the built image)
docker run prod image with forbidden env, assert non-zero exit
DEV_AUTH_BYPASS=true
Add the publish-time image-hygiene CI assertions (D-10 / IMG-03) to `.gitea/workflows/publish.yml`: a static assertion (`.dockerignore` exists + covers forbidden patterns + `--target production` still pinned) and a boot-smoke that runs the freshly-built production image with the forbidden `NODE_ENV=production DEV_AUTH_BYPASS=true` combo and asserts it refuses to boot — proving the D-08 guard fires in the ACTUAL shipped image.
Purpose: The runtime guard (16-01) and the .dockerignore (16-04) are only as good as their enforcement at the boundary where the image is actually published. These assertions are the CI-level proof. Critically, they must run after docker build (so the image exists and the smoke can run it) but BEFORE docker push (so a hygiene regression cannot publish a broken image). The image build only happens at publish (push-to-main), so this attaches to publish.yml, not to every PR.
Output: The modified publish.yml. Depends on 16-01 (the boot guard + ENV NODE_ENV=production must be in the image for the smoke to pass) and 16-04 (the .dockerignore the static assertion greps for). Does not touch ci.yml.
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-CONTEXT.md
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md
@.gitea/workflows/publish.yml
Task 1: Restructure the Build/push step so build, assertions, and push are separable
- .gitea/workflows/publish.yml (the "Build and push" step lines 79-92 — currently builds then pushes in one run block; the tags step lines 43-55 outputs sha_tag/latest; the Docker logout step lines 95-97)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (publish.yml section — assertions placed BEFORE the docker push lines; set -euo pipefail; if: always() cleanup pattern)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (D-10 section — ordering rule: static assertions then boot-smoke BEFORE push; the existing structure runs steps sequentially)
In publish.yml, split the current "Build and push" step (lines 79-92) so the `docker build --target production ...` invocation is its own step ("Build production image") that builds + tags the image but does NOT push, and the two `docker push` lines move into a separate later "Push image" step (`set -euo pipefail`; push the immutable sha_tag FIRST, then latest — preserve the existing WR-04 ordering comment). Keep the `--target production`, `-f apps/api/Dockerfile`, both `-t` tags, and the root `.` context identical. Leave the Compute-tags step, Docker login, and Docker logout steps unchanged. This creates the seam where Task 2's assertions insert between build and push. Commit: `ci(16-06): split publish build and push into separate steps`.
python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/publish.yml')); steps=[s.get('name','') for s in d['jobs']['publish']['steps']]; assert any('Build' in n for n in steps) and any('Push' in n for n in steps), steps; print('build/push split OK:',steps)"
- publish.yml has a distinct build step (docker build, no push) and a distinct push step (docker push sha_tag then latest)
- --target production, the Dockerfile path, both tags, and the `.` context are unchanged
- The immutable-tag-first push ordering (WR-04) is preserved in the push step
Build and push are separate steps, creating an insertion point for the hygiene assertions.
Task 2: Insert static image-hygiene assertion + boot-smoke between build and push
- .gitea/workflows/publish.yml (the build step and push step from Task 1; the tags step outputs steps.tags.outputs.sha_tag)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (publish.yml section — exact static-assertion grep loop over patterns; the boot-smoke `timeout 15 docker run --rm --env NODE_ENV=production --env DEV_AUTH_BYPASS=true "$IMAGE"` block with the EXIT==0 fail, EXIT==124 timeout fail, otherwise PASS logic)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (D-10 section — full static + boot-smoke step bodies; Pitfall 5: guard fires before DB/OIDC/VAPID so no env beyond the forbidden combo is needed)
- .dockerignore (16-04 — the forbidden patterns the static assertion greps for must match)
- apps/api/src/lib/bootGuards.ts (16-01 — the guard the boot-smoke proves fires in the image)
Insert two new steps in publish.yml AFTER the "Build production image" step and BEFORE the "Push image" step. Step A "Image hygiene — static assertions" (`set -euo pipefail`): fail if .dockerignore is absent; loop over the forbidden patterns (.env, node_modules, apps/api/scripts, .git, .planning, apps/api/tests, apps/pwa/e2e) and fail if any is missing from .dockerignore; fail if `--target production` is no longer grep-able in .gitea/workflows/publish.yml; echo a PASS line. Step B "Image hygiene — boot-smoke (must refuse dev-bypass in production)" (`set -euo pipefail`): set IMAGE to ${{ steps.tags.outputs.sha_tag }}; `set +e`; `timeout 15 docker run --rm --env NODE_ENV=production --env DEV_AUTH_BYPASS=true "$IMAGE" 2>&1 | head -20`; capture EXIT; `set -e`; fail with a clear message if EXIT==0 (image started — guard not working) or EXIT==124 (timeout — guard not firing); otherwise echo PASS (image refused to start). Because both steps precede the push step and `set -euo pipefail` / non-zero exits stop the job, a failure blocks the push. Commit: `ci(16-06): add static image-hygiene assertion + boot-smoke before push`.
python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/publish.yml')); names=[s.get('name','') for s in d['jobs']['publish']['steps']]; bi=next(i for i,n in enumerate(names) if 'Build' in n); si=next(i for i,n in enumerate(names) if 'Push' in n); seg=names[bi+1:si]; assert any('static' in n.lower() for n in seg) and any('boot-smoke' in n.lower() for n in seg), names; print('assertions between build and push OK')"
- A static-assertions step and a boot-smoke step both appear strictly between the build step and the push step
- The static assertion greps for all forbidden .dockerignore patterns AND the `--target production` pin
- The boot-smoke runs the sha_tag image with NODE_ENV=production + DEV_AUTH_BYPASS=true and fails on EXIT 0 or 124, passes otherwise
- publish.yml parses as valid YAML; the push step still runs last (after the assertions)
The publish job builds, then asserts hygiene + boot-smoke, then pushes — a hygiene failure blocks publish.
<threat_model>
Trust Boundaries
Boundary
Description
built image → container registry
The push is the point of no return; once published, the image is pullable/deployable
Dockerfile/config drift → shipped image
A future change could re-introduce dev-bypass tolerance or strip the .dockerignore; the assertions catch that at publish
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-16-18
Elevation of Privilege
A regressed production image that tolerates DEV_AUTH_BYPASS=true is published
mitigate
D-10: boot-smoke runs the built image with the forbidden combo and fails the publish unless it exits non-zero (Task 2), proving the D-08 guard fires in the shipped artifact before push
T-16-19
Information Disclosure
A future change removes/weakens .dockerignore or drops --target production, shipping secrets/dev files
mitigate
D-10 static assertion fails the publish if .dockerignore is missing/incomplete or --target production is unpinned (Task 2)
T-16-20
Tampering
Assertions run after push, allowing a bad image to publish before the check fails
mitigate
Ordering enforced: build → assertions → push (Tasks 1-2); push is a separate later step, so any assertion failure stops the job before push
T-16-21
Denial of Service
Boot-smoke hangs if the guard does not fire, wedging the publish job
mitigate
timeout 15 caps the smoke; EXIT==124 is treated as a guard-not-firing failure (Task 2)
</threat_model>
- publish.yml parses as valid YAML
- Step order: Build production image → static assertions → boot-smoke → Push image
- Static assertion patterns match the .dockerignore authored in 16-04
- Boot-smoke uses sha_tag, the forbidden env combo, timeout 15, and the EXIT 0/124 fail logic
- Live proof (an actual publish run showing the smoke PASS) is the phase-verification check after merge
<success_criteria>
Static + boot-smoke assertions inserted between build and push
A hygiene/boot regression blocks the push
Boot-smoke proves the D-08 guard fires in the real production image