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