---
phase: quick-260611-ozt
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - .gitea/workflows/publish.yml
  - .gitea/workflows/ci.yml
  - README.md
autonomous: true
requirements: [WR-01]

must_haves:
  truths:
    - 'On a pull_request to main, Gitea no longer creates a CI / publish (pull_request) commit status (no orphan pending status).'
    - 'On push to main (PR merge), the Publish workflow builds and pushes git.bergerhouse.net/luckberg/familysync-api with :latest and :<MILESTONE>-<shortsha> tags.'
    - 'The three required PR status contexts (CI / fast-checks, CI / api, CI / harness) are unchanged in name and behavior.'
    - 'A maintainer reading the README and publish.yml header can determine how, when, and under what safety gate publishing happens, plus how to bump MILESTONE.'
  artifacts:
    - path: '.gitea/workflows/publish.yml'
      provides: 'Standalone push-to-main image publish workflow with documented release model'
      contains: 'name: Publish'
    - path: '.gitea/workflows/ci.yml'
      provides: 'PR-only CI workflow (fast-checks, api, harness); no publish job, no push trigger, no MILESTONE env'
      contains: 'name: CI'
    - path: 'README.md'
      provides: 'Release / image-publishing documentation section'
      contains: 'Publishing'
  key_links:
    - from: '.gitea/workflows/publish.yml'
      to: 'secrets.REGISTRY_PAT'
      via: 'docker login --password-stdin'
      pattern: "secrets\\.REGISTRY_PAT"
    - from: '.gitea/workflows/publish.yml'
      to: 'env.MILESTONE'
      via: 'Compute image tags step reads workflow-level MILESTONE'
      pattern: "env\\.MILESTONE"
---

<objective>
Split the `publish` job out of `.gitea/workflows/ci.yml` into a new standalone push-only `.gitea/workflows/publish.yml`, and document the release model where maintainers will find it. Mechanical refactor — no CI behavior change beyond the split.

Purpose: `ci.yml`'s `on:` includes `pull_request`, so Gitea registers an orphaned `CI / publish (pull_request)` commit status that sits pending forever on every PR (skipped jobs never resolve their status in Gitea Actions). A push-only `publish.yml` stops the orphan from ever being created. Motivated by phase-8 review finding WR-01 and the new branch-protection rule on `main` (direct/force push blocked; required checks = the three PR jobs).

Output: `.gitea/workflows/publish.yml` (new), edited `.gitea/workflows/ci.yml`, README "Publishing / Releases" section.
</objective>

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

<context>
@.planning/STATE.md
@.gitea/workflows/ci.yml
@README.md
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create publish.yml and strip publish from ci.yml</name>
  <files>.gitea/workflows/publish.yml, .gitea/workflows/ci.yml</files>
  <action>
Create `.gitea/workflows/publish.yml` as a standalone push-only workflow:
- `name: Publish`
- Trigger: `on: push: branches: [main]` ONLY. Do NOT carry over the `if: github.event_name == 'push' && github.ref == 'refs/heads/main'` guard from the old job — the push-to-main trigger alone fully replaces it (redundant guard).
- Workflow-level `env: MILESTONE: v1.1` (this env is publish-only and moves to this file).
- A single job `publish` with `runs-on: ubuntu-latest` (ubuntu-latest is mandatory per D-PROBE-01 — the runner has no self-hosted label). Move the job's four real steps VERBATIM from the current ci.yml publish job (ci.yml lines 318-362): `actions/checkout@v4`; `Compute image tags` (id: tags); `Docker login` (PAT piped via `--password-stdin` from `secrets.REGISTRY_PAT`, username `luckberg`, registry `git.bergerhouse.net`); `Build and push` (docker build `--target production -f apps/api/Dockerfile` from repo root `.`, two `-t` tags, two `docker push`); `Docker logout` with `if: always()`.
- Preserve EVERY existing inline comment on those steps verbatim — they encode load-bearing rationale: PAT-via-stdin security (Pitfall 13), REGISTRY_PAT naming because Gitea reserves the GITEA_ prefix (D-PAT-NAMING), build-from-repo-root for the pnpm workspace manifest+lockfile (T-08-10), and the D-04 two-tag scheme (:latest moving + :<milestone>-<shortsha> immutable). The Compute-image-tags comment references "the workflow-level env var (set to v1.1 above)" — keep that accurate since MILESTONE now lives at the top of THIS file.
- Add a header comment block (see Task 2 — same content as the README section, condensed) at the very top of publish.yml above `name: Publish`.

