Files
familysync/.planning/phases/08-gitea-ci/08-RESEARCH.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

64 KiB

Phase 8: Gitea CI — Research

Researched: 2026-06-11 Domain: Gitea Actions / act_runner, GitHub Actions service containers, Playwright CI, Docker registry Confidence: MEDIUM — the runner does not yet exist on the Unraid host (0 runners registered); all runner-mode and service-container behaviour is inferred from Gitea/act docs and community reports and must be confirmed via the runner-probe task.


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

D-01 — Dev-stack bring-up: bare background processes + MariaDB service container. No docker compose, no production image. MariaDB = Gitea service container (shared with integration job). API = background pnpm dev:api (needs build first; dev script is node --watch dist/index.js). PWA Vite dev server = started by Playwright's own webServer config. reuseExistingServer: !process.env.CI means Playwright WILL start Vite itself when CI=true.

D-02 — Harness step must wait for both :3000 (API) and :5173 (Vite) to be ready before Playwright launches. The harness global-setup.ts already polls baseURL/health (proxied to the API) and gates /api/me for DEV_AUTH_BYPASS. CI must additionally ensure the API process is up before global-setup runs. MariaDB readiness uses healthcheck.sh --connect --innodb_initialized (never mysqladmin ping — removed in MariaDB 11).

D-03 — One workflow file with parallel, event-gated jobs. pull_request → main: fast-checks (lint + typecheck + unit tests) in parallel with API-integration job and harness job. push → main (merge): build-and-publish job.

D-04 — Two tags on merge: :latest + :<milestone>-<shortsha> (e.g. v1.1-4303a1b). Milestone string read from PROJECT.md/ROADMAP.md (currently v1.1), not hardcoded inline. Short SHA = first 7 chars of GITHUB_SHA.

D-05 — Full Phase 7 device matrix in CI: iPhone 14/WebKit AND Pixel 7/Chromium. Install WebKit system deps on runner.

D-06 — On harness failure, upload test-results/ (traces/screenshots/videos) as CI artifacts. Reporter 'github' in playwright.config.ts may not render annotations in Gitea — verify and fall back to list+html if so.

Claude's Discretion

  • Exact job names, step ordering within a job, pnpm store cache key strategy, whether fast-checks is one job or split.
  • Whether API background process is pnpm dev:api vs a built node dist/index.js — pick whatever gives reliable :3000 readiness under DEV_AUTH_BYPASS.
  • Registry hostname / image repository path under the Gitea registry.

Deferred Ideas (OUT OF SCOPE)

  • ROADMAP.md bookkeeping error (Phase 8 marked completed at line 29 while line 204 says "Not started") — docs cleanup, not CI scope. </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
CI-01 Every PR targeting main runs full regression — lint, typecheck (both apps), unit tests, API integration tests against a MariaDB service container, and the Phase 7 mobile Playwright harness (CI brings up API + PWA dev servers + MariaDB + DEV_AUTH_BYPASS) — result gates merge. See §Architecture Patterns for job topology, §Service Containers for MariaDB, §Dev-Stack Bring-Up for harness orchestration, §Runner-Probe Checklist for what must be verified first.
CI-02 On merge to main, API Docker image is built and published to the Gitea container registry. See §Docker Registry Push for Gitea registry mechanics, §Image Tagging for v1.1-<sha> strategy.

</phase_requirements>


Summary

Phase 8 adds a single .gitea/workflows/ci.yml file that delivers a PR regression gate and a merge-triggered publish job. The technical unknowns cluster around three areas that all require a runner-probe task before anything else is trusted: (1) which GitHub Actions marketplace actions resolve on this self-hosted act_runner and in what runner mode it operates; (2) whether the services: key starts MariaDB when the runner runs jobs in Docker-container mode (the recommended mode), and what hostname the job container uses to reach it; (3) whether actions/upload-artifact@v4 works on Gitea 1.26 or whether the gitea-upload-artifact fork is required.

The single most important finding: service containers (services:) work when act_runner runs jobs in Docker-container mode (the default), but are NOT supported when the runner is configured for host-executor mode. The runner-probe's first task is to determine which mode the Unraid runner is in. If the runner is in host mode, the plan must pivot: either spin up MariaDB via a docker run step in the workflow (instead of services:), or request that the runner be reconfigured to Docker mode.

For the publish job, the Gitea container registry path is git.bergerhouse.net/luckberg/<image>. The built-in GITHUB_TOKEN does NOT work for Gitea package registry pushes — a PAT with write:package scope stored as a repository secret is required. docker/login-action@v3 + docker/build-push-action@v6 resolve from GitHub by default (via DEFAULT_ACTIONS_URL) and appear to work in most Gitea installations; the runner-probe confirms.

Primary recommendation: Write the workflow in three phases — (W0) a runner-probe-only workflow that prints Node/pnpm/Docker versions and tests the key assumptions; (W1) the fast-checks + integration jobs; (W2) the harness job + publish job. Each wave is committed only after the previous wave's probe confirms the assumptions it depends on.


Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Workflow orchestration CI runner (Gitea Actions) Gitea Actions owns job scheduling
MariaDB service container CI runner (act_runner Docker daemon) Spawned as a sibling container by act_runner
API background process CI runner (host or job container) pnpm build && node dist/index.js in a step
Vite dev server Playwright webServer config Playwright starts it; reuseExistingServer=false in CI
DB seed (global-setup) Playwright globalSetup API (through mysql2 direct connection) global-setup.ts connects directly to MariaDB
Docker image build CI runner (Docker socket / DinD) docker build in a workflow step
Container registry push Gitea package registry docker push git.bergerhouse.net/luckberg/familysync-api
Artifact upload (traces) Gitea Actions artifact storage Via gitea-upload-artifact fork (see §Artifacts)

