gitmote

gitmote  ·  history  ·  branches

feat: build CI container images behind the GITMOTE_CI_ALLOW_BUILDS opt-in

681e72eb4afd935cb99a2a94309e70ca80c57933
Atanas Minev <atmin@protonmail.com> · 2026-07-17 12:31

A daemon-backed local/VPS runner can build images, but act mounts the host
Docker socket into job containers by default — untrusted workflow code reaching
the daemon is host root. Suppress that mount unless the opt-in is set: builds are
an explicit trusted-repos-only choice and the default tightens prior behavior.
Adds socket-gate unit tests and a `make e2e-build` DinD proof wired into CI, plus
docs for a local on-demand forge over a remote S3 bucket.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c61df30..e47581a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,9 +17,10 @@ jobs:
- run: make all
# Self-hosting proof: push/clone the real repo against a gitmote container
- # backed by MinIO (scripts/e2e-local.sh), plus the litestream cold-start restore
- # (scripts/e2e-restore.sh). Gated — builds an image and runs Docker, so it stays
- # off the fast per-PR path and runs on master / on demand.
+ # backed by MinIO (scripts/e2e-local.sh), the litestream cold-start restore
+ # (scripts/e2e-restore.sh), and the CI image-build gate (scripts/e2e-build.sh).
+ # Gated — 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
@@ -29,9 +30,10 @@ jobs:
fetch-depth: 0 # push needs the full history, not a shallow clone
- run: make e2e-local
- run: make e2e-restore
+ - run: make e2e-build
# Deploy the single writer to Scaleway on every green master push (docs/ops.md).
- # Safety is by lease now, not procedure: the server runs in s3lite RoleAuto, so a
+ # Safety is by lease, 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).
diff --git a/Makefile b/Makefile
index cde7bfe..1e773e6 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 image prod publish
+.PHONY: all fmt lint test build dev dev-reset e2e-local e2e-restore e2e-build image prod publish
all: lint test build
@@ -64,6 +64,13 @@ e2e-local:
e2e-restore:
./scripts/e2e-restore.sh
+# Proof that GITMOTE_CI_ALLOW_BUILDS gates real image builds: runs act in a
+# privileged DinD box (a rootful daemon in a box) with the socket suppressed
+# (build fails) then mounted (build succeeds). Skips with no daemon. Requires
+# docker or podman.
+e2e-build:
+ ./scripts/e2e-build.sh
+
# Build the actual server container image (distinct from `make build`, the Go
# binaries), tagged with VERSION — the exact image CI publishes and `make prod`
# runs. (The runner image is CI/amd64-only; see the IMAGE_SERVER note above.)
diff --git a/README.md b/README.md
index 4519533..f767b41 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,46 @@ Nothing else is required: the session cookie key and CI worker secret are
generated and persisted on first run. See [docs/ops.md](docs/ops.md) for the full
env surface, CI, and the Scaleway deployment.
+### Remote bucket, local on-demand forge
+
+Because the bucket *is* the forge, you don't need any always-on compute. Keep the
+bucket in the cloud, run gitmote **locally only when you want to push or pull**,
+and stop it afterwards — your git remote is `localhost`, but the durable state
+lives in S3. This is the cheapest way to self-host: you pay for object storage,
+not a running server.
+
+```sh
+# Start the forge against your CLOUD bucket, then push/pull, then Ctrl-C.
+docker run --rm -p 8080:8080 \
+ -e GITMOTE_S3_BUCKET=my-gitmote-bucket \
+ -e GITMOTE_S3_ENDPOINT=https://s3.fr-par.scw.cloud \ # your provider's endpoint
+ -e GITMOTE_S3_PREFIX=objects \ # MUST match the bucket's existing prefix
+ -e AWS_REGION=fr-par \
+ -e AWS_ACCESS_KEY_ID=… -e AWS_SECRET_ACCESS_KEY=… \
+ ghcr.io/atmin/gitmote:master
+
+git remote add gitmote http://admin:<token>@localhost:8080/<repo>
+git push gitmote main
+```
+
+Two things to get right:
+
+- **`GITMOTE_S3_PREFIX` is a data address, not a preference.** A local instance
+ pointed at an existing bucket must use the **same** prefix (and endpoint/region)
+ that bucket was written with, or gitmote comes up advertising refs whose objects
+ it can't find and aborts startup. If you created the bucket with this same
+ quickstart, match whatever you used then.
+- **Use your existing token.** A populated bucket already has an admin; don't
+ re-bootstrap it — sign in with the token you saved (or `bootstrap -reissue` to
+ mint a fresh one).
+
+**Only one writer at a time — and that's enforced, not your job.** gitmote takes a
+single-writer *lease* on the bucket, so running a second instance (or a stray cloud
+container) can't corrupt anything: whoever holds the lease writes, the other serves
+reads and returns a retryable `503` on push. So a local on-demand forge is safe
+even if you also run gitmote in the cloud — no coordination, no harm. Details in
+[docs/ops.md](docs/ops.md) (“Single writer is a correctness invariant”).
+
## Develop locally
`make dev` gives you a running instance in one command: it builds the binaries,
@@ -90,8 +130,14 @@ container — clones again to prove the repo survives on the object store and
persisted refs (not local disk). Requires Docker + Docker Compose.
`make e2e-restore` additionally exercises the litestream cold-start path (wipes
-the metadata DB and restores it from S3). Deployment — Scaleway Serverless
-Containers, single writer, `gitmote.atmin.net` — lives in [docs/ops.md](docs/ops.md).
+the metadata DB and restores it from S3).
+
+`make e2e-build` proves the `GITMOTE_CI_ALLOW_BUILDS` gate: it runs `act` in a
+privileged docker-in-docker box (a rootful daemon in a box) with the daemon socket
+suppressed (a `docker build` step fails) then mounted (it succeeds), so the safe
+default and the opt-in are both verified against a real build. Skips cleanly with
+no daemon. Deployment — Scaleway Serverless Containers, single writer,
+`gitmote.atmin.net` — lives in [docs/ops.md](docs/ops.md).
## Bootstrap
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 2c2dd25..6a82aca 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -14,6 +14,7 @@ import (
"os"
"os/signal"
"path/filepath"
+ "strconv"
"strings"
"syscall"
"time"
@@ -413,6 +414,9 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
case gitmoteURL != "":
trigger = ci.NewLocalTrigger(runnerBinaryPath(), logger)
logger.Info("CI trigger: local runner", "runner", runnerBinaryPath(), "url", gitmoteURL)
+ if allow, _ := strconv.ParseBool(os.Getenv("GITMOTE_CI_ALLOW_BUILDS")); allow {
+ logger.Warn("GITMOTE_CI_ALLOW_BUILDS is on: CI can reach the host Docker daemon (host root) — trusted repos only")
+ }
default:
logger.Warn("CI trigger disabled; runs record but do not execute " +
"(set GITMOTE_URL for local, or SCW_CI_JOB_DEFINITION_ID + GITMOTE_URL for cloud)")
diff --git a/docs/architecture/ci.md b/docs/architecture/ci.md
index a500256..9a9f75f 100644
--- a/docs/architecture/ci.md
+++ b/docs/architecture/ci.md
@@ -113,20 +113,43 @@ branch (`checkout -B`) so `github.ref` resolves.
- **Fire-and-forget** — dispatch and trigger never fail or block a push.
- **Single writer holds** — only the leader dispatches and writes run state.
-## Limitations
+## Container image builds
+
+Image builds are **substrate-dependent**, and off by default:
-- **No container builds in CI.** The Serverless Job sandbox has no user
+- **Scaleway (cloud) cannot build.** The Serverless Job sandbox has no user
namespaces and drops the capabilities image builders need — `docker build`,
- `buildah`, `podman build`, and kaniko-as-a-step all fail. CI can compile, test,
- lint, and produce file artifacts in any language (node, rust, go, …); it
- **cannot** build an OCI image. A workflow that builds+pushes an image must do
- that step on the GitHub mirror (a real builder), or use registry-API assembly
- (`ko`/`crane`) for the rare static-binary case. Making arbitrary image builds
- work in-CI would need a build-capable substrate (a VM with a daemon), a
- separate effort.
-- **gitmote does not deploy itself.** The self-deploy loop (a green `master` run
- rebuilding gitmote's own image in-Job) was evaluated and **dropped** — it runs
- straight into the limitation above. Deployment stays on **GitHub Actions**
+ `buildah`, `podman build`, and kaniko-as-a-step all fail. On the cloud runner CI
+ can compile, test, lint, and produce file artifacts in any language, but
+ **cannot** build an OCI image; such a step must run on the GitHub mirror (a real
+ builder), or use registry-API assembly (`ko`/`crane`) for the static-binary case.
+- **Local/VPS *can* build, behind an opt-in.** On a daemon-backed host `act` runs
+ in nested mode, and it mounts the host Docker socket into each job container by
+ default — so `docker build` reaches the daemon. Because that hands **untrusted
+ workflow code the daemon (host root)**, gitmote suppresses the mount
+ (`act --container-daemon-socket -`) unless **`GITMOTE_CI_ALLOW_BUILDS`** is
+ truthy. Turn it on only for **trusted repos** on a host you control. With it on,
+ 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).
+
+ > **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
+ > dispatched and recorded, but fails at act's "Set up job" step — the log reads
+ > `failed to connect to the docker API … check if … the daemon is running`, and
+ > the run is marked failed (`ActEngine.Run` → `passed=false`). No workflow step,
+ > and no build, executes. The build opt-in only matters where a daemon exists.
+
+## 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.
+ 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
diff --git a/docs/architecture/safety.md b/docs/architecture/safety.md
index c407506..eddb7d1 100644
--- a/docs/architecture/safety.md
+++ b/docs/architecture/safety.md
@@ -8,7 +8,7 @@
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 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
+ is 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. This makes the S3 backend's support for **atomic
@@ -26,7 +26,7 @@
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
+ [reader-writer-split](../evolution/reader-writer-split.md), 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`.
@@ -82,3 +82,36 @@
every repo's secrets, so a replica leak must never expose it. An explicit env
still wins for both, and generation is get-or-create on the leader (a follower's
DB is read-only), so restarts and rolling deploys reuse the one restored value.
+
+7. **Container image builds are an opt-in privilege escalation — off by default.**
+ Workflow code is attacker-controlled; the isolation boundary is the job
+ container. Building an image needs a container builder, and the pragmatic path
+ (`act` nested mode) mounts the **host Docker socket** into the job container —
+ which is equivalent to handing that untrusted code **root on the host** (a
+ trivial container escape). So gitmote's default **suppresses** the mount
+ (`act --container-daemon-socket -`, in [`internal/runner/act.go`](../../internal/runner/act.go)),
+ and only restores it when **`GITMOTE_CI_ALLOW_BUILDS`** is set. This is
+ deliberately not per-repo: it is a host-level trust decision the operator makes
+ for a machine running only repos they trust (a personal/VPS forge building its
+ own code). It has no effect on the Scaleway substrate (self-hosted mode, no
+ daemon, no socket to mount — and it can't build anyway). The server logs a loud
+ warning at startup when the opt-in is on.
+
+ **Why not just always use Docker-in-Docker (DinD) and drop the knob?** Because
+ DinD does not remove the escape — it relocates it. A nested daemon requires a
+ `--privileged` container (all capabilities, host devices, unmasked `/proc`/`/sys`,
+ no seccomp), which is itself host-root-equivalent; and untrusted workflow code is
+ exactly what drives the builder, so it inherits that reach. DooD vs DinD is a
+ choice about build *state isolation* (own daemon/cache vs the host's), **not**
+ about safety against untrusted code. The axis that actually bounds untrusted
+ builds is **rootless vs rootful**: on a rootful daemon anything the build reaches
+ is real host root, whereas a rootless engine caps the whole container tree —
+ privileged nesting included — at the invoking unprivileged user. So "always DinD
+ where supported" would be *more* machinery (provision/wire/lifecycle a privileged
+ daemon, detect support — and Scaleway can't do privileged, leaving the same
+ local/VPS set), still couldn't drop the operator-consent gate (auto-enabling
+ builds for any pushed repo is a host-root grant by default), and buys no safety.
+ A genuine "solve it from the root" is a **rootless builder** (rootless
+ BuildKit/kaniko/buildah — an escape lands as an unprivileged user) or a
+ **per-job VM boundary** (Kata/Firecracker); both are additions, tracked as
+ future work, and the consent gate stays until one exists.
diff --git a/docs/evolution/ci-runner.md b/docs/evolution/ci-runner.md
index 8f2c506..ac84862 100644
--- a/docs/evolution/ci-runner.md
+++ b/docs/evolution/ci-runner.md
@@ -2,11 +2,14 @@
> **Status: largely realized — see [architecture/ci.md](../architecture/ci.md).**
> CI (run `.gitmote/workflows` on push, `act` self-hosted on Scaleway Jobs, logs,
-> status UI, encrypted secrets) has landed. The one part **dropped**: the
-> self-deploy loop below — the Serverless Job sandbox can't build container
-> images (no user namespaces / privileged mode), so gitmote does not rebuild its
-> own image; deployment stays on GitHub Actions. This note is kept for the
-> original reasoning; don't cite it as a spec.
+> status UI, encrypted secrets) has landed. **Container image builds work on a
+> daemon-backed local/VPS runner** behind the `GITMOTE_CI_ALLOW_BUILDS` opt-in
+> (off by default; it grants CI host-daemon/root access — trusted repos only), so
+> a VPS-hosted gitmote *can* build its own image. Still **not built**: the full
+> self-deploy loop below (build → push → redeploy with a latest-wins guard). The
+> Scaleway substrate still can't build (no daemon / no user namespaces), so cloud
+> deployment stays on GitHub Actions. This note is kept for the original reasoning;
+> don't cite it as a spec.
## The idea
@@ -20,7 +23,7 @@ stops being a downgrade.
The loop then closes on itself: a push runs CI **and redeploys gitmote** —
gitmote hosting, building, and shipping its own next version. That used to be the
scary case (an instance replacing itself mid-write is the two-writer hazard), but
-the **leased writer** ([reader-writer-split](reader-writer-split.md), now shipped)
+the **leased writer** ([reader-writer-split](reader-writer-split.md), shipped)
makes it safe by construction: the new image boots as a follower, the old releases
the lease on SIGTERM, the new promotes — the ouroboros never has two writers. The
GitHub mirror then earns a second job: **break-glass.** If a bad push ever wedges
diff --git a/docs/ops.md b/docs/ops.md
index ad7f704..f8cfd57 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -29,7 +29,7 @@ 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.
-The invariant is now enforced **by a lease, not by procedure.** The server opens
+The invariant is 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), and runs as a read-only follower
@@ -107,6 +107,7 @@ one of these turns a trigger on — see [CI substrate](#ci-substrate)).
| `SCW_SECRET_KEY` | secret — Scaleway API secret key (UUID) to start the job (the same key also deploys the container from CI) |
| `SCW_REGION` | region for the job start (falls back to `AWS_REGION`) |
| `GITMOTE_CI_SECRET_KEY_V<n>` | secret — base64 of 32 bytes; **env-only** master key for per-repo CI secrets, never persisted ([safety.md §5](architecture/safety.md)). Add `_V2`, … to rotate (highest is current; old envelopes still decrypt). Unset → the secrets UI is off and none are injected |
+| `GITMOTE_CI_ALLOW_BUILDS` | `1`/`true` → let workflows build container images on a **daemon-backed local/VPS** runner (leaves `act`'s host-Docker-socket mount in place). ⚠️ **Hands untrusted workflow code the host daemon (host root) — trusted repos only** ([safety.md §7](architecture/safety.md)). Unset/false (default) → the socket mount is suppressed. No effect on the Scaleway substrate (can't build regardless) |
**Advanced overrides — rarely needed** (the defaults are derived).
@@ -161,9 +162,23 @@ from the env at startup** (one runner, three substrates — see
**Prerequisite for both executing substrates:** the runner runs `.gitmote/workflows`
with [`act`](https://github.com/nektos/act), which needs a reachable **Docker or
podman daemon** — in the cloud that's the Serverless Job image; locally it's the
-daemon on the host (the same one MinIO uses under `make dev`). A leader-only ticker
+daemon on the host (the same one MinIO uses under `make dev`). **No daemon → every
+CI run fails visibly**: the job is still dispatched and recorded, but act fails at
+its "Set up job" step with a `check if … the daemon is running` error and the run is
+marked failed — no workflow step (and no build) executes. A leader-only ticker
sweeps jobs stuck `running` past ~1h back to `error`.
+**Container image builds (local/VPS only).** With `GITMOTE_CI_ALLOW_BUILDS` set,
+`act`'s default behaviour of mounting the host Docker socket into each job container
+is kept, so a workflow step (`docker build`, `docker/build-push-action`, …) reaches
+the daemon and builds images — including gitmote's own. Off by default because it
+gives untrusted workflow code the daemon, i.e. host root ([safety.md §7](architecture/safety.md));
+turn it on only on a host running trusted repos. The runtime follows `DOCKER_HOST`:
+plain Docker needs nothing extra; **podman** must expose its Docker-API socket
+(`systemctl --user enable --now podman.socket`, then
+`DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock`) — a *daemonless* podman
+cannot back `act`. The Scaleway substrate ignores the flag (self-hosted, no daemon).
+
## CI secrets (GitHub Actions)
Separate from the container env above: these are the Scaleway API credentials the
@@ -333,7 +348,7 @@ same path is exercised locally by `make e2e-restore`.
```bash
scw container container get <CONTAINER_ID> # status, error messages, instance count
scw container container redeploy <CONTAINER_ID> # re-pull the current image tag
-# Images live in GHCR now — inspect/pull them there:
+# Images live in GHCR — inspect/pull them there:
docker manifest inspect ghcr.io/atmin/gitmote:master # server image
docker manifest inspect ghcr.io/atmin/gitmote-runner:master # CI runner image
```
diff --git a/internal/runner/act.go b/internal/runner/act.go
index 4ad230d..425859a 100644
--- a/internal/runner/act.go
+++ b/internal/runner/act.go
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"os/exec"
+ "strconv"
"strings"
)
@@ -22,6 +23,15 @@ const secretEnvPrefix = "GITMOTE_CI_SECRET_"
// act's default nested-container behavior, unchanged.
const actPlatformsEnv = "GITMOTE_ACT_PLATFORMS"
+// allowBuildsEnv opts a nested-mode runner into container image builds. In
+// nested mode act bind-mounts the host Docker socket into every job container by
+// default, so untrusted workflow code can reach the daemon — i.e. host root.
+// Unless this is truthy (strconv.ParseBool) we pass `--container-daemon-socket -`
+// to suppress that mount; set it only for trusted repos on a daemon-backed
+// local/VPS host that must run `docker build`. In self-hosted mode there is no
+// job container to mount into, so the knob is moot.
+const allowBuildsEnv = "GITMOTE_CI_ALLOW_BUILDS"
+
// 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
@@ -73,6 +83,7 @@ func (ActEngine) Run(ctx context.Context, repoDir, workflowDir string) ([]byte,
// Pure, so it is unit-testable without act on PATH.
func actArgs(workflowDir string, environ []string) (args, extraEnv []string) {
args = []string{"--workflows", workflowDir}
+ var selfHosted, allowBuilds bool
for _, kv := range environ {
name, val, _ := strings.Cut(kv, "=")
switch name {
@@ -80,8 +91,11 @@ func actArgs(workflowDir string, environ []string) (args, extraEnv []string) {
for _, p := range strings.Split(val, ",") {
if p = strings.TrimSpace(p); p != "" {
args = append(args, "-P", p)
+ selfHosted = true
}
}
+ case allowBuildsEnv:
+ allowBuilds, _ = strconv.ParseBool(strings.TrimSpace(val))
default:
if secret, ok := strings.CutPrefix(name, secretEnvPrefix); ok && secret != "" {
extraEnv = append(extraEnv, secret+"="+val)
@@ -89,5 +103,12 @@ 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 {
+ args = append(args, "--container-daemon-socket", "-")
+ }
return args, extraEnv
}
diff --git a/internal/runner/act_test.go b/internal/runner/act_test.go
index 4bbc82b..10b98b4 100644
--- a/internal/runner/act_test.go
+++ b/internal/runner/act_test.go
@@ -39,9 +39,41 @@ func TestActArgsDefaultsNoOverrides(t *testing.T) {
if len(extra) != 0 {
t.Errorf("extraEnv = %v, want none", extra)
}
- // Just the workflows flag — no -s, no -P (local dev: act's default nesting).
+ // No -s, no -P (local dev: act's default nesting).
if hasFlag(args, "-P", "") || slices.Contains(args, "-s") {
- t.Errorf("args = %v, want only --workflows with no overrides", args)
+ t.Errorf("args = %v, want no secret/platform overrides", args)
+ }
+ // Safe default: nested mode without an opt-in suppresses the daemon-socket
+ // mount so untrusted workflow code can't reach the host daemon.
+ if !hasFlag(args, "--container-daemon-socket", "-") {
+ t.Errorf("args = %v, want --container-daemon-socket - by default", args)
+ }
+}
+
+func TestActArgsAllowBuildsKeepsSocket(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)
+ }
+}
+
+func TestActArgsAllowBuildsFalseSuppressesSocket(t *testing.T) {
+ args, _ := actArgs(".gitmote/workflows", []string{"GITMOTE_CI_ALLOW_BUILDS=false"})
+ if !hasFlag(args, "--container-daemon-socket", "-") {
+ t.Errorf("args = %v, want the socket suppressed when builds are explicitly false", args)
+ }
+}
+
+func TestActArgsSelfHostedOmitsSocketFlag(t *testing.T) {
+ // Self-hosted has no job container to mount into, so the socket flag is moot
+ // and must not be emitted (it would be meaningless / could error).
+ args, _ := actArgs(".gitmote/workflows", []string{
+ "GITMOTE_ACT_PLATFORMS=ubuntu-latest=-self-hosted",
+ })
+ if slices.Contains(args, "--container-daemon-socket") {
+ t.Errorf("args = %v, want no socket flag in self-hosted mode", args)
}
}
diff --git a/scripts/e2e-build.sh b/scripts/e2e-build.sh
new file mode 100755
index 0000000..e4ed4af
--- /dev/null
+++ b/scripts/e2e-build.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+# End-to-end proof that GITMOTE_CI_ALLOW_BUILDS gates real container image builds.
+#
+# gitmote's runner (internal/runner/act.go, actArgs) runs `act` in nested mode and,
+# unless GITMOTE_CI_ALLOW_BUILDS is truthy, passes `--container-daemon-socket -` to
+# suppress act's default host-Docker-socket mount. The unit tests prove actArgs
+# emits exactly that flag (off) or omits it (on); this script proves those two
+# arg-states actually flip a real `docker build` from failing (no daemon access)
+# to succeeding — the other half of the chain.
+#
+# It runs everything inside one privileged docker-in-docker container with its own
+# rootful daemon — "a Linux VPS with docker" in a box — so it works even where the
+# host engine is rootless podman (whose socket-mount permissions otherwise block
+# the job container; that's a rootless-userns quirk, not a gitmote behaviour).
+#
+# Requires a reachable Docker/podman daemon. Skips (exit 0) when none is present —
+# no daemon means no CI and no builds anyway, which is the point. Not part of
+# `make all`; run explicitly with `make e2e-build`.
+set -euo pipefail
+
+# Pin act to the version baked into the runner image (Dockerfile.runner).
+ACT_VERSION="v0.2.89"
+CN="gitmote-e2e-build"
+# A small job image that ships the docker CLI, so the build step can reach the
+# daemon without pulling act's ~1GB default runner image. The socket-mount gate
+# under test is identical regardless of which job image is used.
+JOB_IMAGE="docker:cli"
+
+DOCKER="${DOCKER:-docker}"
+
+if ! "$DOCKER" info >/dev/null 2>&1; then
+ echo "SKIP: no reachable Docker/podman daemon (nothing to build against)."
+ exit 0
+fi
+
+cleanup() {
+ local code=$?
+ "$DOCKER" rm -f "$CN" >/dev/null 2>&1 || true
+ exit $code
+}
+trap cleanup EXIT
+
+fail() { echo "E2E BUILD FAIL: $*" >&2; exit 1; }
+
+echo "--- starting privileged DinD box (own rootful daemon) ---"
+"$DOCKER" rm -f "$CN" >/dev/null 2>&1 || true
+"$DOCKER" run -d --privileged --name "$CN" docker:dind >/dev/null
+for _ in $(seq 1 30); do
+ "$DOCKER" exec "$CN" docker info >/dev/null 2>&1 && break
+ sleep 1
+done
+"$DOCKER" exec "$CN" docker info >/dev/null 2>&1 || fail "inner daemon never came up"
+echo "inner daemon: $("$DOCKER" exec "$CN" docker version --format '{{.Server.Version}}'), uid $("$DOCKER" exec "$CN" id -u)"
+
+echo "--- installing act $ACT_VERSION + tools inside ---"
+"$DOCKER" exec "$CN" sh -c 'apk add --no-cache bash git curl tar >/dev/null'
+arch="$("$DOCKER" exec "$CN" uname -m)"
+case "$arch" in
+ aarch64) a=arm64 ;;
+ x86_64) a=x86_64 ;;
+ *) a="$arch" ;;
+esac
+"$DOCKER" exec "$CN" sh -c \
+ "curl -fsSL https://github.com/nektos/act/releases/download/${ACT_VERSION}/act_Linux_${a}.tar.gz | tar -xz -C /usr/local/bin act" \
+ || fail "could not install act"
+"$DOCKER" exec "$CN" docker pull -q "$JOB_IMAGE" >/dev/null || fail "could not pull $JOB_IMAGE"
+
+echo "--- writing a workflow that builds an image ---"
+"$DOCKER" exec "$CN" sh -c 'mkdir -p /repo/.gitmote/workflows; cat > /repo/.gitmote/workflows/build.yml <<YML
+name: build
+on: push
+defaults:
+ run:
+ shell: sh
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - run: |
+ printf "FROM alpine\nRUN echo IMAGE_BUILT_OK\n" > Dockerfile
+ docker build -t probe .
+YML'
+
+# actArgs always passes --workflows; the only difference between off and on is the
+# socket-suppression flag below.
+act_off() { "$DOCKER" exec -w /repo "$CN" act --workflows .gitmote/workflows -P "ubuntu-latest=$JOB_IMAGE" --container-daemon-socket - push; }
+act_on() { "$DOCKER" exec -w /repo "$CN" act --workflows .gitmote/workflows -P "ubuntu-latest=$JOB_IMAGE" push; }
+
+echo "--- builds OFF (default: --container-daemon-socket -) — expect FAIL ---"
+if act_off >/dev/null 2>&1; then
+ fail "build succeeded with the socket suppressed (gate leaks)"
+fi
+echo "OK: build failed with no daemon access (as designed)"
+
+echo "--- builds ON (GITMOTE_CI_ALLOW_BUILDS=1: socket mounted) — expect PASS ---"
+act_on >/dev/null 2>&1 || fail "build failed even with daemon access"
+"$DOCKER" exec "$CN" docker image inspect probe >/dev/null 2>&1 || fail "no image produced"
+echo "OK: image built and present in the daemon ($("$DOCKER" exec "$CN" docker image inspect probe --format '{{.RepoTags}}'))"
+
+echo "E2E BUILD PASS: the socket gate flips a real image build off/on as designed."
diff --git a/tasks/README.md b/tasks/README.md
index ac983d6..fad7de2 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -8,7 +8,9 @@ The **"easy to operate"** theme has fully landed: auto-generated secrets +
first-run auto-bootstrap (shrink the surface), public GHCR images with
`make image`/`prod`/`publish` (the image), and the `docker run` quickstart + env /
CI-substrate docs (the cap). `docker run ghcr.io/atmin/gitmote` with a bucket and
-credentials is now a working forge; kill/restart restores from S3.
+credentials is a working forge; kill/restart restores from S3. CI on a
+daemon-backed local/VPS runner builds container images behind the
+`GITMOTE_CI_ALLOW_BUILDS` opt-in (off by default — trusted repos only).
The **URLs** epic has fully landed: a flat single-segment repo namespace,
`tree`/`blob`/`raw` content routing with in-path greedy refs, rendered-markdown
@@ -16,5 +18,11 @@ link rewriting, public/private visibility with spectators & collaborators, and a
viewer-scoped dashboard with top-level globals and per-repo management. See
[../docs/architecture/urls.md](../docs/architecture/urls.md).
-No active tasks. Speculative directions live in
-[../docs/evolution/](../docs/evolution/).
+Upcoming:
+
+- **Self-deploy loop** — a VPS runner can build gitmote's own image (opt-in
+ above); wire build → push → redeploy with a latest-wins SHA guard and a restart
+ mechanism. Design/reasoning in
+ [../docs/evolution/ci-runner.md](../docs/evolution/ci-runner.md).
+
+Other speculative directions live in [../docs/evolution/](../docs/evolution/).