Merge remote-tracking branch 'origin/main' into gsd/phase-13-real-lint-gate-eslint
CI / fast-checks (pull_request) Failing after 1m47s
CI / api (pull_request) Successful in 1m0s
CI / harness (pull_request) Successful in 3m30s

# Conflicts:
#	.planning/ROADMAP.md
This commit is contained in:
Lucas Berger
2026-06-11 21:25:14 -04:00
5 changed files with 372 additions and 121 deletions
+7 -1
View File
@@ -303,5 +303,11 @@ jobs:
uses: https://github.com/ChristopherHX/gitea-upload-artifact@v4
with:
name: playwright-traces-${{ github.run_id }}
path: apps/pwa/test-results/
# Upload BOTH the raw traces/screenshots/videos (test-results/) AND the
# navigable HTML report (playwright-report/, built by --reporter=list,html).
# Without the report dir the most useful triage artifact for a remote CI
# failure is built on every run and then discarded at runner teardown (WR-02).
path: |
apps/pwa/test-results/
apps/pwa/playwright-report/
retention-days: 14
+21 -3
View File
@@ -41,6 +41,12 @@ jobs:
- name: Compute image tags
id: tags
run: |
set -euo pipefail
# Fail closed if GITHUB_SHA is empty/unset (Gitea runner env parity is not
# guaranteed across versions). Without this guard SHORT_SHA degrades to ""
# and the immutable tag silently becomes :v1.1- — a valid-but-wrong tag that
# overwrites the milestone pointer and destroys rollback traceability (WR-03).
: "${GITHUB_SHA:?GITHUB_SHA is empty — refusing to build a malformed image tag}"
SHORT_SHA=${GITHUB_SHA:0:7}
MILESTONE="${{ env.MILESTONE }}"
echo "latest=git.bergerhouse.net/luckberg/familysync-api:latest" >> $GITHUB_OUTPUT
@@ -53,8 +59,15 @@ jobs:
# 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
# Bind the secret through env: so it is never substituted into the rendered
# script body. Read it as $REGISTRY_PAT and pipe with printf '%s' (echo is not
# safe for arbitrary strings — a trailing newline or shell-significant char
# would mangle the password into a confusing `unauthorized`) (WR-05).
env:
REGISTRY_PAT: ${{ secrets.REGISTRY_PAT }}
run: |
echo "${{ secrets.REGISTRY_PAT }}" | \
set -euo pipefail
printf '%s' "$REGISTRY_PAT" | \
docker login git.bergerhouse.net \
--username luckberg \
--password-stdin
@@ -63,13 +76,18 @@ jobs:
# lockfile from the root context; building from apps/api/ would fail to find them.
- name: Build and push
run: |
set -euo pipefail
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 }}
# Push the IMMUTABLE :<milestone>-<sha> tag FIRST. set -euo pipefail stops on
# the first failed push, so :latest is only moved after the immutable,
# rollback-traceable tag has landed — a failed second push can never leave
# :latest advanced without a corresponding rollback tag (WR-04).
docker push ${{ steps.tags.outputs.sha_tag }} # immutable first
docker push ${{ steps.tags.outputs.latest }} # move pointer only after immutable lands
# Always drop the stored credential from the runner after push (defence in depth).
- name: Docker logout
+118 -117
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
- [x] **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 (completed 2026-06-12)
- [ ] **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
@@ -44,27 +46,27 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
**Requirements**: TEST-01, TEST-02
**Success Criteria** (what must be TRUE):
1. An automated run can load the PWA in a mobile-emulated viewport (device profile + mobile UA + touch) and assert on responsive layout / tap targets.
2. The automated run reaches the authenticated PWA via the existing `DEV_AUTH_BYPASS` on the host-side dev stack — no manual login and no Authelia/OIDC mocking.
3. The harness runs repeatably day-over-day without re-capturing any session state (no stale storage-state failures).
4. The harness specs are structured so they can run headlessly in CI (Phase 8) against a dev stack the runner brings up — no dependence on a developer's already-running host stack.
1. An automated run can load the PWA in a mobile-emulated viewport (device profile + mobile UA + touch) and assert on responsive layout / tap targets.
2. The automated run reaches the authenticated PWA via the existing `DEV_AUTH_BYPASS` on the host-side dev stack — no manual login and no Authelia/OIDC mocking.
3. The harness runs repeatably day-over-day without re-capturing any session state (no stale storage-state failures).
4. The harness specs are structured so they can run headlessly in CI (Phase 8) against a dev stack the runner brings up — no dependence on a developer's already-running host stack.
**Pitfalls this phase owns** (from PITFALLS.md):
- **No stale storage-state** (Pitfall 14): use `DEV_AUTH_BYPASS=true` for the automated harness rather than a checked-in storage-state.json with an expiring session cookie; decide the auth strategy before the first test.
- **Service worker block** (Pitfall 15): set `serviceWorkers: 'block'` (or explicitly unregister) in the context so a previous run's SW does not intercept requests / return stale cached responses; verify no SW-sourced responses in the trace.
- Hard constraints: targets the dev build via `DEV_AUTH_BYPASS` (DEV_AUTH_BYPASS user 1 has no CalDAV credential/calendars — verify layout/flows, not live event-create); real prod-service-worker / iOS-Safari-standalone mobile testing stays a human/device gate (out of scope).
- **No stale storage-state** (Pitfall 14): use `DEV_AUTH_BYPASS=true` for the automated harness rather than a checked-in storage-state.json with an expiring session cookie; decide the auth strategy before the first test.
- **Service worker block** (Pitfall 15): set `serviceWorkers: 'block'` (or explicitly unregister) in the context so a previous run's SW does not intercept requests / return stale cached responses; verify no SW-sourced responses in the trace.
- Hard constraints: targets the dev build via `DEV_AUTH_BYPASS` (DEV_AUTH_BYPASS user 1 has no CalDAV credential/calendars — verify layout/flows, not live event-create); real prod-service-worker / iOS-Safari-standalone mobile testing stays a human/device gate (out of scope).
**Plans**: 4 plans (3 waves)Plans:
**Wave 1**
- [x] 07-01-PLAN.md — Harness foundation: @playwright/test + WebKit/Chromium browsers, playwright.config.ts (iPhone/WebKit + Pixel/Chromium matrix, serviceWorkers block, env baseURL, vite webServer), vitest exclude, scripts (Wave 1)
**Wave 2** _(blocked on Wave 1 completion)_
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 07-02-PLAN.md — global-setup.ts: /health readiness poll + deterministic mysql2 reset-and-seed (calendar id 10 INSERT IGNORE guard, list + items) + e2e README/guardrails (Wave 2)
**Wave 3** _(blocked on Wave 2 completion)_
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 07-03-PLAN.md — layout.spec.ts: tap targets >=44px, no overflow, in-viewport, accessible names (UI-SPEC Rules 1-4) + harness self-validation injected-defect proofs (Wave 3)
- [x] 07-04-PLAN.md — calendar.spec.ts + lists.spec.ts: populated/empty/error states (Rules 4/5) + DEV_AUTH_BYPASS auth-reached + no-SW-controller precondition (Wave 3)
@@ -79,35 +81,35 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
**Requirements**: CI-01, CI-02
**Success Criteria** (what must be TRUE):
1. Opening or updating a PR targeting `main` triggers a workflow that runs lint, typecheck (both apps), unit tests, and API integration tests against a MariaDB service container — and a failing run blocks the merge.
2. The API integration tests connect to the service-container MariaDB (DB_HOST=127.0.0.1, service creds) and pass reliably on a cold first run, not only on re-run.
3. The same PR workflow brings up the dev stack inside the runner — the API dev server, the PWA dev server, and the MariaDB service container, with `DEV_AUTH_BYPASS=true` — and runs the Phase 7 mobile Playwright harness specs headlessly against that authed PWA; a harness failure blocks the merge.
4. The harness step waits for both the API and PWA dev servers to be ready (readiness probe / poll) before launching Playwright, so it does not flake on startup races.
5. On merge to `main`, the API Docker image is built and pushed to the Gitea container registry under a sensible tag.
6. Registry credentials never appear in plaintext in the CI logs.
1. Opening or updating a PR targeting `main` triggers a workflow that runs lint, typecheck (both apps), unit tests, and API integration tests against a MariaDB service container — and a failing run blocks the merge.
2. The API integration tests connect to the service-container MariaDB (DB_HOST=127.0.0.1, service creds) and pass reliably on a cold first run, not only on re-run.
3. The same PR workflow brings up the dev stack inside the runner — the API dev server, the PWA dev server, and the MariaDB service container, with `DEV_AUTH_BYPASS=true` — and runs the Phase 7 mobile Playwright harness specs headlessly against that authed PWA; a harness failure blocks the merge.
4. The harness step waits for both the API and PWA dev servers to be ready (readiness probe / poll) before launching Playwright, so it does not flake on startup races.
5. On merge to `main`, the API Docker image is built and pushed to the Gitea container registry under a sensible tag.
6. Registry credentials never appear in plaintext in the CI logs.
**Pitfalls this phase owns** (from PITFALLS.md):
- **Runner-probe first** (Pitfall 12): the first workflow only probes `node --version` / `pnpm --version` / Docker access on the `self-hosted` runner before any test or build steps are designed; pin Node 22 explicitly, do not assume `actions/setup-node` works as on GitHub.
- **MariaDB readiness wait** (Pitfall 11): add an explicit readiness loop (e.g. `healthcheck.sh --connect --innodb_initialized`, NOT `mysqladmin ping` which is removed in MariaDB 11) before any `drizzle-kit migrate` / integration test step; healthy ≠ accepting connections.
- **Dev-stack readiness races (NEW for the harness step):** running the PWA and API dev servers _inside_ CI adds startup/readiness races on top of the MariaDB-11 readiness race. The harness step must wait for **both** the API and PWA dev servers to be accepting connections (poll their URLs / health endpoints) before Playwright launches — do not race the browser against a not-yet-listening server. Run with `DEV_AUTH_BYPASS=true` so the harness reaches the authed PWA exactly as in Phase 7.
- **--password-stdin** (Pitfall 13): `docker login` via `--password-stdin` with the token piped from a registered Gitea secret (PAT with `write:package`); never `-p $TOKEN` on the command line.
- Hard constraints: API integration tests need a real MariaDB and live in `apps/api/tests/` (never `src/`); cache the pnpm store; Drizzle generate+migrate to set up the CI DB schema; the harness step reuses the Phase 7 specs unchanged (CI owns only the stack bring-up + readiness wait, not the spec content).
- **Runner-probe first** (Pitfall 12): the first workflow only probes `node --version` / `pnpm --version` / Docker access on the `self-hosted` runner before any test or build steps are designed; pin Node 22 explicitly, do not assume `actions/setup-node` works as on GitHub.
- **MariaDB readiness wait** (Pitfall 11): add an explicit readiness loop (e.g. `healthcheck.sh --connect --innodb_initialized`, NOT `mysqladmin ping` which is removed in MariaDB 11) before any `drizzle-kit migrate` / integration test step; healthy ≠ accepting connections.
- **Dev-stack readiness races (NEW for the harness step):** running the PWA and API dev servers *inside* CI adds startup/readiness races on top of the MariaDB-11 readiness race. The harness step must wait for **both** the API and PWA dev servers to be accepting connections (poll their URLs / health endpoints) before Playwright launches — do not race the browser against a not-yet-listening server. Run with `DEV_AUTH_BYPASS=true` so the harness reaches the authed PWA exactly as in Phase 7.
- **--password-stdin** (Pitfall 13): `docker login` via `--password-stdin` with the token piped from a registered Gitea secret (PAT with `write:package`); never `-p $TOKEN` on the command line.
- Hard constraints: API integration tests need a real MariaDB and live in `apps/api/tests/` (never `src/`); cache the pnpm store; Drizzle generate+migrate to set up the CI DB schema; the harness step reuses the Phase 7 specs unchanged (CI owns only the stack bring-up + readiness wait, not the spec content).
**Plans**: 4 plans (4 waves)Plans:
**Wave 1**
- [x] 08-01-PLAN.md — Runner probe + operator runner/PAT registration (W0; answers the Docker-vs-host fork)
**Wave 2** _(blocked on Wave 1 completion)_
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 08-02-PLAN.md — ci.yml: fast-checks (lint/typecheck/PWA unit) + API job (MariaDB service + migrate + DB-backed tests)
**Wave 3** _(blocked on Wave 2 completion)_
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 08-03-PLAN.md — ci.yml: harness job (dev-stack bring-up + readiness waits + Phase 7 Playwright specs, both profiles)
**Wave 4** _(blocked on Wave 3 completion)_
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 08-04-PLAN.md — ci.yml: publish job (build production image + push :latest + :v1.1-<sha> via --password-stdin)
@@ -121,17 +123,17 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
**Requirements**: CAL-15
**Success Criteria** (what must be TRUE):
1. After creating/editing/deleting an event, the change lands in Fastmail in ~1-2s in the common case (drain is signalled on enqueue, not waited-for on the interval) — observable as the change appearing in the Fastmail native app well before the old ~15s window.
2. The route handler still returns an optimistic 202 immediately and never makes a CalDAV call inline — the event-driven signal is fire-and-forget.
3. Edit-as-move still writes the new event before deleting the old one (create-before-delete ordering preserved); no event is ever lost when a move drains under rapid enqueues.
4. No duplicate CalDAV PUTs occur for the same outbox row when the signal and the 15s fallback interval overlap (exactly-once per uid preserved).
5. The 15s `setInterval` fallback still runs and recovers any rows missed by the signal path (startup catch-up, transient errors).
1. After creating/editing/deleting an event, the change lands in Fastmail in ~1-2s in the common case (drain is signalled on enqueue, not waited-for on the interval) — observable as the change appearing in the Fastmail native app well before the old ~15s window.
2. The route handler still returns an optimistic 202 immediately and never makes a CalDAV call inline — the event-driven signal is fire-and-forget.
3. Edit-as-move still writes the new event before deleting the old one (create-before-delete ordering preserved); no event is ever lost when a move drains under rapid enqueues.
4. No duplicate CalDAV PUTs occur for the same outbox row when the signal and the 15s fallback interval overlap (exactly-once per uid preserved).
5. The 15s `setInterval` fallback still runs and recovers any rows missed by the signal path (startup catch-up, transient errors).
**Pitfalls this phase owns** (from PITFALLS.md):
- **No double-drain** (Pitfall 5): the trigger must set a `drainRequested` flag funnelled through the single setInterval-controlled path / the existing `isDraining` guard — never call `runOutboxDrain()` directly from the signal in a way that bypasses the guard or escapes the error-caught wrapper.
- **Create-before-delete under concurrent enqueues** (Pitfall 6): enqueue CREATE before DELETE; do not fire the signal between the two inserts of a move (publish after both inserts / after the transaction commits).
- Hard constraints: `setInterval` only (no node-cron); single-process by design — **no Redis** for the drain (Redis stays for list SSE); all outbox guarantees (fresh-etag-before-PUT, 412 conflict flow, per-uid exactly-once) unchanged.
- **No double-drain** (Pitfall 5): the trigger must set a `drainRequested` flag funnelled through the single setInterval-controlled path / the existing `isDraining` guard — never call `runOutboxDrain()` directly from the signal in a way that bypasses the guard or escapes the error-caught wrapper.
- **Create-before-delete under concurrent enqueues** (Pitfall 6): enqueue CREATE before DELETE; do not fire the signal between the two inserts of a move (publish after both inserts / after the transaction commits).
- Hard constraints: `setInterval` only (no node-cron); single-process by design — **no Redis** for the drain (Redis stays for list SSE); all outbox guarantees (fresh-etag-before-PUT, 412 conflict flow, per-uid exactly-once) unchanged.
**Plans**: TBD
@@ -143,17 +145,19 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
**Requirements**: ADMIN-01, ADMIN-02, ADMIN-03
**Success Criteria** (what must be TRUE):
1. An admin sees an Admin section in Settings and can list household members with their credential status; a non-admin member never sees it and cannot invoke any `/api/admin/*` route (gets 403).
2. An admin can enter or rotate a member's Fastmail app password; it is validated against CalDAV (PROPFIND) before saving and stored encrypted — and the password is never displayed, echoed in a response, or logged.
3. An admin can pick which synced calendar is the shared family calendar from a list, and the `calendars.is_shared` flag updates accordingly (replacing the manual `UPDATE calendars SET is_shared=1` step).
4. The role check is role-agnostic and member-count-agnostic: it gates on `users.is_admin`, so more admins can be added later without reworking the guard.
5. The DB migration (is_admin, reminder_lead_minutes, app_config) is applied via generate+migrate and is in place for downstream phases (reminder_lead_minutes for Phase 11, app_config.setup_complete for Phase 12).
1. An admin sees an Admin section in Settings and can list household members with their credential status; a non-admin member never sees it and cannot invoke any `/api/admin/*` route (gets 403).
2. An admin can enter or rotate a member's Fastmail app password; it is validated against CalDAV (PROPFIND) before saving and stored encrypted — and the password is never displayed, echoed in a response, or logged.
3. An admin can pick which synced calendar is the shared family calendar from a list, and the `calendars.is_shared` flag updates accordingly (replacing the manual `UPDATE calendars SET is_shared=1` step).
4. The role check is role-agnostic and member-count-agnostic: it gates on `users.is_admin`, so more admins can be added later without reworking the guard.
5. The DB migration (is_admin, reminder_lead_minutes, app_config) is applied via generate+migrate and is in place for downstream phases (reminder_lead_minutes for Phase 11, app_config.setup_complete for Phase 12).
**Pitfalls this phase owns** (from PITFALLS.md):
- **Admin role check inside the sub-router** (Pitfall 9): apply `requireAdmin` with `.use('*', ...)` inside `adminRouter`, not only at the parent mount; integration test must assert 403 for a non-admin authenticated user.
- **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.
- **Admin role check inside the sub-router** (Pitfall 9): apply `requireAdmin` with `.use('*', ...)` inside `adminRouter`, not only at the parent mount; integration test must assert 403 for a non-admin authenticated user.
- **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
@@ -166,19 +170,19 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
**Requirements**: CAL-13, CAL-14, NOTIF-04, NOTIF-05, NOTIF-06
**Success Criteria** (what must be TRUE):
1. When creating or editing a timed event, the user can pick a reminder lead from the preset list (None default); the choice round-trips to Fastmail as a VALARM and is visible/honored on re-open.
2. Editing an event that already has a reminder set in another client (Fastmail / Apple Calendar) preserves that VALARM — it is never silently dropped on round-trip.
3. A reminder push fires at the event's chosen lead time (e.g. T-30 for a 30-minute lead), not a hardcoded 15-minute lead.
4. An event with no reminder set produces no reminder push (no default 15-minute fire).
5. An all-day event's reminder fires at a sensible local time (9 AM on the alert day), not midnight; the reminder selector is disabled/hidden for all-day events in the UI; and reminder delivery stays exactly-once across catch-up scans and rescheduled events.
1. When creating or editing a timed event, the user can pick a reminder lead from the preset list (None default); the choice round-trips to Fastmail as a VALARM and is visible/honored on re-open.
2. Editing an event that already has a reminder set in another client (Fastmail / Apple Calendar) preserves that VALARM — it is never silently dropped on round-trip.
3. A reminder push fires at the event's chosen lead time (e.g. T-30 for a 30-minute lead), not a hardcoded 15-minute lead.
4. An event with no reminder set produces no reminder push (no default 15-minute fire).
5. An all-day event's reminder fires at a sensible local time (9 AM on the alert day), not midnight; the reminder selector is disabled/hidden for all-day events in the UI; and reminder delivery stays exactly-once across catch-up scans and rescheduled events.
**Pitfalls this phase owns** (from PITFALLS.md):
- **Preserve-on-edit** (Pitfall 1): the update path extracts and preserves existing VALARM sub-components from `rawVevent` (mirroring the WR-01 RRULE-preserve pattern) — never rebuild-from-scratch and silently strip; `outboxPayloadSchema` distinguishes "no change" from explicit "no reminder".
- **No TRIGGER VALUE=TEXT** (Pitfall 2): build the trigger with `ICAL.Duration.fromSeconds(-n*60)`, not a bare string; unit-test that the ICS emits a DURATION trigger with no `VALUE=TEXT`.
- **All-day 9AM semantics** (Pitfall 3): guard `buildVeventString` (`if (!allDay && reminderMinutes > 0)`), disable the selector when allDay, keep the scheduler's all-day handling at 9 AM local.
- **uid:dtstartMs dedup** (Pitfall 4): change the scheduler dedup key from bare `uid` to compound `uid:dtstartMs` and widen the scan to a variable per-event window so long leads fire and rescheduled events re-fire; keep `eventFieldsSchema` and `outboxPayloadSchema` in sync (IN-03).
- Hard constraints: `setInterval` only; scheduler reads `reminder_lead_minutes` from the DB (ground truth), not the outbox payload; drop the `isShared`-only reminder restriction (a user who set an alarm wants it regardless of calendar).
- **Preserve-on-edit** (Pitfall 1): the update path extracts and preserves existing VALARM sub-components from `rawVevent` (mirroring the WR-01 RRULE-preserve pattern) — never rebuild-from-scratch and silently strip; `outboxPayloadSchema` distinguishes "no change" from explicit "no reminder".
- **No TRIGGER VALUE=TEXT** (Pitfall 2): build the trigger with `ICAL.Duration.fromSeconds(-n*60)`, not a bare string; unit-test that the ICS emits a DURATION trigger with no `VALUE=TEXT`.
- **All-day 9AM semantics** (Pitfall 3): guard `buildVeventString` (`if (!allDay && reminderMinutes > 0)`), disable the selector when allDay, keep the scheduler's all-day handling at 9 AM local.
- **uid:dtstartMs dedup** (Pitfall 4): change the scheduler dedup key from bare `uid` to compound `uid:dtstartMs` and widen the scan to a variable per-event window so long leads fire and rescheduled events re-fire; keep `eventFieldsSchema` and `outboxPayloadSchema` in sync (IN-03).
- Hard constraints: `setInterval` only; scheduler reads `reminder_lead_minutes` from the DB (ground truth), not the outbox payload; drop the `isShared`-only reminder restriction (a user who set an alarm wants it regardless of calendar).
**Plans**: TBD
**UI hint**: yes
@@ -191,64 +195,84 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
**Requirements**: SETUP-01, SETUP-02, SETUP-03, SETUP-04
**Success Criteria** (what must be TRUE):
1. On a fresh install with nothing configured, the operator reaches a setup wizard (via `GET /api/setup/status` mounted before the OIDC guard) and walks through bootstrap steps instead of editing files by hand.
2. Each input is validated before the step can complete: DB connects, VAPID private key decodes to exactly 32 bytes and pairs with the public key, OIDC discovery resolves, and the Fastmail app password reaches CalDAV (PROPFIND).
3. Generated secrets (session secret, encryption key, VAPID keypair) are displayed for the operator to copy into env; they are never written to the DB or returned in a way that persists, and `APP_PASSWORD_ENCRYPTION_KEY`/`VAPID_PRIVATE_KEY` never enter the DB at all.
4. After completion, the wizard-completing user is promoted to admin (`is_admin`), `app_config.setup_complete` is set, and any further call to a setup endpoint returns 423 Locked.
5. The 423 guard is enforced on every invocation (checked against member-credentials + VAPID env present), not only at startup.
1. On a fresh install with nothing configured, the operator reaches a setup wizard (via `GET /api/setup/status` mounted before the OIDC guard) and walks through bootstrap steps instead of editing files by hand.
2. Each input is validated before the step can complete: DB connects, VAPID private key decodes to exactly 32 bytes and pairs with the public key, OIDC discovery resolves, and the Fastmail app password reaches CalDAV (PROPFIND).
3. Generated secrets (session secret, encryption key, VAPID keypair) are displayed for the operator to copy into env; they are never written to the DB or returned in a way that persists, and `APP_PASSWORD_ENCRYPTION_KEY`/`VAPID_PRIVATE_KEY` never enter the DB at all.
4. After completion, the wizard-completing user is promoted to admin (`is_admin`), `app_config.setup_complete` is set, and any further call to a setup endpoint returns 423 Locked.
5. The 423 guard is enforced on every invocation (checked against member-credentials + VAPID env present), not only at startup.
**Pitfalls this phase owns** (from PITFALLS.md):
- **Guard on every invocation** (Pitfall 8): the "already set up" guard returns 423 from all setup routes once configured — implement and test the guard before the happy path; a second POST after completion must return 423, not 200.
- **Secrets stay in env, never in DB** (Pitfalls 8 & 10): the wizard validates secrets by performing a test operation (test encrypt/decrypt, structural VAPID check), never by accepting/storing the key value; no DB column for `vapid_private_key` or `app_password_encryption_key`; never log/echo the app password.
- Hard constraints: `GET /api/setup/status` mounts **before** the OIDC guard (like `/health`); do NOT create `/api/setup/credentials` — reuse the Phase 10 admin routes; Drizzle generate+migrate (any `app_config` seeding via migration).
- **Guard on every invocation** (Pitfall 8): the "already set up" guard returns 423 from all setup routes once configured — implement and test the guard before the happy path; a second POST after completion must return 423, not 200.
- **Secrets stay in env, never in DB** (Pitfalls 8 & 10): the wizard validates secrets by performing a test operation (test encrypt/decrypt, structural VAPID check), never by accepting/storing the key value; no DB column for `vapid_private_key` or `app_password_encryption_key`; never log/echo the app password.
- Hard constraints: `GET /api/setup/status` mounts **before** the OIDC guard (like `/health`); do NOT create `/api/setup/credentials` — reuse the Phase 10 admin routes; Drizzle generate+migrate (any `app_config` seeding via migration).
**Plans**: TBD
**UI hint**: yes
### Phase 13: Real Lint Gate (ESLint)
**Goal**: The CI `fast-checks` lint step stops being a hollow no-op — a real, type-aware ESLint flat config (typescript-eslint `recommendedTypeChecked` + React/react-hooks for the PWA) plus Prettier are wired across both apps, all first-run violations are genuinely fixed, and `pnpm lint` / `pnpm format:check` actually fail on violations and gate PRs to main.
**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 (Gitea CI shipped the lint gate slot wired to auto-activate once a package `lint` script lands; this phase makes it real and adds the format gate). Independent of Phases 9/10/11/12.
**Requirements**: none (promoted from backlog 999.16; no formal REQ-IDs)
**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. **SC-1:** `pnpm lint` exits non-zero on an introduced violation (today it exits 0); `pnpm format:check` exits non-zero on an unformatted file.
2. **SC-2:** The CI `fast-checks` lint step blocks a PR to main on lint violations, and a new `format:check` step blocks on format violations.
3. **SC-3:** The first real run's existing violations are resolved so the baseline gate ends green — `pnpm lint` AND `pnpm format:check` both exit 0 across both apps.
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.
**Decisions** (LOCKED, from 13-CONTEXT.md): D-13-01 `recommendedTypeChecked` via `projectService:true`; D-13-02 React/hooks plugins scoped to `apps/pwa` only; D-13-03 no strict presets; D-13-04 `--max-warnings 0`; D-13-05 fix all violations now; D-13-06 fixes address, never mask (justified suppressions only); D-13-07 Prettier standalone + `eslint-config-prettier`; D-13-08 isolated reformat commit; D-13-09 lint all TS/TSX incl. tests/e2e/config; D-13-10 `disableTypeChecked` override for non-project files.
**Plans**: 3 plans
**Wave 1**
**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**: 3 plans — all complete (scope expanded during planning to add a Prettier `format:check` gate)
- [x] 13-01-PLAN.md — Install ESLint/Prettier deps + flat config + package scripts + prove the gate fails (SC-1)
**Wave 2** _(blocked on Wave 1 completion)_
- [x] 13-02-PLAN.md — Fix all first-run lint violations across both apps, green `pnpm lint` (D-13-05/06)
- [x] 13-03-PLAN.md — Prettier reformat (isolated commit) + CI format:check step + green baseline (SC-2/SC-3)
**Wave 3** _(blocked on Wave 2 completion)_
**UI hint**: no
- [x] 13-03-PLAN.md — Prettier reformat (isolated commit) + CI format:check step + green baseline (SC-2, SC-3)
### 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 |
| --------------------------------- | --------- | -------------- | ----------- | ---------- |
| 1. Foundation + Broker Spike | v1.0 | 4/4 | Complete | 2026-06-04 |
| 2. Calendar Display | v1.0 | 5/5 | Complete | 2026-06-05 |
| 3. Event Write-Back + PWA Install | v1.0 | 12/12 | Complete | 2026-06-07 |
| 4. Shared Lists + Live Sync | v1.0 | 7/7 | Complete | 2026-06-09 |
| 5. Web Push Notifications | v1.0 | 8/8 | Complete | 2026-06-10 |
| 6. UX Polish | v1.0 | 6/6 | Complete | 2026-06-10 |
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
| 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 |
| 9. Faster Write-Back | v1.1 | 0/? | Not started | - |
| 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 | 3/3 | Complete | 2026-06-12 |
| Phase | Milestone | Plans Complete | Status | Completed |
| ----- | --------- | -------------- | -------- | ---------- |
| 1. Foundation + Broker Spike | v1.0 | 4/4 | Complete | 2026-06-04 |
| 2. Calendar Display | v1.0 | 5/5 | Complete | 2026-06-05 |
| 3. Event Write-Back + PWA Install| v1.0 | 12/12 | Complete | 2026-06-07 |
| 4. Shared Lists + Live Sync | v1.0 | 7/7 | Complete | 2026-06-09 |
| 5. Web Push Notifications | v1.0 | 8/8 | Complete | 2026-06-10 |
| 6. UX Polish | v1.0 | 6/6 | Complete | 2026-06-10 |
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
| 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 |
| 9. Faster Write-Back | v1.1 | 0/? | Not started | - |
| 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 | 3/3 | Complete | 2026-06-12 |
| 14. Desktop E2E Coverage | v1.1 | 0/? | Not started | - |
## Backlog
@@ -256,7 +280,7 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
**Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern.
**Requirements:** TBD
**Plans:** 3/3 plans complete
**Plans:** 3/4 plans executed
Plans:
@@ -264,7 +288,7 @@ Plans:
### Phase 999.4: Per-event reminder configuration (VALARM authoring + scheduler honors it) (BACKLOG)
**Goal:** [Captured for future planning] End-to-end per-event reminders — let the user choose _when_ (or whether) to be reminded per event, and make the push scheduler honor that choice instead of a hardcoded lead.
**Goal:** [Captured for future planning] End-to-end per-event reminders — let the user choose *when* (or whether) to be reminded per event, and make the push scheduler honor that choice instead of a hardcoded lead.
**Half A — author the VALARM (event form):** The event create/edit form has no UI to set a reminder ("remind me 10 min / 1 hour / 1 day before", or **no reminder**), so the written `.ics` carries no `VALARM` and no reminder can fire — in native clients or via web push. Add a reminder selector (including an explicit "none"), serialize chosen offsets as `VALARM` (TRIGGER) on write-back, and parse existing `VALARM`s on read so edits preserve them. Feeds the Phase 5 web-push requirement (push needs reminder data to notify about).
@@ -282,31 +306,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:
@@ -356,7 +355,7 @@ Plans:
- **An authenticated entry path for automated runs** so the assistant can reach the real PWA past Authelia — e.g. a reusable saved storage-state/cookie, a test-only bypass on a non-prod host, or driving the Authelia login once and reusing the session. (Note: this overlaps the existing `DEV_AUTH_BYPASS`, but that only works on the host-side dev stack, not the prod-mode PWA that has the real service worker. A mobile, authed, SW-enabled target is the gap.)
- Optionally: a documented way to point the harness at the Pangolin HTTPS URL with a persisted session, and/or remote-debug a real device.
**Boundary:** genuinely device-only behaviour (iOS-Safari standalone push, real APNs/FCM delivery, OS notification-channel importance) still needs a human — this item is about everything SHORT of that (responsive layout, tap flows, in-page notification UI states, auth redirects) which a mobile-emulated authed browser _could_ cover but currently can't.
**Boundary:** genuinely device-only behaviour (iOS-Safari standalone push, real APNs/FCM delivery, OS notification-channel importance) still needs a human — this item is about everything SHORT of that (responsive layout, tap flows, in-page notification UI states, auth redirects) which a mobile-emulated authed browser *could* cover but currently can't.
**Context:** Surfaced 2026-06-10 during Phase 5 UAT — repeated mobile-only bugs were caught only by the operator because the assistant had no mobile, authenticated browser to test in. **Related:** [[feedback-playwright-verify]] (use playwright-cli over manual verification — this extends it to mobile/authed). Tags: testing, playwright, mobile, pwa, oidc, dx.
@@ -431,6 +430,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
@@ -453,7 +454,7 @@ 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))no formal REQ-IDs; scope expanded to include a Prettier `format:check` gate.** Backlog entry retained for history.
> **Promoted into v1.1 Phase 13 (Real Lint Gate / ESLint) — 2026-06-11.** Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
+216
View File
@@ -0,0 +1,216 @@
---
phase: 08-gitea-ci
reviewed: 2026-06-11T00:00:00Z
depth: deep
files_reviewed: 3
files_reviewed_list:
- .gitea/workflows/ci.yml
- .gitea/workflows/publish.yml
- apps/api/src/db/migrations/0000_baseline.sql
findings:
critical: 0
warning: 5
info: 2
total: 7
status: fixes_applied
fix_summary:
fixed: [WR-02, WR-03, WR-04, WR-05, IN-01]
deferred: [IN-02] # ESLint gate is Phase 13's deliverable
fixed_at: 2026-06-11
---
# Phase 8: Code Review Report (Re-Review, Post-Split)
**Reviewed:** 2026-06-11
**Depth:** deep
**Files Reviewed:** 3
**Status:** issues_found
## Summary
Re-review of Phase 8 (Gitea CI) against the post-split state: `publish` is now its own
`publish.yml` triggered on `push: branches: [main]`, and `ci.yml` holds the three PR-gated
jobs (`fast-checks`, `api`, `harness`).
**Resolved since prior review:**
- **WR-01 (publish ran without test gating / orphaned pending status)** — RESOLVED. Publish
is split into `publish.yml`, triggered only on `push` to `main`. PR jobs gate the merge via
required status checks + branch protection (direct/force push blocked). No `needs:` is needed
because publish never shares a workflow invocation with the test jobs. Architecture confirmed
intentional; not re-raised.
**Still open (re-located to the split files):**
- WR-02 (harness HTML report built then discarded) — still open, now in `ci.yml`.
- WR-03 (unguarded `${GITHUB_SHA:0:7}` → malformed tag) — still open, now in `publish.yml`.
- WR-04 (non-atomic two-push, `:latest` before immutable tag) — still open, now in `publish.yml`.
- IN-01 (>3072-byte UNIQUE indexes, MariaDB-only) — still open in `0000_baseline.sql`.
**New findings from the deep pass:**
- WR-05 (secret interpolated into `run:` script body via `echo` instead of `env:`) — new.
- IN-02 (`lint` step is a documented no-op that masks lint failures) — new.
No Critical findings. The publish flow has no test `needs:` by design (gated by branch
protection), so it is not flagged. The dominant theme is robustness/observability gaps in the
publish + harness steps and MariaDB-specific schema portability.
## Narrative Findings (AI reviewer)
## Warnings
### WR-02: Harness HTML report is generated but never uploaded — FIXED (commit 44a9c30)
> Resolution: extended the existing `ChristopherHX/gitea-upload-artifact@v4` failure step's
> `path:` to a multi-line list uploading both `apps/pwa/test-results/` and
> `apps/pwa/playwright-report/`. Reporter unchanged; step still `if: failure()`.
**File:** `.gitea/workflows/ci.yml:293` (report generation) and `:301-307` (upload step)
**Issue:** The harness runs Playwright with `--reporter=list,html`. The `html` reporter writes
its output to `apps/pwa/playwright-report/` (Playwright's default `outputFolder`, not overridden
in `playwright.config.ts`). The failure-artifact upload step (`if: failure()`) only uploads
`path: apps/pwa/test-results/`. The HTML report — the most useful artifact for triaging a remote
CI failure — is built on every run and then discarded when the runner is torn down. `test-results/`
contains traces/screenshots/videos but not the navigable HTML report.
**Fix:** Either drop `html` from the reporter (saves build time if it is genuinely unwanted), or
upload it. Preferred — add the report to the existing upload, or a second upload step:
```yaml
- name: Upload Playwright HTML report
if: failure()
uses: https://github.com/ChristopherHX/gitea-upload-artifact@v4
with:
name: playwright-report-${{ github.run_id }}
path: apps/pwa/playwright-report/
retention-days: 14
```
(Or set `path: |` with both `apps/pwa/test-results/` and `apps/pwa/playwright-report/` on the
existing step.)
### WR-03: Unguarded `${GITHUB_SHA:0:7}` can emit a malformed image tag — FIXED (commit 6bcf867)
> Resolution: added `set -euo pipefail` + a `: "${GITHUB_SHA:?...}"` fail-closed guard
> before `SHORT_SHA=${GITHUB_SHA:0:7}`, so an empty SHA aborts the step instead of
> producing `:v1.1-`.
**File:** `.gitea/workflows/publish.yml:44`
**Issue:** `SHORT_SHA=${GITHUB_SHA:0:7}` has no guard for an empty/unset `GITHUB_SHA`. If the
runner does not populate `GITHUB_SHA` (Gitea Actions env parity is not guaranteed across runner
versions; the comment only asserts it via probe P-13, not a runtime check), `SHORT_SHA` becomes
empty and the immutable tag silently degrades to `git.bergerhouse.net/luckberg/familysync-api:v1.1-`
— a valid-but-wrong tag that overwrites the milestone pointer and destroys rollback traceability.
Because the default Actions shell runs with `pipefail`/`-e` but NOT `-u`, the empty expansion does
not error; it proceeds.
**Fix:** Fail closed when the SHA is missing:
```bash
set -euo pipefail
if [ -z "${GITHUB_SHA:-}" ]; then
echo "GITHUB_SHA is empty — cannot compute immutable tag" >&2
exit 1
fi
SHORT_SHA=${GITHUB_SHA:0:7}
```
### WR-04: Build-and-push is non-atomic and pushes `:latest` before the immutable tag — FIXED (commit 4001cd5)
> Resolution: reordered the pushes so the immutable `:<milestone>-<sha>` tag goes first,
> `:latest` second; added `set -euo pipefail` so the step stops on the first failed push.
**File:** `.gitea/workflows/publish.yml:64-72`
**Issue:** The step runs `docker build` then two sequential `docker push` calls. `:latest` is
pushed first (line 71), then `:<milestone>-<sha>` (line 72). If the second push fails (registry
hiccup, auth expiry, network), `:latest` already moved to the new image while the immutable,
rollback-traceable tag was never published — the exact tag operators would reach for to roll back
does not exist, but `:latest` already advanced. Although the Actions default shell injects `-e`
(so a failed first command aborts the step), ordering still means a partial-failure window leaves
`:latest` ahead of the immutable record. Push order should be immutable-first.
**Fix:** Push the immutable tag first, then `:latest`, and make the shell strict explicitly:
```bash
set -euo pipefail
docker build --target production \
-f apps/api/Dockerfile \
-t ${{ steps.tags.outputs.latest }} \
-t ${{ steps.tags.outputs.sha_tag }} \
.
docker push ${{ steps.tags.outputs.sha_tag }} # immutable first
docker push ${{ steps.tags.outputs.latest }} # move pointer only after immutable lands
```
### WR-05: Registry PAT is interpolated into the `run:` script body instead of passed via `env:` — FIXED (commit 58861d9)
> Resolution: bound `REGISTRY_PAT` through step-level `env:` and replaced the
> `echo "${{ secrets.REGISTRY_PAT }}" | ...` pipe with `printf '%s' "$REGISTRY_PAT" | ...`.
> Existing PAT-via-stdin and REGISTRY_PAT-naming comments preserved.
**File:** `.gitea/workflows/publish.yml:55-60`
**Issue:** `echo "${{ secrets.REGISTRY_PAT }}" | docker login ... --password-stdin` interpolates
the secret into the shell script text at template-expansion time. Two problems:
(1) Robustness — if the PAT ever contains a shell-significant character or a trailing newline,
`echo` may mangle or split it (`echo` is not safe for arbitrary strings; `printf %s` is). A
mangled-but-nonempty password produces a confusing `unauthorized` rather than a clear failure.
(2) Surface — template-substituting a secret into the script body is the documented anti-pattern
versus binding it through `env:` (the script then references `$REGISTRY_PAT`), which keeps the
secret out of the rendered command line / step definition and is the recommended pattern for
Actions-compatible runners. Gitea's log scrubber masks it either way, so this is a robustness/
hardening WARNING, not a leak.
**Fix:**
```yaml
- name: Docker login
env:
REGISTRY_PAT: ${{ secrets.REGISTRY_PAT }}
run: |
set -euo pipefail
printf '%s' "$REGISTRY_PAT" | docker login git.bergerhouse.net \
--username luckberg --password-stdin
```
## Info
### IN-01: Two UNIQUE constraints exceed the 3072-byte index limit (MariaDB-only) — FIXED, docs-only (commit bf09110)
> Resolution: added explanatory SQL comments above `uniq_calendar_user_url` and
> `uniq_push_endpoint` in the already-applied `0000_baseline.sql`, documenting the
> over-length-index dependency on MariaDB 11's long-unique HASH behavior. NO schema,
> column, or index altered (migration is live on main/production).
**File:** `apps/api/src/db/migrations/0000_baseline.sql:100` (`uniq_push_endpoint`) and `:48`
(`uniq_calendar_user_url`)
**Issue:** With the default `utf8mb4` charset (4 bytes/char):
- `uniq_push_endpoint UNIQUE(endpoint)` where `endpoint varchar(2048)` → 2048 × 4 = 8192 bytes.
- `uniq_calendar_user_url UNIQUE(user_id, url)` where `url varchar(1024)` → 4 + 1024 × 4 = 4100 bytes.
Both exceed InnoDB's 3072-byte index-key limit. They succeed on MariaDB 11.x (the CI service image
`mariadb:11`) because MariaDB silently builds over-length UNIQUE constraints as long-unique HASH
indexes. The same DDL fails hard on MySQL 8 and on MariaDB configured with
`innodb_large_prefix` semantics disabled or a stricter SQL mode. This is engine-pinned, not a bug
on the current target — the project hard-constrains to MariaDB (no PostgreSQL/MySQL) — so it is INFO.
**Fix:** No change required while MariaDB is the only target. If portability is ever wanted, either
(a) shorten the columns (e.g. `url varchar(768)`, `endpoint` hashed to a `char(64)` digest column
with the UNIQUE on the digest), or (b) add an explicit comment in `schema.ts` documenting the
MariaDB long-unique-HASH dependency so a future MySQL migration is not silently broken. Schema.ts
already carries a CR-02 note on the `endpoint(2048)` width; extend it to record the index-limit
caveat.
### IN-02: `Lint` step is a no-op that will mask real lint failures once ESLint is wired — DEFERRED to Phase 13
> Not fixed this phase. Wiring a real ESLint gate (`pnpm -r --if-present lint`) is Phase 13's
> deliverable. `ci.yml`'s lint step left untouched intentionally.
**File:** `.gitea/workflows/ci.yml:31-32` (`pnpm lint`)
**Issue:** Per the in-file comment, no package defines a `lint` script, so `pnpm lint` (root) prints
`ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT` but exits 0 — the step is green regardless. This is acceptable
for the current phase (lint wiring is explicitly out of scope), but it is a latent trap: when a
`lint` script is later added to one package, `pnpm lint` at the root still will not run it unless
the invocation is `pnpm -r lint`, and even then `--if-present` semantics differ. The step gives a
false sense that linting is enforced.
**Fix:** When lint is wired, switch to `pnpm -r --if-present lint` (runs lint only in packages that
define it, fails the job on real lint errors) and remove the no-op comment. No action this phase;
tracked so the green-but-empty step is not mistaken for working lint enforcement.
---
_Reviewed: 2026-06-11_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: deep_
@@ -45,6 +45,11 @@ CREATE TABLE `calendars` (
`last_synced_at` timestamp,
`is_shared` boolean NOT NULL DEFAULT false,
CONSTRAINT `calendars_id` PRIMARY KEY(`id`),
-- NOTE (IN-01): UNIQUE(user_id, url) with url varchar(1024)/utf8mb4 is ~4100 bytes,
-- over InnoDB's 3072-byte index-key limit. Succeeds only because MariaDB 11 silently
-- builds over-length UNIQUE constraints as long-unique HASH indexes; this same DDL
-- fails on MySQL 8 or with stricter SQL modes. Engine-pinned to MariaDB (project hard
-- constraint). Already applied on main/production — do not alter.
CONSTRAINT `uniq_calendar_user_url` UNIQUE(`user_id`,`url`)
);
--> statement-breakpoint
@@ -97,6 +102,11 @@ CREATE TABLE `push_subscriptions` (
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `push_subscriptions_id` PRIMARY KEY(`id`),
-- NOTE (IN-01): UNIQUE(endpoint) with endpoint varchar(2048)/utf8mb4 is ~8192 bytes,
-- over InnoDB's 3072-byte index-key limit. Succeeds only because MariaDB 11 silently
-- builds over-length UNIQUE constraints as long-unique HASH indexes; this same DDL
-- fails on MySQL 8 or with stricter SQL modes. Engine-pinned to MariaDB (project hard
-- constraint). Already applied on main/production — do not alter.
CONSTRAINT `uniq_push_endpoint` UNIQUE(`endpoint`)
);
--> statement-breakpoint