Standard Stack

Workflow Actions

Action Version Purpose Status
actions/checkout @v4 Clone repo into job [ASSUMED] Mirrored at gitea.com/actions/checkout; resolves from GitHub by default via DEFAULT_ACTIONS_URL. Probe confirms.
actions/setup-node @v4 Pin Node.js 22 [ASSUMED] Mirrored at gitea.com/actions/setup-node. Probe confirms.
actions/cache @v4 pnpm store cache [ASSUMED] Known networking issue: cache server runs in runner container but job container is on a different network. May time out. Probe is required — fall back to no-cache if it fails.
https://github.com/ChristopherHX/gitea-upload-artifact @v4 Upload Playwright traces [VERIFIED: github.com/ChristopherHX/gitea-upload-artifact] Required replacement for actions/upload-artifact@v4 which detects Gitea as GHES and aborts.
docker/login-action @v3 Authenticate to Gitea registry [ASSUMED] Referenced from GitHub by absolute URL; probe confirms.
docker/build-push-action @v6 Build and push Docker image [ASSUMED] Referenced from GitHub by absolute URL; probe confirms.

No pnpm/action-setup needed

The repo root package.json declares "packageManager": "pnpm@11.5.1". With Node.js installed via actions/setup-node, enabling corepack via corepack enable pnpm in a step is sufficient. [ASSUMED] — probe confirms pnpm is resolvable this way.

Workflow file location

.gitea/workflows/ci.yml — Gitea primarily reads .gitea/workflows/. Both .gitea/ and .github/ are supported, but having files in .gitea/ takes precedence. [CITED: docs.gitea.com/usage/actions/quickstart]


Package Legitimacy Audit

Only gitea-upload-artifact is an external action introduced by this phase. All other tools are GitHub-maintained official actions or Docker-maintained actions that are well-established.

Package / Action Registry / Source Age Downloads Source Repo Verdict Disposition
actions/checkout@v4 github.com/actions/checkout 5+ yrs Millions github.com/actions/checkout OK Approved
actions/setup-node@v4 github.com/actions/setup-node 5+ yrs Millions github.com/actions/setup-node OK Approved
actions/cache@v4 github.com/actions/cache 5+ yrs Millions github.com/actions/cache OK Approved — but probe may fall back
ChristopherHX/gitea-upload-artifact@v4 github.com/ChristopherHX/gitea-upload-artifact ~2 yrs Moderate, known fix for Gitea github.com/ChristopherHX/gitea-upload-artifact OK Approved — known and cited solution to v4 GHES blocker
docker/login-action@v3 github.com/docker/login-action 4+ yrs Millions github.com/docker/login-action OK Approved
docker/build-push-action@v6 github.com/docker/build-push-action 4+ yrs Millions github.com/docker/build-push-action OK Approved

Packages removed due to SLOP verdict: none Packages flagged as suspicious SUS: none


Runner-Probe Checklist

This is the single most important planning output for Phase 8. Every runner assumption MUST be confirmed by running a minimal probe workflow before the real CI steps are designed.

The runner-probe workflow lives at .gitea/workflows/runner-probe.yml, runs only on a named test branch (e.g. gsd/phase-08-gitea-ci), and does nothing destructive.

What the probe must answer

# Check Command in Probe What it confirms
P-01 Node.js version node --version Node 22 available or needs setup-node
P-02 pnpm availability pnpm --version OR corepack enable pnpm && pnpm --version pnpm reachable; version matches 11.x
P-03 Runner mode `cat /proc/1/cgroup head -5andhostnameandls /.dockerenv 2>/dev/null` Is the job running in a Docker container (act_runner Docker mode) or on bare host? This is the critical fork: service containers only work in Docker mode.
P-04 Docker socket access `docker info 2>&1 head -10` Docker accessible from job; needed for service containers AND publish job
P-05 Service container spawn Add services: mariadb: image: mariadb:11 to probe job; check if docker ps in a step shows the mariadb container Service containers work at all
P-06 MariaDB reachability After P-05: mysql -h 127.0.0.1 -P 3306 -u root -proot -e "SELECT 1" (host runner) OR -h mariadb (job container) Which hostname resolves to the MariaDB service
P-07 actions/checkout uses: actions/checkout@v4 Action resolves; DEFAULT_ACTIONS_URL is set to github.com
P-08 actions/setup-node uses: actions/setup-node@v4 with node-version: '22' setup-node works; pins Node 22
P-09 actions/cache uses: actions/cache@v4 with a test key Cache works without timeout; if it hangs, confirm no-cache fallback
P-10 Playwright deps (WebKit) `npx playwright install --with-deps webkit chromium 2>&1 tail -20` System deps installed; no sudo/apt failures
P-11 upload-artifact uses: https://github.com/ChristopherHX/gitea-upload-artifact@v4 with a dummy file Upload succeeds; artifact appears in Gitea UI
P-12 Docker login + push `echo $SECRET docker login git.bergerhouse.net --username luckberg --password-stdin` Registry auth works with PAT
P-13 GITHUB_SHA echo ${GITHUB_SHA:0:7} Short SHA expression produces 7-char string

