gitmote

gitmote  ·  history  ·  branches

docs: plan CI — staged tasks for on-push workflows on a Scaleway runner

3b9ac0de8f67a88471ff53bfbaebdfb638d6e42d
Atanas Minev <atmin@protonmail.com> · 2026-07-03 18:04

diff --git a/tasks/16-ci.md b/tasks/16-ci.md
new file mode 100644
index 0000000..3f2c00f
--- /dev/null
+++ b/tasks/16-ci.md
@@ -0,0 +1,233 @@
+# CI — run workflows on push (epic index)
+
+> **Epic index — Stage 0 resolved, stages split into their own tasks.** This file
+> holds the locked cross-cutting decisions (Stage 0) and the staged overview; the
+> implementable units are **[tasks 17–24](README.md)**, each self-contained with
+> Spec/Current/Change/Verify. Keep this index (and delete it only once every stage
+> has landed); the stage tasks are deleted as they land, per the frontier rule.
+> The *why* lives in [../docs/evolution/ci-runner.md](../docs/evolution/ci-runner.md);
+> this file is the *how*, not a re-argument of the vision.
+
+## Spec
+
+Run workflows on push, in a limited-but-real form: on a successful ref advance,
+execute the repo's workflow in an isolated container, keep logs, report pass/fail,
+surface status in the UI. The strategic payoff (per the evolution note) is
+**inverting the source of truth** — S3 + gitmote become the origin, GitHub a
+mirror — and closing the loop so a push can **redeploy gitmote itself**, made
+safe by the leased writer ([safety.md §1](../docs/architecture/safety.md),
+[ops.md](../docs/ops.md)).
+
+**Non-goals (MVP):** full GitHub Actions parity — marketplace, matrix builds,
+service containers, remote caching, OIDC. ~80% of personal CI is "on push, run
+steps in a container, pass/fail, keep logs"; build that, grow compat only where
+the lack is felt.
+
+## Current — the seams this reuses
+
+CI does not bolt on; it hangs off primitives already built (the evolution note's
+table, made concrete):
+
+| CI need | Existing seam | File |
+|---|---|---|
+| Trigger (`on: push`) | The ref **CAS commit** — the one durable "a ref advanced" moment | [`Writer.handle`](../internal/githttp/receive.go#L189) — immediately after `CASRefs` succeeds (`receive.go:216`), before `Response{OK:true}`. There is already a `beforeCAS` hook; this adds an **after-commit, fire-and-forget** dispatch. |
+| Read the workflow file at the pushed SHA | The browse reader from tasks 14/15 | [`repo.Tree` / `repo.Blob`](../internal/repo/browse.go) over a `Materialize`d dir — no new object plumbing. |
+| Run / job state | An s3lite table, single-writer | [`internal/meta`](../internal/meta) — new `ci_runs` / `ci_jobs`, written **only by the leader** (like refs). |
+| Logs & artifacts | Append-only blobs in S3, content-before-pointer | [`store.Store`](../internal/store) under a new `ci/` key space. |
+| Runner → server report | An authenticated internal API + shared secret | Same discipline as the [hookrpc](../internal/hookrpc) push channel; runners **never touch s3lite directly**. |
+| Status UI + log view | Admin-gated server-rendered pages + sanitized rendering | [`internal/webui`](../internal/webui) + [`internal/render`](../internal/render). |
+
+**Runner substrate — Scaleway Serverless Jobs.** A finite task that scales to
+zero, with a real Docker daemon + CPU the always-warm Container can't offer. The
+**exact invocation pattern is proven in a sibling project** and directly
+stealable — `~/dev/pan0.com/lib/scaleway/jobs.go`:
+
+- `POST https://api.scaleway.com/serverless-jobs/v1alpha2/regions/{region}/job-definitions/{id}/start`
+- Header `X-Auth-Token: <SCW_SECRET_KEY>`; body `{"environment_variables": { … }}`.
+- The job **definition** is created once, out of band (`scw jobs definition create … cpu-limit … memory-limit … local-storage-capacity …`); the app injects **per-run env at start** (so one definition serves all repos/branches).
+- **Fire-and-forget + webhook callback**, not polling: worker reads `JOB_ID`/`APP_URL`/`WORKER_SECRET` from env, `GET`s the job spec to claim it, runs, then `POST`s completion — all authenticated with a constant-time `WORKER_SECRET` compare. pan0's `handle_internal_jobs.go` (claim + idempotent `ApplyCompletion`) is the template for our internal API.
+- No-op when the job-definition ID env is unset → local dev / tests run the whole flow with a stub, exactly as pan0 does.
+
+## Stage 0 — Decisions to lock first (no code)
+
+These gate the design; resolve before Stage 1.
+
+1. **Engine — ✅ LOCKED: `act`** (not Woodpecker). Decisive reason is the mirror:
+ `act` runs `.github/workflows` YAML (~80% Actions-compatible), so **one** CI
+ definition runs both self-hosted *and* on the GitHub mirror — the break-glass
+ redeploy uses the same workflow. Woodpecker would mean **two** definitions that
+ drift, weakening "GitHub as a clean redeploy." Actions-compat is load-bearing
+ here, not a preference. The runner image (Stage 4) is built around `act`'s
+ engine (cf. Forgejo's `act_runner`).
+2. **Workflow file location & format — ✅ settled by (1):** `.github/workflows/*.yml`,
+ GitHub Actions YAML. No native format.
+3. **Secrets model — ✅ LOCKED.** Per-repo CI secrets, encrypted at rest,
+ injected into the runner env only at trigger time. Prior art:
+ `~/dev/atmin.net` has mature crypto worth stealing *selectively* — but its
+ **trust model is inverted from ours and that difference drives the design.**
+
+ - **Trust model (the honest part first).** atmin.net is E2E: the server never
+ sees plaintext, the master key is *derived from a user password client-side*.
+ gitmote is the opposite — the **server must decrypt** to inject secrets into
+ the runner, so it **holds the master key** (a container secret env var,
+ alongside `GITMOTE_COOKIE_KEY` / AWS keys — see [ops.md](../docs/ops.md)).
+ Therefore encryption-at-rest here buys exactly one thing: **a compromise of
+ the S3 replica / DB snapshot (the litestream WAL, the object bucket) does not
+ leak secrets, because the master key lives only in the container env, not in
+ the replicated DB.** It does **not** protect against a compromised *running*
+ server. Say this plainly in the safety doc — don't oversell it. No Argon2id,
+ no password-KDF: there's no user password in this path.
+ - **Primitive — port directly.** AES-256-GCM, **12-byte random IV per
+ encryption** from `crypto/rand`, never reused (atmin.net's `backupEncrypt`,
+ `web/src/lib/crypto.ts`). In Go: `crypto/aes` + `crypto/cipher` GCM.
+ - **Per-repo subkeys — steal the HKDF-per-purpose pattern.** Derive a per-repo
+ key from the master with HKDF-SHA256 (`info = "ci-secret:" + repoID`), so a
+ single container-held master gives per-repo key isolation for free (mirrors
+ atmin.net's `hkdfDerive(secret, info)`).
+ - **Versioned envelope + O(1) rotation — steal the key-chain idea.** Store
+ `{v, iv, ciphertext}` with a leading key version; rotate by **wrapping the old
+ key under the new one** (a `key_chain`) rather than re-encrypting every secret
+ — atmin.net's ADR-0012 / `key-chain.ts`. Lazy migration: rotation is O(1),
+ old-secret reads walk the chain.
+ - **One improvement over the prior art:** atmin.net's envelope does **not**
+ bind `v`/name into the GCM tag (an acknowledged AAD gap). We should pass
+ **AAD = (repoID, secretName, version)** to GCM, so a stolen ciphertext can't
+ be replayed under a different repo/name/version.
+ - **Storage:** a `ci_secrets` table in `meta` (s3lite) — single-writer,
+ litestream-replicated like everything else; the master key is the one thing
+ *not* in there. Never logged, never rendered (Stage 5 test asserts this).
+4. **Runner clone auth — ✅ LOCKED.** A **per-run, read-only,
+ repo-scoped, short-lived token**, minted by the leader at dispatch and revoked
+ on completion. The runner clones over normal git-HTTPS
+ (`https://x-access-token:<tok>@gitmote…/owner/repo`, checkout the SHA) — the
+ **same authenticated path as any client**, no CI backdoor in the git handler.
+ Rejected: a `WORKER_SECRET`-derived clone credential (shared across all runs →
+ a leak grants broad standing access; violates least-privilege). Rejected:
+ presigned-S3 / git-bundle hand-off (duplicates the git path, and the runner
+ would need broad bucket creds).
+ - **Cost:** a contained extension to the token model — the
+ [tokens table](../internal/meta) gains nullable `expires_at`, a `repo_scope`
+ (single repo), and a `read_only` flag; [`auth.Guard`](../internal/auth)
+ honors them (expired / over-scoped → denied). Expiring, scoped tokens are
+ generally useful beyond CI. TTL = run max-duration + margin; sweep expired
+ tokens on the log-GC cadence. Lands as a small precursor sub-task to Stage 4.
+5. **Log storage & retention — ✅ LOCKED.**
+ - **Layout:** a new top-level `ci/` key space in the object store (peer of
+ `objects/` and `meta/`): `ci/{repoID}/{runID}/{jobID}.log`. Artifacts, when
+ they exist, go under `ci/{repoID}/{runID}/artifacts/` (out of MVP scope).
+ - **Write:** MVP uploads the **complete log once on completion** (one PUT, like
+ pan0's worker); the pointer is recorded in `meta` **after** the blob lands
+ (content-before-pointer, unchanged). Live tailing / chunked append is a later
+ want, not MVP.
+ - **Caps (no silent truncation):** a per-log size cap (e.g. 10 MiB) so a
+ runaway/malicious workflow can't fill S3; on hitting it, truncate with an
+ explicit marker (the "more" discipline from task 14), never silently.
+ - **Retention:** time-based default (**30 days**) plus keep-last-N per repo,
+ swept by the **leader** — deletes old run rows and their `ci/` blobs together.
+ Logs aren't sacred (a re-run regenerates them), so they need no
+ litestream-grade durability; being in the same durable bucket is enough.
+6. **Concurrency / cancellation — ✅ LOCKED.** MVP does **not** cancel
+ in-flight runs. Dispatch is serialized by the single writer (runs enqueue in
+ push order); execution is parallel on Scaleway. When a newer push supersedes an
+ older run's ref, the older run is **marked `superseded`** in the UI but **runs
+ to completion** — correctness comes from Stage 7's **latest-wins deploy guard**
+ (a stale run deploys nothing), not from racing to cancel. Actively stopping a
+ superseded Scaleway job (to save compute) and a per-repo concurrent-run cap are
+ **follow-ups**, added only if compute cost or a push-storm is felt; rely on
+ Scaleway's own job-concurrency limits until then.
+
+## Staged plan
+
+Each stage lands non-breaking and tested (golden **and** failure path, per
+[CONTRIBUTING](../CONTRIBUTING.md)). Ordered so value appears early and the risky
+surfaces (untrusted execution, secrets) come after the data model is solid.
+
+### Stage 1 — Data model + dispatch seam (no execution)
+- `ci_runs` / `ci_jobs` tables in `meta`; a `Runs` API mirroring `refs` discipline.
+- Add an **after-commit** callback to `Writer` (sibling to `beforeCAS`), invoked
+ after `CASRefs` succeeds. It records a queued run row and returns immediately —
+ **never blocks or fails the push** (a failed dispatch is a *missed deploy, not a
+ failed push* — the content-before-pointer rule, applied to CI).
+- **Verify:** a ref-advancing push creates exactly one queued run; a rejected /
+ non-fast-forward push creates none; a forced dispatch error leaves the push
+ green.
+
+### Stage 2 — Config discovery
+- On dispatch, read the workflow file from the pushed commit's tree via the
+ browse reader at the SHA. No file → no run. Malformed config → a run recorded as
+ **failed with a clear message**, never a crash.
+- **Verify:** repo with / without the file; a malformed file surfaces as a failed
+ run, not a 500 or a panic in the writer.
+
+### Stage 3 — Scaleway Jobs client + trigger
+- New `internal/scaleway` `JobsClient.Trigger` — port of pan0's `jobs.go`
+ (raw `net/http`, `X-Auth-Token`, `…/start`, env-var body). No-op when the
+ job-definition ID is unset.
+- Wire Stage 1's dispatch to call `Trigger` with `RUN_ID`, `APP_URL`,
+ `WORKER_SECRET`, repo, SHA. Fire-and-forget.
+- **Verify:** an httptest server asserts URL / `X-Auth-Token` / body shape; unset
+ definition ID → no-op; a trigger error marks the run failed but the push stays
+ green.
+
+### Stage 4 — Runner image + internal report API
+- `cmd/gitmote-runner` + `Dockerfile.runner`: claim the job
+ (`GET /internal/ci/jobs/{id}`, `X-Worker-Secret`), clone at the SHA, run the
+ engine over the workflow, stream logs to S3, `POST …/complete`.
+- `internal/webui` (or a dedicated internal handler): claim + **idempotent**
+ completion (pan0's `ApplyCompletion` shape), constant-time secret compare,
+ runner **never** touches s3lite.
+- **Verify:** end-to-end against a test server with a local/fake engine;
+ completion is idempotent (double-POST is a no-op); missing/incorrect secret →
+ 401; a run that never reports is reconcilable (timeout → failed).
+
+### Stage 5 — Secrets store
+- Per-repo secrets, AES-256-GCM at rest, decrypted only to inject into the trigger
+ env. Never logged, never rendered.
+- **Verify:** encrypt/decrypt round-trip; a secret never appears in logs or the
+ UI; injected only into the Scaleway `start` env, not persisted in the run row.
+
+### Stage 6 — Status UI + logs
+- webui: per-repo runs list, run detail with per-job status, a log viewer
+ (`render`, ANSI→HTML if needed). A commit-status affordance in the browse views
+ (tasks 14/15). Admin-gated like the rest of `/ui`.
+- **Verify:** route tests, golden + failure, still behind `requireAdmin`.
+
+### Stage 7 — Self-deploy loop + latest-wins guard
+- A green run on `master` redeploys gitmote (the ouroboros). **Latest-wins:** the
+ deploy step no-ops unless its target SHA is still the branch tip in gitmote — a
+ slow, stale run deploys nothing rather than regressing prod. The GitHub mirror
+ becomes **break-glass**: an independent clean copy to redeploy from by hand if a
+ bad push wedges the loop.
+- **Verify:** a run whose SHA is no longer the tip deploys nothing; the leased
+ writer keeps a redeploy from ever running two writers (already proven by
+ `make e2e-restore` + the lease); `make e2e-local` stays green.
+
+## Cross-cutting safety (likely a new [safety.md](../docs/architecture/safety.md) section)
+
+- **Untrusted code execution.** Repo workflows are attacker-controlled. The
+ **Scaleway Job is the isolation boundary** — the writer never executes repo
+ code. The runner holds only a scoped clone token + injected secrets, reports
+ over the authenticated API, and cannot reach s3lite.
+- **Fire-and-forget everywhere.** CI dispatch, trigger, and deploy must never fail
+ or block a push (content-before-pointer, generalized).
+- **Single writer holds.** Only the leader dispatches and writes run state;
+ runners funnel results back through the API. Nothing here adds a second writer.
+- **Secrets** are the sole genuinely new surface — Stage 5's encrypted store, kept
+ separate and never logged.
+
+## Ops (deferred to the relevant stages; lands in [ops.md](../docs/ops.md))
+
+- One-time `scw jobs definition create` for the runner image (cpu/memory/storage),
+ mirroring pan0's worker definition; **one definition serves all repos**, env
+ injected at trigger.
+- New CI secrets / env: `SCW_SECRET_KEY`, `SCW_REGION`, `SCW_CI_JOB_DEFINITION_ID`,
+ `WORKER_SECRET`, and the secrets-store master key — on the container, distinct
+ from the deploy pipeline's GitHub Actions secrets.
+
+## Open questions
+
+Beyond Stage 0's gating decisions: run retention & GC; per-run resource limits
+and timeouts; how `act`'s Docker-in-Docker needs map onto a Scaleway Job's
+capabilities; whether pull-request-style events exist before there's a PR concept;
+surfacing status back to the GitHub mirror (checks API) or leaving them
+independent.
diff --git a/tasks/17-ci-data-model.md b/tasks/17-ci-data-model.md
new file mode 100644
index 0000000..d6e23f5
--- /dev/null
+++ b/tasks/17-ci-data-model.md
@@ -0,0 +1,89 @@
+# CI stage 1 — run/job data model + after-commit dispatch seam
+
+Part of the CI epic ([16-ci.md](16-ci.md)); rationale + locked decisions live
+there and in [../docs/evolution/ci-runner.md](../docs/evolution/ci-runner.md).
+This stage adds **no execution** — it makes "a branch advanced" durably enqueue a
+CI run, and nothing more. Non-breaking on its own.
+
+## Spec
+
+On a successful push that advances a branch, record a **queued CI run** in the
+metadata DB. The dispatch is **fire-and-forget**: it must never block or fail the
+push (a missed run is not a failed push — the content-before-pointer discipline,
+[safety.md §3](../docs/architecture/safety.md), applied to CI). Only the leader
+dispatches (it is the only instance that processes receive-pack).
+
+## Current
+
+- The one durable "a ref advanced" moment is the ref CAS in
+ [`Writer.handle`](../internal/githttp/receive.go#L189): after
+ `w.meta.CASRefs(...)` succeeds it returns `Response{OK:true}`. There is already
+ a `beforeCAS func() error` hook field on `Writer` (used before the CAS); there
+ is **no after-commit hook**.
+- `Writer` is built by [`NewWriter`](../internal/githttp/receive.go#L60) and wired
+ in [cmd/gitmote/main.go](../cmd/gitmote/main.go#L210).
+- The schema ([internal/meta/schema.sql](../internal/meta/schema.sql)) is applied
+ on every `Open` and must stay idempotent (`CREATE TABLE IF NOT EXISTS`; s3lite
+ has no version table). No CI tables exist.
+- `meta` write helpers (e.g. [refs.go](../internal/meta/refs.go),
+ [repos.go](../internal/meta/repos.go)) are the pattern for new CRUD.
+
+## Change
+
+**1. Schema** ([schema.sql](../internal/meta/schema.sql)) — two idempotent tables:
+
+```sql
+CREATE TABLE IF NOT EXISTS ci_runs (
+ id INTEGER PRIMARY KEY,
+ repo_id INTEGER NOT NULL REFERENCES repos(id),
+ ref TEXT NOT NULL, -- "refs/heads/main"
+ sha TEXT NOT NULL, -- the new tip
+ status TEXT NOT NULL, -- queued|running|passed|failed|error|superseded
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS ci_jobs (
+ id INTEGER PRIMARY KEY,
+ run_id INTEGER NOT NULL REFERENCES ci_runs(id),
+ name TEXT NOT NULL, -- workflow file / job name (filled in stage 2)
+ status TEXT NOT NULL, -- queued|running|passed|failed|error
+ log_key TEXT, -- ci/ object key, set on completion (stage 4)
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+```
+
+**2. `meta/ci.go`** — CRUD, written **only by the leader**: `CreateRun(ctx,
+repoID, ref, sha) (Run, error)` (status `queued`), `SetRunStatus`, `GetRun`,
+`ListRuns(ctx, repoID, limit)`; job helpers arrive in later stages (stage 2
+creates job rows). Mirror the existing `now()`/`parseTime` conventions.
+
+**3. A `ci` package with a `Dispatcher`** (`internal/ci`) so the write path stays
+thin and this is unit-testable. Stage 1's `Dispatcher.Dispatch(ctx, ev)` takes a
+small event `{RepoID, RepoName, Ref, OldSHA, NewSHA}` and, **only for a branch
+create/update** (`refs/heads/*`, new ≠ zero), calls `meta.CreateRun`. Tag pushes
+and deletions create no run. Later stages extend `Dispatch` (config read, trigger).
+
+**4. The after-commit seam** — add an `AfterCommit func(context.Context,
+[]CommitInfo)` field to `Writer`, invoked once **after** `CASRefs` succeeds,
+before returning `OK`. It must be non-fatal: wrap it in its own bounded context
+and **recover-and-log** any panic/error — the push has already committed and must
+return green regardless. `CommitInfo` carries `{Ref, Old, New}` per updated ref;
+the handler already has `op.repoID`/`op.repoName` and `req.Commands`. Wire it in
+[main.go](../cmd/gitmote/main.go) to `ci.Dispatcher.Dispatch`.
+
+## Verify
+
+- **meta unit tests:** `CreateRun` inserts a `queued` row with the right
+ repo/ref/sha; `SetRunStatus` transitions; `ListRuns` orders newest-first.
+- **dispatcher unit tests:** a branch update → one run; a tag push → none; a
+ branch **delete** (new = zero) → none; a multi-ref push → one run per updated
+ branch.
+- **seam integration test** (githttp, real `git push` like
+ [receive_test.go](../internal/githttp/receive_test.go)): a fast-forward push
+ produces exactly one `queued` run for the branch tip; a **rejected**
+ non-fast-forward push produces **none**; an `AfterCommit` that panics or errors
+ leaves the push **successful** (the client sees a green push, the ref is
+ advanced) — this is the safety invariant and gets an explicit test.
+- `gofmt`/`golangci-lint`/`go test ./...` clean; `make e2e-local` +
+ `make e2e-restore` stay green (the write path's success contract is unchanged).
diff --git a/tasks/18-ci-config-discovery.md b/tasks/18-ci-config-discovery.md
new file mode 100644
index 0000000..d9abe69
--- /dev/null
+++ b/tasks/18-ci-config-discovery.md
@@ -0,0 +1,72 @@
+# CI stage 2 — workflow config discovery
+
+Part of the CI epic ([16-ci.md](16-ci.md)). Depends on **stage 1
+([17](17-ci-data-model.md))** having landed: `ci_runs`/`ci_jobs` tables, the
+`ci.Dispatcher`, and the after-commit seam. Engine is locked to **`act`**, so the
+workflow location/format is `.github/workflows/*.yml|*.yaml`, GitHub Actions YAML
+(epic §Stage 0). Still no execution — this stage decides *whether* there is work
+and records the jobs.
+
+## Spec
+
+When the dispatcher fires for a branch push, read the pushed commit's
+`.github/workflows/` directory at the new SHA:
+
+- **No workflow files** → create **no run** (nothing to do; log at debug).
+- **Workflow file(s) present** → the run gets one `ci_job` per workflow file.
+- **Malformed workflow** (not valid YAML) → the run is recorded **failed** with a
+ clear message, never a crash or panic in the write path.
+
+Deep validation (does `act` accept it, do the jobs make sense) is the runner's
+job at execution time; this stage only does cheap syntactic gating.
+
+## Current
+
+- Stage 1's `ci.Dispatcher.Dispatch` creates a `queued` run for a branch update
+ and stops there.
+- The browse reader ([internal/repo/browse.go](../internal/repo/browse.go)) reads
+ a tree/blob at an arbitrary SHA over a materialized dir: `repo.Tree(ctx, dir,
+ sha, path)` and `repo.Blob(ctx, dir, sha, path)`, with `safePath` already
+ rejecting traversal. `ErrNotFound` distinguishes "no such path".
+- The shared `*repo.Materializer` (built in [main.go](../cmd/gitmote/main.go),
+ passed to githttp and webui) turns a repo name into an on-disk dir via
+ `Materialize(ctx, name)` — idempotent and warm right after a push.
+- No YAML dependency in [go.mod](../go.mod) yet.
+
+## Change
+
+**1. Give the `Dispatcher` the `Materializer`** (constructor dep). After
+`CreateRun`, `Materialize(repoName)` to get the dir (the push just materialized
+it, so this is warm), then `repo.Tree(ctx, dir, sha, ".github/workflows")`.
+
+- `repo.ErrNotFound` (dir absent) → treat as "no workflows": delete/skip the run
+ (prefer **not creating the run until after** discovery, so an empty result
+ leaves no row — reorder stage 1's `CreateRun` to follow discovery).
+- Filter entries to `type == blob` with a `.yml`/`.yaml` suffix.
+
+**2. Minimal YAML validation** — add `gopkg.in/yaml.v3`; for each workflow blob,
+`repo.Blob` then `yaml.Unmarshal` into a loose `map[string]any`. Syntactically
+valid → create a `ci_job` (name = the file's base name) in `queued`. Any file
+failing to parse → set the **run** `failed` with the first parse error as the
+message and still create the job rows you can, so the failure is visible in the
+UI (stage 6). No file parses / none present → no run.
+
+**3. `meta` job helpers** — `CreateJob(ctx, runID, name)`, `ListJobs(ctx,
+runID)`, `SetJobStatus`. (Run status setter exists from stage 1.)
+
+Keep discovery **non-fatal** to the push, exactly like stage 1: a materialize or
+read error marks the run `error` and is logged; it never propagates to the client.
+
+## Verify
+
+- **dispatcher unit tests** (with a seeded materialized fixture repo, cf.
+ [webui/browse_test.go](../internal/webui/browse_test.go) `seedBrowseRepo`):
+ - repo with one workflow → run + one `queued` job named after the file.
+ - repo with **no** `.github/workflows` → **no run** created.
+ - two workflow files → two jobs under one run.
+ - a **malformed** workflow → run recorded `failed` with the parse message, no
+ panic; the push (simulated event) is unaffected.
+- **path safety:** the fixed `.github/workflows` path plus `safePath` means no
+ traversal; add a test that a repo without the dir 404s cleanly rather than
+ erroring the run as a crash.
+- `gofmt`/`golangci-lint`/`go test ./...` clean; e2e stays green.
diff --git a/tasks/19-ci-scaleway-trigger.md b/tasks/19-ci-scaleway-trigger.md
new file mode 100644
index 0000000..73b991a
--- /dev/null
+++ b/tasks/19-ci-scaleway-trigger.md
@@ -0,0 +1,84 @@
+# CI stage 3 — Scaleway Serverless Jobs client + trigger
+
+Part of the CI epic ([16-ci.md](16-ci.md)). Depends on **stages 1–2
+([17](17-ci-data-model.md), [18](18-ci-config-discovery.md))**: the dispatcher
+now creates a run with `queued` jobs. This stage makes the dispatcher **trigger a
+Scaleway Serverless Job per CI job** — fire-and-forget. The runner binary and the
+report-back API are stage 4 ([21](21-ci-runner.md)); this stage only fires the
+trigger and records the outcome.
+
+## Spec
+
+For each `queued` job, POST a Scaleway Serverless Jobs `start` with per-run env
+(the runner reads it in stage 4). The trigger is fire-and-forget: a failure marks
+that job/run `error` and is logged, but never blocks the push or the other jobs.
+When the job-definition ID is unset (local dev / tests), the trigger is a
+**no-op** so the whole pipeline runs under a stub.
+
+## Current
+
+- The dispatcher creates runs + jobs (stages 1–2) but does nothing external.
+- **Proven, stealable pattern** in the sibling project
+ `~/dev/pan0.com/lib/scaleway/jobs.go`:
+ `POST https://api.scaleway.com/serverless-jobs/v1alpha2/regions/{region}/job-definitions/{id}/start`,
+ header `X-Auth-Token: <SCW_SECRET_KEY>`, body
+ `{"environment_variables": { … }}`; a `10s` client timeout on the trigger call
+ only; a no-op when the definition ID is empty. Its `server.go` shows the env
+ wiring and the "fail fast if the definition ID is set but required env is
+ missing" check.
+- gitmote's public base URL isn't currently a config value; the container knows
+ it as `gitmote.atmin.net` (see [ops.md](../docs/ops.md)).
+
+## Change
+
+**1. `internal/scaleway/jobs.go`** — port pan0's client, adapted:
+
+```go
+type JobsClient struct {
+ secretKey, region, jobDefinitionID string
+ httpClient *http.Client
+}
+func NewJobsClient(secretKey, region, jobDefinitionID string) *JobsClient { … }
+
+// Trigger starts one job run with the given env. No-op when jobDefinitionID == "".
+func (c *JobsClient) Trigger(ctx context.Context, env map[string]string) error
+```
+
+`Trigger` marshals `{"environment_variables": env}`, sets `X-Auth-Token`, POSTs to
+the `…/start` URL, and treats any `>= 300` as an error (include the response body).
+
+**2. Dispatcher wiring.** After jobs are created (stage 2), for each job call
+`Trigger` with env:
+
+| env | value |
+|---|---|
+| `GITMOTE_CI_RUN_ID` / `GITMOTE_CI_JOB_ID` | the run/job ids |
+| `GITMOTE_URL` | gitmote's public base URL (new config) |
+| `WORKER_SECRET` | shared runner-auth secret (used in stage 4) |
+| `GITMOTE_REPO` / `GITMOTE_SHA` / `GITMOTE_REF` | what to check out |
+| `GITMOTE_CI_CLONE_TOKEN` | per-run scoped token — **placeholder in this stage**, wired once [20](20-ci-scoped-tokens.md) lands |
+
+A `Trigger` error → `SetJobStatus(error)` (and the run `error` if all jobs fail);
+log and continue. Still non-fatal to the push.
+
+**3. Config/env plumbing** ([main.go](../cmd/gitmote/main.go)): read
+`SCW_SECRET_KEY`, `SCW_REGION` (fallback `AWS_REGION`), `SCW_CI_JOB_DEFINITION_ID`,
+`WORKER_SECRET`, `GITMOTE_URL`; build the `JobsClient` and pass it to the
+`Dispatcher`. When `SCW_CI_JOB_DEFINITION_ID` is unset, the dispatcher still
+creates run/job rows but the trigger no-ops (local dev behaves fully except for
+the external call). Add these to [ops.md](../docs/ops.md)'s env tables (the
+one-time `scw jobs definition create` for the runner image lands with stage 4).
+
+## Verify
+
+- **`scaleway` unit tests** with an `httptest.Server`: `Trigger` POSTs the correct
+ URL path (region + definition id), sets `X-Auth-Token`, and sends
+ `{"environment_variables": …}` with the expected keys; a `>= 300` response is a
+ returned error including the body.
+- **no-op:** `NewJobsClient("","","")`.Trigger returns nil without any HTTP call.
+- **dispatcher tests:** with a stub trigger, a successful dispatch leaves jobs
+ `queued`→(triggered) and the run intact; a trigger that errors marks the job
+ `error` and the push/event still succeeds; one job's trigger failure doesn't
+ abort the others.
+- `gofmt`/`golangci-lint`/`go test ./...` clean; e2e green (the trigger no-ops in
+ the e2e env, so the write path is unchanged).
diff --git a/tasks/20-ci-scoped-tokens.md b/tasks/20-ci-scoped-tokens.md
new file mode 100644
index 0000000..4618f7a
--- /dev/null
+++ b/tasks/20-ci-scoped-tokens.md
@@ -0,0 +1,82 @@
+# CI stage 4 (precursor) — scoped, expiring access tokens
+
+Part of the CI epic ([16-ci.md](16-ci.md), decision §Stage 0 #4). A small,
+self-contained extension to the token model, valuable on its own (expiring scoped
+tokens are generally useful), and the prerequisite for the runner's **per-run,
+read-only, repo-scoped clone credential** in stage 4 ([21](21-ci-runner.md)). No
+CI wiring here — just the auth capability + tests.
+
+## Spec
+
+A personal access token can carry three new, optional constraints, all enforced
+at auth time:
+
+- **expiry** — past `expires_at` → the token is rejected.
+- **repo scope** — a scoped token authorizes only its one repo; any other repo is
+ denied even if the owner has ACLs there.
+- **read-only** — a read-only token is denied for write/admin operations
+ (push), allowed for read (clone/fetch).
+
+Existing unscoped PATs are unaffected: absent = no expiry, all the owner's repos,
+not read-only.
+
+## Current
+
+- The `tokens` table ([schema.sql](../internal/meta/schema.sql#L28)) has
+ `id, user_id, selector, verifier, label, created_at, last_used` — **no** expiry
+ or scope.
+- [`auth.Guard.VerifyToken`](../internal/auth/guard.go#L72) looks the row up by
+ selector (`meta.TokenBySelector`), compares the verifier in constant time, and
+ returns the owner; [`Authorize`](../internal/auth/guard.go#L31) then checks the
+ ACL perm via `allows`. Neither consults expiry or scope.
+- `Mint()` ([auth/token.go](../internal/auth/token.go#L42)) makes the
+ `selector.secret` pair; `meta.CreateToken` persists it.
+- The schema is applied on every `Open` and **must stay idempotent**, and s3lite
+ has **no version table**. SQLite has no `ADD COLUMN IF NOT EXISTS`, so a bare
+ `ALTER TABLE … ADD COLUMN` would fail on the second `Open` of an existing DB —
+ this is the one fiddly part.
+
+## Change
+
+**1. Schema migration (idempotent, mind the existing prod DB).** Add nullable
+columns `expires_at TEXT`, `repo_scope INTEGER REFERENCES repos(id)`, and
+`read_only INTEGER NOT NULL DEFAULT 0` to `tokens`. Because the columns must be
+added to an already-populated table without a version table, do it as a small
+**guarded Go migration** in [meta.go](../internal/meta/meta.go): query
+`pragma_table_info('tokens')` and `ALTER TABLE tokens ADD COLUMN …` only for
+columns not already present. Fresh DBs can get them straight in the `CREATE TABLE`
+for clarity; the guarded ALTER covers the existing prod DB. (Document this as the
+established pattern for future additive columns.)
+
+**2. `meta`** — `TokenBySelector` returns the new fields on the token record; add
+`CreateScopedToken(ctx, userID, selector, verifier, label string, repoScope
+*int64, readOnly bool, expiresAt time.Time) (Token, error)`. `DeleteToken`
+(revoke) already exists.
+
+**3. `auth.Guard`** — inject a clock (`now func() time.Time`, default
+`time.Now`, override in tests):
+- `VerifyToken`: after the verifier check, reject with `ErrUnauthorized` when
+ `expires_at` is set and in the past. Optionally bump `last_used`.
+- `Authorize`: if the token is `repo_scope`d and the requested `repoName`'s id ≠
+ the scope → deny; if `read_only` and the required perm is write/admin → deny.
+ This means `Authorize` must thread the token's constraints from verification
+ into the perm decision (return them from a shared internal `authenticate` step
+ rather than re-fetching).
+- Add `MintScoped(...)` helper (or expose via the guard) that mints + persists a
+ scoped token and returns the raw string, for stage 4's dispatcher to call.
+
+## Verify
+
+- **auth unit tests** (golden + failure, cf. existing guard tests):
+ - a not-yet-expired token authorizes; an **expired** one is rejected (inject a
+ clock).
+ - a token scoped to repo A authorizes A, is **denied** on repo B (even with an
+ ACL on B).
+ - a **read-only** token allows a read perm, is **denied** a write perm.
+ - an ordinary unscoped PAT is unaffected (no expiry, any owned repo, read+write
+ per ACL).
+- **migration idempotency:** open an existing DB (with a pre-populated `tokens`
+ table lacking the columns) twice — no error, columns present, old rows read
+ back with NULL/0 defaults. A fresh DB works too.
+- `gofmt`/`golangci-lint`/`go test ./...` clean; `make e2e-local` +
+ `make e2e-restore` green (existing push/clone auth unchanged).
diff --git a/tasks/21-ci-runner.md b/tasks/21-ci-runner.md
new file mode 100644
index 0000000..49c4be1
--- /dev/null
+++ b/tasks/21-ci-runner.md
@@ -0,0 +1,90 @@
+# CI stage 4 — runner image + internal report API
+
+Part of the CI epic ([16-ci.md](16-ci.md)). Depends on **stages 1–3
+([17](17-ci-data-model.md)–[19](19-ci-scaleway-trigger.md))** (runs/jobs +
+trigger with env) and the **scoped-token precursor ([20](20-ci-scoped-tokens.md))**
+(per-run clone credential). This is the stage that actually **executes** a
+workflow. It is the largest stage and may split into 4a (internal API) and 4b
+(runner image) during implementation.
+
+> **Infra unknown to resolve first (blocks 4b, not 4a):** `act` runs each job in
+> a Docker container, so the runner needs a Docker daemon inside the Scaleway
+> Serverless Job. Confirm Jobs expose a usable daemon (privileged / DinD) or pick
+> the fallback: run steps in a single provided container without full `act`
+> nesting. Spike this before building the image; the internal API (4a) does not
+> depend on it.
+
+## Spec
+
+A runner process, launched as a Scaleway Serverless Job with the stage-3 env,
+**claims** its job from gitmote, **clones** the repo at the SHA using the per-run
+read-only scoped token, **runs `act`** over `.github/workflows`, streams the
+combined log back, and **reports completion**. gitmote exposes an authenticated
+internal API for claim + completion, secured by a constant-time `WORKER_SECRET`
+compare. **Runners never touch s3lite or S3 directly** — logs and status flow
+through the API (the parent writes the `ci/` log blob), matching the push-hook
+discipline.
+
+## Current
+
+- The dispatcher triggers a job with `GITMOTE_CI_JOB_ID`, `GITMOTE_URL`,
+ `WORKER_SECRET`, `GITMOTE_REPO/SHA/REF`, `GITMOTE_CI_CLONE_TOKEN` (stages 3+20).
+- The **template** is pan0's `cmd/worker/main.go` +
+ `server/handle_internal_jobs.go`: `GET /internal/jobs/{id}` (claim, header
+ `X-Worker-Secret`) → run → `POST /internal/jobs/{id}/complete` with
+ `{status,error,outputs}`, and an **idempotent** `ApplyCompletion` (no-op if the
+ job is already terminal). Steal the shape.
+- gitmote already speaks git-HTTPS; a scoped token (stage 20) is a valid clone
+ credential on the normal auth path — no special-casing in the git handler.
+- Log layout + caps are locked (epic §Stage 0 #5): `ci/{repoID}/{runID}/{jobID}.log`,
+ 10 MiB cap with an explicit truncation marker, 30-day retention.
+
+## Change
+
+**4a. Internal report API** (new `internal/ci` handlers, mounted on the main mux,
+**not** under `/ui`):
+- `GET /internal/ci/jobs/{id}` — constant-time `X-Worker-Secret`
+ (`crypto/subtle`); atomically claim (`queued`→`running`); return the job spec
+ `{repo, sha, ref, workflowDir}`.
+- `POST /internal/ci/jobs/{id}/complete` — `{status, error}` + the log as the
+ body (or a multipart field). **Content-before-pointer:** enforce the 10 MiB cap
+ (truncate-with-marker), `store.Put` the log under the `ci/` key, **then**
+ `SetJobStatus` + `log_key` and roll the run status up. Idempotent: a second
+ complete for a terminal job is a no-op.
+- `WORKER_SECRET` from env; reject missing/wrong with 401. Only the **leader**
+ can write completions (a follower can't write s3lite) — a follower returns a
+ retryable 503, same as receive-pack.
+
+**4b. Runner** (`cmd/gitmote-runner` + `Dockerfile.runner`):
+- Read env; `GET` claim; `git clone https://x-access-token:$TOKEN@$GITMOTE_URL/$REPO`
+ and checkout `$SHA` (shallow where possible).
+- Run `act` over `.github/workflows`, capturing combined stdout/stderr.
+- `POST …/complete` with pass/fail (from `act`'s exit) + the captured log. Exit
+ non-zero on internal failure so a crashed runner is distinguishable from a
+ failed build.
+- `Dockerfile.runner`: `act` + git + the daemon strategy from the spike above.
+ One-time provisioning (`scw jobs definition create name=gitmote-ci-runner
+ image=… cpu-limit=… memory-limit=… local-storage-capacity=…`) documented in
+ [ops.md](../docs/ops.md); one definition serves all repos (env injected at
+ trigger, per stage 3).
+
+**Reconciliation:** a runner that dies without completing leaves a `running` job
+forever. Add a timeout sweep (the leader marks `running` jobs older than a bound
+`error`); this pairs naturally with the log/run GC in stage 6/retention.
+
+## Verify
+
+- **internal API tests** (httptest, cf. pan0): claim transitions `queued`→
+ `running` and returns the spec; **double-complete** is idempotent; missing/wrong
+ `X-Worker-Secret` → 401 (constant-time); a follower (`IsLeader()==false`) →
+ retryable 503; a log over 10 MiB is stored **truncated with the marker**, never
+ silently cut; the log blob lands under `ci/{repoID}/{runID}/{jobID}.log` with
+ the recorded `log_key`.
+- **runner test** against a stub gitmote server with a **fake engine** (a script
+ that echoes and exits 0/1): full claim→clone→run→complete flow drives the job
+ to `passed`/`failed`; the reported log round-trips.
+- **reconciliation:** a `running` job past the bound is swept to `error`.
+- **security:** the runner holds only the scoped token + `WORKER_SECRET`; assert
+ it has no S3/DB credentials in its env.
+- `gofmt`/`golangci-lint`/`go test ./...` clean; `make e2e-local` +
+ `make e2e-restore` green (internal CI routes don't touch the git write path).
diff --git a/tasks/22-ci-secrets.md b/tasks/22-ci-secrets.md
new file mode 100644
index 0000000..dd6cf20
--- /dev/null
+++ b/tasks/22-ci-secrets.md
@@ -0,0 +1,74 @@
+# CI stage 5 — encrypted per-repo secrets store
+
+Part of the CI epic ([16-ci.md](16-ci.md), decision §Stage 0 #3 — **locked**).
+Depends on the dispatcher building the trigger env (stage 3
+[19](19-ci-scaleway-trigger.md)). Adds per-repo CI secrets, encrypted at rest,
+injected into the runner env at trigger. This is the one genuinely new security
+surface — treat it with care.
+
+## Spec
+
+Per-repo named secrets that an admin can set/delete (write-only — values are
+never shown again). At trigger time the dispatcher decrypts the repo's secrets and
+adds them to the Scaleway job env. Encrypted at rest with a **server-held** master
+key so a compromise of the S3 replica / DB snapshot does not leak secrets — but
+**not** a defense against a compromised running server. State that honestly in the
+safety doc; do not oversell it.
+
+## Current
+
+- No secret store. The dispatcher (stage 3) already assembles a `map[string]string`
+ env for `Trigger` — the injection point.
+- Prior-art crypto in `~/dev/atmin.net` (adapt, don't copy — its trust model is
+ inverted, being end-to-end with a password-derived key):
+ `web/src/lib/crypto.ts` (AES-256-GCM, 12-byte random IV; HKDF-per-purpose),
+ `key-chain.ts` + ADR-0012 (versioned rotation). See epic §Stage 0 #3.
+- Container secret env is the home for the master key, alongside
+ `GITMOTE_COOKIE_KEY` / AWS keys ([ops.md](../docs/ops.md)).
+
+## Change
+
+**1. `internal/secrets` package** — pure-crypto, unit-testable:
+- **Master keys from env, versioned:** `GITMOTE_CI_SECRET_KEY_V1` (base64, 32
+ bytes), `…_V2`, … The process holds a `map[version][32]byte`; the highest
+ present is *current*. **Because the keys are all server-held, we keep a
+ version→key map rather than atmin.net's on-disk wrapped key-chain** — same lazy,
+ O(1)-rotation property (each envelope decrypts with its own version's key), far
+ simpler. Rotation = add a new key version + bump current; no re-encryption.
+- **Per-repo subkey:** `HKDF-SHA256(masterKey[v], info="ci-secret:"+repoID)` →
+ the AES key. (Isolates repos under one master, mirrors atmin.net's
+ `hkdfDerive(secret, info)`.)
+- **Encrypt/Decrypt:** AES-256-GCM (`crypto/aes`+`crypto/cipher`), 12-byte IV from
+ `crypto/rand` per encryption. **AAD = repoID ‖ name ‖ version** (the improvement
+ over the prior art — binds the envelope so a ciphertext can't be replayed under
+ a different repo/name/version). Envelope = `{v, iv, ct}`.
+- **Fail closed:** decrypt requested but the needed key version is absent → a
+ clear error; never a silent empty or a panic.
+
+**2. `meta` + schema** — `ci_secrets(repo_id, name, v, iv, ct, created_at,
+PRIMARY KEY(repo_id,name))` (idempotent `CREATE TABLE IF NOT EXISTS`). Helpers:
+`SetSecret` (upsert the envelope), `ListSecretNames(repoID)` (names only — never
+values), `GetSecrets(repoID)` (envelopes, for decryption at trigger), `DeleteSecret`.
+
+**3. Wire into the dispatcher** — at trigger, `GetSecrets(repoID)` → decrypt each
+→ add to the job env map. Never log secret names+values; scrub from any error
+strings.
+
+**4. Admin UI** ([internal/webui](../internal/webui)) — a per-repo secrets panel:
+list **names** + set (name+value form) + delete, behind `requireAdmin`. The set
+form is write-only; the value is never rendered back.
+
+## Verify
+
+- **`secrets` unit tests:** encrypt→decrypt round-trip; a decrypt with the wrong
+ repoID/name/version (i.e. wrong AAD) **fails** on the auth tag; two encryptions
+ of the same plaintext differ (fresh IV); a value with the needed key version
+ absent → fail-closed error.
+- **rotation:** a secret written under `V1` still decrypts after `V2` is added and
+ becomes current; new writes use `V2`.
+- **leak checks (safety invariant, explicit test):** a set secret's **value**
+ never appears in `ListSecretNames`, in any rendered UI page, or in logs; master
+ key never logged.
+- **webui route tests:** set/list/delete behind `requireAdmin`; unauth → denied;
+ the value isn't echoed in the response.
+- `gofmt`/`golangci-lint`/`go test ./...` clean; e2e green.
diff --git a/tasks/23-ci-status-ui.md b/tasks/23-ci-status-ui.md
new file mode 100644
index 0000000..3097377
--- /dev/null
+++ b/tasks/23-ci-status-ui.md
@@ -0,0 +1,60 @@
+# CI stage 6 — status UI + log viewer
+
+Part of the CI epic ([16-ci.md](16-ci.md)). Depends on runs/jobs/logs existing
+(stages 1–4, [17](17-ci-data-model.md)–[21](21-ci-runner.md)) and reuses the
+browse UI + rendering from the shipped tasks 14/15. Admin-gated like the rest of
+the UI. Additive — no write-path or protocol change.
+
+## Spec
+
+Surface CI in the web UI: a per-repo **runs list**, a **run detail** page (its
+jobs + statuses), and a **log viewer** for a job. Add a commit-status affordance
+to the existing browse commit view (a badge linking to the run for that SHA).
+
+## Current
+
+- `ci_runs` / `ci_jobs` with statuses (stages 1–2), and `log_key` pointing at a
+ `ci/` object once a job completes (stage 4).
+- The webui browse handlers ([internal/webui/browse.go](../internal/webui/browse.go))
+ already split a `/browse/{repo}/-/…` path manually and gate on `requireAdmin`;
+ templates + the shared `browse_head` partial are the pattern.
+- [internal/render](../internal/render) sanitizes/renders HTML (markdown +
+ chroma) with a size guard — the model for rendering a log safely. No ANSI→HTML
+ converter yet.
+- `store.Get` reads a `ci/` log blob back.
+
+## Change
+
+**1. Routes** (behind `requireAdmin`, same manual `/-/` split as browse):
+- `GET /browse/{repo}/-/runs` — recent runs (`meta.ListRuns`), each linking to
+ its detail; newest first, with a "more" marker if capped (no silent truncation).
+- `GET /browse/{repo}/-/run/{id}` — run metadata + its jobs (`meta.ListJobs`) and
+ statuses, each job linking to its log.
+- `GET /browse/{repo}/-/run/{id}/job/{jid}/log` — the log view.
+
+**2. Log rendering.** Fetch the blob via `store.Get(log_key)`; render as text in a
+`<pre>` (reuse the blob template's isolated text block). Add a small **ANSI→HTML**
+step (a minimal escape-code→`<span class>` converter, or a vetted tiny dep) so
+`act`'s colored output is readable; **size-guard** it like blobs (over the cap →
+plain, note shown). All output stays sanitized — log bytes are untrusted.
+
+**3. Commit status in browse.** In `browse_commit.html` (task 14), look up the
+latest run for that SHA (`meta` helper `LatestRunForSHA(repoID, sha)`) and show a
+status badge linking to the run. Absent → nothing.
+
+**4. Templates** — `ci_runs.html`, `ci_run.html`, `ci_log.html`, reusing
+[layout.html](../internal/webui/templates/layout.html) and `browse_head`. A
+`ci` nav affordance from the repos list (like the browse link) is fine.
+
+## Verify
+
+- **webui route tests** (golden + failure, still behind `requireAdmin`, cf.
+ [browse_test.go](../internal/webui/browse_test.go)): seed runs/jobs + a log blob;
+ the runs list renders and links; run detail shows job statuses; the log view
+ renders the stored bytes; an unknown run/job → 404; unauth → redirect/deny.
+- **ANSI + safety:** a log containing ANSI codes renders as spans, not raw escape
+ bytes; a log with HTML-ish content is escaped (no injection); an over-cap log
+ falls back to plain with the note.
+- **commit badge:** a commit with a run shows the badge linking to it; one without
+ shows nothing.
+- `gofmt`/`golangci-lint`/`go test ./...` clean; e2e green.
diff --git a/tasks/24-ci-self-deploy.md b/tasks/24-ci-self-deploy.md
new file mode 100644
index 0000000..5b81357
--- /dev/null
+++ b/tasks/24-ci-self-deploy.md
@@ -0,0 +1,66 @@
+# CI stage 7 — self-deploy loop + latest-wins guard
+
+Part of the CI epic ([16-ci.md](16-ci.md)). The capstone: a green CI run on
+`master` **redeploys gitmote**, closing the ouroboros. Depends on the full CI
+pipeline (stages 1–6). The leased writer already makes an instance replacing
+itself safe by construction ([ops.md](../docs/ops.md), the single-writer section)
+— this stage adds the trigger and the anti-regression guard.
+
+## Spec
+
+On a green run for `master`, deploy the new image. **Latest-wins:** the deploy
+no-ops unless its target SHA is still `master`'s tip in gitmote, so a slow/stale
+run never regresses prod (pairs with stage 6's `superseded` marking). The GitHub
+mirror runs the **same** `.github/workflows` (engine is `act`, epic §Stage 0 #1),
+so it doubles as **break-glass**: an independent clean copy to redeploy from by
+hand if a bad push ever wedges the self-deploy loop.
+
+## Current
+
+- Today's deploy is GitHub Actions (`.github/workflows/ci.yml`) → build+push the
+ amd64 image → `scw container container update … --wait` ([ops.md](../docs/ops.md)
+ "Deploying"). No drain step (the lease covers it).
+- With `act`, that **same workflow** now also runs self-hosted on gitmote CI —
+ one definition, two runners (self + mirror).
+- gitmote exposes `master`'s current tip via the ref advertisement
+ (`info/refs`) / the metadata layer.
+
+## Change
+
+**1. Deploy job in the workflow.** Keep the existing build+push+`scw … update`
+steps (they already work on GitHub Actions and, via `act`, self-hosted). No new
+deploy mechanism — reuse it.
+
+**2. Latest-wins guard.** Before the `scw … update` step, check that the run's
+target SHA still equals `master`'s tip in gitmote (query `info/refs` or a tiny
+read endpoint for the branch SHA); if not, **skip the deploy** and log that a
+newer commit superseded this run — deploy nothing rather than ship an older SHA.
+Prefer implementing the guard as a workflow step (portable to both runners) over
+server-side logic, so the mirror enforces it too.
+
+**3. Docs.** Update [ops.md](../docs/ops.md) "Deploying" for the self-hosted path
+and the latest-wins guard, and add a **break-glass** note: if the self-deploy loop
+is wedged, redeploy by hand from the GitHub mirror (the same workflow, clean
+copy). Add a new [safety.md](../docs/architecture/safety.md) subsection tying the
+loop to the leased-writer invariant (new boots follower, old releases on SIGTERM,
+new promotes — never two writers). Cross-link from
+[evolution/ci-runner.md](../docs/evolution/ci-runner.md) (mark the loop realized).
+
+## Verify
+
+- **latest-wins guard test:** a run whose SHA is still the tip proceeds; a run
+ whose SHA is **no longer** the tip deploys **nothing** (unit-test the guard's
+ decision against a stubbed tip; integration if a runner harness exists).
+- **safety regression:** `make e2e-restore` (cold-start restore) and
+ `make e2e-local` (push/clone + restart durability) stay green — the redeploy
+ path relies on the same lease behavior they already prove; no two-writer window
+ is introduced.
+- **mirror parity:** the same `.github/workflows` runs on both GitHub Actions and
+ the self-hosted runner without divergence (the load-bearing reason `act` was
+ chosen).
+- `gofmt`/`golangci-lint`/`go test ./...` clean.
+
+## Not in scope
+
+Deploy environments/approvals, rollbacks beyond "redeploy the previous image
+tag", and multi-service deploys. The loop here is gitmote deploying gitmote.
diff --git a/tasks/README.md b/tasks/README.md
index 72b4564..23be88c 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -12,4 +12,24 @@ starts (litestream restore), and enforces the single writer by a lease (s3lite
`RoleAuto`), so rolling deploys are safe by construction. Speculative directions
live in [../docs/evolution/](../docs/evolution/).
-_No active tasks — the frontier is clear. Add the next unit of work here._
+**CI — run workflows on push.** Design + locked Stage 0 decisions in the epic
+index [16-ci.md](16-ci.md) (the *why* is in
+[../docs/evolution/ci-runner.md](../docs/evolution/ci-runner.md)). Split into
+dependency-ordered stages, each implementable from fresh context:
+
+- [17-ci-data-model.md](17-ci-data-model.md) — run/job tables + the fire-and-forget
+ after-commit dispatch seam (no execution).
+- [18-ci-config-discovery.md](18-ci-config-discovery.md) — read `.github/workflows`
+ at the pushed SHA; create jobs, or no run / a failed run.
+- [19-ci-scaleway-trigger.md](19-ci-scaleway-trigger.md) — Scaleway Serverless Jobs
+ client + per-job trigger (no-op when unconfigured).
+- [20-ci-scoped-tokens.md](20-ci-scoped-tokens.md) — expiring, repo-scoped,
+ read-only tokens (precursor: the runner's clone credential).
+- [21-ci-runner.md](21-ci-runner.md) — runner image + internal claim/complete API
+ (the execution stage; has an infra spike to resolve first).
+- [22-ci-secrets.md](22-ci-secrets.md) — encrypted per-repo secrets store
+ (AES-256-GCM + HKDF + versioned keys), injected at trigger.
+- [23-ci-status-ui.md](23-ci-status-ui.md) — runs list, run detail, log viewer,
+ commit status badge (admin-gated).
+- [24-ci-self-deploy.md](24-ci-self-deploy.md) — green master run redeploys
+ gitmote; latest-wins guard; GitHub mirror as break-glass.