gitmote

gitmote  ·  history  ·  branches

feat: derive the metadata replica from the bucket and collapse env into GITMOTE_DATA

214746e291a6fd7449cf941599f0d6c4b1cb6574
Atanas Minev <atmin@protonmail.com> · 2026-07-05 09:25

diff --git a/Dockerfile b/Dockerfile
index 2392437..09a1012 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,6 +21,9 @@ RUN apt-get update \
# gitmote resolves the hook as gitmote-hook beside its own executable, so keep
# both in the same directory.
COPY --from=build /out/gitmote /out/gitmote-hook /usr/local/bin/
-RUN mkdir -p /data /cache
+# db, cache, and socket all live under GITMOTE_DATA; mount one volume at /data to
+# persist metadata (also restored from S3 on cold start).
+ENV GITMOTE_DATA=/data
+RUN mkdir -p /data
EXPOSE 8080
ENTRYPOINT ["gitmote"]
diff --git a/README.md b/README.md
index 83c2c7c..9d39ed2 100644
--- a/README.md
+++ b/README.md
@@ -62,12 +62,12 @@ 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_DB=/data/meta.sqlite3 gitmote bootstrap -handle atmin -repo atmin/gitmote
+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_DB`) and clone/push with the token:
+sharing the same `GITMOTE_DATA`) and clone/push with the token:
```sh
git clone http://atmin:<token>@<host>/atmin/gitmote
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index f4cd85f..c77a74c 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -34,6 +34,10 @@ var version = "dev"
const shutdownTimeout = 10 * time.Second
+// listenAddr is the fixed bind address. Ports are mapped at the container, so
+// there is no reason to make this a knob.
+const listenAddr = ":8080"
+
const (
// reconcileInterval is how often the leader sweeps abandoned CI jobs.
reconcileInterval = 5 * time.Minute
@@ -54,20 +58,17 @@ func main() {
return
}
- addr := flag.String("addr", envOr("GITMOTE_ADDR", ":8080"), "listen address")
- flag.Parse()
-
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
- if err := run(context.Background(), logger, *addr); err != nil {
+ if err := run(context.Background(), logger, listenAddr); err != nil {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}
// runBootstrap creates the first admin, token, and repo from an empty instance.
-// It opens the metadata DB per the environment (GITMOTE_DB, and GITMOTE_DB_REPLICA
-// for litestream) and prints the one-time token to out on success. The deferred
+// It opens the metadata DB per the environment (the data dir, and the replica
+// derived from the bucket) and prints the one-time token to out on success. The deferred
// Close durably flushes replication, so this short-lived process reliably pushes
// the new admin/token/repo to S3 for the server to restore.
func runBootstrap(ctx context.Context, args []string, out io.Writer) error {
@@ -242,11 +243,10 @@ func alwaysServable(p string) bool {
// HMAC key that signs session cookies); it shares this metadata handle, so it
// runs alongside the git server.
//
-// Metadata lives at GITMOTE_DB (default ./gitmote.sqlite3), replicated to
-// GITMOTE_DB_REPLICA when set (litestream; empty is local-only). Materialized
-// repos cache under GITMOTE_CACHE (default a temp dir). The push path listens on
-// GITMOTE_SOCK (default a temp path) and installs the pre-receive hook binary
-// at GITMOTE_HOOK (default gitmote-hook alongside this binary).
+// The db, cache, and socket derive from GITMOTE_DATA (see dataDir); metadata
+// replicates to a bucket-derived replica (see replicaTarget). The push path
+// installs the pre-receive hook binary at GITMOTE_HOOK (default gitmote-hook
+// alongside this binary).
func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *webui.Handler, *ci.ReportAPI, func() bool, func() error, error) {
noop := func() error { return nil }
@@ -276,14 +276,12 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
logger.Warn("lost the writer lease; now read-only", "error", err)
})
- sockPath := envOr("GITMOTE_SOCK", filepath.Join(os.TempDir(), "gitmote.sock"))
- writer, err := githttp.NewWriter(md, objs, hookBinaryPath(), sockPath, logger)
+ writer, err := githttp.NewWriter(md, objs, hookBinaryPath(), sockPath(), logger)
if err != nil {
_ = md.Close()
return nil, nil, nil, nil, noop, fmt.Errorf("write path: %w", err)
}
- cacheRoot := envOr("GITMOTE_CACHE", filepath.Join(os.TempDir(), "gitmote-repos"))
- materializer := repo.New(md, objs, cacheRoot)
+ materializer := repo.New(md, objs, cachePath())
guard := auth.NewGuard(md)
// CI secrets: master keys from GITMOTE_CI_SECRET_KEY_V<n>. A malformed key is
@@ -425,20 +423,20 @@ func runnerBinaryPath() string {
}
// metaConfigFromEnv builds the metadata database config from the environment.
-// GITMOTE_DB_REPLICA is an s3:// URL used for both restore-on-cold-start and
-// continuous backup (litestream); empty leaves the database local-only (tests,
-// local dev), so the same binary runs unreplicated or replicated by env alone.
-// The replica reuses the object store's endpoint and the AWS default credential
-// chain, so one credential set covers both git objects and the metadata WAL.
+// The replica (see replicaTarget) is an s3:// URL used for both
+// restore-on-cold-start and continuous backup (litestream); empty leaves the
+// database local-only (tests, ephemeral runs with no bucket), so the same binary
+// runs unreplicated or replicated by env alone. The replica reuses the object
+// store's endpoint and the AWS default credential chain, so one credential set
+// covers both git objects and the metadata WAL.
//
// role selects single-writer coordination and is applied only when a replica is
// configured — there is nothing to coordinate on without a shared WAL, so an
-// unreplicated database stays RoleOff (always writer), keeping tests and local
-// dev unchanged.
+// unreplicated database stays RoleOff (always writer), keeping tests unchanged.
func metaConfigFromEnv(logger *slog.Logger, role s3lite.Role) meta.Config {
- replica := os.Getenv("GITMOTE_DB_REPLICA")
+ replica := replicaTarget()
cfg := meta.Config{
- LocalPath: envOr("GITMOTE_DB", "gitmote.sqlite3"),
+ LocalPath: dbPath(),
RestoreFrom: replica,
BackupTo: replica,
S3: s3lite.S3Config{Endpoint: os.Getenv("GITMOTE_S3_ENDPOINT")},
@@ -450,6 +448,38 @@ func metaConfigFromEnv(logger *slog.Logger, role s3lite.Role) meta.Config {
return cfg
}
+// replicaTarget resolves the metadata replica URL. An explicit GITMOTE_DB_REPLICA
+// wins as an override; otherwise, whenever a bucket is set, the replica is derived
+// as s3://{bucket}/meta — a sibling of the git objects. A bucket alone therefore
+// yields durability and the single-writer lease with no second env.
+//
+// Derive from the bucket only, never bucket+prefix: s3://{bucket}/{prefix}meta
+// would be a different path and orphan the existing sibling replica. The sibling
+// layout (objects/ + meta) is deliberate.
+func replicaTarget() string {
+ if replica := os.Getenv("GITMOTE_DB_REPLICA"); replica != "" {
+ return replica
+ }
+ if bucket := os.Getenv("GITMOTE_S3_BUCKET"); bucket != "" {
+ return "s3://" + bucket + "/meta"
+ }
+ return ""
+}
+
+// dataDir is the base directory for gitmote's local, restore-from-S3 state:
+// GITMOTE_DATA, defaulting to a temp dir. The db, cache, and socket derive from
+// it — one -v ./data:/data mounts the lot — each still overridable individually.
+func dataDir() string {
+ return envOr("GITMOTE_DATA", filepath.Join(os.TempDir(), "gitmote"))
+}
+
+// dbPath, cachePath, and sockPath resolve the metadata DB, materialized-repo
+// cache, and pre-receive hook socket under dataDir, honoring the per-path
+// overrides (GITMOTE_DB / GITMOTE_CACHE / GITMOTE_SOCK) when set.
+func dbPath() string { return envOr("GITMOTE_DB", filepath.Join(dataDir(), "meta.sqlite3")) }
+func cachePath() string { return envOr("GITMOTE_CACHE", filepath.Join(dataDir(), "cache")) }
+func sockPath() string { return envOr("GITMOTE_SOCK", filepath.Join(dataDir(), "gitmote.sock")) }
+
// envOr returns the value of the environment variable key, or fallback if
// it is unset or empty.
func envOr(key, fallback string) string {
diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
index c214e28..a3fdf6f 100644
--- a/cmd/gitmote/main_test.go
+++ b/cmd/gitmote/main_test.go
@@ -10,6 +10,8 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/atmin/s3lite"
)
func TestHealthz(t *testing.T) {
@@ -169,6 +171,94 @@ func TestRunBootstrap(t *testing.T) {
}
}
+func TestReplicaTarget(t *testing.T) {
+ // A bucket alone derives s3://{bucket}/meta; the object prefix must NOT leak
+ // into the replica path (that would orphan the existing sibling replica). An
+ // explicit GITMOTE_DB_REPLICA overrides the derivation.
+ tests := []struct {
+ name string
+ replica string
+ bucket string
+ prefix string
+ want string
+ }{
+ {"derived from bucket", "", "gitmote", "", "s3://gitmote/meta"},
+ {"prefix does not leak", "", "gitmote", "objects/", "s3://gitmote/meta"},
+ {"explicit replica overrides", "s3://other/wal", "gitmote", "objects/", "s3://other/wal"},
+ {"no bucket, no replica", "", "", "", ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Setenv("GITMOTE_DB_REPLICA", tt.replica)
+ t.Setenv("GITMOTE_S3_BUCKET", tt.bucket)
+ t.Setenv("GITMOTE_S3_PREFIX", tt.prefix)
+ if got := replicaTarget(); got != tt.want {
+ t.Errorf("replicaTarget() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestMetaConfigDerivesReplicaAndRole(t *testing.T) {
+ // With a bucket set, metaConfigFromEnv derives the replica and applies the
+ // leased role — a bucket always means durability + the single-writer lease.
+ t.Setenv("GITMOTE_DB_REPLICA", "")
+ t.Setenv("GITMOTE_S3_BUCKET", "gitmote")
+ t.Setenv("GITMOTE_S3_PREFIX", "objects/")
+ t.Setenv("GITMOTE_DATA", t.TempDir())
+
+ cfg := metaConfigFromEnv(nil, s3lite.RoleAuto)
+ if cfg.RestoreFrom != "s3://gitmote/meta" || cfg.BackupTo != "s3://gitmote/meta" {
+ t.Errorf("replica = %q / %q, want s3://gitmote/meta (prefix must not leak)", cfg.RestoreFrom, cfg.BackupTo)
+ }
+ if cfg.Role != s3lite.RoleAuto {
+ t.Errorf("Role = %v, want RoleAuto (a bucket must yield the lease)", cfg.Role)
+ }
+}
+
+func TestMetaConfigNoBucketStaysRoleOff(t *testing.T) {
+ // No bucket and no replica: nothing to coordinate on, so the database stays
+ // local-only and RoleOff (always writer) — tests and ephemeral runs unchanged.
+ t.Setenv("GITMOTE_DB_REPLICA", "")
+ t.Setenv("GITMOTE_S3_BUCKET", "")
+
+ cfg := metaConfigFromEnv(nil, s3lite.RoleAuto)
+ if cfg.RestoreFrom != "" || cfg.BackupTo != "" {
+ t.Errorf("replica = %q / %q, want empty (no bucket)", cfg.RestoreFrom, cfg.BackupTo)
+ }
+ if cfg.Role != s3lite.RoleOff {
+ t.Errorf("Role = %v, want RoleOff", cfg.Role)
+ }
+}
+
+func TestDataPathsUnderGitmoteData(t *testing.T) {
+ // GITMOTE_DATA alone places the db, cache, and socket under it.
+ dir := t.TempDir()
+ t.Setenv("GITMOTE_DATA", dir)
+ t.Setenv("GITMOTE_DB", "")
+ t.Setenv("GITMOTE_CACHE", "")
+ t.Setenv("GITMOTE_SOCK", "")
+
+ if got, want := dbPath(), filepath.Join(dir, "meta.sqlite3"); got != want {
+ t.Errorf("dbPath() = %q, want %q", got, want)
+ }
+ if got, want := cachePath(), filepath.Join(dir, "cache"); got != want {
+ t.Errorf("cachePath() = %q, want %q", got, want)
+ }
+ if got, want := sockPath(), filepath.Join(dir, "gitmote.sock"); got != want {
+ t.Errorf("sockPath() = %q, want %q", got, want)
+ }
+}
+
+func TestDataPathOverride(t *testing.T) {
+ // The explicit per-path vars still override the GITMOTE_DATA derivation.
+ t.Setenv("GITMOTE_DATA", "/data")
+ t.Setenv("GITMOTE_DB", "/custom/db.sqlite3")
+ if got, want := dbPath(), "/custom/db.sqlite3"; got != want {
+ t.Errorf("dbPath() = %q, want the GITMOTE_DB override %q", got, want)
+ }
+}
+
func TestRunFailsOnBadAddr(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
diff --git a/docker-compose.yml b/docker-compose.yml
index 9b49bda..22f1664 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,7 +1,7 @@
# Local self-hosting stack: MinIO (object store) + gitmote (single writer).
-# Driven by `make e2e-local` (scripts/e2e-local.sh). Not a production topology —
-# litestream replication of the metadata DB lands in the deploy task; here the
-# sqlite file lives on the gitmote-data volume, so refs survive a restart.
+# Driven by `make e2e-local` (scripts/e2e-local.sh). A bucket alone derives the
+# metadata replica and single-writer lease, so refs survive both a restart (via
+# the gitmote-data volume) and a full wipe (restored from S3 — see e2e-restore).
name: gitmote-e2e
services:
@@ -35,15 +35,12 @@ services:
image: gitmote:e2e
depends_on: [minio]
environment:
- GITMOTE_ADDR: ":8080"
GITMOTE_S3_BUCKET: gitmote
GITMOTE_S3_ENDPOINT: http://minio:9000
- GITMOTE_DB: /data/meta.sqlite3 # persistent (named volume) — refs survive restart
- GITMOTE_CACHE: /cache # ephemeral — materialized repos rebuild from S3
- GITMOTE_SOCK: /tmp/gitmote.sock
- # Opt-in litestream replication of the metadata DB. Empty by default
- # (e2e-local proves volume durability); e2e-restore.sh sets it to exercise
- # restore-from-S3 after the DB volume is wiped — the deploy cold-start path.
+ GITMOTE_DATA: /data # db, cache, and socket all live here
+ # A bucket alone derives the replica (s3://gitmote/meta), so replication is
+ # on by default. e2e-restore.sh sets this explicitly to the same value while
+ # it wipes the volume, exercising the restore-from-S3 cold-start path.
GITMOTE_DB_REPLICA: ${GITMOTE_DB_REPLICA:-}
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: minioadmin
diff --git a/docs/ops.md b/docs/ops.md
index 65b7997..ca15851 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -77,14 +77,10 @@ Setting these on the container does nothing for CI, and vice versa.
| Variable | Value / meaning |
|----------|-----------------|
-| `GITMOTE_ADDR` | bind address — `:8080` (Scaleway routes to `port=8080`) |
-| `GITMOTE_S3_BUCKET` | `gitmote` |
+| `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_S3_PREFIX` | `objects/` — git objects live under this prefix |
-| `GITMOTE_DB_REPLICA` | `s3://gitmote/meta` — litestream restore + backup target |
-| `GITMOTE_DB` | `/tmp/gitmote/meta.sqlite3` — ephemeral; restored from the replica on cold start |
-| `GITMOTE_CACHE` | `/tmp/gitmote/cache` — ephemeral; materialized repos rebuild from S3 |
-| `GITMOTE_SOCK` | `/tmp/gitmote/gitmote.sock` — pre-receive hook RPC socket |
+| `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`) |
@@ -108,12 +104,11 @@ Setting these on the container does nothing for CI, and vice versa.
> `.github/workflows` with `act`, which needs a reachable Docker daemon. A
> leader-only ticker sweeps jobs stuck `running` past ~1h back to `error`.
-`GITMOTE_DB` and `GITMOTE_CACHE` are 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 → wake, or a redeploy) the DB is restored from
-`GITMOTE_DB_REPLICA` and repos re-materialize from `objects/`. This path is
-proven locally by `make e2e-restore` (wipes the DB volume, confirms the repo
-still clones).
+`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 →
+wake, or a redeploy) the DB is restored from the derived replica and repos
+re-materialize from the bucket. This path is proven locally by `make e2e-restore`
+(wipes the data volume, confirms the repo still clones).
Resources: **250 mVCPU / 512 MB / 2 GB ephemeral** (`cpu-limit=250`,
`memory-limit-bytes=512MB` — the CLI requires a `G`/`GB`/`MB` unit). Observed
@@ -186,12 +181,8 @@ scw container container create \
memory-limit-bytes=512MB \
port=8080 \
environment-variables.GITMOTE_S3_BUCKET=gitmote \
- environment-variables.GITMOTE_S3_PREFIX=objects/ \
environment-variables.GITMOTE_S3_ENDPOINT=https://s3.fr-par.scw.cloud \
- environment-variables.GITMOTE_DB_REPLICA=s3://gitmote/meta \
- environment-variables.GITMOTE_DB=/tmp/gitmote/meta.sqlite3 \
- environment-variables.GITMOTE_CACHE=/tmp/gitmote/cache \
- environment-variables.GITMOTE_SOCK=/tmp/gitmote/gitmote.sock \
+ 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> \
@@ -222,9 +213,9 @@ already holds it, so running it while the server is live can no longer produce
two writers — it just refuses.
```bash
-GITMOTE_DB=/tmp/bootstrap.sqlite3 \
-GITMOTE_DB_REPLICA=s3://gitmote/meta \
+GITMOTE_S3_BUCKET=gitmote \
GITMOTE_S3_ENDPOINT=https://s3.fr-par.scw.cloud \
+GITMOTE_DATA=/tmp/bootstrap \
AWS_REGION=fr-par AWS_ACCESS_KEY_ID=<KEY> AWS_SECRET_ACCESS_KEY=<SECRET> \
gitmote bootstrap -handle atmin -repo atmin/gitmote -default-branch master
```
diff --git a/scripts/dev.sh b/scripts/dev.sh
index 0886896..de23002 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -10,18 +10,16 @@ cd "$ROOT"
COMPOSE=(docker compose -p gitmote-dev -f docker-compose.dev.yml)
TOKEN_FILE="$ROOT/data/dev-token"
-# All-local configuration — none of these are secret. The DB, cache, and socket
-# live under data/ (gitignored) so refs survive restarts; the token below is
-# minted against that same DB, so the two stay in sync.
-export GITMOTE_ADDR=":8080"
+# All-local configuration — none of these are secret. The db, cache, and socket
+# live under data/ (gitignored, one GITMOTE_DATA dir) so refs survive restarts;
+# the token below is minted against that same DB, so the two stay in sync. The
+# bucket alone now derives the metadata replica + single-writer lease.
export GITMOTE_S3_BUCKET="gitmote"
export GITMOTE_S3_ENDPOINT="http://localhost:9100"
export AWS_REGION="us-east-1"
export AWS_ACCESS_KEY_ID="minioadmin"
export AWS_SECRET_ACCESS_KEY="minioadmin"
-export GITMOTE_DB="$ROOT/data/meta.sqlite3"
-export GITMOTE_CACHE="$ROOT/data/cache"
-export GITMOTE_SOCK="$ROOT/data/gitmote.sock"
+export GITMOTE_DATA="$ROOT/data"
export GITMOTE_HOOK="$ROOT/bin/gitmote-hook"
export GITMOTE_RUNNER="$ROOT/bin/gitmote-runner"
export GITMOTE_COOKIE_KEY="dev-cookie-key-not-for-production"
diff --git a/tasks/README.md b/tasks/README.md
index e0aa476..a4687a6 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -15,10 +15,6 @@ Two independent chains plus a docs cap — order within a chain matters, the cha
run in parallel:
**Chain A — shrink the surface**
-- [minimal-env-replica.md](minimal-env-replica.md) — derive the DB replica from
- the bucket (drop `GITMOTE_DB_REPLICA`); a bucket ⇒ replicate + lease (durable by
- default, `RoleOff`-with-a-bucket retired); collapse db/cache/sock into one
- `GITMOTE_DATA`.
- [minimal-env-secrets.md](minimal-env-secrets.md) — auto-generate + persist
`GITMOTE_COOKIE_KEY` and (lazily) `WORKER_SECRET` in meta; CI master key stays
env-only.
@@ -31,8 +27,8 @@ run in parallel:
- [public-registry.md](public-registry.md) — publish `ghcr.io/atmin/gitmote` +
`…/gitmote-runner` publicly; deploy from GHCR; kill the by-hand runner push.
- [dogfood-make.md](dogfood-make.md) — `make image` / `make prod` / `make publish`;
- `make prod` runs the real image against dev MinIO. *(needs -replica for full
- shared state, and -registry for the tags.)*
+ `make prod` runs the real image against dev MinIO. *(needs -registry for the
+ tags.)*
**Cap**
- [operate-docs.md](operate-docs.md) — `docker run` quickstart (incl. "grab the
diff --git a/tasks/dogfood-make.md b/tasks/dogfood-make.md
index a8f682d..8cb0d91 100644
--- a/tasks/dogfood-make.md
+++ b/tasks/dogfood-make.md
@@ -1,9 +1,9 @@
# Dogfood Make targets — build, run, publish the image
Part of [easy to operate](README.md). Operator ergonomics for the container
-lifecycle. Depends on [minimal-env-replica](minimal-env-replica.md) (so
-`make prod` restores the *whole* forge from S3) and
-[public-registry](public-registry.md) (the image tags).
+lifecycle. The bucket-derived replica already lets `make prod` restore the
+*whole* forge from S3; this depends on [public-registry](public-registry.md)
+(the image tags).
## Spec
diff --git a/tasks/minimal-env-replica.md b/tasks/minimal-env-replica.md
deleted file mode 100644
index b19a7a3..0000000
--- a/tasks/minimal-env-replica.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# Shrink the server env — derive the replica, collapse the data paths
-
-Part of [easy to operate](README.md). Cut required and optional envs so a run is
-a handful of vars, and make "stateless, restores-on-restart" the default.
-
-## Spec
-
-- **Derive the replica from the bucket.** With `GITMOTE_S3_BUCKET` set, gitmote
- replicates metadata to S3 and runs the single-writer lease **without a second
- env**. `GITMOTE_DB_REPLICA` becomes an optional override, not a requirement.
-- **A bucket means durability, always.** Once the replica is derived, "bucket set
- but not replicating" (`RoleOff` with a bucket) has no real use — ephemeral/test
- runs use the in-memory store (no bucket). So `RoleOff` stops being a
- bucket-mode: a bucket ⇒ `RoleAuto` (replicate + lease). This is a behavior
- change for `make dev` / `e2e-local` (they gain replication + a lease); it is the
- point — it's what lets `make prod` share state and restore from S3.
-- **One data dir.** Collapse `GITMOTE_DB` / `GITMOTE_CACHE` / `GITMOTE_SOCK` into
- a single `GITMOTE_DATA` (default a temp dir): db `= $DATA/meta.sqlite3`, cache
- `= $DATA/cache`, sock `= $DATA/gitmote.sock`. One `-v ./data:/data` for the
- whole container. The three specific vars can stay as overrides if trivial, but
- the documented knob is one.
-- **Fewer knobs.** Hardcode the listen address to `:8080` — drop `GITMOTE_ADDR`
- (map ports at the container). Retire `GITMOTE_S3_PREFIX` as a documented knob
- (keep the code default `""` so an existing prefixed bucket still works, but it
- leaves the surface — it only mattered for sharing a bucket across apps).
-
-## Current
-
-[`metaConfigFromEnv`](../cmd/gitmote/main.go) sets `Role` only when
-`GITMOTE_DB_REPLICA` is non-empty → `RoleOff` otherwise. It's called **twice** —
-`RoleWriter` for the bootstrap CLI and `RoleAuto` for the server — so the
-derivation lands in one place and fixes both. Prod runs
-`GITMOTE_S3_PREFIX=objects/` with the replica at `s3://gitmote/meta` — meta and
-objects are **siblings** under the bucket root. Path vars default individually
-(`GITMOTE_DB` → `gitmote.sqlite3`, cache/sock → temp).
-
-## Change
-
-- In `metaConfigFromEnv`, when `GITMOTE_DB_REPLICA` is empty but the bucket is
- set, derive `replica = s3://{bucket}/meta`. **Derive from the bucket only — not
- bucket + prefix.** `s3://{bucket}/{prefix}meta` would be a *different* path and
- orphan the existing replica; the sibling layout (`objects/` + `meta`) is
- deliberate. (No prod data is at risk today, but get the layout right so it
- stays migration-free.)
-- Drop the `RoleOff`-with-a-bucket path; a bucket always yields `RoleAuto`.
-- Add `GITMOTE_DATA` and derive the three paths from it; keep the explicit vars as
- overrides.
-- Replace `envOr("GITMOTE_ADDR", ":8080")` with a `:8080` constant (drop the flag
- + env). Stop reading/documenting `GITMOTE_S3_PREFIX` in the ops surface (leave
- `store.NewS3FromEnv`'s default `""` intact for back-compat).
-- Update `scripts/dev.sh` (drop the explicit replica + the path vars; use
- `GITMOTE_DATA=data`), and the `docs/ops.md` / `Dockerfile` env docs.
-
-## Verify
-
-- Unit test: derivation is `s3://{bucket}/meta` for any prefix (assert the prefix
- does **not** leak into the replica path); an explicit `GITMOTE_DB_REPLICA` still
- overrides.
-- `make e2e-restore` (cold-start restore) and `make e2e-local` stay green with the
- now-default replication.
-- `GITMOTE_DATA=./data` alone places db/cache/sock under it.
-- `gofmt`/`golangci-lint`/`go test ./...` clean.