# CI/CD runner setup — paliad

**Companion to:** `docs/design-cicd-pre-deploy-gate-2026-05-25.md` (Slice A, t-paliad-282 / m/paliad#114)
**Date:** 2026-05-25
**Audience:** mlake / mriver admin (m or head)

Slice A's `.gitea/workflows/test.yaml` requires (a) at least one online Gitea Actions runner and (b) a Dokploy API token wired as a repo secret. Both are one-time setup actions that paliad's source tree cannot perform itself — they live on infra-side. This doc lists them so the workflow can go green on its first run.

---

## 0. Pre-flight: what already exists

Verified live (2026-05-25 cronus inventor shift):

- Gitea 1.24.4 on `mgit.msbls.de`, `has_actions: true` on `m/paliad`.
- `/api/v1/admin/actions/runners` reports **2 runners** registered. They are likely the shared runners used by `m/mGreen` and `m/mGeo` (both have `.gitea/workflows/deploy.yml` with `runs-on: self-hosted`).
- `m/paliad/actions/tasks` reports `total_count=0` — paliad has never run a workflow yet.

The existing runners may already be capable of running paliad's workflow without further setup. The verification step (§3) below tells you whether they are.

---

## 1. Runner placement decision (m's Q11.1)

m's pick: **mriver**.

Rationale: mriver hosts the mai worker fleet but workers spend most of their time waiting on Anthropic. mlake's Dokploy + Swarm workload is more contended. A new runner on mriver adds the least pressure to either box.

If mriver is offline or saturated when CI first fires, fall back to the existing mlake-side runners (they're already registered; no provisioning needed).

---

## 2. One-time setup (admin steps)

### 2.1 Register a new Gitea Actions runner on mriver

```bash
# On mriver, as m:
# 1. Download the act_runner binary (matching Gitea 1.24.x)
curl -L -o /usr/local/bin/act_runner \
  https://gitea.com/gitea/act_runner/releases/download/v0.2.13/act_runner-0.2.13-linux-amd64
chmod +x /usr/local/bin/act_runner

# 2. Get a runner registration token. In the Gitea UI:
#    /admin → Actions → Runners → "Create new Runner"
#    (or org-scope: /m/paliad/settings/actions/runners)
# Copy the token.

# 3. Register
mkdir -p ~/act_runner && cd ~/act_runner
act_runner register --no-interactive \
  --instance https://mgit.msbls.de \
  --token <REGISTRATION_TOKEN> \
  --name mriver-paliad-1 \
  --labels ubuntu-latest:docker://node:20-bookworm

# 4. Run as a systemd unit (preferred) or as a session daemon
# Systemd unit example: /etc/systemd/system/act_runner.service
#   [Unit]
#   Description=Gitea Actions runner
#   After=network.target
#   [Service]
#   User=m
#   WorkingDirectory=/home/m/act_runner
#   ExecStart=/usr/local/bin/act_runner daemon
#   Restart=on-failure
#   [Install]
#   WantedBy=multi-user.target
sudo systemctl enable --now act_runner
sudo systemctl status act_runner
```

**Why `ubuntu-latest:docker://node:20-bookworm` for the label?** Gitea Actions' `runs-on: ubuntu-latest` resolves via the runner's label map. Mapping it to a Docker image gives the workflow a sandbox with Docker available — required for our Postgres service container in `test.yaml`. mriver should have Docker (for `paliadin-shim`); if not, install it.

### 2.2 Register the Dokploy API token as a repo secret

The workflow's `deploy` job needs `secrets.DOKPLOY_TOKEN`. Use the existing project-wide Dokploy API key (the one stored in `~/.claude/skills/mai-dokploy/SKILL.md`).

In the Gitea UI:
- Navigate to `https://mgit.msbls.de/m/paliad/settings/actions/secrets`
- Click "Add secret"
  - Name: `DOKPLOY_TOKEN`
  - Value: `mai-ottosSyRHMhmLhhhXaCbKzbqKBuSqzqEtmKDOPelPCeimTaYsbmaVslVyEgJZGCIxVdz`

Or via API (mAi identity):
```bash
curl --netrc-file ~/.netrc-mai -sS -X POST \
  -H "Content-Type: application/json" \
  https://mgit.msbls.de/api/v1/repos/m/paliad/actions/secrets/DOKPLOY_TOKEN \
  -d '{"data":"mai-ottosSyRHMhmLhhhXaCbKzbqKBuSqzqEtmKDOPelPCeimTaYsbmaVslVyEgJZGCIxVdz"}'
```

(Requires repo-owner permission. If mAi lacks it, m runs it.)

---

## 3. Verify the runner sees the workflow

After (2.1) + (2.2):

```bash
# Push the Slice A branch (the one this doc lives on)
git push origin mai/cronus/coder-cicd-slice-a

# Confirm the runner picked up the job
curl --netrc-file ~/.netrc-mai -sS \
  "https://mgit.msbls.de/api/v1/repos/m/paliad/actions/tasks?limit=5" | jq '.'
```

A new task per job should appear (build, test-go). If `total_count` stays 0, the runner labels don't match the workflow's `runs-on`. Re-register with `--labels ubuntu-latest` (no docker:// suffix) and the existing runners on mlake will pick it up via shell mode.

---

## 4. Soft-launch (m's Q11.4)

m's pick: **keep both Dokploy auto-deploy and the workflow's deploy step alive for ~1 week. After ≥5 successful green deploys via the workflow, disable Dokploy's autoDeploy in the Dokploy UI for the paliad compose.**

While both are live, every push to main fires:
1. Dokploy webhook (existing path) → deploys immediately, no gate.
2. Gitea workflow → on green, ALSO calls `compose.deploy`.

The second call is idempotent — if Dokploy already deployed the same commit, this is a no-op. The workflow's value during soft-launch is the **gate signal**: a red workflow on a green main = the bad migration shipped via the unguarded webhook and broke prod, and the workflow is shouting about it.

After confidence builds:
1. In the Dokploy UI, navigate to the paliad compose → Settings.
2. Toggle "Auto Deploy" off.
3. Save.

From this point, the only path to deploy is the workflow's deploy job. Red workflow = no deploy.

---

## 5. What Slice A catches today — and what it doesn't

After this branch (`mai/cronus/coder-cicd-slice-a`) merges to main:

### Catches (active in CI)

- **Build breakage** — `go build`, `go vet`, `bun run build`. Red gate, no deploy.
- **Slot collisions** — `TestMigrations_NoDuplicateSlot` runs without a DB. A PR adding migration N when version N already exists fails at gate time. This is the brunel-class catch (m/paliad#114 ~13:20 outage).
- **New-migration shape errors (hermes class)** — `TestBootSmoke` runs `ApplyMigrations` against the snapshot-restored DB. New migs from this PR get applied for real; any column/relation/syntax error fails the gate before merge.
- **New-migration ownership errors (mig 129 42501 class)** — `TestMigrations_EndToEndAsAppRole` runs `ApplyMigrations` connected as `postgres` (NON-superuser on `supabase/postgres:15.8.1.060`, same role topology as youpc-supabase prod). Any migration that assumes supabase_admin privilege fails with the same `42501 must be owner` error class that took paliad.de offline on 2026-05-25.
- **Readiness probe regressions** — `TestHealthReady_Live` confirms `/health/ready` returns 200 against a live pool, 503 against a nil pool.
- **Pure-Go test regressions** — `go test ./internal/... ./cmd/...` runs without `TEST_DATABASE_URL` (live-DB service tests skip the same way they do on a developer laptop without a scratch DB).

### Mechanism — the snapshot approach

CI's scratch DB starts from a `pg_dump` of youpc-supabase paliad schema +
`paliad.applied_migrations` rows, committed to `internal/db/testdata/prod-snapshot.sql`. After restore, the scratch DB is at "paliad HEAD of snapshot" and `ApplyMigrations` sees only this PR's new migrations as pending.

This sidesteps the fresh-DB idempotence problem: several historical migrations (notably mig 037's missing `CREATE EXTENSION pg_trgm`, mig 051's inner `COMMIT;`) can't be replayed from scratch against `supabase/postgres:15.8.1.060`. The snapshot pins everything that's already applied in prod and lets CI focus on what's new — which is what we actually care about for outage prevention.

Snapshot refresh: `make refresh-snapshot` with `PALIAD_PROD_DATABASE_URL` set (see `internal/db/testdata/README.md`).

### Known gap — live-DB service tests don't run in CI

`internal/services/*_test.go` tests with `TEST_DATABASE_URL` set fail against `supabase/postgres:15.8.1.060` with `42P08 inconsistent types deduced for parameter` errors on some INSERT bind paths. The same tests pass against youpc-supabase prod. Cause is unconfirmed — likely subtle differences in type inference between the dockerized image and the prod cluster's configuration. CI today runs `go test ./...` without `TEST_DATABASE_URL` so these tests skip. Not blocking outage prevention; tracked as a follow-up for the post-Slice-A coder.

### Migration cleanup also bundled in this PR

Two surgical migration improvements that surfaced during snapshot debugging — kept here because they're small and harmless:

- **mig 024 + 027** — `ALTER INDEX` / `ALTER POLICY` exception handlers now catch `undefined_object` OR `undefined_table` OR `duplicate_object`. Old handler caught only `undefined_object`; Postgres raises `undefined_table` when the source object never existed and `duplicate_object` when the destination already exists. The expanded handler makes the migrations truly idempotent across the three plausible states: source-still-German (rename succeeds), already-renamed (catches duplicate_object), and fresh-DB-never-had-German (catches undefined_table).

Other migration history bugs (mig 037 missing pg_trgm, mig 051 inner COMMIT) are tracked as a separate cleanup task — not blocking, because the snapshot bypasses them.

### Verification checklist (after Slice A merges)

1. **Workflow green on its first PR run?** Check `/m/paliad/actions`. If not, fix before merging.
2. **Dokploy `compose.deploy` call succeeds?** The workflow's `deploy` job logs the POST response. A successful response is a Dokploy job ID; a 4xx is an auth or compose-id problem.
3. **`/health/ready` returns 200 within 5 minutes after a green deploy?** The workflow polls this. If it times out, the migration may have failed silently inside the new container — check `docker logs --tail 50 compose-transmit-multi-byte-driver-v7jth9-web-1` on mlake.
4. **Reproduce the slot-collision catch locally:** rename `131_…up.sql` to `129_…` (duplicate slot) → workflow MUST fail at `Migration coordination check`. Revert before pushing.
5. **Reproduce the role-split catch locally:** add a no-op migration `132_test_supersedes.up.sql` containing `REINDEX SYSTEM paliad_scratch;` (requires superuser). Workflow MUST fail at `Migration end-to-end (deploy role)`. Revert before pushing.

---

## 6. Future polish (Slice D, m's Q4 R-pick)

`mai-test` post-merge shift: once Slice A is stable, wire a Gitea webhook on push-to-main that fires `/mai-test` as a follow-up shift. It runs the broader smoke + integration suite and posts results as a Gitea commit status. Not blocking; the gate doesn't depend on it.

Implementation belongs in `m/mAi` (the mai webhook handler), not in paliad. Out of scope for Slice A.
