gitmote

gitmote  ·  history  ·  branches

docs: add docker run quickstart, tier the env table, document CI substrate

bbaa890f1ba74a304dcd224f533f1bd82945040c
Atanas Minev <atmin@protonmail.com> · 2026-07-05 18:17

diff --git a/README.md b/README.md
index 9d39ed2..5f57951 100644
--- a/README.md
+++ b/README.md
@@ -20,13 +20,48 @@ production on Scaleway Object Storage at
[gitmote.atmin.net](https://gitmote.atmin.net). The design lives in
[docs/architecture/](docs/architecture/).
+## Quickstart — run it anywhere
+
+One container, one bucket. S3 is the single source of truth and the container is
+disposable: kill it and restart — it restores the whole forge from S3 and
+continues. Point it at any S3-compatible bucket with credentials:
+
+```sh
+docker run --rm -p 8080:8080 \
+ -e GITMOTE_S3_BUCKET=my-gitmote-bucket \
+ -e AWS_REGION=us-east-1 \
+ -e AWS_ACCESS_KEY_ID=… -e AWS_SECRET_ACCESS_KEY=… \
+ ghcr.io/atmin/gitmote
+# Non-AWS S3 (Scaleway, MinIO, R2): add -e GITMOTE_S3_ENDPOINT=https://s3.fr-par.scw.cloud
+# Keep the local cache across restarts (optional): -v gitmote-data:/data
+```
+
+On the **first run** gitmote auto-bootstraps an admin and prints a one-time access
+token to the logs behind a `SAVE IT NOW` banner — grab it there (no setup page).
+Then sign in at <http://localhost:8080/login> by pasting it, and clone/push:
+
+```sh
+git clone http://admin:<token>@localhost:8080/<owner>/<repo> # create the repo in /ui first
+```
+
+Lost the token? It's never stored (only its hash), so re-mint one — stop the
+container, then:
+
+```sh
+docker run --rm -e GITMOTE_S3_BUCKET=… -e AWS_… ghcr.io/atmin/gitmote bootstrap -reissue
+```
+
+Nothing else is required: the session cookie key and CI worker secret are
+generated and persisted on first run. See [docs/ops.md](docs/ops.md) for the full
+env surface, CI, and the Scaleway deployment.
+
## Develop locally
`make dev` gives you a running instance in one command: it builds the binaries,
starts MinIO in a container (S3 on :9100), runs gitmote **natively** on :8080,
-and bootstraps an admin/token/repo on the first run. State (the metadata DB,
-object cache, and the minted token) persists under `data/`, so the printed token
-keeps working across restarts:
+and ensures an admin + a `atmin/gitmote` repo (auto-bootstrap), minting a fresh
+token each run and printing it. State (the metadata DB and object cache) persists
+under `data/`, so repos and history survive restarts:
```sh
make dev # first run prints the token, clone URL, and UI URL
@@ -57,30 +92,25 @@ Containers, single writer, `gitmote.atmin.net` — lives in [docs/ops.md](docs/o
## Bootstrap
-An empty instance has no users, so token auth is a chicken-and-egg. Run the
-one-time `bootstrap` subcommand **inside the single writer** to create the first
-admin, mint a token (printed once), and create the initial repo:
-
-```sh
-GITMOTE_DATA=/data gitmote bootstrap -handle atmin -repo atmin/gitmote
-```
-
-It prints an access token exactly once — save it. Re-running is safe: it refuses
-to clobber an existing admin. Then start the server (`GITMOTE_S3_BUCKET` et al.,
-sharing the same `GITMOTE_DATA`) and clone/push with the token:
+An empty instance has no users, so token auth would be a chicken-and-egg — so the
+server **auto-bootstraps on first run**: when it is the writer and no admin
+exists, it creates the admin (`GITMOTE_ADMIN_HANDLE`, default `admin`) and prints
+a one-time token to the logs (see the Quickstart). No second command.
-```sh
-git clone http://atmin:<token>@<host>/atmin/gitmote
-```
+You can still bootstrap by hand against the bucket **before** the server is live
+(`gitmote bootstrap [-handle …] [-repo …]`), and recover a lost token with
+`gitmote bootstrap -reissue` — both refuse to clobber and print the token once.
+Only the token's hash is ever stored, so a lost token is re-minted, never
+recovered.
## Management UI
The things you can't do over git — create/list repos, mint/revoke tokens, and
-manage per-repo ACLs — live in a small server-rendered web UI under `/ui`. Set
-`GITMOTE_COOKIE_KEY` (the HMAC key that signs session cookies) to enable it; it
-runs alongside the git server. Sign in at `/login` by pasting an **admin** token
-(the same PAT format git uses); the server issues a signed, stateless session
-cookie. Access is limited to global admins.
+manage per-repo ACLs — live in a small server-rendered web UI under `/ui`, always
+on. Sign in at `/login` by pasting an **admin** token (the same PAT format git
+uses); the server issues a signed, stateless session cookie signed with a key it
+auto-generates and persists (override with `GITMOTE_COOKIE_KEY`). Access is
+limited to global admins.
---
diff --git a/docs/architecture/safety.md b/docs/architecture/safety.md
index 822ebae..c407506 100644
--- a/docs/architecture/safety.md
+++ b/docs/architecture/safety.md
@@ -11,7 +11,10 @@
is now enforced by a **lease**, not by procedure. The server opens s3lite in
`RoleAuto`: it writes only while it holds the lease (litestream's `s3.Leaser`,
a conditional-write CAS on `lock.json` in the replica bucket) and otherwise runs
- as a read-only follower. gitmote gates **all metadata-derived requests** on
+ as a read-only follower. This makes the S3 backend's support for **atomic
+ conditional writes** (compare-and-swap: `If-None-Match`/`If-Match`) a correctness
+ requirement — the lease can't be enforced without it, so a provider lacking the
+ primitive cannot safely back gitmote. gitmote gates **all metadata-derived requests** on
`IsLeader()` — a follower refuses them with a retryable `503`; only the liveness
probes and static assets stay up (gating those would deadlock a rolling deploy,
since the new instance can't promote until the old drains). Writes are gated for
diff --git a/docs/evolution/reader-writer-split.md b/docs/evolution/reader-writer-split.md
index 1edd669..a5a05e8 100644
--- a/docs/evolution/reader-writer-split.md
+++ b/docs/evolution/reader-writer-split.md
@@ -33,8 +33,8 @@ Stop conflating "an instance is running" with "an instance is the writer." Let
writers, by construction.
**How it shipped.** The coordination substrate is a conditional-write CAS on the
-replica bucket (Scaleway now supports `If-Match`/`If-None-Match`) — no second
-provider, no moving the WAL. litestream ships the lease primitive
+replica bucket (`If-Match`/`If-None-Match`, which the S3 provider must support —
+Scaleway and AWS S3 do) — no second provider, no moving the WAL. litestream ships the lease primitive
(`litestream.Leaser` / `s3.Leaser`: `If-None-Match:*` to acquire, `If-Match` to
renew, 30 s TTL, monotonic `Generation` fencing token), but does **not** auto-gate
replication on it. So **s3lite** wraps it (v0.1.0, `RoleAuto`/`RoleWriter`/
diff --git a/docs/ops.md b/docs/ops.md
index 3718026..321b80d 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -3,8 +3,9 @@
Canonical infra doc — resources, env, deploy, and the one operational rule. Keep
it current whenever infra or env changes.
-gitmote runs on the same account and host as atmin.net (see that repo's
-`docs/ops.md` for the Scaleway pattern in more depth); this doc is gitmote-specific.
+gitmote is `gitmote.atmin.net` — a subdomain of atmin.net by DNS only. It is
+otherwise independent; it currently happens to run on Scaleway Serverless
+Containers like atmin.net, but that is incidental and may change.
## Infrastructure
@@ -31,17 +32,21 @@ in-process-race concern: it is a data-integrity precondition.
The invariant is now enforced **by a lease, not by procedure.** The server opens
its metadata in s3lite `RoleAuto`: it becomes the writer only while it holds a
lease (litestream's `s3.Leaser` — an `If-None-Match`/`If-Match` conditional-write
-CAS on `lock.json` in the same bucket, now that Scaleway supports conditional
-writes), and runs as a read-only follower otherwise. gitmote gates receive-pack
-on `IsLeader()`, so a follower refuses pushes with a retryable `503` while still
-serving clones/fetches.
-
-- **Rolling deploys are safe by construction.** Scaleway's rolling deploy briefly
- runs old + new together (observed: instance count spikes to 2 in Cockpit). The
- new instance boots as a **follower** (the old still holds the lease); the old
- releases on its graceful SIGTERM (`Close` flushes, then releases the lease
- last); the new then **promotes** on its next lease poll. Never two writers — no
- drain step, no `POST /admin/quit`. Brief handoff window: the new instance is up
+CAS on `lock.json` in the same bucket), and runs as a read-only follower
+otherwise. This makes **atomic conditional writes a hard requirement on the S3
+provider**: the lease — and therefore the single-writer correctness invariant —
+holds only if the backend supports the compare-and-swap primitive (`If-None-Match`
+to acquire, `If-Match` to renew/release). Most do (AWS S3, Scaleway, …); one that
+doesn't cannot safely back gitmote. gitmote gates receive-pack on `IsLeader()`, so
+a follower refuses pushes with a retryable `503` while still serving clones/fetches.
+
+- **Rolling deploys are safe by construction.** A rolling deploy briefly runs a
+ second container alongside the old one — two instances live at once — which does
+ **not** violate the single-writer invariant. The new instance boots as a
+ **follower** (the old still holds the lease); the old releases on its graceful
+ SIGTERM (`Close` flushes, then releases the lease last); the new then
+ **promotes** on its next lease poll. Never two writers — no drain step, no
+ `POST /admin/quit`. Brief handoff window: the new instance is up
read-only for ≤ ~lease-TTL/3 after the old exits, so a push in that gap gets the
retryable `503`; reads are unaffected. (After a *hard* kill the successor waits
out the ≤30 s lease TTL before acquiring — a graceful exit releases at once.)
@@ -56,8 +61,8 @@ serving clones/fetches.
reclaims), never a ref pointing at a missing object.
The [safety.md §4](architecture/safety.md) escape hatch (refs behind a
-conditional-PUT CAS on object storage) is likewise possible on Scaleway now if
-ever wanted; gitmote's ref CAS remains a SQL transaction in the metadata DB
+conditional-PUT CAS on object storage) would rely on the same conditional-write
+primitive; gitmote's ref CAS remains a SQL transaction in the metadata DB
(unchanged — it never needed an S3 precondition).
## Runtime env vars (on the container)
@@ -75,34 +80,44 @@ Setting these on the container does nothing for CI, and vice versa.
> `update`.** Plain `environment-variables` are a separate map, unaffected. The CI
> deploy's `update` sets no env vars, so it preserves both maps.
+**Required — the whole core.** A bucket plus its credentials is a complete forge.
+
| Variable | Value / meaning |
|----------|-----------------|
| `GITMOTE_S3_BUCKET` | `gitmote` — a bucket alone derives the metadata replica (`s3://gitmote/meta`) and the single-writer lease |
-| `GITMOTE_S3_ENDPOINT` | `https://s3.fr-par.scw.cloud` |
-| `GITMOTE_DATA` | `/tmp/gitmote` — base dir for the db (`meta.sqlite3`), cache, and socket; ephemeral, restored from S3 on cold start |
-| `GITMOTE_DB_REPLICA` | optional override for the derived `s3://{bucket}/meta` replica target |
-| `GITMOTE_HOOK` | pre-receive hook binary (defaults to `gitmote-hook` beside the server) |
-| `GITMOTE_RUNNER` | CI runner binary the local trigger spawns (defaults to `gitmote-runner` beside the server) |
-| `GITMOTE_COOKIE_KEY` | secret — signs management-UI session cookies (enables `/ui`) |
-| `GITMOTE_URL` | public base URL (`https://gitmote.atmin.net`) — injected into the CI runner's env so it clones and reports back here |
-| `SCW_CI_JOB_DEFINITION_ID` | the Scaleway Serverless Job definition for the CI runner. **Set → cloud trigger** (Scaleway job per CI job) |
-| `SCW_SECRET_KEY` | secret — Scaleway API secret key (the UUID) used to start the CI job (same key also deploys the container from CI) |
-| `SCW_REGION` | Scaleway region for the CI job start (falls back to `AWS_REGION`) |
-| `WORKER_SECRET` | secret — shared runner-auth secret; injected into the runner env and compared on its report-back |
-| `GITMOTE_CI_SECRET_KEY_V1` | secret — base64 of 32 bytes; master key for per-repo CI secrets. Add `_V2`, … to rotate (highest is current; old envelopes still decrypt). Unset → the secrets UI is disabled and none are injected |
-| `AWS_REGION` | `fr-par` |
-| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | secret — Scaleway API key pair; covers both the object store and the litestream replica |
-
-> **CI trigger selection.** The dispatcher always records runs/jobs; what executes
-> them depends on the env. With `SCW_CI_JOB_DEFINITION_ID` set, the **Scaleway**
-> trigger starts a Serverless Job per CI job (cloud); it then requires
-> `WORKER_SECRET` and `GITMOTE_URL`, and the server refuses to start without them.
-> With that unset but `GITMOTE_URL` + `WORKER_SECRET` present, the **local** trigger
-> spawns `gitmote-runner` as a local process — the *same runner code and env
-> contract*, a local substrate instead of a cloud job (this is what `make dev`
-> uses). With none of them set, runs record but nothing executes. The runner runs
-> `.github/workflows` with `act`, which needs a reachable Docker daemon. A
-> leader-only ticker sweeps jobs stuck `running` past ~1h back to `error`.
+| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | secret — S3 key pair; covers both the object store and the litestream replica (Scaleway API key on prod) |
+| `AWS_REGION` | `fr-par` (any valid region for non-AWS S3) |
+| `GITMOTE_S3_ENDPOINT` | `https://s3.fr-par.scw.cloud` — the S3 endpoint; **omit for real AWS S3** |
+
+**Auto-managed — leave unset; generated and persisted in meta on first run (an
+explicit env still wins).** No longer part of the required surface.
+
+| Variable | Value / meaning |
+|----------|-----------------|
+| `GITMOTE_COOKIE_KEY` | signs management-UI session cookies; auto-generated + persisted (stable across restart / scale-to-zero) |
+| `WORKER_SECRET` | runner report-back auth; auto-generated + persisted **when a CI trigger is configured** (below) |
+
+**CI — optional, off by default** (runs are still recorded; nothing executes until
+one of these turns a trigger on — see [CI substrate](#ci-substrate)).
+
+| Variable | Value / meaning |
+|----------|-----------------|
+| `SCW_CI_JOB_DEFINITION_ID` | the Scaleway Serverless Job definition. **Set → cloud trigger** (a Scaleway job per CI job); requires `GITMOTE_URL` |
+| `GITMOTE_URL` | public base URL (`https://gitmote.atmin.net`) the runner clones + reports back to; required for any CI. **Set alone → local `act` trigger** |
+| `SCW_SECRET_KEY` | secret — Scaleway API secret key (UUID) to start the job (the same key also deploys the container from CI) |
+| `SCW_REGION` | region for the job start (falls back to `AWS_REGION`) |
+| `GITMOTE_CI_SECRET_KEY_V<n>` | secret — base64 of 32 bytes; **env-only** master key for per-repo CI secrets, never persisted ([safety.md §5](architecture/safety.md)). Add `_V2`, … to rotate (highest is current; old envelopes still decrypt). Unset → the secrets UI is off and none are injected |
+
+**Advanced overrides — rarely needed** (the defaults are derived).
+
+| Variable | Value / meaning |
+|----------|-----------------|
+| `GITMOTE_DATA` | base dir for the db (`meta.sqlite3`), cache, and socket; **preset to `/data` in the image** — mount a volume there to keep the cache across restarts. Ephemeral by design (restored from S3 on cold start) |
+| `GITMOTE_DB_REPLICA` | override the derived `s3://{bucket}/meta` replica target |
+| `GITMOTE_DB` / `GITMOTE_CACHE` / `GITMOTE_SOCK` | override the individual paths that otherwise derive from `GITMOTE_DATA` |
+| `GITMOTE_S3_PREFIX` | key prefix for git objects inside the bucket (does **not** affect the `meta` replica path) |
+| `GITMOTE_HOOK` / `GITMOTE_RUNNER` | hook / CI-runner binary paths (default beside the server) |
+| `GITMOTE_ADMIN_HANDLE` | first-run auto-bootstrap admin handle (default `admin`) |
`GITMOTE_DATA` is ephemeral on purpose: the object store + litestream replica are
the durable state, and the local disk is a cache. On a cold start (scale-to-zero →
@@ -126,6 +141,27 @@ instant first-push latency is worth the always-on cost. `max-scale` stays **1**
regardless (see the single-writer section — follower reads aren't fresh enough to
scale out yet).
+## CI substrate
+
+gitmote always records a run per pushed workflow; **what executes it is chosen
+from the env at startup** (one runner, three substrates — see
+[ci.md](architecture/ci.md)):
+
+1. `SCW_CI_JOB_DEFINITION_ID` set → **Scaleway Serverless Jobs** (cloud): a job per
+ CI job. Also requires `GITMOTE_URL` (the runner clones + reports back there);
+ `WORKER_SECRET` is auto-provisioned. The server refuses to start if the job
+ definition is set without `GITMOTE_URL`.
+2. else `GITMOTE_URL` set → **local `act`**: the server spawns `gitmote-runner` as a
+ local process — the *same* runner code and env contract, a local substrate
+ instead of a cloud job (this is what `make dev` uses).
+3. else → **disabled**: runs and jobs are recorded, but nothing executes.
+
+**Prerequisite for both executing substrates:** the runner runs `.github/workflows`
+with [`act`](https://github.com/nektos/act), which needs a reachable **Docker or
+podman daemon** — in the cloud that's the Serverless Job image; locally it's the
+daemon on the host (the same one MinIO uses under `make dev`). A leader-only ticker
+sweeps jobs stuck `running` past ~1h back to `error`.
+
## CI secrets (GitHub Actions)
Separate from the container env above: these are the Scaleway API credentials the
@@ -175,9 +211,10 @@ docker push ghcr.io/atmin/gitmote-runner:master
# 3. Create the container (single writer: min-scale=0 idle-to-zero, max-scale=1).
# Arg names per the current CLI: image= (not registry-image), cpu-limit (mVCPU),
# memory-limit-bytes, and key-based (secret-)environment-variables.KEY=value.
-# Only credentials and the cookie key are secret; the rest are plain
-# environment-variables. The ephemeral /tmp scratch tier is coupled to the
-# memory tier — 512 MB unlocks 2 GB scratch (set in the console).
+# Only the credentials are secret; the rest are plain environment-variables. The
+# cookie key and worker secret are NOT set — they auto-generate and persist. The
+# ephemeral /tmp scratch tier is coupled to the memory tier — 512 MB unlocks 2 GB
+# scratch (set in the console).
scw container container create \
namespace-id=<NAMESPACE_ID> \
name=gitmote \
@@ -190,10 +227,8 @@ scw container container create \
environment-variables.GITMOTE_S3_ENDPOINT=https://s3.fr-par.scw.cloud \
environment-variables.GITMOTE_DATA=/tmp/gitmote \
environment-variables.AWS_REGION=fr-par \
- secret-environment-variables.GITMOTE_COOKIE_KEY="$(openssl rand -base64 32)" \
secret-environment-variables.AWS_ACCESS_KEY_ID=<KEY> \
secret-environment-variables.AWS_SECRET_ACCESS_KEY=<SECRET>
-# GITMOTE_COOKIE_KEY is container-only, so a fresh random inline is fine for it.
# 4. Custom domain — two parts:
# (a) DNS: add a CNAME gitmote.atmin.net → <container-endpoint>.scw.cloud
diff --git a/tasks/README.md b/tasks/README.md
index 367cae3..e78a585 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -4,21 +4,11 @@ Active and upcoming work. One file per unit of work, `Spec → Current → Chang
Verify`, **deleted once it lands** (commits are the record — this list never
becomes a changelog).
-## Easy to operate — one portable container, S3 as the single source of truth
-
-The milestone (`gitmote can host itself`) is reached; the next theme is **making
-gitmote trivial to run**: `docker run ghcr.io/atmin/gitmote` with a handful of
-envs, on a laptop or on Scaleway, identically. Kill it — fine; restart it — it
-restores from S3 and continues. Localhost is a first-class deployment.
-
-Chain A (shrink the surface — auto-generated secrets, first-run auto-bootstrap)
-and Chain B (the image — public GHCR, `make image`/`prod`/`publish`) have landed.
-All that remains is the docs cap:
-
-**Cap**
-- [operate-docs.md](operate-docs.md) — `docker run` quickstart (incl. "grab the
- token from the logs") + CI substrate auto-detection docs. *(last — describes the
- final surface.)*
+The **"easy to operate"** theme has fully landed: auto-generated secrets +
+first-run auto-bootstrap (shrink the surface), public GHCR images with
+`make image`/`prod`/`publish` (the image), and the `docker run` quickstart + env /
+CI-substrate docs (the cap). `docker run ghcr.io/atmin/gitmote` with a bucket and
+credentials is now a working forge; kill/restart restores from S3.
## Later
diff --git a/tasks/operate-docs.md b/tasks/operate-docs.md
deleted file mode 100644
index 8f920b5..0000000
--- a/tasks/operate-docs.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# Operate docs — quickstart + CI substrate
-
-Part of [easy to operate](README.md). Lands after the env cuts + GHCR, so the docs
-describe the reduced surface.
-
-## Spec
-
-A newcomer copies one `docker run` and has a working forge. Make true in the docs:
-
-1. **Run quickstart** — the minimal env set (`GITMOTE_S3_BUCKET`, the three
- `AWS_*`, `GITMOTE_S3_ENDPOINT` if non-AWS; optionally `GITMOTE_DATA` for a
- mounted volume) and a copy-paste `docker run ghcr.io/atmin/gitmote`. State that
- S3 is the single source of truth and the container is disposable
- (kill/restart restores), and that **the first-run admin token is printed once
- to the logs** — where to look, how to sign in, and how to re-mint
- (`bootstrap --reissue`) if lost.
-2. **CI substrate auto-detection** — how gitmote picks its runner: present
- `SCW_CI_JOB_DEFINITION_ID` → Scaleway Serverless Jobs; else
- `GITMOTE_URL`+`WORKER_SECRET` → local `act` (needs docker/podman on the host);
- else CI records runs but does not execute. State the docker/podman prerequisite
- explicitly.
-
-## Current
-
-`docs/ops.md` documents the full pre-cut env list and the Scaleway-first deploy;
-no minimal quickstart, and the CI substrate fallback is undocumented (it's in
-[ci.md](../docs/architecture/ci.md)'s runner table but not as operator guidance).
-`README.md` has no "run it" path.
-
-## Change
-
-- Add a **Quickstart** to `README.md` (or a short `docs/run.md`): the minimal env
- set, `docker run ghcr.io/atmin/gitmote …`, and the "grab the token from the
- logs" first-login step.
-- Refresh `docs/ops.md`'s env table: mark removed (`GITMOTE_DB_REPLICA`, the
- individual `GITMOTE_DB`/`CACHE`/`SOCK` → `GITMOTE_DATA`), auto-managed
- (`GITMOTE_COOKIE_KEY`, `WORKER_SECRET`), and feature-gated (`SCW_*`,
- `GITMOTE_URL`, `GITMOTE_CI_SECRET_KEY_V<n>`); keep the required core small.
-- Add a short **CI substrate** section (auto-detection rules + docker/podman note)
- and cross-link `ci.md`.
-
-## Verify
-
-- The env tables match the code after the earlier tasks (no stale vars).
-- Following the quickstart from a clean machine yields a running, pushable forge.
-- Docs and code agree (CONTRIBUTING's "fix one in the same change").