Then edit `.gitea/workflows/ci.yml`:

- Remove the entire `publish:` job (current lines 314-362).
- Remove the `push:` trigger key from `on:` (lines 6-7), leaving only `pull_request: branches: [main]`.
- Remove the workflow-level `env: MILESTONE: v1.1` block (lines 9-10) — it was referenced ONLY by the publish job (confirmed: grep ci.yml for MILESTONE returns only the publish Compute-image-tags step). Do not leave an empty `env:` key.
- Do NOT rename `name: CI` or the job ids `fast-checks` / `api` / `harness`, and do NOT remove their `if: github.event_name == 'pull_request'` guards — renaming or removing would change/break the required status contexts (`CI / fast-checks (pull_request)`, `CI / api (pull_request)`, `CI / harness (pull_request)`). The guards are harmless now that `push:` is gone; leave them.
  </action>
  <verify>
  <automated>test -f .gitea/workflows/publish.yml && grep -q 'name: Publish' .gitea/workflows/publish.yml && grep -q 'secrets.REGISTRY_PAT' .gitea/workflows/publish.yml && grep -q '--password-stdin' .gitea/workflows/publish.yml && grep -q 'MILESTONE: v1.1' .gitea/workflows/publish.yml && grep -Eq '^\s*push:' .gitea/workflows/publish.yml && ! grep -q "github.event_name == 'push'" .gitea/workflows/publish.yml && ! grep -q 'publish:' .gitea/workflows/ci.yml && ! grep -q 'MILESTONE' .gitea/workflows/ci.yml && ! grep -Eq '^\s*push:' .gitea/workflows/ci.yml && grep -q 'pull_request:' .gitea/workflows/ci.yml && grep -q 'name: CI' .gitea/workflows/ci.yml && grep -c 'if:' .gitea/workflows/ci.yml | grep -qE '^[3-9]'</automated>
  </verify>
  <done>publish.yml exists with name=Publish, push-to-main-only trigger, no redundant if-guard, workflow-level MILESTONE, the four publish steps with all inline comments intact, and a header doc block. ci.yml has no publish job, no push trigger, no MILESTONE env, retains name=CI and all three PR jobs with their guards.</done>
  </task>

<task type="auto">
  <name>Task 2: Document the release model and verify YAML well-formedness</name>
  <files>README.md, .gitea/workflows/publish.yml</files>
  <action>
