6 Commits
Author SHA1 Message Date
Lucas Berger bcc9682a01 docs(roadmap): promote Phases 13 (real lint gate) + 14 (desktop e2e) into v1.1; fold backlog 999.5 self-service onboarding into admin-credential phase
CI / fast-checks (pull_request) Successful in 50s
CI / api (pull_request) Successful in 1m1s
CI / harness (pull_request) Successful in 3m33s
2026-06-11 19:13:31 -04:00
luckberg b12e10d129 Merge pull request 'chore(ci): split publish into standalone push-only workflow + document release model' (#5) from gsd/quick-split-publish into main
Publish / publish (push) Successful in 7s
Reviewed-on: #5
2026-06-11 19:04:30 -04:00
Lucas Berger 234384c142 docs(quick-260611-ozt): split publish job into standalone gitea workflow + document release model
CI / fast-checks (pull_request) Successful in 49s
CI / api (pull_request) Successful in 56s
CI / harness (pull_request) Successful in 3m22s
2026-06-11 18:08:27 -04:00
Lucas Berger 92353e1860 docs(260611-ozt): document release model in README Publishing/Releases section
- Add Publishing / Releases section covering auto-trigger, image tags,
  REGISTRY_PAT secret naming, branch-protection safety gate, and MILESTONE bump
- publish.yml already carries condensed header block (committed in prior task)
2026-06-11 18:06:46 -04:00
Lucas Berger bb331fd110 chore(260611-ozt): split publish job into standalone publish.yml
- Create .gitea/workflows/publish.yml (push-to-main only, name=Publish)
- Strip publish job, push trigger, and MILESTONE env from ci.yml
- Eliminates orphaned CI / publish (pull_request) status on PRs
- Preserves all three required PR status contexts unchanged
2026-06-11 18:06:46 -04:00
Lucas Berger e14054c264 docs(260611-ozt): pre-dispatch plan for split publish job into standalone gitea workflow 2026-06-11 18:02:57 -04:00
7 changed files with 365 additions and 83 deletions
-55
View File
@@ -3,11 +3,6 @@ name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
env:
MILESTONE: v1.1
jobs:
fast-checks:
@@ -310,53 +305,3 @@ jobs:
name: playwright-traces-${{ github.run_id }}
path: apps/pwa/test-results/
retention-days: 14
publish:
runs-on: ubuntu-latest
# Push to main only — never on pull_request (D-03). No dev-bypass flag in this job (T-08-09).
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
# Compute both image tags per D-04:
# :latest — moving pointer for easy pulls
# :<milestone>-<shortsha> — immutable, rollback-traceable (e.g. v1.1-4303a1b)
# GITHUB_SHA is confirmed available in Gitea Actions (probe P-13).
# MILESTONE is read from the workflow-level env var (set to v1.1 above) — update at milestone boundaries.
- name: Compute image tags
id: tags
run: |
SHORT_SHA=${GITHUB_SHA:0:7}
MILESTONE="${{ env.MILESTONE }}"
echo "latest=git.bergerhouse.net/luckberg/familysync-api:latest" >> $GITHUB_OUTPUT
echo "sha_tag=git.bergerhouse.net/luckberg/familysync-api:${MILESTONE}-${SHORT_SHA}" >> $GITHUB_OUTPUT
# Pitfall 13 (load-bearing security step): PAT piped via stdin — never via -p/--password.
# GITEA_TOKEN/GITHUB_TOKEN cannot push packages; a PAT with write:package scope is required
# (confirmed: Gitea forum + registry docs). Token is masked by Gitea's secret-log scrubber
# and never echoed elsewhere or set as a plain env var.
# Secret is named REGISTRY_PAT (not GITEA_REGISTRY_PAT): Gitea reserves the GITEA_ prefix
# for secret names, so the GITEA_-prefixed name cannot be created.
- name: Docker login
run: |
echo "${{ secrets.REGISTRY_PAT }}" | \
docker login git.bergerhouse.net \
--username luckberg \
--password-stdin
# Build from REPO ROOT (T-08-10): the Dockerfile copies the pnpm workspace manifest +
# lockfile from the root context; building from apps/api/ would fail to find them.
- name: Build and push
run: |
docker build --target production \
-f apps/api/Dockerfile \
-t ${{ steps.tags.outputs.latest }} \
-t ${{ steps.tags.outputs.sha_tag }} \
.
docker push ${{ steps.tags.outputs.latest }}
docker push ${{ steps.tags.outputs.sha_tag }}
# Always drop the stored credential from the runner after push (defence in depth).
- name: Docker logout
if: always()
run: docker logout git.bergerhouse.net || true
+77
View File
@@ -0,0 +1,77 @@
# Publishing / Releases
#
# Trigger: push to main — i.e. when any PR merges.
# Image: git.bergerhouse.net/luckberg/familysync-api
# Tags:
# :latest — moving pointer for easy pulls
# :<MILESTONE>-<shortsha> — immutable, rollback-traceable (e.g. v1.1-98acff8)
#
# Required secret: REGISTRY_PAT — a Gitea Actions secret holding a PAT with write:package scope.
# Named REGISTRY_PAT (not GITEA_*): Gitea reserves the GITEA_ prefix for secret names, so
# GITEA_-prefixed names cannot be created. GITEA_TOKEN / GITHUB_TOKEN cannot push packages.
#
# Safety gate: branch protection on main, NOT a needs: dependency in this file.
# The PR test jobs (fast-checks, api, harness in ci.yml) run on pull_request — they never
# run in the same workflow invocation as publish.yml. Tests gate the PR; main is trusted to
# be green because direct push and force push are blocked and the three required checks
# (CI / fast-checks, CI / api, CI / harness) must pass before merge.
#
# To bump the milestone tag at a milestone boundary: edit MILESTONE below.
name: Publish
on:
push:
branches: [main]
env:
MILESTONE: v1.1
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Compute both image tags per D-04:
# :latest — moving pointer for easy pulls
# :<milestone>-<shortsha> — immutable, rollback-traceable (e.g. v1.1-4303a1b)
# GITHUB_SHA is confirmed available in Gitea Actions (probe P-13).
# MILESTONE is read from the workflow-level env var (set to v1.1 above) — update at milestone boundaries.
- name: Compute image tags
id: tags
run: |
SHORT_SHA=${GITHUB_SHA:0:7}
MILESTONE="${{ env.MILESTONE }}"
echo "latest=git.bergerhouse.net/luckberg/familysync-api:latest" >> $GITHUB_OUTPUT
echo "sha_tag=git.bergerhouse.net/luckberg/familysync-api:${MILESTONE}-${SHORT_SHA}" >> $GITHUB_OUTPUT
# Pitfall 13 (load-bearing security step): PAT piped via stdin — never via -p/--password.
# GITEA_TOKEN/GITHUB_TOKEN cannot push packages; a PAT with write:package scope is required
# (confirmed: Gitea forum + registry docs). Token is masked by Gitea's secret-log scrubber
# and never echoed elsewhere or set as a plain env var.
# Secret is named REGISTRY_PAT (not GITEA_REGISTRY_PAT): Gitea reserves the GITEA_ prefix
# for secret names, so the GITEA_-prefixed name cannot be created.
- name: Docker login
run: |
echo "${{ secrets.REGISTRY_PAT }}" | \
docker login git.bergerhouse.net \
--username luckberg \
--password-stdin
# Build from REPO ROOT (T-08-10): the Dockerfile copies the pnpm workspace manifest +
# lockfile from the root context; building from apps/api/ would fail to find them.
- name: Build and push
run: |
docker build --target production \
-f apps/api/Dockerfile \
-t ${{ steps.tags.outputs.latest }} \
-t ${{ steps.tags.outputs.sha_tag }} \
.
docker push ${{ steps.tags.outputs.latest }}
docker push ${{ steps.tags.outputs.sha_tag }}
# Always drop the stored credential from the runner after push (defence in depth).
- name: Docker logout
if: always()
run: docker logout git.bergerhouse.net || true
+52 -27
View File
@@ -3,7 +3,7 @@
## Milestones
-**v1.0 MVP** — Phases 16 (shipped 2026-06-10) — see [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md)
- 🚧 **v1.1 Operability & Polish** — Phases 712 (planning) — mobile test harness, Gitea CI (runs the harness), faster write-back, in-app admin, per-event reminders, guided setup
- 🚧 **v1.1 Operability & Polish** — Phases 714 (planning) — mobile test harness, Gitea CI (runs the harness), faster write-back, in-app admin, per-event reminders, guided setup, real lint gate, desktop e2e
## Phases
@@ -21,7 +21,7 @@ Full phase detail archived in [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROA
</details>
### 🚧 v1.1 Operability & Polish (Phases 712)
### 🚧 v1.1 Operability & Polish (Phases 714)
Make FamilySync configurable, administrable, and maintainable for real multi-member use — without hand-editing env files or the database. The new critical path runs **mobile test harness → Gitea CI** (CI consumes the harness specs for UI regression), and the **admin role → reminders / setup wizard** chain (a single `/api/admin` + `/api/setup` route surface carrying the v1.1 DB migration). Faster write-back is a fully independent track.
@@ -31,6 +31,8 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
- [ ] **Phase 10: Admin Role & Settings** - DB foundation (is_admin / reminder_lead / app_config) + role-gated admin UI to rotate app passwords and designate the shared calendar
- [ ] **Phase 11: Per-Event Reminders** - Reminder selector on the event form (incl. "None") serialized as VALARM, with a variable-lead scheduler that honors each event's choice
- [ ] **Phase 12: Initial Setup Wizard** - First-run validated bootstrap of env/VAPID/DB/OIDC + first app password, reusing the admin route surface
- [ ] **Phase 13: Real Lint Gate (ESLint)** - Wire ESLint flat config (typescript-eslint + React) across both apps so the Phase 8 CI lint slot actually fails on violations instead of no-op'ing
- [ ] **Phase 14: Desktop E2E Coverage** - Add a Desktop Chrome Playwright profile + make the mobile-authored specs desktop-safe so the Phase 8 regression gate validates desktop, not just mobile
## Phase Details
@@ -155,6 +157,8 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
- **App password never logged/echoed** (Pitfall 7): custom zod-validator `hook` returns a generic 400 (no Zod `received`/`value` field); no `console.log` of request bodies in `routes/admin*`.
- Hard constraints: Drizzle **generate+migrate, never push** (false destructive diff on populated MariaDB); reuse `broker/crypto.ts` `encryptPassword` (no changes to crypto); `/api/admin/credentials` and `/api/admin/calendars/:id/shared` are the single shared surface — do NOT duplicate them into `/api/setup/*` in Phase 12.
**Folded-in scope** (from backlog 999.5, self-service member onboarding): the credential surface this phase builds is the same one a member needs on first login. Expose a `needsProviderSetup` signal (member has no `member_credentials` row) and let a member enter/validate (CalDAV PROPFIND) + encrypt their **own** Fastmail app password — the self-service counterpart of the admin-managed flow, sharing the validation/encryption/initial-sync path. Non-technical-friendly instructions (link to Fastmail's app-password page, required Calendars/CalDAV scope) are the hard UX constraint. Member-scoped: a member can only set their own credential; never log/echo the password.
**Plans**: TBD
**UI hint**: yes
@@ -206,6 +210,46 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
**Plans**: TBD
**UI hint**: yes
### Phase 13: Real Lint Gate (ESLint)
**Goal**: The CI lint gate actually fails on lint violations. A real ESLint flat config (`eslint.config.js`, `typescript-eslint`; React + react-hooks plugins for `apps/pwa`) plus a package-level `lint` script in `apps/api` and `apps/pwa` makes the existing root `pnpm -r --if-present lint` run a real linter, replacing the hollow no-op gate that exits 0 because no linter exists.
**Mode:** standard
**Depends on**: Phase 8 (the CI `fast-checks` job already runs `pnpm lint`; this fills the slot Phase 8 shipped wired to auto-activate once a package `lint` script lands). Independent of all other phases.
**Requirements**: TBD (promoted from backlog 999.16)
**Success Criteria** (what must be TRUE):
1. `pnpm lint` runs ESLint across both `apps/api` and `apps/pwa` and exits non-zero on an introduced violation (verified by a deliberate test violation), where today it exits 0 with no linter present.
2. The CI `fast-checks` lint step blocks a PR to main on lint violations — the gate can now fail.
3. The first real run's existing violations are resolved (fix / warn / disable decided per rule) so the baseline gate ends green.
**Pitfalls this phase owns**:
- Pick a baseline ruleset (recommended vs strict-type-checked) deliberately — strict surfaces a large upfront cleanup; decide blocking vs advisory before flipping the gate to blocking.
- `typecheck`/tsc already gates type errors; ESLint should not duplicate type-checking rules unnecessarily.
**Plans**: TBD
**UI hint**: no
### Phase 14: Desktop E2E Coverage
**Goal**: The Phase 8 regression gate exercises the desktop layout and flows, not just mobile. A `desktop` Playwright project (`devices['Desktop Chrome']`, no touch, wide viewport) is added to `apps/pwa/playwright.config.ts`, and the existing mobile-authored specs are reviewed/adjusted (or appropriately skipped) so `pnpm test:e2e` passes on a no-touch desktop viewport as well as the `iphone`/`pixel` profiles.
**Mode:** standard
**Depends on**: Phase 7 (the harness it extends) and Phase 8 (CI runs `pnpm test:e2e` and picks up the new project automatically — no CI plumbing change needed beyond any desktop-profile runtime/wait). Independent of Phases 913.
**Requirements**: TBD (promoted from backlog 999.15)
**Success Criteria** (what must be TRUE):
1. A `desktop` project exists in `playwright.config.ts` (Desktop Chrome, wide viewport, no `hasTouch`).
2. The existing e2e specs pass (or are explicitly, justifiably skipped) on the desktop profile — touch-gesture / mobile-drawer / mobile-only-layout assumptions are handled.
3. `pnpm test:e2e` in CI runs and gates on both mobile and desktop profiles (blocking-vs-advisory for desktop decided when planned).
**Pitfalls this phase owns**:
- The real work is the spec-compat pass, not CI plumbing — Phase 8 reused the Phase 7 harness unchanged, so the config addition is small but specs authored for touch/mobile need per-spec review.
- Desktop WebKit is optional — the Apple member is already covered on mobile Safari via `iphone`; Desktop Chrome is likely sufficient for a shared/wall browser.
**Plans**: TBD
**UI hint**: no
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
@@ -222,6 +266,8 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
| 10. Admin Role & Settings | v1.1 | 0/? | Not started | - |
| 11. Per-Event Reminders | v1.1 | 0/? | Not started | - |
| 12. Initial Setup Wizard | v1.1 | 0/? | Not started | - |
| 13. Real Lint Gate (ESLint) | v1.1 | 0/? | Not started | - |
| 14. Desktop E2E Coverage | v1.1 | 0/? | Not started | - |
## Backlog
@@ -255,31 +301,6 @@ Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.5: First-login provider setup — prompt + instructions to add a Fastmail app password (BACKLOG)
**Goal:** [Captured for future planning] On a member's first login there is no onboarding to connect their own calendar provider. Today the broker uses a single seeded Fastmail app password (the operator's), so a second member (e.g. the wife) who logs in sees only what that token reaches — she has no way to attach her **own** Fastmail personal calendar (the D-09 per-member app-password model). Add a first-login flow that detects a member has no `member_credentials` row and prompts them to create + paste a Fastmail app password, with clear step-by-step instructions (where to generate it in Fastmail settings, required scope: Calendars/CalDAV, that one app password covers all of that account's calendars). Store it encrypted (APP_PASSWORD_ENCRYPTION_KEY, existing crypto path), then trigger an initial sync so their personal calendar lane populates.
**Context** (surfaced 2026-06-07, Gate 2 live testing): the wife logged in on her iPhone and added the PWA to her Home Screen, but there is no provider-setup step — so her personal calendar can't be connected. This is the onboarding half of the "each member's personal calendar" v1 requirement.
**Scope to decide when promoted:**
- Detect "no credential yet" state server-side (`GET /api/me` exposes a `needsProviderSetup` flag, or a dedicated endpoint) and gate a setup screen in the PWA.
- App-password entry UI + validation (test the credential with a CalDAV PROPFIND before saving), encrypted storage, and triggering the first sync.
- Non-technical-friendly instructions (the hard UX constraint) — ideally with a direct link to Fastmail's app-password page and a screenshot/walkthrough.
- Decide the model: does every member attach their own personal calendar, or do some members only see the shared family calendar? (Open question from D-16.)
- Security: never log/echo the app password; member-scoped; T-03-19 style scoping.
**Severity:** high for true multi-member use — without it the second member has no personal calendar. Tags: phase-03, onboarding, auth, caldav, per-member-credential, D-09.
> **Note:** v1.1 covers the *admin-managed* counterpart (ADMIN-01, Phase 10) — an admin can set any member's app password. *Self-service* member onboarding (member adds their own) stays deferred here.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.10: Admin Settings / Administration section — manage app passwords + designate the shared calendar via UI (BACKLOG)
**Goal:** [Captured for future planning] Add an in-app **Settings/Administration** section, gated to an administrator role, for configuration that today requires manual backend/DB steps:
@@ -404,6 +425,8 @@ Plans:
**Context:** Deferred from Phase 8 (Gitea CI) planning, 2026-06-11 — user wants both mobile and desktop validated, but desktop needs a config addition + spec review that is out of Phase 8's CI-plumbing scope. Tags: testing, playwright, e2e, desktop, harness, ci.
> **Promoted into v1.1 Phase 14 (Desktop E2E Coverage) — 2026-06-11.** Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
@@ -426,6 +449,8 @@ Plans:
**Context:** Raised during Phase 8 execution, 2026-06-11 — user noted the `--if-present` lint step "didn't fix the linter, just made it so it didn't have to exist to proceed" and wants a lint gate that actually fails. Tags: ci, lint, eslint, typescript-eslint, quality, gitea.
> **Promoted into v1.1 Phase 13 (Real Lint Gate / ESLint) — 2026-06-11.** Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
+2 -1
View File
@@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-06-10)
Phase: 08 (gitea-ci) — COMPLETE
Plan: 4 of 4 (08-04 complete)
Status: Phase 08 complete — all 4 plans executed, CI-01 + CI-02 delivered
Last activity: 2026-06-11 -- 08-04 complete; publish job green (run #14): both tags pushed, PAT masked, REGISTRY_PAT naming fix applied. Phase 8 fully complete.
Last activity: 2026-06-11 -- Quick task 260611-ozt: split publish into standalone push-only publish.yml (kills orphaned CI / publish (pull_request) pending status, WR-01); release model documented in README + publish.yml. Branch-protection contexts unchanged.
## Performance Metrics
@@ -181,6 +181,7 @@ Recent decisions affecting current work:
| 260610-jlp | Fix broken "How to enable" link in notifications-blocked UI (Phase 5 UAT Test 4) — extracted InstructionSheet into a shared component; SettingsSheet "How to enable" now opens the OS-step instructions instead of just closing the sheet. 187 pwa tests pass, build green | 2026-06-10 | f82837c | Verified | [260610-jlp-fix-broken-how-to-enable-link-in-notific](./quick/260610-jlp-fix-broken-how-to-enable-link-in-notific/) |
| 260610-k1z | Persist OIDC session cookie (AUTH-02) — @hono/oidc-auth 1.8.3 sets a session-scoped `oidc-auth` cookie (no maxAge) so it died on PWA/browser close → re-login almost every return (both devices). Added persistSessionCookie middleware re-issuing the cookie with maxAge(=OIDC_AUTH_EXPIRES)+SameSite=Lax, ONLY when a valid session exists (no resurrection guard). NOT an Authelia/refresh issue. 14 auth tests pass | 2026-06-10 | 8343fad | Verified | [260610-k1z-persist-oidc-session-cookie-with-maxage-](./quick/260610-k1z-persist-oidc-session-cookie-with-maxage-/) |
| 260610-ka9 | Fix silent Android push (Phase 5 UAT Test 4) — SW showNotification had only {body,tag,data} → Android Chromium/Edge showed them silently. Added icon/badge/renotify:true/vibrate; generalized re-enable instructions to Chrome-or-Edge. iOS unaffected. Build emits sw.js with renotify; 187 pwa tests pass | 2026-06-10 | c864fc4 | Verified | [260610-ka9-fix-silent-android-push-notifications-en](./quick/260610-ka9-fix-silent-android-push-notifications-en/) |
| 260611-ozt | Split publish job into standalone .gitea/workflows/publish.yml (on: push→main only, no redundant event-guard if:; MILESTONE env moved with it) and strip it + the push trigger from ci.yml — kills the orphaned `CI / publish (pull_request)` pending status (phase-8 code-review WR-01). name:CI + fast-checks/api/harness job ids held stable so the required branch-protection contexts stay valid. Documented the release model in README "Publishing / Releases" + publish.yml header. Both YAML validated (yq) | 2026-06-11 | 92353e1 | | [260611-ozt-split-publish-job-into-standalone-gitea-](./quick/260611-ozt-split-publish-job-into-standalone-gitea-/) |
## Deferred Items
@@ -0,0 +1,126 @@
---
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>
@@ -0,0 +1,92 @@
---
phase: quick-260611-ozt
plan: 01
subsystem: ci
tags: [gitea-actions, ci, publish, workflow-split]
dependency_graph:
requires: []
provides: [standalone-publish-workflow, clean-ci-pr-statuses]
affects: [.gitea/workflows/ci.yml, .gitea/workflows/publish.yml, README.md]
tech_stack:
added: []
patterns: [push-only-publish-workflow, branch-protection-safety-gate]
key_files:
created:
- .gitea/workflows/publish.yml
modified:
- .gitea/workflows/ci.yml
- README.md
decisions:
- "D-OZT-01: Safety gate is branch protection on main (not needs:) — publish.yml runs in a separate workflow invocation from ci.yml PR jobs"
- "D-OZT-02: README is the documentation home for the release model (not a separate docs/RELEASE.md) — consistent with this project's single-maintainer pattern"
- "D-OZT-03: Dropped the redundant if: github.event_name == 'push' guard — push-to-main trigger in publish.yml fully replaces it"
metrics:
duration: ~5 minutes
completed: "2026-06-11"
tasks_completed: 2
tasks_total: 2
files_changed: 3
---
# Quick Task 260611-ozt: Split publish job into standalone Gitea workflow
Split the `publish` job out of `.gitea/workflows/ci.yml` into a new standalone `.gitea/workflows/publish.yml`. Mechanical refactor — no CI behavior change beyond the split.
## What Changed
### .gitea/workflows/publish.yml (created)
New standalone push-only workflow:
- `name: Publish`, `on: push: branches: [main]` only
- Workflow-level `MILESTONE: v1.1` env (moved from ci.yml)
- Single `publish` job with all four steps verbatim from ci.yml: checkout, compute image tags, docker login, build+push, docker logout
- All inline comments preserved including load-bearing rationale (Pitfall 13 PAT-via-stdin, D-PAT-NAMING REGISTRY_PAT naming, T-08-10 build-from-repo-root, D-04 two-tag scheme)
- Header comment block documenting trigger, tags, REGISTRY_PAT requirement, safety gate, and MILESTONE bump instruction
- Dropped the redundant `if: github.event_name == 'push' && github.ref == 'refs/heads/main'` guard — push-to-main trigger is sufficient
### .gitea/workflows/ci.yml (modified)
- Removed `publish:` job (was lines 314-362)
- Removed `push: branches: [main]` trigger — now `pull_request` only
- Removed workflow-level `env: MILESTONE: v1.1` block (was only referenced by the publish job)
- `name: CI` unchanged
- Job ids `fast-checks`, `api`, `harness` unchanged
- `if: github.event_name == 'pull_request'` guards on all three jobs unchanged
### README.md (modified)
Added "Publishing / Releases" section between "Deployment" and "License" covering:
- Auto-trigger on push to main (PR merge)
- Image name and two-tag scheme (:latest + :<MILESTONE>-<shortsha>)
- REGISTRY_PAT secret requirement and naming rationale
- Branch-protection safety gate (why no needs: in publish.yml)
- How to bump the MILESTONE tag
## Why This Matters
`ci.yml`'s `on:` previously included `push:` so Gitea registered a `CI / publish (pull_request)` commit status on every PR that sat pending forever — skipped jobs never resolve their status in Gitea Actions. Moving publish to a push-only `publish.yml` stops this orphaned status from ever being created, keeping PR status views clean and the branch-protection required-checks list unambiguous.
## Commits
| Hash | Message |
|------|---------|
| 6efc062 | chore(260611-ozt): split publish job into standalone publish.yml |
| 0c9139b | docs(260611-ozt): document release model in README Publishing/Releases section |
## YAML Verification
Both workflow files validated with `docker run --rm -i mikefarah/yq:4 e '.' -`:
- `.gitea/workflows/publish.yml`: **VALID**
- `.gitea/workflows/ci.yml`: **VALID**
## Deviations from Plan
None — plan executed exactly as written. The header comment block in publish.yml was created in Task 1 (the plan referenced it as "see Task 2" but it is part of the publish.yml file created in Task 1; both tasks committed separately as planned).
## Self-Check: PASSED
- `.gitea/workflows/publish.yml` exists with `name: Publish`, `push:` trigger, `REGISTRY_PAT`, `MILESTONE: v1.1`, no redundant guard
- `.gitea/workflows/ci.yml` has no `publish:` job, no `push:` trigger, no `MILESTONE`, retains `name: CI` and three PR job ids
- `README.md` has Publishing/Releases section with all six required elements
- Commits 6efc062 and 0c9139b confirmed in git log
- Both YAML files parse clean under yq:4
+16
View File
@@ -122,6 +122,22 @@ Store the app password in the database via the `/me` endpoint after first login.
See [`docs/deployment.md`](docs/deployment.md) for Unraid/Docker Compose deployment notes including the Pangolin/Newt tunnel configuration.
## Publishing / Releases
Publishing happens automatically on every push to `main` — i.e. when a PR merges. The `.gitea/workflows/publish.yml` workflow runs and builds + pushes the API image to the Gitea container registry.
**Image:** `git.bergerhouse.net/luckberg/familysync-api`
**Tags (two per release):**
- `:latest` — moving pointer for easy pulls
- `:<MILESTONE>-<shortsha>` — immutable, rollback-traceable (e.g. `v1.1-98acff8`)
**Required secret:** `REGISTRY_PAT` — a Gitea Actions secret holding a PAT with `write:package` scope. Named `REGISTRY_PAT` (not `GITEA_*`): Gitea reserves the `GITEA_` prefix for secret names, so `GITEA_`-prefixed names cannot be created. `GITEA_TOKEN` / `GITHUB_TOKEN` cannot push packages.
**Safety gate:** Branch protection on `main`, not a `needs:` dependency in `publish.yml`. The PR test jobs (`fast-checks`, `api`, `harness` in `ci.yml`) run on `pull_request` — they never run in the same workflow invocation as `publish.yml`. Tests gate the PR; `main` is trusted to be green because direct push and force push are blocked and the three required checks (`CI / fast-checks (pull_request)`, `CI / api (pull_request)`, `CI / harness (pull_request)`) must pass before merge.
**To bump the milestone tag** at a milestone boundary: edit the `MILESTONE` env value at the top of `.gitea/workflows/publish.yml`.
## License
Private — not open source.