gitmote

gitmote  ·  history  ·  branches

feat: self-deploy via the local runner — env-seeded CI secrets

9cb75a2022431f13a6e637d9ef7fbd2e51a443e4
Atanas Minev <atmin@protonmail.com> · 2026-07-18 09:49 · CI passed

A local instance can now build and deploy gitmote to gitmote.atmin.net as a
break-glass fallback (`make self-deploy`): it seeds the deploy secrets from a
gitignored .env via GITMOTE_REPO_SECRET_<REPO>__<NAME> — a second secret source
that's never stored and needs no master key — and pushes HEAD to `self-deploy`,
which triggers .gitmote/workflows/deploy.yml. GitHub Actions stays the everyday
deployer on master; the branch confinement keeps the two from double-deploying.
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..f451f05
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,18 @@
+# Break-glass self-deploy secrets (`make self-deploy`). Copy to .env (gitignored)
+# and fill in. These are injected into the deploy workflow as ${{ secrets.NAME }},
+# seeded per-repo for the `gitmote` repo via the GITMOTE_REPO_SECRET_<REPO>__<NAME>
+# convention (repo uppercased: gitmote → GITMOTE). Never stored, never encrypted —
+# the host env is the trust boundary (docs/architecture/safety.md §7).
+
+# Scaleway API credentials that deploy the container (the same five the GitHub
+# pipeline uses). The access key starts with SCW…; the secret key is a UUID.
+GITMOTE_REPO_SECRET_GITMOTE__SCW_SECRET_KEY=
+GITMOTE_REPO_SECRET_GITMOTE__SCW_ACCESS_KEY=
+GITMOTE_REPO_SECRET_GITMOTE__SCW_ORGANIZATION_ID=
+GITMOTE_REPO_SECRET_GITMOTE__SCW_PROJECT_ID=
+GITMOTE_REPO_SECRET_GITMOTE__SCW_CONTAINER_ID=
+
+# GHCR push credentials — a PAT with write:packages (GitHub uses its built-in
+# GITHUB_TOKEN, which doesn't exist off GitHub, so the local path needs a PAT).
+GITMOTE_REPO_SECRET_GITMOTE__GHCR_USER=
+GITMOTE_REPO_SECRET_GITMOTE__GHCR_TOKEN=
diff --git a/.gitignore b/.gitignore
index b5109ce..4385fb4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
bin/
data/
+data-selfdeploy/
+.env
diff --git a/.gitmote/workflows/deploy.yml b/.gitmote/workflows/deploy.yml
new file mode 100644
index 0000000..09578d5
--- /dev/null
+++ b/.gitmote/workflows/deploy.yml
@@ -0,0 +1,81 @@
+name: self-deploy
+
+# Break-glass self-deploy: gitmote building and deploying itself through its own
+# local runner, when the GitHub deployer is unavailable or an out-of-band deploy
+# is wanted. GitHub Actions stays the everyday deployer on `master`
+# (.github/workflows/ci.yml); this path runs only when the `self-deploy` branch is
+# pushed to a *local* gitmote instance — the only substrate that can build (the
+# Scaleway Serverless Job can't; docs/architecture/ci.md). That instance needs
+# GITMOTE_CI_ALLOW_BUILDS=1, a running Docker daemon, and the CI secrets below.
+#
+# Near-identical to ci.yml's `deploy` job — three deliberate differences:
+# 1. gated on `self-deploy`, not `master`, so it never overlaps the GitHub path;
+# 2. GHCR auth uses a PAT (GHCR_USER/GHCR_TOKEN) — there's no built-in
+# GITHUB_TOKEN off GitHub;
+# 3. builds linux/amd64 only — Scaleway needs amd64, and the arm64 tag GitHub
+# also pushes is just for local dev, irrelevant to a deploy.
+
+on:
+ push:
+ branches: [self-deploy]
+ workflow_dispatch:
+
+jobs:
+ deploy:
+ # Belt-and-braces gate: even if the engine runs this file on another branch,
+ # github.ref (resolved from the runner's `checkout -B <branch>`) keeps the
+ # deploy confined to `self-deploy`. Mirrors ci.yml's job-level `if`.
+ if: github.ref == 'refs/heads/self-deploy'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-qemu-action@v3 # amd64 build on an arm64 dev host runs under emulation
+ - uses: docker/setup-buildx-action@v3
+
+ - name: Login to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ secrets.GHCR_USER }}
+ password: ${{ secrets.GHCR_TOKEN }}
+
+ # Tag with the commit SHA (deployed below) and a moving `self-deploy` tag.
+ # Deliberately NOT `:master` — that tag belongs to the GitHub deployer; a
+ # break-glass build must never clobber it.
+ - name: Build & push public image (amd64 for Scaleway)
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: linux/amd64
+ push: true
+ build-args: |
+ VERSION=${{ github.sha }}
+ tags: |
+ ghcr.io/atmin/gitmote:self-deploy
+ ghcr.io/atmin/gitmote:${{ github.sha }}
+
+ - name: Install Scaleway CLI
+ run: |
+ SCW_URL=$(curl -s https://api.github.com/repos/scaleway/scaleway-cli/releases/latest \
+ | grep -o '"browser_download_url": *"[^"]*linux_amd64"' | cut -d'"' -f4)
+ sudo curl -fsSL "$SCW_URL" -o /usr/local/bin/scw
+ sudo chmod +x /usr/local/bin/scw
+
+ # Identical to ci.yml's deploy: point the container at the unique SHA-tagged
+ # image and re-assert the scale pins. The writer lease makes the rolling swap
+ # safe by construction (new boots as follower, old releases on SIGTERM).
+ #
+ # ⚠️ Pass NO secret-environment-variables: `update` REPLACES the whole map, so
+ # setting any here would wipe prod's AWS/litestream keys (docs/ops.md). The
+ # env-less update preserves it.
+ - name: Deploy (single writer)
+ env:
+ SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
+ SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }}
+ SCW_DEFAULT_ORGANIZATION_ID: ${{ secrets.SCW_ORGANIZATION_ID }}
+ SCW_DEFAULT_PROJECT_ID: ${{ secrets.SCW_PROJECT_ID }}
+ SCW_DEFAULT_REGION: fr-par
+ run: |
+ scw container container update ${{ secrets.SCW_CONTAINER_ID }} \
+ image=ghcr.io/atmin/gitmote:${{ github.sha }} \
+ min-scale=0 max-scale=1 --wait
diff --git a/Makefile b/Makefile
index 1e773e6..8815862 100644
--- a/Makefile
+++ b/Makefile
@@ -22,7 +22,7 @@ PORT ?= 8080
# joins this project's network so the container reaches MinIO at minio:9000.
DEV_COMPOSE := docker compose -p gitmote-dev -f docker-compose.dev.yml
-.PHONY: all fmt lint test build dev dev-reset e2e-local e2e-restore e2e-build image prod publish
+.PHONY: all fmt lint test build dev dev-reset self-deploy self-deploy-reset e2e-local e2e-restore e2e-build image prod publish
all: lint test build
@@ -53,6 +53,20 @@ dev-reset:
docker compose -p gitmote-dev -f docker-compose.dev.yml down -v 2>/dev/null || true
rm -rf data
+# Break-glass self-deploy: build+deploy gitmote to gitmote.atmin.net through its
+# own local runner, secrets seeded from a gitignored .env (no Secrets UI). Runs a
+# throwaway instance on :8081 with its own MinIO (:9200) and pushes HEAD to the
+# `self-deploy` branch. GitHub Actions stays the everyday deployer. Requires act +
+# docker; the deploy workflow needs GITMOTE_CI_ALLOW_BUILDS (the script sets it).
+# See docs/ops.md. Copy .env.example → .env and fill it in first.
+self-deploy: build
+ ./scripts/self-deploy.sh
+
+# Wipe the throwaway self-deploy state (its MinIO volume and data dir).
+self-deploy-reset:
+ docker compose -p gitmote-selfdeploy -f docker-compose.selfdeploy.yml down -v 2>/dev/null || true
+ rm -rf data-selfdeploy
+
# End-to-end: gitmote hosts itself against MinIO in Docker (CONTRIBUTING.md —
# integration tests drive real git). Requires docker + docker compose.
e2e-local:
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index a43d347..e617478 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -37,9 +37,18 @@ 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"
+// defaultListenAddr is the bind address in the container, where ports are mapped
+// externally. GITMOTE_LISTEN_ADDR overrides it — for running a second native
+// instance beside `make dev` (e.g. the break-glass self-deploy on :8081).
+const defaultListenAddr = ":8080"
+
+// listenAddrFromEnv returns GITMOTE_LISTEN_ADDR when set, else defaultListenAddr.
+func listenAddrFromEnv() string {
+ if v := os.Getenv("GITMOTE_LISTEN_ADDR"); v != "" {
+ return v
+ }
+ return defaultListenAddr
+}
const (
// reconcileInterval is how often the leader sweeps abandoned CI jobs.
@@ -63,7 +72,7 @@ func main() {
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
- if err := run(context.Background(), logger, listenAddr); err != nil {
+ if err := run(context.Background(), logger, listenAddrFromEnv()); err != nil {
logger.Error("server failed", "error", err)
os.Exit(1)
}
@@ -420,12 +429,20 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
// A successful, branch-advancing push discovers its workflows, records a run,
// and triggers a runner per job — fire-and-forget, so dispatch never fails the
// push (tasks/16-ci.md).
+ // Operator-supplied per-repo CI secrets from the process env
+ // (GITMOTE_REPO_SECRET_<REPO>__<NAME>): never stored, no master key needed —
+ // the automated self-deploy seeds them from a gitignored .env (docs/ops.md).
+ envSecrets := ci.LoadEnvSecrets(os.Environ())
+ if red := envSecrets.Redacted(); len(red) > 0 {
+ logger.Info("env CI secrets loaded (never persisted)", "repos", red)
+ }
dispatcher := ci.NewDispatcher(ci.Config{
Runs: md,
Materializer: materializer,
Trigger: trigger,
Minter: guard,
Secrets: secretsSvc,
+ EnvSecrets: envSecrets,
BaseURL: gitmoteURL,
WorkerSecret: workerSecret,
Logger: logger,
diff --git a/docker-compose.selfdeploy.yml b/docker-compose.selfdeploy.yml
new file mode 100644
index 0000000..427814e
--- /dev/null
+++ b/docker-compose.selfdeploy.yml
@@ -0,0 +1,33 @@
+# Self-deploy stack: MinIO only, reachable from a natively-run `bin/gitmote`.
+# Driven by `make self-deploy` (scripts/self-deploy.sh). A sibling of
+# docker-compose.dev.yml with a distinct project name, ports (9200/9201, not
+# 9100/9101), and volume, so a break-glass self-deploy can run without touching —
+# or colliding with — a live `make dev`.
+name: gitmote-selfdeploy
+
+services:
+ minio:
+ image: minio/minio
+ command: server /data --console-address :9201
+ environment:
+ MINIO_ROOT_USER: minioadmin
+ MINIO_ROOT_PASSWORD: minioadmin
+ ports:
+ - "9200:9000" # S3 API → GITMOTE_S3_ENDPOINT=http://localhost:9200
+ - "9201:9201" # console → http://localhost:9201 (minioadmin/minioadmin)
+ volumes:
+ - minio-data:/data
+
+ # One-shot bucket creation; invoked with `docker compose run --rm mc` (see dev).
+ 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"
+
+volumes:
+ minio-data:
diff --git a/docs/architecture/ci.md b/docs/architecture/ci.md
index 64eeaf6..2955709 100644
--- a/docs/architecture/ci.md
+++ b/docs/architecture/ci.md
@@ -98,6 +98,15 @@ reads the value from its env — so the value never touches the `act` argv and t
workflow sees `${{ secrets.NAME }}` as on GitHub. Values are write-only in the UI
(only names shown) and never logged.
+**Env-seeded secrets (a second source).** Beside the encrypted UI/DB secrets, the
+dispatcher overlays any per-repo secrets supplied in the server's own environment
+as `GITMOTE_REPO_SECRET_<REPO>__<NAME>` (repo uppercased, `-`→`_`). These are
+plain operator config — never stored, never encrypted, and needing **no master
+key** — so they're invisible to the UI and win over a same-named DB secret. The
+host env is already the CI trust boundary ([safety.md §7](safety.md)), so this
+adds no exposure; it exists so the automated self-deploy (`make self-deploy`) can
+carry its Scaleway/GHCR secrets in a gitignored `.env` instead of the UI.
+
## Clone auth
The runner clones over ordinary git-HTTPS with a **per-run, read-only,
@@ -132,8 +141,12 @@ Image builds are **substrate-dependent**, and off by default:
gitmote builds container images — including its own — locally or on a VPS.
Runtime is selected by `DOCKER_HOST`: Docker works out of the box; podman needs
its API socket service running (`podman system service`) and `DOCKER_HOST`
- pointed at it — a truly daemonless podman can't back `act` at all. See
- [ops.md](../ops.md).
+ pointed at it — a truly daemonless podman can't back `act` at all. When builds
+ are on, gitmote mounts the **daemon-side** socket path (`/var/run/docker.sock`,
+ override `GITMOTE_ACT_DAEMON_SOCKET`) rather than let `act` derive the mount from
+ `DOCKER_HOST` — for a **VM-backed daemon** (colima, Docker Desktop on macOS)
+ `DOCKER_HOST` is a host path the in-VM daemon can't resolve, so the socket mount
+ would fail. See [ops.md](../ops.md).
> **No daemon → every run fails visibly.** `act` needs a reachable Docker/podman
> daemon to spawn job containers. On a host without one each CI job is still
@@ -144,16 +157,27 @@ Image builds are **substrate-dependent**, and off by default:
## Limitations
-- **gitmote does not deploy itself (on Scaleway).** The self-deploy loop (a green
- `master` run rebuilding gitmote's own image in-Job) was evaluated and **dropped**
- for the cloud deployment — the Serverless Job can't build (above). A VPS with
- `GITMOTE_CI_ALLOW_BUILDS` on *can* rebuild the image, but wiring build → push →
- redeploy (latest-wins guard, restart) is a deferred follow-up, not yet built.
+- **gitmote does not deploy itself on Scaleway; it *can* locally (break-glass).**
+ The cloud self-deploy loop (a green `master` run rebuilding gitmote's own image
+ in-Job) was evaluated and **dropped** — the Serverless Job can't build (above).
Production deployment stays on **GitHub Actions**
([`.github/workflows/ci.yml`](../../.github/workflows/ci.yml)): build+push the
public image to GHCR (`ghcr.io/atmin/gitmote`), then `scw container update` to
pull it; the leased writer keeps the swap safe (see [ops.md](../ops.md)). The
- GitHub mirror is deployer and break-glass both.
+ GitHub mirror is the everyday deployer.
+
+ A **local** instance, though, is a real builder, so gitmote now closes the loop
+ as a **break-glass fallback**, automated by `make self-deploy`: it runs a
+ throwaway instance with `GITMOTE_CI_ALLOW_BUILDS` on, seeds the deploy secrets
+ from a gitignored `.env` (per-repo, via `GITMOTE_REPO_SECRET_<REPO>__<NAME>` —
+ never stored, no master key needed), and pushes `HEAD` to `self-deploy`. That
+ triggers [`.gitmote/workflows/deploy.yml`](../../.gitmote/workflows/deploy.yml) —
+ the GitHub deploy job, gated on `self-deploy` instead of `master` — to build the
+ amd64 image, push it to GHCR, and run the same `scw container update`. Confined to
+ the `self-deploy` branch, it never overlaps the GitHub path (no double-deploy),
+ and the same writer lease makes its rolling swap safe. It is a fallback for when
+ the GitHub deployer is unavailable, not the primary route. See
+ [ops.md](../ops.md).
- **The runner image is built by CI**, not by hand: a change to
[`Dockerfile.runner`](../../Dockerfile.runner) or the runner source on `master`
(or a release) publishes `ghcr.io/atmin/gitmote-runner` publicly via
diff --git a/docs/ops.md b/docs/ops.md
index 1edfedb..6f4da97 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -321,6 +321,39 @@ a release) via `.github/workflows/publish-runner.yml` →
git push origin master
```
+**Break-glass: self-deploy from a local instance (`make self-deploy`).** When
+GitHub is unavailable (or an out-of-band deploy is wanted), a local gitmote
+instance builds and deploys itself via
+[`.gitmote/workflows/deploy.yml`](../.gitmote/workflows/deploy.yml) — the GitHub
+deploy job gated on the `self-deploy` branch instead of `master`. It runs only on a
+local instance (Scaleway can't build).
+
+`make self-deploy` automates it end to end: copy [`.env.example`](../.env.example)
+to `.env` (gitignored) and fill in the deploy secrets, then run it. The target
+starts a throwaway instance on `:8081` with its own MinIO (`:9200`, separate from
+`make dev`), `GITMOTE_CI_ALLOW_BUILDS=1`, and the `.env` secrets seeded per-repo via
+`GITMOTE_REPO_SECRET_GITMOTE__<NAME>` — then pushes `HEAD` to `self-deploy`, which
+triggers the workflow. These env secrets are **never stored and need no
+`GITMOTE_CI_SECRET_KEY`** (that key only guards the UI/DB secrets path): the host
+env is the trust boundary ([safety.md §7](architecture/safety.md)). Requires `act`
+and a running Docker daemon — the script resolves the socket into `DOCKER_HOST`
+from the active docker context (so colima and Docker Desktop both work) and fails
+with setup steps if none is up. On Apple Silicon, `colima start --vm-type vz
+--vz-rosetta` gives Rosetta-accelerated `linux/amd64` builds.
+
+The secrets to put in `.env` are the same Scaleway five the GitHub pipeline uses
+(below) **plus** `GHCR_USER`/`GHCR_TOKEN` (a GHCR PAT with `write:packages` — there
+is no built-in `GITHUB_TOKEN` off GitHub). The build is `linux/amd64` only
+(Scaleway's arch; emulated on an arm64 dev host, so the first build is slow); it
+tags `:self-deploy` + `:<sha>` and deploys the SHA tag — never `:master`, which the
+GitHub deployer owns. Confined to `self-deploy`, it never double-deploys with the
+`master` path, and the writer lease keeps its rolling swap safe exactly as the
+GitHub deploy's. The instance is disposable — `make self-deploy` resets its
+throwaway state (the `data-selfdeploy/` dir and its own MinIO volume) at the start
+of every run, so an interrupted prior run can't leave stale litestream state drifted
+from the bucket; the previous run's state survives only until the next run.
+`make self-deploy-reset` wipes it on demand.
+
## Verifying it hosts itself
```bash
diff --git a/internal/ci/dispatcher.go b/internal/ci/dispatcher.go
index 15169f3..b8cde23 100644
--- a/internal/ci/dispatcher.go
+++ b/internal/ci/dispatcher.go
@@ -96,8 +96,12 @@ type Config struct {
Trigger Trigger
Minter Minter // mints the per-run clone token; nil leaves it empty
Secrets Secrets // decrypts per-repo CI secrets to inject; nil injects none
- BaseURL string // GITMOTE_URL
- WorkerSecret string // WORKER_SECRET
+ // EnvSecrets are operator-supplied per-repo CI secrets read from the process
+ // env (GITMOTE_REPO_SECRET_<REPO>__<NAME>); overlaid on and winning over the
+ // DB secrets. Empty is the common case (no env seeding).
+ EnvSecrets EnvSecrets
+ BaseURL string // GITMOTE_URL
+ WorkerSecret string // WORKER_SECRET
Logger *slog.Logger
}
@@ -105,15 +109,16 @@ type Config struct {
// Only the leader constructs and calls it (it is the only instance that
// processes receive-pack).
type Dispatcher struct {
- runs Runs
- mz Materializer
- trigger Trigger
- minter Minter
- secrets Secrets
- baseURL string
- secret string
- logger *slog.Logger
- now func() time.Time // injectable clock for the clone-token expiry
+ runs Runs
+ mz Materializer
+ trigger Trigger
+ minter Minter
+ secrets Secrets
+ envSecrets EnvSecrets
+ baseURL string
+ secret string
+ logger *slog.Logger
+ now func() time.Time // injectable clock for the clone-token expiry
}
// NewDispatcher returns a Dispatcher from cfg.
@@ -123,15 +128,16 @@ func NewDispatcher(cfg Config) *Dispatcher {
logger = slog.New(slog.DiscardHandler)
}
return &Dispatcher{
- runs: cfg.Runs,
- mz: cfg.Materializer,
- trigger: cfg.Trigger,
- minter: cfg.Minter,
- secrets: cfg.Secrets,
- baseURL: cfg.BaseURL,
- secret: cfg.WorkerSecret,
- logger: logger,
- now: time.Now,
+ runs: cfg.Runs,
+ mz: cfg.Materializer,
+ trigger: cfg.Trigger,
+ minter: cfg.Minter,
+ secrets: cfg.Secrets,
+ envSecrets: cfg.EnvSecrets,
+ baseURL: cfg.BaseURL,
+ secret: cfg.WorkerSecret,
+ logger: logger,
+ now: time.Now,
}
}
@@ -265,19 +271,32 @@ func (d *Dispatcher) jobEnv(ev Event, runID, jobID int64, cloneToken string, sec
return env
}
-// repoSecrets decrypts the repo's CI secrets for injection. It returns nil (and
-// logs) when no service is configured or decryption fails — running without
-// secrets is preferable to failing the fire-and-forget dispatch; the workflow
-// surfaces the missing value itself.
+// repoSecrets assembles the repo's CI secrets for injection: the encrypted,
+// DB-stored secrets (when a service is configured) overlaid with any
+// operator-supplied env secrets (GITMOTE_REPO_SECRET_*), which win on a name
+// collision and need no master key. It never fails the fire-and-forget dispatch —
+// a decrypt error drops the DB secrets (logged) but env secrets still apply, and
+// the workflow surfaces any value it is missing. Returns nil when there are none.
func (d *Dispatcher) repoSecrets(ctx context.Context, ev Event) map[string]string {
- if d.secrets == nil {
- return nil
+ var m map[string]string
+ if d.secrets != nil {
+ db, err := d.secrets.Secrets(ctx, ev.RepoID)
+ if err != nil {
+ d.logger.Error("ci: load secrets failed; running without them",
+ "repo", ev.RepoName, "error", err)
+ } else {
+ m = db
+ }
}
- m, err := d.secrets.Secrets(ctx, ev.RepoID)
- if err != nil {
- d.logger.Error("ci: load secrets failed; running without them",
- "repo", ev.RepoName, "error", err)
- return nil
+ env := d.envSecrets.For(ev.RepoName)
+ if len(env) == 0 {
+ return m
+ }
+ if m == nil {
+ m = make(map[string]string, len(env))
+ }
+ for name, val := range env {
+ m[name] = val // env overrides DB on collision
}
return m
}
diff --git a/internal/ci/dispatcher_test.go b/internal/ci/dispatcher_test.go
index 7a7e94d..a143c91 100644
--- a/internal/ci/dispatcher_test.go
+++ b/internal/ci/dispatcher_test.go
@@ -474,6 +474,60 @@ func TestDispatchSecretsFailureRunsWithout(t *testing.T) {
}
}
+func TestDispatchInjectsEnvSecrets(t *testing.T) {
+ ctx := context.Background()
+ md, s, mz := newFixture(t)
+ r, head := seedRepo(t, md, s, "app", map[string]string{
+ ".gitmote/workflows/ci.yml": "name: CI\non: push\n",
+ })
+
+ // No DB Secrets service configured at all — env secrets alone must inject,
+ // since they need no master key (the fully-automated .env path).
+ tr := &stubTrigger{}
+ NewDispatcher(Config{
+ Runs: md, Materializer: mz, Trigger: tr,
+ EnvSecrets: LoadEnvSecrets([]string{"GITMOTE_REPO_SECRET_APP__DEPLOY_KEY=k3y"}),
+ BaseURL: "https://gitmote.test",
+ WorkerSecret: "worker-secret",
+ }).Dispatch(ctx, branchEvent(r, head))
+
+ if len(tr.calls) != 1 {
+ t.Fatalf("trigger calls = %d, want 1", len(tr.calls))
+ }
+ if got := tr.calls[0]["GITMOTE_CI_SECRET_DEPLOY_KEY"]; got != "k3y" {
+ t.Errorf("GITMOTE_CI_SECRET_DEPLOY_KEY = %q, want the env-seeded value", got)
+ }
+}
+
+func TestDispatchEnvSecretsOverrideDBSecrets(t *testing.T) {
+ ctx := context.Background()
+ md, s, mz := newFixture(t)
+ r, head := seedRepo(t, md, s, "app", map[string]string{
+ ".gitmote/workflows/ci.yml": "name: CI\non: push\n",
+ })
+
+ // On a name collision the env value wins; a DB-only secret still comes through.
+ tr := &stubTrigger{}
+ NewDispatcher(Config{
+ Runs: md, Materializer: mz, Trigger: tr,
+ Secrets: stubSecrets{env: map[string]string{"TOKEN": "from-db", "DB_ONLY": "keep"}},
+ EnvSecrets: LoadEnvSecrets([]string{"GITMOTE_REPO_SECRET_APP__TOKEN=from-env"}),
+ BaseURL: "https://gitmote.test",
+ WorkerSecret: "worker-secret",
+ }).Dispatch(ctx, branchEvent(r, head))
+
+ if len(tr.calls) != 1 {
+ t.Fatalf("trigger calls = %d, want 1", len(tr.calls))
+ }
+ env := tr.calls[0]
+ if env["GITMOTE_CI_SECRET_TOKEN"] != "from-env" {
+ t.Errorf("collision: TOKEN = %q, want env to win (from-env)", env["GITMOTE_CI_SECRET_TOKEN"])
+ }
+ if env["GITMOTE_CI_SECRET_DB_ONLY"] != "keep" {
+ t.Errorf("DB_ONLY = %q, want the DB secret preserved", env["GITMOTE_CI_SECRET_DB_ONLY"])
+ }
+}
+
// failNthTrigger fails the Nth (1-based) trigger call and succeeds the others.
type failNthTrigger struct {
failOn int
diff --git a/internal/ci/envsecrets.go b/internal/ci/envsecrets.go
new file mode 100644
index 0000000..682f0d6
--- /dev/null
+++ b/internal/ci/envsecrets.go
@@ -0,0 +1,82 @@
+package ci
+
+import (
+ "sort"
+ "strings"
+)
+
+// envSecretPrefix is the process-env prefix that seeds a repo's CI secrets
+// without the UI/DB path: GITMOTE_REPO_SECRET_<REPO>__<NAME>=value. It is
+// deliberately distinct from the runner-facing secretEnvPrefix. These values are
+// operator-supplied host config — never persisted, never encrypted, and needing
+// no master key (GITMOTE_CI_SECRET_KEY_V<n>): the host env is already the CI
+// trust boundary (docs/architecture/safety.md §7). This is what lets the
+// fully-automated self-deploy keep its secrets in a gitignored .env instead of
+// the Secrets UI (docs/ops.md).
+const envSecretPrefix = "GITMOTE_REPO_SECRET_"
+
+// EnvSecrets maps a normalized repo key (see repoKey) to that repo's env-seeded
+// CI secrets (name→value). Build it once at boot with LoadEnvSecrets; resolve it
+// per dispatch with For.
+type EnvSecrets map[string]map[string]string
+
+// LoadEnvSecrets parses the GITMOTE_REPO_SECRET_<REPO>__<NAME> entries out of
+// environ (os.Environ() form, "KEY=VALUE"). Entries without the prefix, without
+// the "__" repo/name delimiter, or with an empty repo or name are ignored. The
+// repo segment is normalized with repoKey so it matches For(repoName) regardless
+// of the casing written in the env; the name is kept verbatim (secret names are
+// case-sensitive, matching ${{ secrets.NAME }}). A later duplicate key wins.
+func LoadEnvSecrets(environ []string) EnvSecrets {
+ out := EnvSecrets{}
+ for _, kv := range environ {
+ key, val, _ := strings.Cut(kv, "=")
+ rest, ok := strings.CutPrefix(key, envSecretPrefix)
+ if !ok {
+ continue
+ }
+ repo, name, ok := strings.Cut(rest, "__")
+ if !ok || repo == "" || name == "" {
+ continue
+ }
+ rk := repoKey(repo)
+ m := out[rk]
+ if m == nil {
+ m = map[string]string{}
+ out[rk] = m
+ }
+ m[name] = val
+ }
+ return out
+}
+
+// For returns the env-seeded secrets for repoName, or nil if none. repoName is
+// normalized to the env-key form (uppercased, '-' → '_'), so a repo "my-repo"
+// matches GITMOTE_REPO_SECRET_MY_REPO__NAME. A repo name containing "__" can't be
+// addressed this way; a single '_' is fine.
+func (e EnvSecrets) For(repoName string) map[string]string {
+ return e[repoKey(repoName)]
+}
+
+// Redacted returns repoKey→sorted secret names, values omitted — safe to log at
+// boot to confirm what was seeded without ever printing a value. Nil when empty.
+func (e EnvSecrets) Redacted() map[string][]string {
+ if len(e) == 0 {
+ return nil
+ }
+ out := make(map[string][]string, len(e))
+ for repo, m := range e {
+ names := make([]string, 0, len(m))
+ for n := range m {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+ out[repo] = names
+ }
+ return out
+}
+
+// repoKey normalizes a repo name to its env-key form: uppercased with '-' → '_',
+// matching how a repo is spelled in a GITMOTE_REPO_SECRET_<REPO>__… var.
+func repoKey(name string) string {
+ return strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
+}
diff --git a/internal/ci/envsecrets_test.go b/internal/ci/envsecrets_test.go
new file mode 100644
index 0000000..2719244
--- /dev/null
+++ b/internal/ci/envsecrets_test.go
@@ -0,0 +1,67 @@
+package ci
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestLoadEnvSecretsParsesPrefixedEntries(t *testing.T) {
+ env := []string{
+ "GITMOTE_REPO_SECRET_GITMOTE__SCW_SECRET_KEY=uuid-here",
+ "GITMOTE_REPO_SECRET_GITMOTE__GHCR_TOKEN=ghp_abc",
+ "GITMOTE_REPO_SECRET_OTHER__API_TOKEN=t0ken",
+ "PATH=/usr/bin", // unrelated — ignored
+ }
+ got := LoadEnvSecrets(env)
+ want := EnvSecrets{
+ "GITMOTE": {"SCW_SECRET_KEY": "uuid-here", "GHCR_TOKEN": "ghp_abc"},
+ "OTHER": {"API_TOKEN": "t0ken"},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("LoadEnvSecrets = %#v, want %#v", got, want)
+ }
+}
+
+func TestLoadEnvSecretsIgnoresMalformed(t *testing.T) {
+ // The failure path: entries missing the prefix, the "__" delimiter, or a repo
+ // or name segment must be dropped, never mis-parsed into a bogus secret.
+ env := []string{
+ "GITMOTE_REPO_SECRET_NOOP", // no '='/value → whole thing is the key, no "__"
+ "GITMOTE_REPO_SECRET_NODELIM=v", // no "__" between repo and name
+ "GITMOTE_REPO_SECRET___NONAME=v", // empty name after "__"
+ "GITMOTE_REPO_SECRET___=v", // empty repo and name
+ "NOT_THE_PREFIX_GITMOTE__SCW=v", // wrong prefix
+ "GITMOTE_REPO_SECRET_GITMOTE__OK=value", // the one valid entry
+ }
+ got := LoadEnvSecrets(env)
+ want := EnvSecrets{"GITMOTE": {"OK": "value"}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("LoadEnvSecrets = %#v, want %#v", got, want)
+ }
+}
+
+func TestEnvSecretsForNormalizesRepoName(t *testing.T) {
+ e := LoadEnvSecrets([]string{"GITMOTE_REPO_SECRET_MY_REPO__A=1"})
+ // A repo named "my-repo" ('-'→'_', uppercased) must resolve the MY_REPO key.
+ if got := e.For("my-repo"); !reflect.DeepEqual(got, map[string]string{"A": "1"}) {
+ t.Errorf("For(my-repo) = %#v, want {A:1}", got)
+ }
+ if got := e.For("absent"); got != nil {
+ t.Errorf("For(absent) = %#v, want nil", got)
+ }
+}
+
+func TestEnvSecretsRedactedOmitsValues(t *testing.T) {
+ e := LoadEnvSecrets([]string{
+ "GITMOTE_REPO_SECRET_GITMOTE__B=2",
+ "GITMOTE_REPO_SECRET_GITMOTE__A=1",
+ })
+ got := e.Redacted()
+ want := map[string][]string{"GITMOTE": {"A", "B"}} // sorted, names only
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("Redacted = %#v, want %#v", got, want)
+ }
+ if EnvSecrets(nil).Redacted() != nil {
+ t.Error("Redacted of empty must be nil")
+ }
+}
diff --git a/internal/runner/act.go b/internal/runner/act.go
index 425859a..addab0b 100644
--- a/internal/runner/act.go
+++ b/internal/runner/act.go
@@ -32,6 +32,19 @@ const actPlatformsEnv = "GITMOTE_ACT_PLATFORMS"
// job container to mount into, so the knob is moot.
const allowBuildsEnv = "GITMOTE_CI_ALLOW_BUILDS"
+// actDaemonSocketEnv overrides the daemon-side Docker socket path act mounts into
+// each job container when builds are enabled. Defaults to defaultActDaemonSocket.
+// The mount source must be the socket path the *daemon* sees, not DOCKER_HOST: for
+// a VM-backed daemon (colima, Docker Desktop on macOS) DOCKER_HOST is a host path
+// the in-VM daemon can't resolve, so letting act derive the mount from it fails
+// with "operation not supported". Override only for an exotic daemon (e.g. rootless
+// docker at $XDG_RUNTIME_DIR/docker.sock).
+const actDaemonSocketEnv = "GITMOTE_ACT_DAEMON_SOCKET"
+
+// defaultActDaemonSocket is the daemon's own socket path for native docker and for
+// VM-backed daemons (colima, Docker Desktop) alike.
+const defaultActDaemonSocket = "/var/run/docker.sock"
+
// ActEngine runs the repo's workflows with nektos/act — the locked engine
// (tasks/16-ci.md Stage 0 #1). act runs each job in a container via the local
// Docker/podman daemon and speaks GitHub-Actions YAML, so a workflow is
@@ -84,6 +97,7 @@ func (ActEngine) Run(ctx context.Context, repoDir, workflowDir string) ([]byte,
func actArgs(workflowDir string, environ []string) (args, extraEnv []string) {
args = []string{"--workflows", workflowDir}
var selfHosted, allowBuilds bool
+ daemonSocket := defaultActDaemonSocket
for _, kv := range environ {
name, val, _ := strings.Cut(kv, "=")
switch name {
@@ -96,6 +110,10 @@ func actArgs(workflowDir string, environ []string) (args, extraEnv []string) {
}
case allowBuildsEnv:
allowBuilds, _ = strconv.ParseBool(strings.TrimSpace(val))
+ case actDaemonSocketEnv:
+ if v := strings.TrimSpace(val); v != "" {
+ daemonSocket = v
+ }
default:
if secret, ok := strings.CutPrefix(name, secretEnvPrefix); ok && secret != "" {
extraEnv = append(extraEnv, secret+"="+val)
@@ -103,12 +121,22 @@ func actArgs(workflowDir string, environ []string) (args, extraEnv []string) {
}
}
}
- // Nested mode mounts the host Docker socket into each job container by
- // default, handing untrusted workflow code the daemon (host root). Suppress
- // that unless builds are explicitly opted in. Self-hosted mode has no job
- // container to mount into, so the flag would be meaningless there.
- if !selfHosted && !allowBuilds {
+ // How the job-container Docker socket is mounted:
+ switch {
+ case selfHosted:
+ // No nested job container to mount into — the step runs in the container
+ // directly, so the flag would be meaningless.
+ case !allowBuilds:
+ // Nested mode mounts the host Docker socket into each job container by
+ // default, handing untrusted workflow code the daemon (host root). Suppress
+ // it unless builds are explicitly opted in.
args = append(args, "--container-daemon-socket", "-")
+ default:
+ // Builds opted in: mount the daemon's own socket so `docker build` reaches
+ // it. Pin the daemon-side path rather than let act derive the mount source
+ // from DOCKER_HOST — a VM-backed daemon (colima, Docker Desktop on macOS)
+ // can't resolve that host path and the mount fails.
+ args = append(args, "--container-daemon-socket", daemonSocket)
}
return args, extraEnv
}
diff --git a/internal/runner/act_test.go b/internal/runner/act_test.go
index 10b98b4..a282f79 100644
--- a/internal/runner/act_test.go
+++ b/internal/runner/act_test.go
@@ -50,12 +50,22 @@ func TestActArgsDefaultsNoOverrides(t *testing.T) {
}
}
-func TestActArgsAllowBuildsKeepsSocket(t *testing.T) {
+func TestActArgsAllowBuildsMountsDaemonSocket(t *testing.T) {
args, _ := actArgs(".gitmote/workflows", []string{"GITMOTE_CI_ALLOW_BUILDS=1"})
- // Opted in: leave act's default socket mount so `docker build` reaches the
- // host daemon.
- if slices.Contains(args, "--container-daemon-socket") {
- t.Errorf("args = %v, want no socket override when builds are allowed", args)
+ // Opted in: mount the daemon's own socket path (not act's DOCKER_HOST-derived
+ // default, which breaks for a VM-backed daemon) so `docker build` reaches it.
+ if !hasFlag(args, "--container-daemon-socket", "/var/run/docker.sock") {
+ t.Errorf("args = %v, want --container-daemon-socket /var/run/docker.sock when builds are allowed", args)
+ }
+}
+
+func TestActArgsDaemonSocketOverride(t *testing.T) {
+ args, _ := actArgs(".gitmote/workflows", []string{
+ "GITMOTE_CI_ALLOW_BUILDS=1",
+ "GITMOTE_ACT_DAEMON_SOCKET=/run/user/1000/docker.sock",
+ })
+ if !hasFlag(args, "--container-daemon-socket", "/run/user/1000/docker.sock") {
+ t.Errorf("args = %v, want the overridden daemon socket path", args)
}
}
diff --git a/scripts/self-deploy.sh b/scripts/self-deploy.sh
new file mode 100755
index 0000000..3a96b02
--- /dev/null
+++ b/scripts/self-deploy.sh
@@ -0,0 +1,172 @@
+#!/usr/bin/env bash
+# Break-glass self-deploy: gitmote builds and deploys itself through its own local
+# runner, with zero manual secret entry. It runs a throwaway gitmote instance
+# natively (MinIO in a container on non-dev ports), seeds the deploy secrets from a
+# gitignored .env as GITMOTE_REPO_SECRET_GITMOTE__* (no Secrets UI, no master key),
+# and pushes HEAD to the `self-deploy` branch — which triggers
+# .gitmote/workflows/deploy.yml to build the amd64 image, push it to GHCR, and
+# `scw container update` prod. Invoked by `make self-deploy`. See docs/ops.md.
+#
+# The everyday deployer stays GitHub Actions on `master`; this is the fallback for
+# when that's unavailable or an out-of-band deploy is wanted.
+set -euo pipefail
+
+ROOT="$(git rev-parse --show-toplevel)"
+cd "$ROOT"
+
+PORT=8081
+S3_PORT=9200
+DATA="$ROOT/data-selfdeploy"
+COMPOSE=(docker compose -p gitmote-selfdeploy -f docker-compose.selfdeploy.yml)
+
+# --- 1. Load the gitignored .env holding the deploy secrets -------------------
+if [ ! -f "$ROOT/.env" ]; then
+ echo "ERROR: no .env — copy .env.example to .env and fill in the deploy secrets." >&2
+ exit 1
+fi
+set -a
+# shellcheck disable=SC1091
+. "$ROOT/.env"
+set +a
+
+# The secrets the deploy workflow reads as ${{ secrets.NAME }}, seeded per-repo for
+# the `gitmote` repo. Fail loud on any missing one — a self-deploy with a blank SCW
+# key would only surface deep inside act.
+REQUIRED=(
+ GITMOTE_REPO_SECRET_GITMOTE__SCW_SECRET_KEY
+ GITMOTE_REPO_SECRET_GITMOTE__SCW_ACCESS_KEY
+ GITMOTE_REPO_SECRET_GITMOTE__SCW_ORGANIZATION_ID
+ GITMOTE_REPO_SECRET_GITMOTE__SCW_PROJECT_ID
+ GITMOTE_REPO_SECRET_GITMOTE__SCW_CONTAINER_ID
+ GITMOTE_REPO_SECRET_GITMOTE__GHCR_USER
+ GITMOTE_REPO_SECRET_GITMOTE__GHCR_TOKEN
+)
+missing=()
+for v in "${REQUIRED[@]}"; do
+ [ -n "${!v:-}" ] || missing+=("$v")
+done
+if [ "${#missing[@]}" -gt 0 ]; then
+ echo "ERROR: .env is missing required deploy secrets:" >&2
+ printf ' %s\n' "${missing[@]}" >&2
+ exit 1
+fi
+
+# --- 2. Preflight: act + a reachable Docker/podman daemon ---------------------
+command -v act >/dev/null 2>&1 || { echo "ERROR: act not on PATH — run: brew install act" >&2; exit 1; }
+if ! git cat-file -e HEAD:.gitmote/workflows/deploy.yml 2>/dev/null; then
+ echo "WARN: HEAD has no .gitmote/workflows/deploy.yml — commit it, or the push triggers no deploy." >&2
+fi
+
+# act (and the image build) need a live Docker-API socket. Resolve one into
+# DOCKER_HOST so act and the runner inherit it instead of defaulting to a socket
+# that may not be there. We ask the docker CLI's active context, which covers
+# colima (~/.colima/default/docker.sock) and Docker Desktop uniformly.
+ping_socket() { # curl liveness of a unix docker socket (catches permission-denied)
+ [ -S "$1" ] && curl -fsS --max-time 3 --unix-socket "$1" http://d/_ping >/dev/null 2>&1
+}
+resolve_docker_host() {
+ # An already-set, reachable DOCKER_HOST wins.
+ if [ -n "${DOCKER_HOST:-}" ] && ping_socket "${DOCKER_HOST#unix://}"; then return 0; fi
+ # The default socket (Docker Desktop, or a colima symlink).
+ if ping_socket /var/run/docker.sock; then export DOCKER_HOST="unix:///var/run/docker.sock"; return 0; fi
+ # Whatever socket the active docker context points at (colima's, typically).
+ if command -v docker >/dev/null 2>&1; then
+ local host
+ host="$(docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null || true)"
+ if [ -n "$host" ] && ping_socket "${host#unix://}"; then export DOCKER_HOST="$host"; return 0; fi
+ fi
+ return 1
+}
+if ! resolve_docker_host; then
+ cat >&2 <<'EOF'
+ERROR: no reachable Docker daemon — act can't build the image.
+ (act connects to DOCKER_HOST, else /var/run/docker.sock.)
+
+Start one, then re-run `make self-deploy`:
+
+ colima (recommended on Apple Silicon — Rosetta-accelerated amd64 builds):
+ brew install colima docker docker-buildx
+ colima start --vm-type vz --vz-rosetta
+
+ Docker Desktop:
+ open -a Docker # wait for it to come up
+EOF
+ exit 1
+fi
+echo "--- daemon: DOCKER_HOST=$DOCKER_HOST ---"
+
+# --- 3. All-local server config (none secret) ---------------------------------
+export GITMOTE_S3_BUCKET="gitmote"
+export GITMOTE_S3_ENDPOINT="http://localhost:${S3_PORT}"
+export AWS_REGION="us-east-1"
+export AWS_ACCESS_KEY_ID="minioadmin"
+export AWS_SECRET_ACCESS_KEY="minioadmin"
+export GITMOTE_DATA="$DATA"
+export GITMOTE_COOKIE_KEY="selfdeploy-cookie-key-not-for-production"
+export GITMOTE_URL="http://localhost:${PORT}"
+export WORKER_SECRET="selfdeploy-worker-secret-not-for-production"
+export GITMOTE_LISTEN_ADDR=":${PORT}"
+# The build capability the deploy needs: hand act the host Docker daemon. Safe here
+# because this instance hosts only gitmote's own (trusted) repo (docs/ops.md).
+export GITMOTE_CI_ALLOW_BUILDS=1
+
+# Free :$PORT from a stale self-deploy server (it must release the writer lease and
+# the MinIO volume before the reset below).
+if pid="$(lsof -nP -tiTCP:${PORT} -sTCP:LISTEN 2>/dev/null)"; then
+ echo "--- stopping process on :${PORT} (pid $pid) ---"
+ kill $pid 2>/dev/null || true
+ for _ in $(seq 1 20); do
+ lsof -nP -tiTCP:${PORT} -sTCP:LISTEN >/dev/null 2>&1 || break
+ sleep 0.1
+ done
+fi
+
+# The self-deploy instance is disposable: always start from a clean slate. This
+# removes the whole class of "an interrupted previous run left litestream state
+# drifted from the bucket" failures (the txid-mismatch replication loop). It wipes
+# ONLY the dedicated gitmote-selfdeploy volume and data-selfdeploy/ dir — nothing
+# shared with `make dev`. The just-finished run's state survives until the re-run.
+echo "--- resetting throwaway state (data-selfdeploy + its MinIO volume) ---"
+"${COMPOSE[@]}" down -v 2>/dev/null || true
+rm -rf "$DATA"
+mkdir -p "$DATA"
+
+echo "--- starting MinIO (S3 :${S3_PORT}, console :9201) ---"
+"${COMPOSE[@]}" up -d minio
+"${COMPOSE[@]}" run --rm mc
+
+echo "--- bootstrapping admin + gitmote repo (fresh) ---"
+bin/gitmote bootstrap -handle admin -repo gitmote -default-branch master >/dev/null
+TOKEN="$(bin/gitmote bootstrap -reissue -handle admin | grep -oE 'gmt_[0-9a-f]+\.[0-9a-f]+' | head -1)"
+[ -n "$TOKEN" ] || { echo "ERROR: no token from 'bootstrap -reissue'" >&2; exit 1; }
+
+# --- 4. Start the server in the background, wait for it to serve ---------------
+echo "--- starting gitmote on :${PORT} (GITMOTE_CI_ALLOW_BUILDS=1) ---"
+bin/gitmote &
+SERVER_PID=$!
+trap 'kill $SERVER_PID 2>/dev/null || true' EXIT
+for _ in $(seq 1 50); do
+ curl -fsS -o /dev/null "http://localhost:${PORT}/login" 2>/dev/null && break
+ kill -0 $SERVER_PID 2>/dev/null || { echo "ERROR: server exited during startup" >&2; exit 1; }
+ sleep 0.2
+done
+
+# --- 5. Push HEAD → self-deploy: this is the trigger --------------------------
+echo "--- pushing HEAD → self-deploy (triggers the deploy workflow) ---"
+git push --force "http://admin:${TOKEN}@localhost:${PORT}/gitmote" HEAD:refs/heads/self-deploy
+
+cat <<EOF
+
+ Self-deploy triggered. The local runner is now building the image and, on
+ success, deploying it to gitmote.atmin.net via scw container update.
+
+ Watch: http://localhost:${PORT}/gitmote (sign in with the token below)
+ token: $TOKEN
+
+ This first build is amd64 under emulation — expect it to be slow. Ctrl-C stops
+ the server (and this script); 'make self-deploy-reset' wipes the throwaway state.
+
+EOF
+
+# Keep the server up so the runner can clone, build, and report back.
+wait $SERVER_PID