Add a "Publishing / Releases" section to `README.md` (insert after the existing "Deployment" section, before "License"). README is the most discoverable location for a maintainer and already documents Commands/Deployment, so no separate docs/RELEASE.md is created (decision: README is where this 2-person project's maintainer looks). The section MUST cover:
- Publishing happens automatically on push to `main` — i.e. when a PR merges. The `.gitea/workflows/publish.yml` workflow runs.
- It builds and pushes `git.bergerhouse.net/luckberg/familysync-api` with TWO tags: `:latest` (moving pointer for easy pulls) and `:<MILESTONE>-<shortsha>` (immutable, rollback-traceable, e.g. `v1.1-98acff8`).
- It requires the `REGISTRY_PAT` repo secret — a Gitea Actions secret holding a PAT with `write:package` scope. Named `REGISTRY_PAT` (not `GITEA_*`) because Gitea reserves the `GITEA_` secret-name prefix (D-PAT-NAMING). `GITEA_TOKEN`/`GITHUB_TOKEN` cannot push packages.
- Safety gate is BRANCH PROTECTION on `main`, not a CI `needs:`. The PR test jobs (fast-checks, api, harness in ci.yml) and the publish job never run in the same workflow invocation, so publish.yml has no `needs:` on the tests. Tests gate the PR; `main` is trusted to be green because direct push and force push are blocked and the three checks (`CI / fast-checks (pull_request)`, `CI / api (pull_request)`, `CI / harness (pull_request)`) are required to merge.
- How to bump the milestone tag at milestone boundaries: edit the `MILESTONE` env value at the top of `.gitea/workflows/publish.yml`.

Then write the SAME information condensed into the header comment block at the top of `publish.yml` (the block referenced in Task 1) — short bullet lines covering: trigger (push to main / PR merge), the two tags + image, the REGISTRY_PAT secret + naming reason, the branch-protection safety gate / why no test needs:, and the MILESTONE-bump instruction.

Finally verify both workflow YAML files are well-formed. No `yamllint`/`act`/`js-yaml`/`pyyaml` is available locally (confirmed during planning: no YAML parser in any node_modules, no pyyaml, no ruby yaml). Docker IS available, so parse both files strictly with the purpose-built yq image (no network beyond the image pull, no repo deps):
`docker run --rm -i mikefarah/yq:4 e '.' - < .gitea/workflows/publish.yml` and likewise for ci.yml — a malformed file makes yq exit non-zero. If the yq image cannot be pulled (offline), fall back to structural inspection: re-read both files end-to-end, confirm consistent 2-space indentation, that every `run: |` block body is indented under its key, that the moved publish steps parse as a list under `jobs.publish.steps`, and explicitly NOTE in the SUMMARY that YAML was verified by inspection only (no parser available).
</action>
<verify>
<automated>grep -qi 'Publishing\|Releases' README.md && grep -q 'REGISTRY_PAT' README.md && grep -q 'familysync-api' README.md && grep -q 'MILESTONE' README.md && grep -q 'branch protection' README.md && grep -qi 'REGISTRY_PAT' .gitea/workflows/publish.yml && (docker run --rm -i mikefarah/yq:4 e '.' - < .gitea/workflows/publish.yml >/dev/null 2>&1 && docker run --rm -i mikefarah/yq:4 e '.' - < .gitea/workflows/ci.yml >/dev/null 2>&1 || echo 'YAML-PARSER-UNAVAILABLE-INSPECTED-MANUALLY')</automated>
</verify>
<done>README has a Publishing/Releases section covering all six points (auto-on-push-to-main, image+two-tags, REGISTRY_PAT secret + naming, branch-protection safety gate, no test needs:, MILESTONE bump). publish.yml header block carries the condensed same. Both YAML files parse cleanly under yq (or are noted as inspected-only if no parser pulled).</done>
</task>

</tasks>

<verification>
- `.gitea/workflows/publish.yml` exists: `name: Publish`, `on: push: branches: [main]` only, workflow-level `MILESTONE: v1.1`, single `publish` job with the four steps + all inline comments preserved, and a documentation header block.
- `.gitea/workflows/ci.yml`: no `publish:` job, no `push:` trigger, no `MILESTONE` env; `name: CI` and the three job ids unchanged; PR guards retained.
- The three required status contexts are name-stable → branch protection on `main` remains valid.
- README documents the release model; both YAML files verified well-formed (parser or noted inspection).
</verification>

<success_criteria>

- A PR to `main` produces only `CI / fast-checks`, `CI / api`, `CI / harness` statuses — no `CI / publish` orphan.
- A merge to `main` triggers the `Publish` workflow, building/pushing `familysync-api:latest` + `familysync-api:v1.1-<shortsha>`.
- No behavior change to the three PR jobs; required checks still satisfiable.
- Release process is discoverable in README and in the publish.yml header.
  </success_criteria>

<output>
Create `.planning/quick/260611-ozt-split-publish-job-into-standalone-gitea-/260611-ozt-SUMMARY.md` when done.
</output>