Critical fork: Docker mode vs host mode (P-03)

If job runs in a Docker container (Docker mode — the recommended act_runner default):

  • Service container hostname = service label name (e.g. mariadb)
  • Job container and service container share a Docker network automatically
  • DB_HOST=mariadb in job env; NO port mapping needed in workflow
  • This is the GitHub-Actions-compatible path; service containers work as documented

If job runs directly on host (host mode):

  • Service containers are NOT supported by act_runner's host executor [CITED: github.com/nektos/act/issues/2711]
  • The plan must use a docker run -d --name mariadb mariadb:11 ... step instead of services:
  • DB_HOST=127.0.0.1 with port 3306:3306 mapping in the docker run step
  • Explicit readiness loop step required (no options: health-check auto-wait)
  • This is the fallback path; probe determines if it applies

CONFIRMED-vs-VERIFY Table

Item Status Notes
Workflow file at .gitea/workflows/ci.yml CONFIRMED [CITED: docs.gitea.com/usage/actions/quickstart]
on: pull_request and on: push triggers CONFIRMED Standard GitHub Actions syntax; Gitea supports these [CITED: comparison page]
actions/checkout@v4 resolves from GitHub CONFIRMED (per docs) DEFAULT_ACTIONS_URL defaults to github.com; VERIFY-ON-RUNNER (P-07)
actions/setup-node@v4 resolves ASSUMED Mirrored at gitea.com/actions/setup-node; VERIFY-ON-RUNNER (P-08)
actions/cache@v4 works in Docker mode ASSUMED with caveat Known networking issue between runner container and job container; VERIFY-ON-RUNNER (P-09)
services: key starts MariaDB in Docker mode ASSUMED from GitHub Actions docs act_runner implements this for Docker mode; does NOT implement for host mode [CITED: nektos/act#2711]; VERIFY-ON-RUNNER (P-03 + P-05)
MariaDB hostname in Docker mode = service name ASSUMED from GitHub Actions semantics "hostname automatically mapped to label name" for containerized jobs [CITED: docs.github.com]; VERIFY-ON-RUNNER (P-06)
MariaDB hostname in host mode = 127.0.0.1 CONFIRMED for host-mode + port-mapped service [CITED: firefart.at MySQL-GitHub-Actions]
healthcheck.sh --connect --innodb_initialized works in options: CONFIRMED [CITED: mariadb.com/docs healthcheck.sh page]
mysqladmin ping does NOT work with MariaDB 11 CONFIRMED mysqladmin binary was removed from the mariadb:11 image [CITED: github.com/mage-os/github-actions/issues/365]
actions/upload-artifact@v4 works natively on Gitea CONFIRMED BROKEN Gitea detected as GHES; v4 aborts with reqPackageAccess error [CITED: github.com/go-gitea/gitea/issues/31256]
ChristopherHX/gitea-upload-artifact@v4 works ASSUMED Known workaround; VERIFY-ON-RUNNER (P-11)
reporter: 'github' renders annotations in Gitea UNCONFIRMED Gitea does not fully implement GitHub workflow commands; annotations likely silently ignored. VERIFY-ON-RUNNER — fall back to ['list', 'html'] if annotations don't appear
GITHUB_SHA available in Gitea Actions CONFIRMED Gitea uses GitHub-compatible env var names [CITED: forum.gitea.com/t/using-github-sha-or-gitea-sha]
Short SHA via ${GITHUB_SHA:0:7} CONFIRMED Bash substring; same forum thread
Docker login to Gitea registry with PAT CONFIRMED (approach) secrets.GITEA_TOKEN does NOT work for packages [CITED: forum.gitea.com/t/proper-container-registry-procedure]; use PAT with write:package scope [CITED: docs.gitea.com/usage/packages/container]
Gitea registry image path: git.bergerhouse.net/luckberg/<image> CONFIRMED Registry uses {host}/{owner}/{image} format [CITED: docs.gitea.com/usage/packages/container]
docker/login-action@v3 + docker/build-push-action@v6 resolve ASSUMED Referenced by absolute GitHub URL; VERIFY-ON-RUNNER (P-12)
Playwright --with-deps installs system deps without sudo CONFIRMED for most cases Playwright handles su internally; may fail if runner has no internet/apt access [CITED: playwright.dev/docs/ci]
npx playwright install does NOT cache browser binaries CONFIRMED (deliberate) Playwright explicitly recommends against caching browser binaries in CI [CITED: playwright.dev/docs/ci]

Architecture Patterns

System Architecture Diagram

PR opened / push to main
        │
        ▼
.gitea/workflows/ci.yml
        │
        ├─── on: pull_request ──────────────────────────────────────────────┐
        │         │                                                          │
        │    ┌────▼──────────────────┐   ┌──────────────────┐              │
        │    │   fast-checks job     │   │  api-integration  │             │
        │    │ (parallel)            │   │  job (parallel)   │             │
        │    │ • pnpm install        │   │ • MariaDB service │             │
        │    │ • lint                │   │ • pnpm install    │             │
        │    │ • typecheck api+pwa   │   │ • drizzle migrate │             │
        │    │ • vitest unit tests   │   │ • vitest run      │             │
        │    └───────────────────────┘   │   (api only)      │             │
        │                                └──────────────────┘              │
        │    ┌──────────────────────────────────────────────────────────┐  │
        │    │   harness job (parallel)                                 │  │
        │    │ • MariaDB service container (shared need, same pattern)  │  │
        │    │ • pnpm install                                           │  │
        │    │ • drizzle generate + migrate                             │  │
        │    │ • pnpm build (api) → node dist/index.js &              │  │
        │    │ • wait :3000 /health                                     │  │
        │    │ • DEV_AUTH_BYPASS=true CI=true PLAYWRIGHT_BASE_URL=...   │  │
        │    │ • pnpm test:e2e (Playwright starts Vite :5173 itself)    │  │
        │    │ • upload test-results/ on failure                        │  │
        │    └──────────────────────────────────────────────────────────┘  │
        │                                                                   │
        └─── on: push (main) ───────────────────────────────────────────────┘
                  │
        ┌─────────▼──────────────┐
        │   publish job          │
        │ • docker login (PAT)   │
        │ • docker build         │
        │   --target production  │
        │ • docker push :latest  │
        │ • docker push :v1.1-sha│
        └────────────────────────┘
.gitea/
└── workflows/
    ├── runner-probe.yml    # Wave 0: probe only, runs on feature branch
    └── ci.yml              # Waves 1-2: real CI after probe passes

Pattern 1: MariaDB Service Container (Docker-mode runner)

What: Declare MariaDB as a services: entry; act_runner starts it as a sibling container on the same Docker network as the job container. Job reaches it by service label hostname.

When to use: Runner probe P-03 confirms the job runs in a Docker container (Docker mode).

# Source: [ASSUMED from GitHub Actions docs + MariaDB docs]
jobs:
  api-integration:
    runs-on: self-hosted
    services:
      mariadb:
        image: mariadb:11
        env:
          MARIADB_ROOT_PASSWORD: root
          MARIADB_DATABASE: familysync
          MARIADB_USER: familysync
          MARIADB_PASSWORD: testpass
        options: >-
          --health-cmd="healthcheck.sh --connect --innodb_initialized"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=10
          --health-start-period=30s
    env:
      DB_HOST: mariadb # service label name — Docker mode only
      DB_PORT: 3306
      DB_USER: familysync
      DB_PASSWORD: testpass
      DB_NAME: familysync

Critical note on --health-start-period: MariaDB 11 takes longer to initialize InnoDB than older versions. Set --health-start-period=30s to avoid premature health-check failures during container startup. [ASSUMED based on MariaDB 11 init time; tune in probe]

Pattern 2: MariaDB Without Service Containers (host-mode runner fallback)

What: If P-03 shows host mode, start MariaDB manually with docker run -d in a step and do an explicit readiness loop.

When to use: Runner probe P-03 shows job runs directly on host (host mode).

# Source: [ASSUMED — standard workaround for host-mode runners]
steps:
  - name: Start MariaDB
    run: |
      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
  - name: Wait for MariaDB
    run: |
      deadline=$((SECONDS + 90))
      until healthcheck_output=$(docker exec mariadb healthcheck.sh --connect --innodb_initialized 2>&1) \
            && [ $? -eq 0 ]; do
        if [ $SECONDS -ge $deadline ]; then
          echo "MariaDB did not become ready in time"
          docker logs mariadb | tail -30
          exit 1
        fi
        sleep 3
      done
      echo "MariaDB ready"
env:
  DB_HOST: 127.0.0.1 # host-mode: service on Docker host reachable via localhost
  DB_PORT: 3306

Pattern 3: API Background Process

What: Build the API, start it as a background process, wait for :3000/health.

When to use: Harness job only (CI-01 harness step).

# Source: [ASSUMED — standard CI background-process pattern]
- name: Build API
  run: pnpm --filter @familysync/api build
  env:
    NODE_ENV: development

- name: Start API
  run: |
    NODE_ENV=development \
    DEV_AUTH_BYPASS=true \
    DB_HOST=${{ env.DB_HOST }} \
    DB_USER=familysync \
    DB_PASSWORD=testpass \
    DB_NAME=familysync \
    node apps/api/dist/index.js &
    echo $! > /tmp/api.pid
    echo "API PID: $(cat /tmp/api.pid)"

- name: Wait for API (:3000)
  run: |
    deadline=$((SECONDS + 60))
    until curl -sf http://localhost:3000/health > /dev/null 2>&1; do
      if [ $SECONDS -ge $deadline ]; then
        echo "API did not start in time"
        kill $(cat /tmp/api.pid) 2>/dev/null || true
        exit 1
      fi
      sleep 2
    done
    echo "API ready"

Why node apps/api/dist/index.js not pnpm dev:api: The dev script is node --watch dist/index.js — it needs a prior pnpm --filter @familysync/api build (tsc). Running via node directly (without --watch) is cleaner for CI since the file watcher is irrelevant. D-discretion covers this choice.

Pattern 4: Drizzle Migration in CI

What: Run drizzle-kit generate (idempotent, generates SQL from schema if needed) then drizzle-kit migrate against the service container. Do NOT use db:push (documented as unsafe on MariaDB — project memory drizzle-mariadb-push-unsafe).

# Source: [ASSUMED — confirmed in project memory and PITFALLS section]
- name: Run DB migrations
  run: pnpm --filter @familysync/api db:migrate
  env:
    DB_HOST: ${{ env.DB_HOST }}
    DB_PORT: 3306
    DB_USER: familysync
    DB_PASSWORD: testpass
    DB_NAME: familysync

Migrations live at apps/api/src/db/migrations/. The db:migrate script calls drizzle-kit migrate which applies existing SQL files — safe because the schema SQL is already in the repo (from generate runs during development). No generate step needed in CI unless the schema changed in the same PR.

Pattern 5: Playwright Harness in CI

What: Run the full Phase 7 harness against the CI-brought-up dev stack. Playwright's webServer starts Vite (:5173) automatically when CI=true (because reuseExistingServer: !process.env.CI is false). The global-setup.ts handles the DB seed and the /health + /api/me readiness gates.

# Source: [ASSUMED — based on playwright.config.ts and global-setup.ts already in repo]
- name: Install Playwright browsers
  run: npx playwright install --with-deps webkit chromium
  working-directory: apps/pwa

- name: Run Playwright harness
  run: pnpm test:e2e
  env:
    CI: true
    PLAYWRIGHT_BASE_URL: http://localhost:5173
    DEV_AUTH_BYPASS: 'true'
    NODE_ENV: development
    DB_HOST: ${{ env.DB_HOST }}
    DB_PORT: 3306
    DB_USER: familysync
    DB_PASSWORD: testpass
    DB_NAME: familysync

- name: Upload test artifacts
  if: failure()
  uses: https://github.com/ChristopherHX/gitea-upload-artifact@v4
  with:
    name: playwright-traces-${{ github.run_id }}
    path: apps/pwa/test-results/
    retention-days: 14

Note on working-directory for playwright install: npx playwright install must be run from the package root where @playwright/test is installed — apps/pwa/. [ASSUMED]

Pattern 6: Docker Image Publish

# Source: [ASSUMED — based on Gitea container registry docs and forum]
publish:
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  runs-on: self-hosted
  steps:
    - uses: actions/checkout@v4

    - name: Compute image tags
      id: tags
      run: |
        SHORT_SHA=${GITHUB_SHA:0:7}
        MILESTONE="v1.1"  # read from PROJECT.md in executor if preferred
        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

    - name: Docker login
      run: |
        echo "${{ secrets.GITEA_REGISTRY_PAT }}" | \
        docker login git.bergerhouse.net \
          --username luckberg \
          --password-stdin

    - name: Build and push
      run: |
        docker build \
          --target production \
          -t ${{ steps.tags.outputs.latest }} \
          -t ${{ steps.tags.outputs.sha_tag }} \
          .
        docker push ${{ steps.tags.outputs.latest }}
        docker push ${{ steps.tags.outputs.sha_tag }}

Secret name: GITEA_REGISTRY_PAT — a PAT with write:package (and read:package) scope created by luckberg. Must be added to the repo secrets in Gitea UI before the publish job runs.

Why not docker/login-action: --password-stdin via a direct docker login step is simpler to verify on a self-hosted runner and avoids a dependency on the action resolving. The action is an option but the shell form is safer as a first iteration.

Anti-Patterns to Avoid

  • mysqladmin ping in MariaDB 11 health check: The mysqladmin binary is not in the mariadb:11 image. Use healthcheck.sh --connect --innodb_initialized. [CONFIRMED: mage-os/github-actions issue]
  • drizzle-kit push in CI: Documented as unsafe on MariaDB — emits false destructive diff that TRUNCATEs tables. Always use generate + migrate. [CONFIRMED: project memory]
  • secrets.GITHUB_TOKEN for Gitea registry push: Returns unauthorized: reqPackageAccess. Use a PAT. [CONFIRMED: Gitea forum]
  • actions/upload-artifact@v4 natively on Gitea: Fails with GHES detection. Use ChristopherHX/gitea-upload-artifact@v4. [CONFIRMED: Gitea issue #31256]
  • reporter: 'github' assumed to render in Gitea: Gitea does not implement the GitHub workflow-command protocol for annotations. The reporter setting in playwright.config.ts currently hardcodes 'github' when CI=true. The planner must add a step that overrides reporter to ['list', 'html'] OR passes --reporter=list to the playwright test invocation. [ASSUMED — verify in probe]
  • Starting API with pnpm dev:api without building first: pnpm dev:api is pnpm --filter @familysync/api dev = node --watch dist/index.js, which requires dist/ to exist. In CI, dist/ does not exist until pnpm --filter @familysync/api build (tsc) runs. Build first.
  • actions/cache without confirming it works: The cache action has a known networking issue in act_runner Docker mode — the cache server runs in the runner container but the job container is on a different network, causing socket hang-up. Do not assume cache works; probe first and make it optional.
  • node-cron in the API background process: Not applicable to CI (short-lived process), but confirming the API uses setInterval (fixed in project) — no concern for CI.

Dev-Stack Bring-Up for the Harness Job

The orchestration order is critical. All of the following must be sequential within the harness job (not parallelizable):

1. MariaDB service container starts (via `services:` or `docker run -d` step)
   └── Wait: options health-check (Docker mode) OR explicit loop (host mode)
       Target: `healthcheck.sh --connect --innodb_initialized`
       Timeout: up to 90s (MariaDB 11 init is slower than 10)

2. pnpm install (workspace)

3. drizzle-kit migrate (DB_HOST = mariadb or 127.0.0.1 per runner mode)

4. pnpm --filter @familysync/api build  (produces dist/index.js)

5. Start API background process:
   NODE_ENV=development DEV_AUTH_BYPASS=true node apps/api/dist/index.js &

6. Wait for :3000/health (curl retry loop, 60s timeout)
   This is SEPARATE from global-setup.ts's poll — global-setup runs AFTER
   Playwright starts, and it polls the Vite proxy. The step-level wait ensures
   the API is up before Playwright even attempts to start Vite.

7. Playwright invocation (pnpm test:e2e):
   a. Playwright webServer starts Vite :5173 (reuseExistingServer=false in CI)
   b. global-setup.ts polls baseURL/health (proxied to :3000) — already up from step 6
   c. global-setup.ts gates /api/me for DEV_AUTH_BYPASS confirmation
   d. global-setup.ts seeds DB via mysql2 direct connection (DB_HOST, etc.)
   e. Specs run against both iPhone 14/WebKit and Pixel 7/Chromium

8. On failure: upload test-results/ via gitea-upload-artifact

Note on PLAYWRIGHT_BASE_URL: Set to http://localhost:5173. The Vite dev server proxies /health, /api, /callbackhttp://localhost:3000. This is how global-setup reaches the API health endpoint through the Vite proxy URL.

Note on NODE_ENV: The global-setup.ts refuses to run if NODE_ENV=production. In CI, set NODE_ENV=development (or leave unset; the guard only blocks production). Do NOT set NODE_ENV=test — the API checks NODE_ENV=development for dev-bypass activation confirmation.

Note on both MariaDB connections: The API (via Drizzle/mysql2) and global-setup.ts (via mysql2 direct) both use the same DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAME env vars. Set them once at job level and they propagate to both.


Docker Registry Push (CI-02)

Registry Details

Property Value Source
Registry host git.bergerhouse.net [CONFIRMED from git remote URL]
Image path format git.bergerhouse.net/{owner}/{image} [CITED: docs.gitea.com/usage/packages/container]
Image name git.bergerhouse.net/luckberg/familysync-api [ASSUMED — owner = luckberg, image name = familysync-api]
Auth method PAT with write:package scope [CONFIRMED: Gitea forum, registry docs]
Token variable secrets.GITEA_REGISTRY_PAT [ASSUMED — name chosen by planner/executor]
docker login approach echo $PAT | docker login git.bergerhouse.net --username luckberg --password-stdin [CONFIRMED: Pitfall 13]

Image Tag Strategy (D-04)

Tag Example Purpose
:latest git.bergerhouse.net/luckberg/familysync-api:latest Moving pointer for easy pulls
:<milestone>-<shortsha> git.bergerhouse.net/luckberg/familysync-api:v1.1-4303a1b Immutable, rollback-traceable

The milestone string v1.1 is hardcoded in the workflow as MILESTONE="v1.1" for now (reading it from PROJECT.md dynamically adds complexity with minimal benefit). The executor can make it a workflow-level env var for easy updates.

Short SHA: ${GITHUB_SHA:0:7} — confirmed available in Gitea Actions. [CITED: forum.gitea.com]

Dockerfile Build Context

The apps/api/Dockerfile must be built from the repo root (not apps/api/), as documented in the Dockerfile header:

docker build --target production -f apps/api/Dockerfile .

This is because the Dockerfile copies the pnpm workspace manifest and lockfile from the repo root. Building from apps/api/ would fail.


Common Pitfalls

Pitfall 1: Service Containers Don't Start (Host Mode Runner)

What goes wrong: services: in the workflow YAML is silently ignored; MariaDB container never appears in docker ps. API integration tests fail with ECONNREFUSED to DB.

Why it happens: act_runner in host-executor mode does not implement service container lifecycle [CITED: github.com/nektos/act/issues/2711]. The act_runner runs jobs directly on the host OS and has no mechanism to start sidecar containers.

How to avoid: Probe P-03 detects the runner mode. If host mode: use docker run -d mariadb:11 in a step instead of services:.

Warning signs: P-05 shows MariaDB container not in docker ps.

Pitfall 2: MariaDB 11 Health Check With mysqladmin (Pitfall 11)

What goes wrong: --health-cmd="mysqladmin ping" in options: causes the health check to always fail; the job times out waiting for the service to become healthy.

Why it happens: mysqladmin binary was removed from the official mariadb:11 Docker image.

How to avoid: Use --health-cmd="healthcheck.sh --connect --innodb_initialized" exclusively. [CONFIRMED: mariadb.com docs]

Warning signs: Job hangs at service startup; docker inspect shows container in unhealthy state.

Pitfall 3: Drizzle-Kit Push in CI

What goes wrong: drizzle-kit push emits a destructive diff (TRUNCATEs tables) on populated MariaDB. The CI DB has just been seeded by global-setup.ts; running push afterwards would wipe it.

Why it happens: MariaDB metadata misread by Drizzle's mysql dialect (project memory: drizzle-mariadb-push-unsafe).

How to avoid: Always drizzle-kit migrate in CI (applies existing SQL migration files). Never drizzle-kit push.

Pitfall 4: API Started Without Building First

What goes wrong: node apps/api/dist/index.js fails with MODULE_NOT_FOUND because dist/ does not exist in CI.

Why it happens: dist/ is gitignored; the repo checkout has no compiled output.

How to avoid: Always run pnpm --filter @familysync/api build (= tsc) before starting the API process.

Pitfall 5: Reporter 'github' Emits Invisible Annotations in Gitea (Pitfall from D-06)

What goes wrong: playwright.config.ts sets reporter: 'github' when CI=true. This emits ::error:: GitHub workflow commands, which Gitea Actions does not render as UI annotations. Test failures appear in raw log output only, with no visual callout in the PR.

Why it happens: Gitea Actions does not implement GitHub's workflow command annotation protocol.

How to avoid: The CI env var triggers the 'github' reporter. Override with --reporter=list,html on the playwright test invocation in CI, OR modify the harness job step to set PLAYWRIGHT_REPORTER=list if Playwright honours that env var. The planner should add a PLAYWRIGHT_REPORTER override. Runner probe P-06 (effectively) confirms this.

Warning signs: PR shows no inline annotation for a test failure; only the raw job log shows the failure.

Pitfall 6: actions/upload-artifact@v4 GHES Detection

What goes wrong: Upload step fails with Error: This version of upload-artifact is not supported. Only GHES version X.Y.Z and above is supported.

Why it happens: Gitea is detected as GitHub Enterprise Server; actions/upload-artifact@v4 has a version gate that rejects GHES below a certain version.

How to avoid: Use https://github.com/ChristopherHX/gitea-upload-artifact@v4 instead. [CONFIRMED: Gitea issue #28853 + #31256]

Pitfall 7: actions/cache Socket Hang-Up in Docker Mode

What goes wrong: Cache step hangs and eventually times out with socket hang up. This may only appear intermittently.

Why it happens: act_runner's cache server runs in the runner container; the job container is on a different Docker network and cannot reach the runner's cache server by its configured address. [CITED: docs.gitea.com/usage/actions/act-runner — cache section]

How to avoid: Probe P-09 tests this. If cache consistently fails, skip it — pnpm install without cache on a fast network takes ~30s. Accept it.

Pitfall 8: DEV_AUTH_BYPASS Not Propagated to API Process

What goes wrong: API starts, /health returns 200, but /api/me returns 302 redirect to Authelia. global-setup.ts's DEV_AUTH_BYPASS gate (WR-01) throws a clear error, but the root cause is that DEV_AUTH_BYPASS=true was not exported into the API background process environment.

Why it happens: If the env var is set at the step level but the node process is launched with & in a separate run: step, environment inheritance between steps is not guaranteed in all runner modes.

How to avoid: Pass DEV_AUTH_BYPASS=true inline on the same line as the node invocation (DEV_AUTH_BYPASS=true node apps/api/dist/index.js &) rather than relying on inherited step env.


Don't Hand-Roll

Problem Don't Build Use Instead Why
MariaDB health check Custom TCP-ping script healthcheck.sh --connect --innodb_initialized Ships in the mariadb:11 image; handles InnoDB init correctly
Upload artifacts to Gitea curl to Gitea API ChristopherHX/gitea-upload-artifact@v4 upload-artifact v4 protocol is complex; the fork wraps it correctly
Docker registry auth Hand-rolled auth header docker login --password-stdin Prevents PAT from appearing in process list
Playwright browser install Manual apt package list npx playwright install --with-deps Playwright knows the correct system deps for each browser version
API readiness check Arbitrary sleep curl retry loop against /health Sleep is flaky; a deterministic health poll is both faster and correct
CI MariaDB in host mode mysqladmin ping loop docker exec mariadb healthcheck.sh --connect --innodb_initialized Avoids mysqladmin-missing error; reuses same logic as Docker healthcheck

Validation Architecture

Test Framework

Property Value
API unit tests Vitest 4.1.x, config at apps/api/vitest.config.ts
API test command pnpm --filter @familysync/api test (= vitest run)
PWA unit tests Vitest (same framework), command pnpm --filter @familysync/pwa test
E2E harness @playwright/test 1.60.0, config at apps/pwa/playwright.config.ts
E2E command pnpm test:e2e (from root) = pnpm --filter @familysync/pwa test:e2e = playwright test

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command Exists?
CI-01 PR gate triggers on pull_request → main workflow trigger test Push a PR and observe After W0
CI-01 lint passes CI step pnpm lint
CI-01 typecheck both apps passes CI step pnpm typecheck
CI-01 unit tests pass CI step pnpm test
CI-01 API integration tests pass with MariaDB CI step pnpm --filter @familysync/api test + DB env
CI-01 Playwright harness passes in CI CI step pnpm test:e2e with CI=true (Phase 7 specs)
CI-02 Docker image pushed to Gitea registry on merge CI step docker pull git.bergerhouse.net/luckberg/familysync-api:latest After W2

Sampling Rate

  • Per task commit (Wave 0): Run runner-probe workflow manually on branch; check Gitea Actions logs
  • Per wave: Confirm all jobs in that wave pass on a test PR
  • Phase gate: Full CI green on a real PR before /gsd-verify-work

Wave 0 Gaps

  • .gitea/workflows/runner-probe.yml — runner probe workflow (new file; Wave 0 task)
  • .gitea/workflows/ci.yml — main CI workflow (new file; Waves 1-2)

Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication no Auth is not modified by this phase
V3 Session Management no Not modified
V4 Access Control no Not modified
V5 Input Validation no No new API endpoints
V6 Cryptography yes (marginal) PAT stored as Gitea repository secret; never in workflow YAML

Known Threat Patterns for CI/Docker

Pattern STRIDE Standard Mitigation
PAT in workflow YAML Information Disclosure Store as secrets.GITEA_REGISTRY_PAT; never echo or print
Docker socket mount (if runner uses it) Elevation of Privilege Known risk; accepted for Unraid self-hosted runner per Gitea docs
DB creds in CI env Information Disclosure Use throwaway test creds (not production DB_PASSWORD); never reuse production secrets
DEV_AUTH_BYPASS=true in CI Spoofing Only active in harness job; never bleeds to publish job; global-setup guard refuses NODE_ENV=production

Environment Availability

Dependency Required By Available Version Fallback
Gitea instance All 1.26.2
Gitea Actions runner All Unknown — 0 registered Unknown Must register runner before Phase 8 can proceed
Docker on runner service containers, publish Unknown Unknown Phase 8 is blocked without Docker on runner
Node.js 22 on runner fast-checks, integration Unknown Unknown actions/setup-node@v4 (probe P-01/P-08)
pnpm 11 on runner All Unknown Unknown corepack enable pnpm (probe P-02)
Internet access from runner actions resolution, npm, Playwright install Unknown Probe P-07 confirms
Gitea registry PAT CI-02 Not yet created Operator must create before publish job

Missing dependencies with no fallback:

  • Gitea Actions runner on Unraid (0 registered) — must be installed and registered before any CI runs
  • Docker on runner — if absent, service containers and publish job both fail; no CI-relevant fallback

Missing dependencies with fallback:

  • Node.js 22 — actions/setup-node@v4 installs it
  • pnpm — corepack enable pnpm resolves it

Assumptions Log

# Claim Section Risk if Wrong
A1 Runner is configured in Docker mode (not host mode) Service Containers, Dev-Stack Bring-Up Entire services: approach breaks; must pivot to docker run -d pattern
A2 actions/checkout@v4 and actions/setup-node@v4 resolve via DEFAULT_ACTIONS_URL=github.com Standard Stack CI fails at checkout; need to mirror or use absolute URLs
A3 actions/cache@v4 works without timeout in this runner's Docker network setup Standard Stack Cache steps time out; must remove and accept full install on every run
A4 ChristopherHX/gitea-upload-artifact@v4 uploads successfully to Gitea 1.26.2 Standard Stack No artifact upload on failure; lose traces; manual debug only
A5 docker/login-action@v3 and docker/build-push-action@v6 resolve from GitHub Standard Stack Must use shell-level docker login + docker build/docker push instead
A6 reporter: 'github' produces invisible output in Gitea (not rendered as annotations) Anti-Patterns If Gitea DOES render them, the --reporter=list override is unnecessary but harmless
A7 GITHUB_SHA is available in Gitea Actions workflows Image Tagging Cannot compute short SHA via ${GITHUB_SHA:0:7}; must use git rev-parse --short HEAD
A8 Image name follows git.bergerhouse.net/luckberg/familysync-api convention Registry Details Push fails with 404; image name may need adjustment
A9 MariaDB --health-start-period=30s is sufficient for initialization Patterns Flaky health-check failures on slow runners; tune upward
A10 Playwright install --with-deps succeeds without root on runner Dev-Stack Bring-Up WebKit missing system libs; jobs fail with browser launch error

Open Questions

  1. Runner mode: Docker vs host?

    • What we know: 0 runners are currently registered; no configuration is visible from outside
    • What's unclear: Whether the Unraid act_runner is/will be configured with Docker mode (service containers work) or host mode (service containers don't work)
    • Recommendation: Runner-probe task P-03 answers this definitively; plan must handle both branches
  2. Unraid Docker socket access from act_runner container?

    • What we know: act_runner typically mounts /var/run/docker.sock to spawn job containers
    • What's unclear: Whether the Unraid act_runner installation (likely via Unraid Community Applications template) has the socket mount configured
    • Recommendation: Probe P-04 (docker info) answers this
  3. actions/cache networking on this runner?

    • What we know: Known issue with act_runner's cache server networking in Docker mode
    • What's unclear: Whether the Gitea 1.26.2 + current act_runner release has fixed this
    • Recommendation: Probe P-09; design the cache step as continue-on-error: true or skip entirely
  4. Playwright reporter: 'github' in Gitea — truly invisible?

    • What we know: Gitea does not document GitHub workflow command support
    • What's unclear: Whether Gitea 1.26.2 partially supports ::error:: annotation commands
    • Recommendation: Probe P-11 (upload artifact test) can also test reporter output; plan to override reporter to ['list', 'html'] as default
  5. Milestone string automation — read from PROJECT.md or hardcode?

    • What we know: PROJECT.md says "Current Milestone: v1.1"; D-04 says "read from PROJECT.md if avoidable"
    • What's unclear: Whether the executor wants a grep step to extract v1.1 dynamically
    • Recommendation: Hardcode v1.1 as a workflow-level env var (MILESTONE: v1.1) for Wave 2; update it manually at milestone boundaries. Simpler than parsing.

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence / ASSUMED)

  • All items tagged [ASSUMED] in this document — confirmed via training knowledge + community reports but not directly verified against the Unraid act_runner; confirmed by runner-probe

Metadata

Confidence breakdown:

  • Gitea Actions workflow syntax: HIGH — standard GitHub Actions YAML; confirmed supported
  • Service containers: MEDIUM — Docker mode works per docs/act design; host mode does not; runner mode unknown
  • MariaDB healthcheck: HIGH — confirmed in official docs and multiple issue threads
  • Registry push / PAT auth: HIGH — confirmed in Gitea docs and forum
  • actions/upload-artifact block on Gitea: HIGH — confirmed in Gitea issue tracker
  • actions/cache networking: MEDIUM — known issue; unclear if fixed in current act_runner
  • Playwright CI: HIGH — official Playwright docs are clear
  • Short SHA syntax: HIGH — confirmed in Gitea forum

Research date: 2026-06-11 Valid until: 2026-09-11 (stable CI/tooling area; 90 days)