diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..296e95b
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,5 @@
+# The image builds from Go sources only; keep the build context small.
+.git/
+bin/
+docs/
+tasks/
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 752bbe1..1444ce4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,6 +4,7 @@ on:
push:
branches: [master]
pull_request:
+ workflow_dispatch:
jobs:
ci:
@@ -14,3 +15,15 @@ jobs:
with:
go-version-file: go.mod
- run: make all
+
+ # Self-hosting proof: push/clone the real repo against a gitmote container
+ # backed by MinIO (scripts/e2e-local.sh). Gated — it builds an image and runs
+ # Docker, so it stays off the fast per-PR path and runs on master / on demand.
+ e2e:
+ if: github.ref == 'refs/heads/master' || github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # push needs the full history, not a shallow clone
+ - run: make e2e-local
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..2392437
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,26 @@
+# Build a static gitmote (+ pre-receive hook) and run it beside stock git.
+# modernc.org/sqlite is pure Go, so CGO_ENABLED=0 yields a static binary and the
+# runtime image needs only git.
+
+# syntax=docker/dockerfile:1
+FROM golang:1.26-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+ARG VERSION=docker
+RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o /out/gitmote ./cmd/gitmote \
+ && CGO_ENABLED=0 go build -o /out/gitmote-hook ./cmd/gitmote-hook
+
+# Debian's git package includes git-http-backend (Alpine's splits it out); the
+# whole design delegates to `git http-backend`, so it must be present.
+FROM debian:bookworm-slim
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends git ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+# 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
+EXPOSE 8080
+ENTRYPOINT ["gitmote"]
diff --git a/Makefile b/Makefile
index 1a13dc4..bc85fcb 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ GOIMPORTS := go run golang.org/x/tools/cmd/goimports@v0.47.0
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
-.PHONY: all fmt lint test build
+.PHONY: all fmt lint test build e2e-local
all: lint test build
@@ -22,3 +22,8 @@ test:
build:
go build -ldflags "-X main.version=$(VERSION)" -o bin/gitmote ./cmd/gitmote
+
+# End-to-end: gitmote hosts itself against MinIO in Docker (CONTRIBUTING.md —
+# integration tests drive real git). Requires docker + docker compose.
+e2e-local:
+ ./scripts/e2e-local.sh
diff --git a/README.md b/README.md
index 20631f6..7865f03 100644
--- a/README.md
+++ b/README.md
@@ -12,8 +12,17 @@ backed to S3) — so the only genuinely hard problem, atomic ref updates, become
single SQL transaction.
**Status:** early implementation — git read/write over smart-HTTP works
-(clone/fetch/push with token auth and per-repo ACLs); the self-hosting milestone
-is in progress. The design lives in [docs/architecture/](docs/architecture/).
+(clone/fetch/push with token auth and per-repo ACLs), and gitmote can host its
+own source repo end-to-end against MinIO (`make e2e-local`). The design lives in
+[docs/architecture/](docs/architecture/).
+
+## Run it locally
+
+`make e2e-local` brings up a gitmote container plus MinIO with
+[docker-compose.yml](docker-compose.yml), bootstraps an admin/token/repo, then
+pushes this working tree, clones it back, and — after force-recreating the
+container — clones again to prove the repo survives on the object store and
+persisted refs (not local disk). Requires Docker + Docker Compose.
## Bootstrap
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..58b481b
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,54 @@
+# 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.
+name: gitmote-e2e
+
+services:
+ minio:
+ image: minio/minio
+ command: server /data --console-address :9001
+ environment:
+ MINIO_ROOT_USER: minioadmin
+ MINIO_ROOT_PASSWORD: minioadmin
+ # No host port: only the compose network (gitmote, mc) reaches MinIO, so we
+ # avoid colliding with anything already bound to :9000 on the host.
+ volumes:
+ - minio-data:/data
+
+ # One-shot bucket creation; not started by `up` (setup profile), invoked with
+ # `docker compose run --rm mc`. MC_HOST_local configures the alias via env, so
+ # no interactive `mc alias set` is needed; it retries until MinIO is reachable.
+ mc:
+ image: minio/mc
+ profiles: ["setup"]
+ depends_on: [minio]
+ environment:
+ MC_HOST_local: http://minioadmin:minioadmin@minio:9000
+ entrypoint: ["/bin/sh", "-c"]
+ command:
+ - "until mc ls local >/dev/null 2>&1; do echo 'waiting for minio'; sleep 1; done; mc mb --ignore-existing local/gitmote"
+
+ gitmote:
+ build:
+ context: .
+ 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
+ AWS_REGION: us-east-1
+ AWS_ACCESS_KEY_ID: minioadmin
+ AWS_SECRET_ACCESS_KEY: minioadmin
+ ports:
+ - "8080:8080"
+ volumes:
+ - gitmote-data:/data
+
+volumes:
+ minio-data:
+ gitmote-data:
diff --git a/scripts/e2e-local.sh b/scripts/e2e-local.sh
new file mode 100755
index 0000000..11592b2
--- /dev/null
+++ b/scripts/e2e-local.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# End-to-end proof that gitmote hosts itself: bring up MinIO + a gitmote
+# container, bootstrap an admin/token/repo, push this working tree to it, clone
+# it back, and assert the clone is byte-for-byte the pushed history. Then
+# force-recreate the container (wiping the ephemeral cache) and clone again to
+# prove durability comes from the object store + persisted refs, not local disk.
+set -euo pipefail
+
+DC="docker compose"
+ROOT="$(git rev-parse --show-toplevel)"
+# Fall back to a name when HEAD is detached (CI checkouts are), since the remote
+# branch we host under is our own choice anyway.
+BRANCH="$(git -C "$ROOT" symbolic-ref --quiet --short HEAD || echo master)"
+PUSHED="$(git -C "$ROOT" rev-parse HEAD)"
+
+# Fail fast instead of prompting; ignore any host credential helper.
+export GIT_TERMINAL_PROMPT=0
+GITC=(git -c credential.helper= -c http.postBuffer=524288000)
+
+CLONES=()
+cleanup() {
+ local code=$?
+ echo "--- tearing down ---"
+ ( cd "$ROOT" && $DC down -v --remove-orphans ) || true
+ for d in "${CLONES[@]:-}"; do [ -n "$d" ] && rm -rf "$d"; done
+ exit $code
+}
+trap cleanup EXIT
+
+fail() { echo "E2E FAIL: $*" >&2; exit 1; }
+
+wait_healthz() {
+ echo "--- waiting for gitmote /healthz ---"
+ for _ in $(seq 1 60); do
+ if curl -fsS http://localhost:8080/healthz >/dev/null 2>&1; then return 0; fi
+ sleep 1
+ done
+ ( cd "$ROOT" && $DC logs gitmote ) || true
+ fail "gitmote did not become healthy"
+}
+
+# assert_clone <label>: clone the repo fresh and check HEAD, fsck, and tree.
+assert_clone() {
+ local label="$1" clone
+ clone="$(mktemp -d)"
+ CLONES+=("$clone")
+ echo "--- clone ($label) ---"
+ "${GITC[@]}" clone "$REPO_URL" "$clone" || fail "$label: clone failed"
+
+ local cloned
+ cloned="$(git -C "$clone" rev-parse HEAD)"
+ [ "$cloned" = "$PUSHED" ] || fail "$label: HEAD $cloned != pushed $PUSHED"
+
+ git -C "$clone" fsck --full --strict || fail "$label: fsck not clean"
+
+ diff <(git -C "$ROOT" ls-tree -r "$PUSHED") <(git -C "$clone" ls-tree -r HEAD) \
+ || fail "$label: file tree differs from pushed commit"
+ echo "--- clone ($label) OK: HEAD=$cloned, fsck clean, tree identical ---"
+}
+
+cd "$ROOT"
+
+echo "--- building gitmote image ---"
+$DC build gitmote
+
+echo "--- starting minio + creating bucket ---"
+$DC up -d minio
+$DC run --rm mc
+
+echo "--- bootstrap (one-shot, writes admin/token/repo to the metadata volume) ---"
+BOOT="$($DC run --rm -T gitmote bootstrap -handle atmin -repo atmin/gitmote -default-branch "$BRANCH")"
+TOKEN="$(printf '%s\n' "$BOOT" | grep -oE 'gmt_[0-9a-f]+\.[0-9a-f]+' | head -1)"
+[ -n "$TOKEN" ] || { printf '%s\n' "$BOOT"; fail "no token in bootstrap output"; }
+
+REPO_URL="http://atmin:${TOKEN}@localhost:8080/atmin/gitmote"
+
+echo "--- starting gitmote server ---"
+$DC up -d gitmote
+wait_healthz
+
+echo "--- push this working tree ($BRANCH @ $PUSHED) ---"
+"${GITC[@]}" -C "$ROOT" push "$REPO_URL" "HEAD:refs/heads/${BRANCH}" || fail "push failed"
+
+assert_clone "initial"
+
+echo "--- force-recreate container (wipes ephemeral cache; refs+objects persist) ---"
+$DC up -d --force-recreate --no-deps gitmote
+wait_healthz
+assert_clone "after-restart"
+
+echo "E2E PASS: gitmote hosted itself — push, clone, and restart-durability all green."
diff --git a/tasks/10-self-host-local.md b/tasks/10-self-host-local.md
deleted file mode 100644
index 8eed29d..0000000
--- a/tasks/10-self-host-local.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# 10 — Self-host, locally (MinIO + Docker)
-
-Depends on: 01–09.
-
-## Spec
-
-Prove gitmote hosts itself in a local, reproducible environment: the real
-gitmote source repo pushed to, and cloned from, a running gitmote container
-backed by MinIO. Exercises both request flows and the safety model end-to-end.
-
-## Current
-
-Components pass their own tests, but nothing runs them together against real
-storage as one system.
-
-## Change
-
-- `docker-compose.yml` — MinIO + gitmote (single writer), seeded bucket.
-- `Dockerfile` — build the static binary; stock `git` present in the image.
-- `make e2e-local` — bring up the stack, run `bootstrap`, create the `gitmote`
- repo, `git push` this working tree to it, `git clone` it into a temp dir, and
- assert the clone matches (same `HEAD`, `git fsck` clean, identical file tree).
-
-## Verify
-
-- `make e2e-local` is green locally and in CI (a tagged / gated job).
-- Kill + restart the container mid-life: after restart the repo is still fully
- clonable (durability via s3lite / litestream).
-- Non-breaking: adds an e2e target; unit gates unchanged.
diff --git a/tasks/README.md b/tasks/README.md
index b7ef4d0..8498a18 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -6,7 +6,7 @@ lands** (commits are the record — this list never becomes a changelog).
Ordered by dependency; each task lands in a non-breaking, tested state.
-- [10-self-host-local.md](10-self-host-local.md) — MinIO + Docker; e2e clone+push of the gitmote repo.
- [11-deploy.md](11-deploy.md) — Real object storage + single-writer deploy; hosts itself for real.
-Milestone is done when **10** and **11** are green: the gitmote repo lives on gitmote.
+Milestone is done when **11** is green: the gitmote repo lives on gitmote for real
+(local self-hosting via `make e2e-local` already passes).