---
phase: 08-gitea-ci
plan: 02
type: execute
wave: 2
depends_on: ['08-01']
files_modified:
  - .gitea/workflows/ci.yml
autonomous: false
requirements: [CI-01]
must_haves:
  truths:
    - 'Opening or updating a PR targeting main triggers ci.yml'
    - 'A fast-checks job runs lint + typecheck (both apps) + PWA unit tests in parallel with the API job'
    - 'An API job stands up a MariaDB 11 service container (or docker-run fallback), waits for real readiness via healthcheck.sh --connect --innodb_initialized, runs drizzle-kit migrate, then runs the DB-backed API test suite'
    - 'Both jobs gate the PR — a failure in either blocks merge once required-checks branch protection is configured'
  artifacts:
    - path: '.gitea/workflows/ci.yml'
      provides: 'PR-triggered fast-checks + API-integration jobs'
      contains: 'pull_request'
  key_links:
    - from: '.gitea/workflows/ci.yml (api job)'
      to: 'mariadb:11 service'
      via: 'DB_HOST + drizzle-kit migrate + vitest'
      pattern: 'healthcheck.sh --connect --innodb_initialized'
    - from: '.gitea/workflows/ci.yml (fast-checks job)'
      to: 'pnpm scripts'
      via: 'run: pnpm lint / typecheck / pwa test'
      pattern: 'pnpm (-r )?(lint|typecheck)'
---

<objective>
Create the single CI workflow file `.gitea/workflows/ci.yml` and populate it with the two PR-gating jobs that need no browser: a fast-checks job (lint + typecheck both apps + PWA unit tests) running in parallel with an API job that runs the DB-backed API test suite against a MariaDB service container. This delivers the non-harness half of CI-01 (ROADMAP criteria 1 + 2) and Pitfall 11 (MariaDB-11 readiness).

Purpose: Fast PR feedback (D-03 — a lint failure does not wait behind the heavier jobs) plus a reliable cold-start API-integration gate. Uses the runner mode answer from 08-01-SUMMARY to choose service-container vs docker-run DB bring-up.

Output: `.gitea/workflows/ci.yml` containing `fast-checks` and `api` jobs gated on `pull_request → main`.

CRITICAL CONTEXT — read 08-01-SUMMARY first for the runner-mode fork:

