diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 83a37dd..ac27075 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,8 +31,10 @@ jobs:
- run: make e2e-restore
# Deploy the single writer to Scaleway on every green master push (docs/ops.md).
- # max-scale=1 is asserted here, not left to a console default: two writers to the
- # shared litestream WAL is the one forbidden state (safety.md §1).
+ # Safety is by lease now, not procedure: the server runs in s3lite RoleAuto, so a
+ # rolling deploy's brief old+new overlap is one writer + one read-only follower,
+ # never two writers (safety.md §1). max-scale=1 is still asserted (read replicas
+ # need fresh followers first — a later step).
deploy:
if: github.ref == 'refs/heads/master'
needs: [ci, e2e]
@@ -67,23 +69,13 @@ jobs:
sudo curl -fsSL "$SCW_URL" -o /usr/local/bin/scw
sudo chmod +x /usr/local/bin/scw
- # Stop-first: drain the running writer to zero BEFORE swapping the image, so
- # the rolling deploy doesn't briefly run two litestream writers (safety.md
- # §1). POST /admin/quit self-SIGTERMs the instance → graceful flush + exit.
- # We then wait (NOT poll — a request would re-wake it) for it to exit; the
- # image swap below then starts a fresh instance with no old one to overlap.
- # Residual: a request racing this ~20s window can re-wake the old image;
- # negligible for an operator-triggered deploy (docs/ops.md).
- - name: Drain the running writer
- env:
- DEPLOY_KEY: ${{ secrets.GITMOTE_DEPLOY_KEY }}
- run: |
- curl -fsS -m 15 -X POST -H "Authorization: Bearer $DEPLOY_KEY" \
- https://gitmote.atmin.net/admin/quit || true
- sleep 20
-
+ # No drain step: the writer lease makes the rolling deploy safe by
+ # construction. The new instance boots as a read-only follower (the old one
+ # holds the lease), the old releases on its graceful SIGTERM, then the new
+ # promotes — never two writers (safety.md §1, docs/ops.md).
+ #
# A unique (SHA-tagged) image reference makes this a clean replacement, and
- # max-scale=1 (the single-writer invariant) is re-asserted every deploy.
+ # max-scale=1 (still the steady-state pin) is re-asserted every deploy.
# min-scale=0 idles to zero between uses. --wait blocks until the new
# deployment is serving. See docs/ops.md for the single-writer deploy note.
- name: Deploy (single writer)
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 189f619..5405db7 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -3,7 +3,6 @@ package main
import (
"context"
- "crypto/subtle"
"errors"
"flag"
"fmt"
@@ -13,7 +12,6 @@ import (
"os"
"os/signal"
"path/filepath"
- "strings"
"syscall"
"time"
@@ -68,7 +66,10 @@ func runBootstrap(ctx context.Context, args []string, out io.Writer) error {
return err
}
- md, err := meta.Open(ctx, metaConfigFromEnv(nil))
+ // Bootstrap is a deliberate single-shot writer: RoleWriter acquires the lease
+ // (when a replica is configured) and fails loudly if a live server already
+ // holds it, so bootstrapping can never race the running writer.
+ md, err := meta.Open(ctx, metaConfigFromEnv(nil, s3lite.RoleWriter))
if err != nil {
return fmt.Errorf("open metadata: %w", err)
}
@@ -115,7 +116,7 @@ func run(ctx context.Context, logger *slog.Logger, addr string) error {
srv := &http.Server{
Addr: addr,
- Handler: newHandler(gitHandler, ui, os.Getenv("GITMOTE_DEPLOY_KEY")),
+ Handler: newHandler(gitHandler, ui),
}
errCh := make(chan error, 1)
@@ -145,9 +146,8 @@ func run(ctx context.Context, logger *slog.Logger, addr string) error {
// newHandler returns the server's HTTP routes. ui, when non-nil, registers the
// management UI under /ui/ and /login (more specific patterns that win over the
// git catch-all). gitHandler, when non-nil, serves the git smart-HTTP endpoints
-// at "/"; the exact health/version routes stay more specific. deployKey, when
-// non-empty, enables POST /admin/quit — the deploy-time self-drain.
-func newHandler(gitHandler http.Handler, ui *webui.Handler, deployKey string) http.Handler {
+// at "/"; the exact health/version routes stay more specific.
+func newHandler(gitHandler http.Handler, ui *webui.Handler) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "ok")
@@ -155,16 +155,6 @@ func newHandler(gitHandler http.Handler, ui *webui.Handler, deployKey string) ht
mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, version)
})
- if deployKey != "" {
- // Deploy-time stop-first: the pipeline drains the running writer before
- // swapping the image, so the rolling deploy never runs two litestream
- // writers (docs/ops.md, safety.md §1). Self-SIGTERM drops into the same
- // graceful-shutdown path that flushes replication.
- mux.HandleFunc("POST /admin/quit", adminQuitHandler(deployKey, func() {
- time.Sleep(200 * time.Millisecond) // let the response flush first
- _ = syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
- }))
- }
if ui != nil {
ui.Register(mux)
}
@@ -174,24 +164,6 @@ func newHandler(gitHandler http.Handler, ui *webui.Handler, deployKey string) ht
return mux
}
-// adminQuitHandler authenticates a deploy-key bearer token in constant time and,
-// on success, acknowledges and triggers quit (graceful shutdown). quit is a seam
-// so tests exercise auth without killing the test process.
-func adminQuitHandler(deployKey string, quit func()) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
- if subtle.ConstantTimeCompare([]byte(got), []byte(deployKey)) != 1 {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
- _, _ = io.WriteString(w, "draining\n")
- if f, ok := w.(http.Flusher); ok {
- f.Flush()
- }
- go quit()
- }
-}
-
// buildGitHandler wires the git read + write paths when an object store is
// configured (GITMOTE_S3_BUCKET). Without it, the server runs health/version
// only — a dev convenience — and the returned close func is a no-op.
@@ -218,10 +190,21 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
return nil, nil, noop, fmt.Errorf("object store: %w", err)
}
- md, err := meta.Open(ctx, metaConfigFromEnv(logger))
+ // RoleAuto: this instance becomes the writer when it can acquire the lease,
+ // otherwise a read-only follower — so a rolling deploy's brief old+new overlap
+ // is one writer + one follower, never two writers (safety.md §1). With no
+ // replica configured this is RoleOff (always writer): tests and local dev
+ // unchanged.
+ md, err := meta.Open(ctx, metaConfigFromEnv(logger, s3lite.RoleAuto))
if err != nil {
return nil, nil, noop, fmt.Errorf("metadata: %w", err)
}
+ md.OnPromote(func() {
+ logger.Info("became writer: acquired the lease", "generation", md.Generation())
+ })
+ md.OnDemote(func(err error) {
+ 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)
@@ -249,6 +232,7 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
Materializer: repo.New(md, objs, cacheRoot),
Authorizer: guard,
Writer: writer,
+ IsWritable: md.IsLeader,
Logger: logger,
})
if err != nil {
@@ -294,15 +278,24 @@ func hookBinaryPath() string {
// 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.
-func metaConfigFromEnv(logger *slog.Logger) meta.Config {
+//
+// 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.
+func metaConfigFromEnv(logger *slog.Logger, role s3lite.Role) meta.Config {
replica := os.Getenv("GITMOTE_DB_REPLICA")
- return meta.Config{
+ cfg := meta.Config{
LocalPath: envOr("GITMOTE_DB", "gitmote.sqlite3"),
RestoreFrom: replica,
BackupTo: replica,
S3: s3lite.S3Config{Endpoint: os.Getenv("GITMOTE_S3_ENDPOINT")},
Logger: logger,
}
+ if replica != "" {
+ cfg.Role = role
+ }
+ return cfg
}
// envOr returns the value of the environment variable key, or fallback if
diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
index ce3cc70..141b7d8 100644
--- a/cmd/gitmote/main_test.go
+++ b/cmd/gitmote/main_test.go
@@ -12,58 +12,8 @@ import (
"time"
)
-func TestAdminQuit(t *testing.T) {
- const key = "s3cr3t-deploy-key"
-
- // Unauthorized: wrong/absent bearer → 401, quit never fires.
- for _, auth := range []string{"", "Bearer wrong", "Basic " + key} {
- called := false
- h := adminQuitHandler(key, func() { called = true })
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/admin/quit", nil)
- if auth != "" {
- req.Header.Set("Authorization", auth)
- }
- h(rec, req)
- if rec.Code != http.StatusUnauthorized {
- t.Errorf("auth %q: status = %d, want 401", auth, rec.Code)
- }
- if called {
- t.Errorf("auth %q: quit fired on unauthorized request", auth)
- }
- }
-
- // Authorized: 200, acknowledges, and triggers quit.
- done := make(chan struct{})
- h := adminQuitHandler(key, func() { close(done) })
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/admin/quit", nil)
- req.Header.Set("Authorization", "Bearer "+key)
- h(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("authorized status = %d, want 200", rec.Code)
- }
- select {
- case <-done:
- case <-time.After(time.Second):
- t.Fatal("quit was not triggered on authorized request")
- }
-
- // The route only exists when a deploy key is configured.
- srv := httptest.NewServer(newHandler(nil, nil, ""))
- defer srv.Close()
- resp, err := http.Post(srv.URL+"/admin/quit", "", nil)
- if err != nil {
- t.Fatalf("POST /admin/quit: %v", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusNotFound {
- t.Errorf("quit with no deploy key = %d, want 404 (route absent)", resp.StatusCode)
- }
-}
-
func TestHealthz(t *testing.T) {
- srv := httptest.NewServer(newHandler(nil, nil, ""))
+ srv := httptest.NewServer(newHandler(nil, nil))
defer srv.Close()
resp, err := http.Get(srv.URL + "/healthz")
@@ -78,7 +28,7 @@ func TestHealthz(t *testing.T) {
}
func TestVersion(t *testing.T) {
- srv := httptest.NewServer(newHandler(nil, nil, ""))
+ srv := httptest.NewServer(newHandler(nil, nil))
defer srv.Close()
resp, err := http.Get(srv.URL + "/version")
@@ -93,7 +43,7 @@ func TestVersion(t *testing.T) {
}
func TestUnknownRouteNotFound(t *testing.T) {
- srv := httptest.NewServer(newHandler(nil, nil, ""))
+ srv := httptest.NewServer(newHandler(nil, nil))
defer srv.Close()
resp, err := http.Get(srv.URL + "/nope")
@@ -113,7 +63,7 @@ func TestGitHandlerMountedAtRoot(t *testing.T) {
gitHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
- srv := httptest.NewServer(newHandler(gitHandler, nil, ""))
+ srv := httptest.NewServer(newHandler(gitHandler, nil))
defer srv.Close()
health, err := http.Get(srv.URL + "/healthz")
diff --git a/docs/architecture/safety.md b/docs/architecture/safety.md
index 24ed33a..b0b3ec6 100644
--- a/docs/architecture/safety.md
+++ b/docs/architecture/safety.md
@@ -4,18 +4,22 @@
**"Safe" is a hard requirement.** The model rests on four points:
-1. **Single writer = single _container_, not single _user_.** s3lite requires
+1. **Single writer = single _lease-holder_, not single _user_.** s3lite requires
exactly one writer instance; that's a deployment fact, not a limit on
collaborators. A few invited users and the web UI's service commits all funnel
- into one container. This suits the scale-to-zero shape: 0↔1 instances is fine.
- **The one operational rule: never run two writer instances** — no overlapping
- deploys of the writer. A rolling deploy would violate this (it briefly runs
- old + new), so the deploy is **stop-first**: it drains the running writer to
- exit before starting the replacement (see [ops.md](../ops.md) — deploy). The
- airtight version — read replicas plus a single leased writer, so overlap is
- safe by construction — is the planned evolution
- ([reader-writer-split](../evolution/reader-writer-split.md)); the current
- drain closes the window in practice, not by proof.
+ into one writer. **The one rule: never two writers on the same WAL** — and this
+ 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 receive-pack on `IsLeader()` — a follower
+ refuses pushes with a retryable `503` and still serves reads. A rolling deploy's
+ brief old+new overlap is therefore one writer + one follower: the new instance
+ boots as a follower, the old releases the lease on its graceful SIGTERM `Close`,
+ and the new promotes on its next lease poll. **Overlap is safe by construction**,
+ so there is no stop-first drain. This is the writer half of
+ [reader-writer-split](../evolution/reader-writer-split.md), now shipped; the
+ remaining half — *fresh* followers that scale reads out (a follower serves only
+ a restored snapshot today) — is why the container still pins `max-scale = 1`.
2. **In-process mutex linearizes writes.** Git's own lockfiles can't be trusted
across a synced/object backend, so a per-repo mutex in the process does the
diff --git a/docs/evolution/reader-writer-split.md b/docs/evolution/reader-writer-split.md
index 6ceb38e..1edd669 100644
--- a/docs/evolution/reader-writer-split.md
+++ b/docs/evolution/reader-writer-split.md
@@ -1,76 +1,65 @@
-# Reader/writer split with a leased writer (speculative)
+# Reader/writer split with a leased writer
-> **Status: evolution note — speculative, not a commitment.** Where gitmote could
-> go, captured so the reasoning survives a fresh session. Nothing here is planned
-> or specced; don't cite it as a spec.
+> **Status: writer half shipped; read-replica half still speculative.** The
+> **leased single writer** below is now implemented (s3lite `RoleAuto`; gitmote
+> gates pushes on the lease — see [safety.md §1](../architecture/safety.md) and
+> [ops.md](../ops.md)). What remains speculative is the **read-scaling** half:
+> `max-scale > 1` with followers fresh enough to serve clones. Don't cite the
+> read-replica part as a spec.
-## The problem it solves
+## The problem it solved
[safety.md §1](../architecture/safety.md) requires **one writer, ever** — two
-litestream writers on the same WAL can corrupt the metadata replica. Today that's
-enforced by running a single container (`max-scale = 1`).
+litestream writers on the same WAL can corrupt the metadata replica.
-But a **deploy** momentarily breaks it. Scaleway Serverless Containers do rolling
+A **deploy** momentarily broke it. Scaleway Serverless Containers do rolling
deploys — the new instance boots while the old drains — so instance count spikes
-to 2 (observed in Cockpit), i.e. two writers. Scaleway won't let you scale to 0
-(`max-scale` min is 1), so it can't stop-first on its own.
+to 2 (observed in Cockpit). The original interim was a **stop-first self-drain**
+(CI `POST /admin/quit` → self-SIGTERM before the image swap): it closed the window
+_in practice_ but not _by proof_, couldn't serve reads during handoff, and left a
+re-wake race. **This is now resolved by the lease** (below) — the drain is gone.
-The shipped interim ([ops.md](../ops.md) — deploy) is **stop-first via self-drain**:
-CI `POST /admin/quit`s the running writer (self-SIGTERM → graceful flush + exit)
-before swapping the image, so the swap starts a fresh instance with no old one to
-overlap. That closes the window *in practice* but not *by proof* — a request
-racing the ~20 s drain→swap window can re-wake the old image. It also can't serve
-reads during the handoff (brief), and cold-start-from-zero must just-write.
-
-## The idea
+## The idea (writer half — shipped)
Stop conflating "an instance is running" with "an instance is the writer." Let
**many instances run and serve reads**; let **exactly one hold a writer lease**.
-- Every instance boots as a **read replica**: serves `upload-pack` (clone/fetch),
- UI reads, `/healthz`. It restores the metadata DB from the replica and follows
- it read-only; it does **not** replicate or accept pushes.
- Exactly one instance holds a **writer lease** and is the litestream leader: it
accepts `receive-pack`, does the ref CAS, and replicates the WAL. A push to a
- non-leader is rejected/redirected.
-- **Handoff on deploy:** the new instance comes up as a reader; when the old
- releases the lease (on SIGTERM drain), the new acquires it and promotes. Zero
- read downtime, a brief write pause, and — because promotion is gated on the
- lease — *never* two writers, by construction.
-
-This is the split [safety.md §2](../architecture/safety.md) already anticipates
-("in-process now, shared coordination later… the SQL CAS keeps the door open")
-and the "litestream read replicas" the read-scaling
-[open question](../architecture/open-questions.md) names.
-
-## What it needs — now unblocked
-
-The lease needs a coordination substrate with **atomic compare-and-swap**. When
-this note was written, Scaleway Object Storage lacked it — but **Scaleway now
-supports conditional writes** (`If-Match` / `If-None-Match`), so the lease can
-live on the same Scaleway bucket; no second provider, no moving the WAL.
-
-Better still, **litestream already ships the lease primitive** (v0.5.8+,
-`litestream.Leaser` / `s3.Leaser`): `If-None-Match: *` to acquire, `If-Match` to
-renew, a 30 s TTL and a monotonic `Generation` (fencing token). We do **not**
-hand-roll CAS/fencing. The catch: litestream does **not** auto-gate its own
-replication on the lease (neither `Store` nor `DB` checks it, as of v0.5.13) — so
-the leader lifecycle (acquire → replicate → renew → step down on loss → release)
-is orchestration s3lite must add around litestream's primitive.
-
-The cold-start subtlety that motivates the lease: a lone instance waking on a
-push can't tell "I'm the only one, safe to write" from "I'm a new deploy while
-the old still writes" — they look identical from inside. Only an external lease
-answers "am I allowed to be the writer?".
-
-This is now a concrete piece of work, specced in s3lite (`tasks/single-writer-lease.md`
-in that repo), since the primitive lives there. gitmote then consumes it: boot as
-reader, promote on lease acquisition, reject pushes when not leader.
-
-## Trigger to build it
-
-When any of: reads get heavy enough to want replicas; deploy-time write pauses or
-the residual overlap window become unacceptable; or `max-scale` needs to exceed 1
-for throughput. Until then the single-writer pin plus stop-first drain is
-sufficient and far simpler — the value now is that the substrate is available, so
-it's build-when-ready rather than blocked.
+ non-leader is rejected (retryable `503`).
+- **Handoff on deploy:** the new instance comes up as a follower; when the old
+ releases the lease (on SIGTERM `Close`), the new acquires it and promotes. A
+ brief write pause, and — because promotion is gated on the lease — _never_ two
+ 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
+(`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`/
+`RoleFollower`, `IsLeader`/`OnPromote`/`OnDemote`), owning the leader lifecycle
+(acquire → replicate → renew at TTL/3 → step down on loss → release on `Close`),
+and **gitmote** consumes it: server boots `RoleAuto`, receive-pack is gated on
+`IsLeader()`. This is the split [safety.md §2](../architecture/safety.md)
+anticipated ("in-process now, shared coordination later").
+
+The cold-start subtlety the lease resolves: a lone instance waking on a push can't
+tell "I'm the only one, safe to write" from "I'm a new deploy while the old still
+writes" — identical from inside. Only the external lease answers "am I allowed to
+be the writer?".
+
+## The read-scaling half — still open
+
+`max-scale` stays **1**. The lease makes running more instances _safe_, but not
+yet _useful_ for reads: an s3lite follower serves a **restored snapshot and
+polls** — it is not a continuously-fresh replica, so it could serve stale refs to
+a clone. Scaling reads out needs follower freshness first (litestream `restore -f`
+follow mode, or VFS read replicas + `PRAGMA litestream_write_enabled` — the latter
+likely needs cgo, incompatible with s3lite's pure-Go `modernc` driver; verify).
+This is what the read-scaling [open question](../architecture/open-questions.md)
+names.
+
+**Trigger to build it:** reads get heavy enough to want replicas, or `max-scale`
+needs to exceed 1 for throughput. Until then, one leased writer at `max-scale = 1`
+is sufficient and simpler.
diff --git a/docs/ops.md b/docs/ops.md
index f857a12..5e417ab 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -28,36 +28,37 @@ replicates its metadata SQLite to S3 with litestream; two instances writing the
same WAL can corrupt the replica. This is stronger than a stateless service's
in-process-race concern: it is a data-integrity precondition.
-- **`max-scale = 1`** on the container, asserted on every deploy (the CI deploy
- step sets it; it is not left to a console default). Autoscaling above 1 opens
- the forbidden state. `min-scale = 0` (idle to zero) is fine — it only changes
- cost/latency, not the invariant; `max-scale = 1` is what guards *steady state*.
-- **Deploys are stop-first, because `max-scale = 1` does *not* cover the deploy
- swap.** Scaleway's rolling deploy briefly runs old + new together (observed:
- instance count spikes to 2 in Cockpit), and Scaleway won't let you scale to 0
- (`max-scale` min is 1), so it can't stop-first on its own. Instead the pipeline
- drains the running writer itself before swapping the image: `POST /admin/quit`
- (bearer `GITMOTE_DEPLOY_KEY`) self-SIGTERMs the instance → graceful flush + exit
- → the image swap then starts a fresh instance with no old one to overlap. The
- drain step **waits** rather than polls, because any request (a health poll
- included) would re-wake the instance.
-- **Residual gap (accepted):** if a request lands in the ~20 s drain→swap window
- it can re-wake the old image and reintroduce a brief overlap. Negligible for an
- operator-triggered deploy with no concurrent push. The *airtight* fix is a
- reader/writer split with a writer lease — deliberately deferred, written up in
- [reader-writer-split](evolution/reader-writer-split.md). And by the
- content-before-pointer ordering ([safety.md §3](architecture/safety.md)) even a
- hit window can only leak orphan objects (garbage `gc` reclaims), never a ref
- pointing at a missing object.
-
-Note: Scaleway Object Storage **now supports conditional writes** (`If-Match` /
-`If-None-Match`). gitmote's ref CAS is still a SQL transaction in the metadata DB
-(unchanged — it never needed an S3 precondition), but this unlocks two things on
-Scaleway itself: a real single-writer **lease** (litestream ships the primitive;
-the leader lifecycle is specced in s3lite `tasks/single-writer-lease.md`), which
-would make deploys safe by construction instead of via the stop-first drain; and
-the [safety.md §4](architecture/safety.md) escape hatch (refs behind a
-conditional-PUT CAS on object storage) is now possible here if ever wanted.
+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
+ 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.)
+- **`max-scale = 1`** still holds, re-asserted on every deploy. Not for the
+ writer invariant anymore (the lease covers that) but because a follower serves
+ a *restored snapshot* and polls — it is not a continuously-fresh read replica —
+ so scaling out for read throughput needs follower-freshness work first. Raising
+ it is the tracked follow-up ([reader-writer-split](evolution/reader-writer-split.md));
+ until then keep it at 1. `min-scale = 0` (idle to zero) is orthogonal — cost/latency only.
+- By the content-before-pointer ordering ([safety.md §3](architecture/safety.md)),
+ even a hypothetical overlap could only leak orphan objects (garbage `gc`
+ 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
+(unchanged — it never needed an S3 precondition).
## Runtime env vars (on the container)
@@ -68,11 +69,11 @@ pipeline's Scaleway API keys are separate GitHub Actions secrets (next section).
Setting these on the container does nothing for CI, and vice versa.
> ⚠️ **`update` replaces the entire `secret-environment-variables` map — it does
-> not merge.** Passing one secret wipes the rest (observed: setting only
-> `GITMOTE_DEPLOY_KEY` dropped the AWS keys, so litestream fell back to EC2 IMDS
-> and the server crash-looped on restore). **Always pass *all* secret env vars
-> together on every `update`.** Plain `environment-variables` are a separate map,
-> unaffected. The CI deploy's `update` sets no env vars, so it preserves both maps.
+> not merge.** Passing one secret wipes the rest (observed: setting a single
+> secret dropped the AWS keys, so litestream fell back to EC2 IMDS and the server
+> crash-looped on restore). **Always pass *all* secret env vars together on every
+> `update`.** Plain `environment-variables` are a separate map, unaffected. The CI
+> deploy's `update` sets no env vars, so it preserves both maps.
| Variable | Value / meaning |
|----------|-----------------|
@@ -85,7 +86,6 @@ Setting these on the container does nothing for CI, and vice versa.
| `GITMOTE_CACHE` | `/tmp/gitmote/cache` — ephemeral; materialized repos rebuild from S3 |
| `GITMOTE_SOCK` | `/tmp/gitmote/gitmote.sock` — pre-receive hook RPC socket |
| `GITMOTE_COOKIE_KEY` | secret — signs management-UI session cookies (enables `/ui`) |
-| `GITMOTE_DEPLOY_KEY` | secret — bearer token for `POST /admin/quit` (deploy stop-first drain); enables the route |
| `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 |
@@ -106,9 +106,11 @@ Scale: **`min-scale = 0`** (idle to zero). A remote pushed to occasionally is id
almost always, and even at 512 MB always-on exceeds Scaleway's free tier
(~2.6M GB-s/mo vs 400k), so scale-to-zero is the cheaper default. The cost is a few seconds of
cold start on the first request after idle (restore + materialize, the path above);
-scale-down is a graceful SIGTERM, so the shutdown `Sync` flushes the WAL — no lost
-writes. Set `min-scale = 1` only if instant first-push latency is worth the
-always-on cost. `max-scale` stays **1** regardless (single-writer invariant).
+scale-down is a graceful SIGTERM, so the shutdown `Close` durably flushes the WAL
+(and releases the writer lease) — no lost writes. Set `min-scale = 1` only if
+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 secrets (GitHub Actions)
@@ -132,7 +134,6 @@ login fails with `api_key … not_found`.
| `SCW_ORGANIZATION_ID` | Scaleway organization ID |
| `SCW_PROJECT_ID` | Scaleway project ID |
| `SCW_CONTAINER_ID` | the gitmote Serverless Container ID |
-| `GITMOTE_DEPLOY_KEY` | bearer token the deploy sends to `/admin/quit` — must equal the container's `GITMOTE_DEPLOY_KEY` env |
If a secret reads as empty despite being set, check: it's under **Secrets** not
**Variables**; it's a **Repository** secret (a job with no `environment:` can't see
@@ -174,13 +175,9 @@ scw container container create \
environment-variables.GITMOTE_SOCK=/tmp/gitmote/gitmote.sock \
environment-variables.AWS_REGION=fr-par \
secret-environment-variables.GITMOTE_COOKIE_KEY="$(openssl rand -base64 32)" \
- secret-environment-variables.GITMOTE_DEPLOY_KEY=<DEPLOY_KEY> \
secret-environment-variables.AWS_ACCESS_KEY_ID=<KEY> \
secret-environment-variables.AWS_SECRET_ACCESS_KEY=<SECRET>
-# <DEPLOY_KEY>: generate once (`openssl rand -base64 32`) and use the SAME value
-# here and as the GITMOTE_DEPLOY_KEY GitHub Actions secret — the deploy's drain
-# call authenticates against it. GITMOTE_COOKIE_KEY is container-only, so a fresh
-# random inline is fine for it.
+# 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
@@ -200,8 +197,10 @@ scw container domain list container-id=<CONTAINER_ID> # status pending → r
An empty instance has no admin/token. Bootstrap **from your machine against the
prod bucket** so you are transiently the single writer — do this *before* the
-container serves traffic (or while it is scaled to zero), never as a job while
-the server runs, or that is two writers.
+container serves traffic (or while it is scaled to zero). Bootstrap opens in
+`RoleWriter`: it **acquires the writer lease** and fails loudly if the server
+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 \
@@ -211,17 +210,18 @@ AWS_REGION=fr-par AWS_ACCESS_KEY_ID=<KEY> AWS_SECRET_ACCESS_KEY=<SECRET> \
gitmote bootstrap -handle atmin -repo atmin/gitmote -default-branch master
```
-It prints the access token once (save it) and `Sync`s the new state to
-`s3://gitmote/meta`; the server restores it on first start. Re-running is safe —
-it refuses to clobber an existing admin.
+It prints the access token once (save it) and durably flushes the new state to
+`s3://gitmote/meta` on `Close`; the server restores it on first start. Re-running
+is safe — it refuses to clobber an existing admin.
## Deploying
Automatic on every green push to `master` (`.github/workflows/ci.yml`, `deploy`
job): `ci` (lint/test/build) → `e2e` (local push/clone + litestream restore) →
-build+push the amd64 image → **drain the running writer** (`POST /admin/quit`,
-stop-first — see the single-writer section) → `scw container container update …
-min-scale=0 max-scale=1 --wait`.
+build+push the amd64 image → `scw container container update … min-scale=0
+max-scale=1 --wait`. No drain step: the writer lease makes the rolling deploy
+safe by construction (new boots as a follower, old releases on SIGTERM, new
+promotes — see the single-writer section).
```bash
git push origin master
diff --git a/internal/githttp/backend.go b/internal/githttp/backend.go
index edef194..a6b0378 100644
--- a/internal/githttp/backend.go
+++ b/internal/githttp/backend.go
@@ -41,6 +41,11 @@ type Config struct {
// Writer enables the push path. Nil serves the read path only; write
// endpoints then 404.
Writer *Writer
+ // IsWritable reports whether this instance may serve writes — it holds the
+ // litestream lease (see meta.Metadata.IsLeader). Nil means always writable
+ // (unreplicated / tests). A follower refuses receive-pack with a retryable
+ // 503; reads are unaffected.
+ IsWritable func() bool
// Logger is optional; nil discards.
Logger *slog.Logger
}
@@ -49,11 +54,12 @@ type Config struct {
// git URL suffix. Mount it at "/"; more specific routes (e.g. /healthz) still
// win under http.ServeMux.
type Handler struct {
- mz *repo.Materializer
- authz Authorizer
- writer *Writer
- gitPath string
- logger *slog.Logger
+ mz *repo.Materializer
+ authz Authorizer
+ writer *Writer
+ isWritable func() bool
+ gitPath string
+ logger *slog.Logger
}
// New returns a handler. It fails if the `git` executable is not on PATH, since
@@ -68,11 +74,12 @@ func New(cfg Config) (*Handler, error) {
logger = slog.New(slog.DiscardHandler)
}
return &Handler{
- mz: cfg.Materializer,
- authz: cfg.Authorizer,
- writer: cfg.Writer,
- gitPath: gitPath,
- logger: logger,
+ mz: cfg.Materializer,
+ authz: cfg.Authorizer,
+ writer: cfg.Writer,
+ isWritable: cfg.IsWritable,
+ gitPath: gitPath,
+ logger: logger,
}, nil
}
@@ -92,6 +99,15 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
+ // Only the lease-holding writer serves receive-pack; a read-only follower
+ // refuses pushes (safety.md §1) with a retryable 503 so the client can try
+ // again once the leader is up. This gates both the advertisement and the
+ // push POST. Reads (upload-pack) are served in either role.
+ if isReceive && h.isWritable != nil && !h.isWritable() {
+ w.Header().Set("Retry-After", "5")
+ http.Error(w, "gitmote: not the current writer, retry shortly", http.StatusServiceUnavailable)
+ return
+ }
if _, err := h.authz.Authorize(r, repoName, perm); err != nil {
h.writeAuthError(w, r, repoName, err)
diff --git a/internal/githttp/receive_test.go b/internal/githttp/receive_test.go
index 16dcdd5..c594f27 100644
--- a/internal/githttp/receive_test.go
+++ b/internal/githttp/receive_test.go
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "strings"
"sync"
"testing"
@@ -94,6 +95,93 @@ func newWriteServer(t *testing.T) (*httptest.Server, *meta.Metadata, store.Store
return srv, m, s, writer
}
+// newGatedServer is newWriteServer with a controllable writer-lease gate
+// (backend Config.IsWritable), to exercise a follower's push refusal.
+func newGatedServer(t *testing.T, isWritable func() bool) (*httptest.Server, *meta.Metadata, store.Store) {
+ t.Helper()
+ m := openMeta(t)
+ s := store.NewMem()
+ sockDir, err := os.MkdirTemp("", "gm")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(sockDir) })
+ writer, err := NewWriter(m, s, buildHook(t), filepath.Join(sockDir, "s.sock"), slog.New(slog.DiscardHandler))
+ if err != nil {
+ t.Fatalf("NewWriter: %v", err)
+ }
+ t.Cleanup(func() { _ = writer.Close() })
+ h, err := New(Config{
+ Materializer: repo.New(m, s, t.TempDir()),
+ Authorizer: auth.NewGuard(m),
+ Writer: writer,
+ IsWritable: isWritable,
+ Logger: slog.New(slog.DiscardHandler),
+ })
+ if err != nil {
+ t.Fatalf("githttp.New: %v", err)
+ }
+ srv := httptest.NewServer(h)
+ t.Cleanup(srv.Close)
+ return srv, m, s
+}
+
+// TestWriteGateFollowerRefusesPush exercises the writer-lease gate: a follower
+// (IsWritable false) refuses receive-pack while still serving reads; promoting
+// back to leader accepts the same push. Mirrors the deploy-handoff window where
+// a new instance is up read-only before it acquires the lease (safety.md §1).
+func TestWriteGateFollowerRefusesPush(t *testing.T) {
+ ctx := context.Background()
+ leader := true
+ srv, m, _ := newGatedServer(t, func() bool { return leader })
+
+ r, err := m.CreateRepo(ctx, repoName, "main")
+ if err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+ raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
+ remote := authedURL(srv.URL, raw) + "/" + repoName
+
+ // Golden path: the leader accepts the first push.
+ src := initCommit(t, "hello\n")
+ head1 := git(t, src, "rev-parse", "HEAD")
+ git(t, src, "push", remote, "main")
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head1 {
+ t.Fatalf("after leader push, server main = %q, want %q", got, head1)
+ }
+
+ // Become a follower: reads still serve, pushes are refused, ref unchanged.
+ leader = false
+ dst := filepath.Join(t.TempDir(), "clone")
+ git(t, "", "clone", remote, dst) // a read succeeds on a follower
+ if got := git(t, dst, "rev-parse", "HEAD"); got != head1 {
+ t.Errorf("follower clone HEAD = %q, want %q", got, head1)
+ }
+
+ if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello\nagain\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, src, "commit", "-am", "second")
+ head2 := git(t, src, "rev-parse", "HEAD")
+ out, err := tryGit(src, "push", remote, "main")
+ if err == nil {
+ t.Fatalf("follower push succeeded, want rejection\n%s", out)
+ }
+ if !strings.Contains(out, "503") {
+ t.Errorf("follower push error missing 503 signal:\n%s", out)
+ }
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head1 {
+ t.Errorf("follower push advanced ref to %q, want unchanged %q", got, head1)
+ }
+
+ // Promote back to leader: the same push now succeeds.
+ leader = true
+ git(t, src, "push", remote, "main")
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head2 {
+ t.Errorf("after promotion, server main = %q, want %q", got, head2)
+ }
+}
+
// tryGit runs a git command hermetically and returns its combined output and
// error without failing the test — for operations expected to be rejected.
func tryGit(dir string, args ...string) (string, error) {
diff --git a/internal/meta/meta.go b/internal/meta/meta.go
index ca5fad5..71bbc3f 100644
--- a/internal/meta/meta.go
+++ b/internal/meta/meta.go
@@ -39,6 +39,17 @@ type Config struct {
S3 s3lite.S3Config
// Logger receives litestream's log records; nil suppresses INFO.
Logger *slog.Logger
+ // Role selects single-writer coordination via the s3lite lease. The zero
+ // value (RoleOff) keeps the always-writer behaviour used by tests and local
+ // unreplicated dev; it only makes sense to set a leased role when BackupTo is
+ // a shared replica the instances coordinate on.
+ Role s3lite.Role
+ // LeaseTTL is the writer-lease duration for leased roles; zero uses s3lite's
+ // default (30s). The holder renews at TTL/3 and stops on any renew failure.
+ LeaseTTL time.Duration
+ // Owner labels the lease holder in the lock file for diagnostics; empty lets
+ // s3lite generate one (hostname:pid).
+ Owner string
}
// Metadata is the query layer over the s3lite-backed database.
@@ -57,6 +68,9 @@ func Open(ctx context.Context, cfg Config) (*Metadata, error) {
S3: cfg.S3,
Logger: cfg.Logger,
Migrations: []string{schemaSQL},
+ Role: cfg.Role,
+ LeaseTTL: cfg.LeaseTTL,
+ Owner: cfg.Owner,
})
if err != nil {
return nil, err
@@ -72,6 +86,23 @@ func (m *Metadata) Close() error { return m.db.Close() }
// of the default timeout, for wiring into a graceful-shutdown deadline.
func (m *Metadata) CloseContext(ctx context.Context) error { return m.db.CloseContext(ctx) }
+// IsLeader reports whether this instance currently holds the writer lease and
+// may serve writes. Always true under RoleOff (sole writer). Gate the write
+// path on it: a follower must refuse pushes (see docs/architecture/safety.md).
+func (m *Metadata) IsLeader() bool { return m.db.IsLeader() }
+
+// Generation returns the lease fencing token — monotonic, bumped on each
+// takeover; 0 when no lease is held.
+func (m *Metadata) Generation() int64 { return m.db.Generation() }
+
+// OnPromote registers a callback fired after this instance becomes the writer
+// (acquired the lease and started replicating).
+func (m *Metadata) OnPromote(fn func()) { m.db.OnPromote(fn) }
+
+// OnDemote registers a callback fired after this instance loses the lease and
+// stops writing.
+func (m *Metadata) OnDemote(fn func(error)) { m.db.OnDemote(fn) }
+
// now is the timestamp format for the TEXT *_at columns: RFC 3339, UTC.
func now() string { return time.Now().UTC().Format(time.RFC3339Nano) }
diff --git a/tasks/12-leased-writer.md b/tasks/12-leased-writer.md
deleted file mode 100644
index f74392a..0000000
--- a/tasks/12-leased-writer.md
+++ /dev/null
@@ -1,119 +0,0 @@
-# Leased writer — dissolve the deploy-overlap hazard with s3lite `RoleAuto`
-
-## Spec
-
-Make two-writers **impossible by construction** instead of avoided by procedure.
-[safety.md §1](../docs/architecture/safety.md) requires exactly one litestream
-writer, today enforced by `max-scale = 1` **plus** a stop-first deploy drain
-([ops.md](../docs/ops.md) — deploy). The drain closes the rolling-deploy overlap
-*in practice, not by proof* — a request racing the ~20 s drain→swap can re-wake
-the old image, and there is a brief write+read outage during handoff.
-
-s3lite v0.1.0 ships a lease (`Role`, `IsLeader`, `OnPromote`/`OnDemote`,
-`Generation`; reuses litestream's `s3.Leaser`, conditional-write CAS on the same
-Scaleway bucket). Booting the server in **`RoleAuto`** makes an instance a writer
-only while it *holds the lease* and a read-only follower otherwise, so a rolling
-deploy's old+new overlap can never be two writers — the new instance waits as a
-follower until the old releases on SIGTERM, then promotes. This is the
-[reader-writer-split](../docs/evolution/reader-writer-split.md) evolution note,
-now a concrete piece of work since the primitive exists.
-
-**Scope boundary:** this task adopts the lease at **`max-scale = 1`** to remove
-the drain hack and prove handoff. It does **not** raise `max-scale` for read
-scaling — s3lite followers serve the *restored snapshot* and poll (writes since
-the last sync are dropped, `lease.go`), so serving clones off many stale
-followers is a **separate** follow-up gated on s3lite follower freshness. Keep
-`max-scale = 1`; the lease's value here is safety + a cleaner deploy, not scale.
-
-## Current
-
-- `cmd/gitmote/main.go` opens metadata with `meta.Open(ctx, metaConfigFromEnv(…))`
- in two places (server `buildGitHandler`, and the `bootstrap` one-shot). No role
- is set → s3lite `RoleOff` → **every instance replicates unconditionally as a
- writer**. `metaConfigFromEnv` maps env → `meta.Config` (no role/lease fields).
-- `meta.Config` mirrors a subset of `s3lite.Config` (`LocalPath`, `RestoreFrom`,
- `BackupTo`, `S3`, `Logger`); `meta.Metadata` wraps `*s3lite.DB`. No leader
- concept is surfaced.
-- Push entry: `githttp.Handler.serveReceivePack` ([backend.go:122]) runs the
- receive-pack path; the actual write goes through `githttp.Writer`. Nothing
- checks "am I allowed to write?" — the single-instance pin is the only guard.
-- Deploy safety is external: `POST /admin/quit` (self-SIGTERM drain,
- `main.go` `adminQuitHandler` + the `deployKey` route) and the CI "Drain the
- running writer" step (curl + sleep 20) in `.github/workflows/ci.yml`, plus the
- `max-scale=1` pin re-asserted every deploy.
-
-## Change
-
-Land in **two non-breaking steps**; step 1 is safe even before the deploy is
-simplified (a lone instance in `RoleAuto` just always wins the lease = today's
-behaviour, now provably single-writer).
-
-### 1. Boot the server as a leased writer
-
-- **meta:** add `Role`, `LeaseTTL`, `Owner` pass-through to `meta.Config` →
- `s3lite.Config`, and surface leader state:
- `func (m *Metadata) IsLeader() bool`, `OnPromote(func())`,
- `OnDemote(func(error))`, `Generation() int64` — thin wrappers over `*s3lite.DB`.
-- **main:** set `Role: s3lite.RoleAuto` in `metaConfigFromEnv` for the **server**
- path only (bootstrap stays a deliberate writer — see step-3 note). Leave
- `LeaseTTL`/`Owner` at defaults (30 s TTL, generated owner) unless tuning shows
- otherwise.
-- **Constraint to honour:** s3lite swaps the underlying `*sql.DB` on
- promote/demote, so consumers **must route every query through `*s3lite.DB`**
- and never cache the embedded `*sql.DB` across a promotion (s3lite docs,
- `s3lite.go:124`). Verify `internal/meta/*.go` holds only `*s3lite.DB` and issues
- each query through it — it does today; keep it that way.
-
-### 2. Gate the write path on leadership
-
-- In `serveReceivePack` (or one guard the receive path funnels through), reject
- when `!md.IsLeader()`: return a git-legible error over the smart-HTTP sideband /
- an HTTP `503` so the client sees "not the writer, retry." Reads (`upload-pack`,
- UI reads, `/healthz`) stay served regardless — a follower clones fine (from its
- snapshot; acceptable during the brief handoff window at `max-scale=1`).
-- Optionally use `OnPromote`/`OnDemote` to log transitions and flip an
- "accepting writes" flag; `IsLeader()` at request time is the source of truth
- (no cached staleness).
-
-### 3. Retire the drain hack (only after 1–2 verified in prod)
-
-- Remove `POST /admin/quit` + `adminQuitHandler` + the `deployKey` plumbing from
- `main.go`; remove the "Drain the running writer" step and the `GITMOTE_DEPLOY_KEY`
- secret from `.github/workflows/ci.yml` and the env/secret tables in `ops.md`.
- The rolling deploy is now safe unaided: new boots as follower, old releases the
- lease on its graceful SIGTERM `Close` (s3lite releases last, after the final
- sync), new promotes.
-- **Bootstrap stays a writer:** keep it opening as a writer that *acquires the
- lease* (`RoleWriter`, or `RoleAuto` — pick and document), so it can never run
- concurrently with a live leader. It already runs while the server is scaled to
- zero; the lease makes that a guarantee, not an assumption. Note the cold-start
- latency: after a *hard* kill, the first writer must wait out the ≤30 s lease TTL
- before acquiring (a graceful exit releases immediately, so this only bites on
- crash).
-- Update [safety.md §1](../docs/architecture/safety.md) — replace "stop-first
- drain, closed in practice not by proof" with "leased single writer, safe by
- construction"; update the [reader-writer-split](../docs/evolution/reader-writer-split.md)
- note (promote from speculative to shipped for the writer half; read replicas
- remain the open follow-up). `max-scale` stays 1 with a one-line reason: read
- replicas need fresh followers first.
-
-## Verify
-
-- **Unit — write gate:** a follower (`IsLeader()==false`) rejects receive-pack
- with the chosen error; a leader serves it. Reads succeed in both roles.
-- **Integration — mutual exclusion & handoff (local, two instances):** two
- gitmote processes, same MinIO replica, both `RoleAuto` → exactly one accepts
- pushes; kill/gracefully-stop the leader → the follower promotes and the next
- push succeeds, with **no interval where both replicate** (assert via
- `Generation()` / lock owner). Mirror s3lite's own lease tests.
-- **e2e — deploy overlap (the point of the task):** simulate the rolling window
- — start instance A (leader, holds a push), start instance B against the same
- replica *before* stopping A; confirm B serves clones but refuses pushes while A
- leads; SIGTERM A; confirm B promotes and accepts the push, and the metadata
- replica is uncorrupted (fsck clean on a fresh restore, as `e2e-restore.sh`).
-- **Regression:** `make e2e-local` + `make e2e-restore` still green (cold-start
- restore + lossless graceful stop unchanged); `go test ./...` green.
-- **Prod smoke:** after deploying step 1–2, one clean rolling deploy shows the
- new instance promote in logs (`OnPromote`) with the old having released; a push
- during the handoff either succeeds (post-promote) or gets the retryable 503
- (never a corrupt replica).
diff --git a/tasks/README.md b/tasks/README.md
index c2f67d5..f8027a5 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -6,12 +6,8 @@ lands** (commits are the record — this list never becomes a changelog).
Ordered by dependency; each task lands in a non-breaking, tested state.
-The **gitmote can host itself** milestone is reached: it runs on Scaleway at
-`gitmote.atmin.net`, deploys itself on every green master push, and survives cold
-starts (litestream restore). Speculative directions live in
-[../docs/evolution/](../docs/evolution/).
-
-- [12-leased-writer.md](12-leased-writer.md) — adopt s3lite `RoleAuto` so the
- writer is a lease-holder: two-writers becomes impossible by construction,
- retiring the stop-first deploy drain. (`max-scale=1` stays; read replicas are a
- later follow-up gated on follower freshness.)
+_No active tasks._ The **gitmote can host itself** milestone is reached: it runs
+on Scaleway at `gitmote.atmin.net`, deploys itself on every green master push,
+survives cold 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/).