50 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:apivs a builtnode dist/index.js— pick whatever gives reliable:3000readiness underDEV_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` |
| P-04 | Docker socket access | `docker info 2>&1 | head -10` |
| 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` |
| 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` |
| 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=mariadbin 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 ofservices: DB_HOST=127.0.0.1with port3306:3306mapping in thedocker runstep- 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│
└────────────────────────┘
Recommended Project Structure
.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 pingin MariaDB 11 health check: Themysqladminbinary is not in themariadb:11image. Usehealthcheck.sh --connect --innodb_initialized. [CONFIRMED: mage-os/github-actions issue]drizzle-kit pushin CI: Documented as unsafe on MariaDB — emits false destructive diff that TRUNCATEs tables. Always usegenerate+migrate. [CONFIRMED: project memory]secrets.GITHUB_TOKENfor Gitea registry push: Returnsunauthorized: reqPackageAccess. Use a PAT. [CONFIRMED: Gitea forum]actions/upload-artifact@v4natively on Gitea: Fails with GHES detection. UseChristopherHX/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 inplaywright.config.tscurrently hardcodes'github'whenCI=true. The planner must add a step that overrides reporter to['list', 'html']OR passes--reporter=listto theplaywright testinvocation. [ASSUMED — verify in probe]- Starting API with
pnpm dev:apiwithout building first:pnpm dev:apiispnpm --filter @familysync/api dev=node --watch dist/index.js, which requiresdist/to exist. In CI,dist/does not exist untilpnpm --filter @familysync/api build(tsc) runs. Build first. actions/cachewithout 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-cronin the API background process: Not applicable to CI (short-lived process), but confirming the API usessetInterval(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, /callback → http://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@v4installs it - pnpm —
corepack enable pnpmresolves 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
-
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
-
Unraid Docker socket access from act_runner container?
- What we know: act_runner typically mounts
/var/run/docker.sockto 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
- What we know: act_runner typically mounts
-
actions/cachenetworking 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: trueor skip entirely
-
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
-
Milestone string automation — read from PROJECT.md or hardcode?
- What we know:
PROJECT.mdsays "Current Milestone: v1.1"; D-04 says "read from PROJECT.md if avoidable" - What's unclear: Whether the executor wants a
grepstep to extractv1.1dynamically - Recommendation: Hardcode
v1.1as a workflow-level env var (MILESTONE: v1.1) for Wave 2; update it manually at milestone boundaries. Simpler than parsing.
- What we know:
Sources
Primary (HIGH confidence)
- Gitea container registry docs — registry host format, image naming, PAT auth requirement
- Gitea Actions comparison page — what is and isn't supported vs GitHub Actions
- Gitea Actions quickstart —
.gitea/workflows/location confirmed - MariaDB healthcheck.sh docs —
--connect --innodb_initializedoptions - ChristopherHX/gitea-upload-artifact README — Gitea-compatible upload-artifact v4 fork
- Gitea issue #31256: upload-artifact@v4 not available — confirmed GHES detection block
- GitHub Actions: Communicating with service containers — host-mode vs container-mode networking semantics
- nektos/act issue #2711: service containers in host mode — host executor does NOT support service containers
- Gitea forum: proper container registry procedure — GITHUB_TOKEN fails; PAT required
- Gitea forum: GITHUB_SHA in Gitea Actions — GITHUB_SHA confirmed, ${hash::10} syntax confirmed
- mage-os issue: mysqladmin removed from mariadb:11 — confirmed mysqladmin absent from mariadb:11 image
- Playwright CI docs —
--with-depsinstall, no-cache recommendation
Secondary (MEDIUM confidence)
- firefart.at: MySQL service with GitHub Actions — service container pattern when job runs on host (port mapping, 127.0.0.1)
- Gitea forum: service container not starting — evidence service containers are unreliable in some configurations; unresolved in forum
- Various community blog posts on Gitea Actions (chrisliebaer, botmonster) — cross-check on action resolution and registry
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-artifactblock on Gitea: HIGH — confirmed in Gitea issue trackeractions/cachenetworking: 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)