- If 08-01 found DOCKER-executor mode: use `services: mariadb:` with `DB_HOST: mariadb` (08-RESEARCH Pattern 1).
- If 08-01 found HOST-executor mode: use a `docker run -d mariadb:11 -p 3306:3306` step + explicit readiness loop with `DB_HOST: 127.0.0.1` (08-RESEARCH Pattern 2). Service containers do NOT work in host mode (nektos/act#2711).
  </objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-gitea-ci/08-RESEARCH.md
@.planning/research/PITFALLS.md
@.planning/phases/08-gitea-ci/08-01-SUMMARY.md
</context>

<artifacts_this_phase_produces>

- `.gitea/workflows/ci.yml` (NEW — this plan creates it; Plans 03/04 extend it)
  </artifacts_this_phase_produces>

<interface_context>
Confirmed repo facts the executor MUST honor (do not re-derive):

- Root scripts: `lint` = `pnpm -r lint`, `typecheck` = `pnpm -r typecheck`, `test` = `pnpm --filter @familysync/api test` (= `vitest run`), PWA unit = `pnpm --filter @familysync/pwa test`.
- IMPORTANT — lint is currently a NO-OP: no package defines a `lint` script and ESLint is not installed, so `pnpm lint` (`pnpm -r lint`) prints `ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT` but EXITS 0 and passes. Run `pnpm lint` as the documented command (satisfies CI-01's "lint" gate literally); do NOT add ESLint config — wiring lint is out of this phase's scope (CI-plumbing-only boundary). Note this in the SUMMARY so it is not mistaken for a bug.
- ALL `apps/api` tests live in `apps/api/tests/` and `apps/api/test/setup.ts` truncates DB tables in an `afterEach` (it swallows errors if no DB). So `pnpm --filter @familysync/api test` REQUIRES a real MariaDB — the API "unit" and "integration" tests are one DB-backed command. The fast-checks job therefore runs only the PWA unit tests (no DB); the API job owns all API tests (with DB).
- `apps/pwa` unit tests (`pnpm --filter @familysync/pwa test`) need NO DB.
- DB env var names (from apps/api/src/db/client.ts + drizzle.config.ts): DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME. Migrations: `pnpm --filter @familysync/api db:migrate` (= drizzle-kit migrate). NEVER db:push (unsafe on MariaDB — project memory).
- packageManager is `pnpm@11.5.1`; no .nvmrc/engines pin → pin Node 22 via `actions/setup-node@v4` + `corepack enable pnpm`.
- Workspace is `apps/*` only (no packages/shared despite CLAUDE.md mention) — `pnpm -r` spans 2 packages.
  </interface_context>

<tasks>

<task type="auto">
  <name>Task 1: Create ci.yml with the fast-checks job</name>
  <files>.gitea/workflows/ci.yml</files>
  <read_first>
    - .planning/phases/08-gitea-ci/08-01-SUMMARY.md (runner-mode fork answer; cache usable y/n)
    - .planning/phases/08-gitea-ci/08-RESEARCH.md (§Architecture Patterns job topology; §Standard Stack action versions; Pitfall 7 cache)
    - package.json (root scripts: lint, typecheck, test:e2e)
    - apps/pwa/package.json (pwa test script)
  </read_first>
  <action>
    Create `.gitea/workflows/ci.yml`. Header `name: CI`. Triggers: `on: { pull_request: { branches: [main] }, push: { branches: [main] } }` — both events declared now (the publish job in Plan 04 consumes the push event; the PR jobs filter to `pull_request`).

    Add a workflow-level `env: { MILESTONE: v1.1 }` (per D-04; Plan 04 uses it).

    Add the `fast-checks` job: `runs-on: self-hosted`, guarded `if: github.event_name == 'pull_request'`. Steps:
      1. `uses: actions/checkout@v4`
      2. `uses: actions/setup-node@v4` with `node-version: '22'`
      3. `run: corepack enable pnpm`
      4. Optional pnpm-store cache via `actions/cache@v4` ONLY if 08-01-SUMMARY reported cache works; otherwise OMIT the cache step entirely (do not add a hanging step). If included, wrap with `continue-on-error: true` (Pitfall 7).
      5. `run: pnpm install --frozen-lockfile`
      6. `run: pnpm lint` (no-op per interface_context, but the documented lint gate)
      7. `run: pnpm typecheck` (= `pnpm -r typecheck` → tsc --noEmit in both apps incl. pwa tsconfig.e2e.json)
      8. `run: pnpm --filter @familysync/pwa test` (PWA unit tests — no DB needed)
    Do NOT run `pnpm test` here (that is the DB-backed API suite — it belongs in the api job).

  </action>
  <verify>
    <automated>test -f .gitea/workflows/ci.yml && grep -q "pull_request" .gitea/workflows/ci.yml && grep -q "node-version: '22'" .gitea/workflows/ci.yml && grep -q "pnpm typecheck" .gitea/workflows/ci.yml && grep -q "@familysync/pwa test" .gitea/workflows/ci.yml && echo FASTCHECKS_OK</automated>
  </verify>
  <done>ci.yml exists with a pull_request-gated fast-checks job pinning Node 22, enabling pnpm via corepack, running lint + typecheck + PWA unit tests; no DB-backed `pnpm test` in this job.</done>
</task>

<task type="auto">
  <name>Task 2: Add the API job (MariaDB service + migrate + DB-backed tests)</name>
  <files>.gitea/workflows/ci.yml</files>
  <read_first>
    - .planning/phases/08-gitea-ci/08-01-SUMMARY.md (Docker vs host mode — selects services: vs docker run; MariaDB hostname)
    - .planning/phases/08-gitea-ci/08-RESEARCH.md (§Pattern 1 service container, §Pattern 2 host-mode fallback, §Pattern 4 Drizzle migrate; Pitfall 1 host-mode, Pitfall 2 mariadb healthcheck)
    - apps/api/test/setup.ts (confirms API tests need a real DB)
    - docker-compose.yml (MariaDB 11 healthcheck reference: healthcheck.sh --connect --innodb_initialized)
  </read_first>
  <action>
    Add an `api` job to ci.yml: `runs-on: self-hosted`, `if: github.event_name == 'pull_request'` (runs in PARALLEL with fast-checks — D-03; no `needs:` linking them).

    DB bring-up — branch on 08-01-SUMMARY runner mode:
      - DOCKER mode: declare `services: mariadb:` with `image: mariadb:11`, env `{ MARIADB_ROOT_PASSWORD: root, MARIADB_DATABASE: familysync, MARIADB_USER: familysync, MARIADB_PASSWORD: testpass }`, and `options: >- --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=10 --health-start-period=30s`. Set job `env.DB_HOST: mariadb`. (08-RESEARCH Pattern 1.) `--health-start-period=30s` because MariaDB 11 InnoDB init is slow (A9).
      - HOST mode: instead, a first step `docker run -d --name mariadb -e MARIADB_ROOT_PASSWORD=root -e MARIADB_DATABASE=familysync -e MARIADB_USER=familysync -e MARIADB_PASSWORD=testpass -p 3306:3306 mariadb:11`, then an explicit readiness-loop step using `docker exec mariadb healthcheck.sh --connect --innodb_initialized` with a ~90s deadline (08-RESEARCH Pattern 2). Set `env.DB_HOST: 127.0.0.1`.

    Regardless of mode, set job-level `env`: DB_PORT: 3306, DB_USER: familysync, DB_PASSWORD: testpass, DB_NAME: familysync (throwaway creds — NEVER reuse production secrets; T-08-03).

    Even in Docker mode (where options: auto-waits), add an explicit readiness step BEFORE migrate: a loop that polls `healthcheck.sh --connect --innodb_initialized` (in Docker mode, via a one-shot `mariadb:11` client container or `mysql -h $DB_HOST ... -e "SELECT 1"`) with a deadline — Pitfall 11: healthy-in-Docker ≠ accepting-connections, and the cold-first-run reliability is ROADMAP criterion 2. Never use `mysqladmin ping` (removed in MariaDB 11).

    Then steps:
      - `uses: actions/checkout@v4`; `uses: actions/setup-node@v4` (node 22); `corepack enable pnpm`; `pnpm install --frozen-lockfile`.
      - `run: pnpm --filter @familysync/api db:migrate` (drizzle-kit migrate — applies repo SQL; NEVER db:push). Pass DB_* env.
      - `run: pnpm --filter @familysync/api test` (the full DB-backed API suite). Pass DB_* env.

    Reuse the same cache decision as Task 1 (include only if 08-01 confirmed cache works).

  </action>
  <verify>
    <automated>grep -q "mariadb:11" .gitea/workflows/ci.yml && grep -q "healthcheck.sh --connect --innodb_initialized" .gitea/workflows/ci.yml && ! grep -q "mysqladmin" .gitea/workflows/ci.yml && grep -q "db:migrate" .gitea/workflows/ci.yml && ! grep -q "db:push" .gitea/workflows/ci.yml && grep -q "@familysync/api test" .gitea/workflows/ci.yml && echo APIJOB_OK</automated>
  </verify>
  <done>ci.yml has a parallel pull_request-gated api job that brings up MariaDB 11 (services: or docker run per runner mode), waits for real readiness via healthcheck.sh (never mysqladmin), runs drizzle-kit migrate (never push), and runs the DB-backed API test suite with throwaway creds.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 3: Verify fast-checks + api jobs on a PR</name>
  <what-built>ci.yml with parallel fast-checks + api jobs (Tasks 1–2), exercised by opening a PR from gsd/phase-08-gitea-ci → main.</what-built>
  <how-to-verify>
    1. Push the branch and open a PR targeting `main`.
    2. In Gitea → Actions, confirm BOTH `fast-checks` and `api` jobs are triggered and run in parallel.
    3. Confirm the api job passes on a COLD first run (ROADMAP criterion 2) — not only on re-run. If it fails with ECONNREFUSED to 3306, the MariaDB readiness wait is too short; lengthen the deadline / start-period (Pitfall 11) rather than re-running.
    4. Confirm fast-checks runs lint (no-op), typecheck, and PWA unit tests green.
    5. (Operator, optional but recommended) Configure branch protection on `main` → required status checks include these jobs, so a failure actually blocks merge (08-VALIDATION Manual-Only).
  </how-to-verify>
  <resume-signal>Type "W1 green" once both jobs pass on a cold PR run, or paste the failing log.</resume-signal>
</task>

</tasks>

<threat_model>

## Trust Boundaries

| Boundary                | Description                                                      |
| ----------------------- | ---------------------------------------------------------------- |
| PR head → CI runner     | PR-triggered job runs untrusted branch content on operator infra |
| Test DB creds → job env | Throwaway creds in CI env; must not be production secrets        |

## STRIDE Threat Register

| Threat ID | Category               | Component                 | Disposition | Mitigation Plan                                                                                                                                                                                                   |
| --------- | ---------------------- | ------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| T-08-03   | Information Disclosure | MariaDB creds in job env  | mitigate    | Use throwaway creds (familysync/testpass, root/root) scoped to the ephemeral service container only; NEVER reference production DB_PASSWORD or any repo secret in these jobs (08-RESEARCH Security Domain).       |
| T-08-04   | Tampering              | drizzle-kit against CI DB | mitigate    | Use `db:migrate` (applies committed SQL) exclusively; `db:push` is forbidden (emits destructive TRUNCATE diff on MariaDB — project memory drizzle-mariadb-push-unsafe). Verified by grep gate (`! grep db:push`). |
| T-08-05   | Denial of Service      | cold-start readiness race | mitigate    | Explicit healthcheck.sh readiness loop before migrate (Pitfall 11) so the gate is reliable on first run, not flaky.                                                                                               |

</threat_model>

<verification>
- ci.yml passes both Task grep gates (service container + readiness + migrate-not-push; fast-checks node-pin + typecheck + pwa test).
- PR run shows fast-checks ∥ api in parallel; api green on cold first run.
- No production secret referenced in either job.
</verification>

<success_criteria>

- CI-01 (non-harness half): PR to main runs lint + typecheck (both apps) + unit tests + API integration vs MariaDB service container; failures gate merge (ROADMAP criteria 1 + 2).
- Pitfall 11 honored: healthcheck.sh --connect --innodb_initialized readiness, never mysqladmin; reliable cold-start.
- One workflow file (D-03), parallel event-gated jobs.
  </success_criteria>

<output>
Create `.planning/phases/08-gitea-ci/08-02-SUMMARY.md` when done. Record: the chosen DB bring-up path (services vs docker-run), final readiness timeout values, whether cache was enabled, and the lint-is-a-no-op note.
</output>
