---
phase: 16-ci-dependency-audit-and-security-checks
plan: 06
type: execute
wave: 2
depends_on: ["16-01", "16-04"]
files_modified:
  - .gitea/workflows/publish.yml
autonomous: true
requirements: [IMG-03]
must_haves:
  truths:
    - "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"
  artifacts:
    - path: ".gitea/workflows/publish.yml"
      provides: "static image-hygiene assertion + boot-smoke steps, ordered before docker push"
      contains: "boot-smoke"
  key_links:
    - from: ".gitea/workflows/publish.yml boot-smoke"
      to: "apps/api/src/lib/bootGuards.ts (via the built image)"
      via: "docker run prod image with forbidden env, assert non-zero exit"
      pattern: "DEV_AUTH_BYPASS=true"
---

<objective>
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.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.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
</context>

<tasks>

<task type="auto">
  <name>Task 1: Restructure the Build/push step so build, assertions, and push are separable</name>
  <read_first>
    - .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)
  </read_first>
  <action>
    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`.
  </action>
  <verify>
    <automated>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)"</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>Build and push are separate steps, creating an insertion point for the hygiene assertions.</done>
</task>

<task type="auto">
  <name>Task 2: Insert static image-hygiene assertion + boot-smoke between build and push</name>
  <read_first>
    - .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)
  </read_first>
  <action>
    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`.
  </action>
  <verify>
    <automated>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')"</automated>
  </verify>
  <acceptance_criteria>
    - 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)
  </acceptance_criteria>
  <done>The publish job builds, then asserts hygiene + boot-smoke, then pushes — a hygiene failure blocks publish.</done>
</task>

</tasks>

<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>

<verification>
- 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
</verification>

<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
- ci.yml untouched; publish topology otherwise unchanged
</success_criteria>

<output>
Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-06-SUMMARY.md` when done.
</output>
