Files
familysync/.planning/phases/08-gitea-ci/08-04-PLAN.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

159 lines
12 KiB
Markdown

---
phase: 08-gitea-ci
plan: 04
type: execute
wave: 4
depends_on: ['08-03']
files_modified:
- .gitea/workflows/ci.yml
autonomous: false
requirements: [CI-02]
user_setup:
- service: gitea-registry-pat
why: 'Publish job authenticates to the Gitea container registry; created in Plan 01'
env_vars:
- name: GITEA_REGISTRY_PAT
source: 'Repo secret created in Plan 01 (write:package scope)'
must_haves:
truths:
- 'A merge (push) to main triggers a publish job that builds the API Docker production image and pushes it to the Gitea container registry'
- 'The image is pushed under two tags: :latest and :<milestone>-<shortsha> (e.g. v1.1-<7charsha>)'
- 'Registry authentication uses docker login --password-stdin with the PAT piped from a repo secret — the token never appears in plaintext in the CI log'
- 'The publish job runs only on push to main, never on pull_request, and never carries DEV_AUTH_BYPASS'
artifacts:
- path: '.gitea/workflows/ci.yml'
provides: 'push-to-main publish job (CI-02)'
contains: 'docker push'
key_links:
- from: '.gitea/workflows/ci.yml (publish job)'
to: 'git.bergerhouse.net registry'
via: 'docker login --password-stdin + docker build --target production + docker push'
pattern: '--password-stdin'
---
<objective>
Add the publish job to `.gitea/workflows/ci.yml`: on merge (push) to `main`, build the API Docker `production` image and push it to the Gitea container registry under `:latest` and `:<milestone>-<shortsha>`, authenticating with the operator PAT via `--password-stdin` so the credential never hits the log. This delivers CI-02 (ROADMAP criteria 5 + 6) and Pitfall 13 (--password-stdin).
Purpose: Every merge to main produces an immutable, traceable image (D-04) plus a moving :latest pointer, with zero credential exposure (ROADMAP criterion 6 is a hard requirement).
Output: a `publish` job in ci.yml gated on `push → main`.
</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/08-gitea-ci/08-RESEARCH.md
@.planning/research/PITFALLS.md
@.planning/phases/08-gitea-ci/08-01-SUMMARY.md
@apps/api/Dockerfile
</context>
<artifacts_this_phase_produces>
- `.gitea/workflows/ci.yml` (EXTENDED — adds the publish job; closes the phase)
</artifacts_this_phase_produces>
<interface_context>
Confirmed facts (do not re-derive):
- Git remote: `https://git.bergerhouse.net/luckberg/familysync.git` → registry host `git.bergerhouse.net`, owner `luckberg`. Image: `git.bergerhouse.net/luckberg/familysync-api` (08-RESEARCH §Registry Details).
- Dockerfile is multi-stage with a `production` target that builds API + PWA and serves both on :3000. It MUST be built from the REPO ROOT with `-f apps/api/Dockerfile .` (the Dockerfile header and 08-RESEARCH §Dockerfile Build Context say so — building from apps/api/ fails because it copies the root workspace manifest + lockfile).
- Milestone = `v1.1` (PROJECT.md "Current Milestone"). Per D-04, expose it as the workflow-level `env.MILESTONE` (already added in Plan 02) rather than hardcoding inline; update at milestone boundaries.
- Short SHA = `${GITHUB_SHA:0:7}` (CONFIRMED available in Gitea Actions; 08-RESEARCH). If 08-01-SUMMARY found GITHUB_SHA unavailable, fall back to `git rev-parse --short=7 HEAD`.
- Registry auth: PAT with write:package in repo secret `GITEA_REGISTRY_PAT` (created Plan 01). `GITHUB_TOKEN`/`GITEA_TOKEN` CANNOT push packages (08-RESEARCH; Gitea forum) — must use the PAT. Username = `luckberg`.
- Pitfall 13: NEVER `docker login -p $TOKEN` (token leaks to the log / process list). ALWAYS `echo "$PAT" | docker login git.bergerhouse.net -u luckberg --password-stdin`.
</interface_context>
<tasks>
<task type="auto">
<name>Task 1: Add the publish job (build + tag + login --password-stdin + push)</name>
<files>.gitea/workflows/ci.yml</files>
<read_first>
- .planning/phases/08-gitea-ci/08-RESEARCH.md (§Pattern 6 Docker publish; §Docker Registry Push; §Image Tag Strategy D-04; Pitfall on GITHUB_TOKEN)
- .planning/research/PITFALLS.md (Pitfall 13 --password-stdin)
- apps/api/Dockerfile (production target; build-from-root requirement)
- .planning/phases/08-gitea-ci/08-01-SUMMARY.md (docker socket access confirmed; GITHUB_SHA availability)
</read_first>
<action>
Add a `publish` job to ci.yml: `runs-on: self-hosted`, guarded `if: github.event_name == 'push' && github.ref == 'refs/heads/main'` (push-to-main ONLY — never pull_request; D-03). It runs independently of the PR jobs (those are pull_request-gated and won't fire on push). Do NOT set DEV_AUTH_BYPASS anywhere in this job (T-08-06 boundary).
Steps:
1. `uses: actions/checkout@v4`.
2. Compute tags (id: tags). Derive `SHORT_SHA=${GITHUB_SHA:0:7}` (fallback `git rev-parse --short=7 HEAD` if 08-01 flagged GITHUB_SHA missing). Use the workflow-level `${{ env.MILESTONE }}` (= v1.1). Emit two outputs:
`latest=git.bergerhouse.net/luckberg/familysync-api:latest`
`sha_tag=git.bergerhouse.net/luckberg/familysync-api:${MILESTONE}-${SHORT_SHA}`
(write to `$GITHUB_OUTPUT`).
3. Docker login via stdin (Pitfall 13 — the load-bearing security step):
`echo "${{ secrets.GITEA_REGISTRY_PAT }}" | docker login git.bergerhouse.net --username luckberg --password-stdin`
NEVER use `-p`/`--password` with the token as an argument. Do not `echo` the secret anywhere else; do not set it as a plain env var.
4. Build + push from REPO ROOT:
`docker build --target production -f apps/api/Dockerfile -t <latest> -t <sha_tag> .`
then `docker push <latest>` and `docker push <sha_tag>`.
5. Final `if: always()` step: `docker logout git.bergerhouse.net || true` to drop the stored credential from the runner after push.
Use `docker/login-action`/`docker/build-push-action` ONLY if 08-01-SUMMARY confirmed they resolve AND you prefer them; the shell `docker login --password-stdin` + `docker build`/`docker push` form is the safer first iteration (08-RESEARCH §Pattern 6 note) and is the recommended path.
</action>
<verify>
<automated>grep -q "github.event_name == 'push'" .gitea/workflows/ci.yml && grep -q "refs/heads/main" .gitea/workflows/ci.yml && grep -q -- "--password-stdin" .gitea/workflows/ci.yml && ! grep -E "docker login.*(-p |--password )[^-]" .gitea/workflows/ci.yml && grep -q "docker build --target production" .gitea/workflows/ci.yml && grep -q "familysync-api:latest" .gitea/workflows/ci.yml && grep -q 'familysync-api:${MILESTONE}' .gitea/workflows/ci.yml && grep -q "docker push" .gitea/workflows/ci.yml && ! grep -qi "DEV_AUTH_BYPASS" <(awk '/publish:/,0' .gitea/workflows/ci.yml) && echo PUBLISH_OK</automated>
</verify>
<done>The publish job runs only on push to main, logs in with --password-stdin (never -p), builds the production target from repo root, pushes :latest and :${MILESTONE}-<shortsha>, logs out, and never sets DEV_AUTH_BYPASS.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 2: Merge, audit the publish log, and verify both tags</name>
<what-built>The publish job (Task 1). Verifying it requires merging the PR to main and auditing the resulting CI log + registry — the executor cannot merge a protected branch.</what-built>
<how-to-verify>
1. Merge the PR (all PR jobs green) into `main` — the push triggers the publish job.
2. In Gitea → Actions, open the publish job log and AUDIT it line by line: the PAT must NOT appear in plaintext anywhere (ROADMAP criterion 6 — hard requirement). The `docker login` line should show `--password-stdin`, never the token. If the token is visible, STOP — rotate the PAT and fix before anything else.
3. Confirm the build used `--target production -f apps/api/Dockerfile .` and succeeded.
4. In Gitea → repo → Packages, confirm `familysync-api` exists with BOTH tags: `latest` and `v1.1-<7charsha>` matching the merge commit.
5. (Optional) `docker pull git.bergerhouse.net/luckberg/familysync-api:latest` from a machine with registry access to confirm the image is pullable.
</how-to-verify>
<resume-signal>Type "publish verified" once both tags exist in the registry AND the log audit confirms no plaintext PAT, or describe the failure.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
| -------------------------- | ----------------------------------------------------------------------- |
| Repo secret → docker login | PAT crosses into the job; the single highest-value secret in this phase |
| publish job → registry | Authenticated push to the package registry |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
| --------- | ---------------------- | ---------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| T-08-PAT | Information Disclosure | GITEA_REGISTRY_PAT in publish job | mitigate | `docker login --password-stdin` exclusively — token piped via stdin, never an `-p`/`--password` argument (Pitfall 13). Token referenced only as `${{ secrets.GITEA_REGISTRY_PAT }}` (Gitea masks registered secrets in logs); never echoed elsewhere; `docker logout` after push. Grep gate forbids `-p`/`--password` forms. Checkpoint requires a line-by-line log audit (ROADMAP criterion 6). This is the load-bearing mitigation for the phase. |
| T-08-09 | Spoofing | DEV_AUTH_BYPASS bleed into publish | mitigate | Publish job never sets DEV_AUTH_BYPASS (grep gate scoped to the publish: block); the bypass is confined to the harness job (Plan 03). |
| T-08-10 | Tampering | wrong build context | mitigate | Build from repo root with `-f apps/api/Dockerfile .` (Dockerfile requires root context for the workspace manifest + lockfile); building from apps/api/ would fail or produce a broken image. |
</threat_model>
<verification>
- ci.yml passes the Task grep gate (push-to-main guard, --password-stdin, no -p, production target from root, both tags, no DEV_AUTH_BYPASS in publish block).
- After merge: both tags present in the Gitea registry; log audit shows no plaintext PAT.
</verification>
<success_criteria>
- CI-02: on merge to main, the API production image is built and pushed to the Gitea registry under :latest + :v1.1-<shortsha> (ROADMAP criterion 5; D-04).
- Registry credentials never appear in plaintext in the CI logs (ROADMAP criterion 6; Pitfall 13) — the load-bearing security outcome of the phase.
- Publish runs only on push to main; DEV_AUTH_BYPASS never bleeds into it.
</success_criteria>
<output>
Create `.planning/phases/08-gitea-ci/08-04-SUMMARY.md` when done. Record: the final image name + both tags pushed, confirmation the log audit found no plaintext PAT, and whether the shell or docker/* action form was used.
</output>