diff --git a/Makefile b/Makefile
index f3aaea5..8d8cfea 100644
--- a/Makefile
+++ b/Makefile
@@ -23,6 +23,7 @@ test:
build:
go build -ldflags "-X main.version=$(VERSION)" -o bin/gitmote ./cmd/gitmote
go build -o bin/gitmote-hook ./cmd/gitmote-hook
+ go build -o bin/gitmote-runner ./cmd/gitmote-runner
# Local dev: MinIO in a container (S3 :9100) + gitmote run natively on :8080,
# with a bootstrapped admin/token/repo persisted under data/ across restarts.
diff --git a/README.md b/README.md
index 5b044b3..83c2c7c 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,13 @@ make dev-reset # wipe MinIO + data/ for a clean slate
Sign in to the UI at <http://localhost:8080/ui> by pasting the token, or
clone/push straight away with the printed URL. Requires Docker + Docker Compose.
+**CI works locally too.** Push a repo with `.github/workflows/*.yml` and gitmote
+records a run and executes it on the spot — it spawns `gitmote-runner` as a local
+process (the *same* runner code the cloud path runs on Scaleway, just a local
+substrate) which runs the workflow with [`act`](https://github.com/nektos/act).
+Install act (`brew install act`); it uses the same Docker/podman daemon MinIO
+does. Watch runs in the UI.
+
## Run it locally
`make e2e-local` brings up a gitmote container plus MinIO with
diff --git a/cmd/gitmote-runner/main.go b/cmd/gitmote-runner/main.go
new file mode 100644
index 0000000..a59643c
--- /dev/null
+++ b/cmd/gitmote-runner/main.go
@@ -0,0 +1,34 @@
+// Command gitmote-runner executes one CI job: it claims a queued job from
+// gitmote's internal report API, checks out the repo at the pushed SHA, runs the
+// workflow with act, and reports the log + pass/fail back. It is spawned per job
+// — locally by LocalTrigger (dev) or as a Scaleway Serverless Job (cloud) — and
+// reads its coordinates from the environment the trigger injects.
+package main
+
+import (
+ "context"
+ "log/slog"
+ "os"
+
+ "github.com/atmin/gitmote/internal/runner"
+)
+
+func main() {
+ logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
+
+ cfg, err := runner.ConfigFromEnv()
+ if err != nil {
+ logger.Error("ci runner: bad configuration", "error", err)
+ os.Exit(1)
+ }
+ cfg.Cloner = runner.GitCloner{}
+ cfg.Engine = runner.ActEngine{}
+ cfg.Logger = logger
+
+ if err := runner.Run(context.Background(), cfg); err != nil {
+ // An internal failure we could not report — exit non-zero so a crashed
+ // runner is distinguishable from a failed build (which reports and exits 0).
+ logger.Error("ci runner: internal failure", "job", cfg.JobID, "error", err)
+ os.Exit(1)
+ }
+}
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 5c59619..1a8ba60 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -32,6 +32,15 @@ var version = "dev"
const shutdownTimeout = 10 * time.Second
+const (
+ // reconcileInterval is how often the leader sweeps abandoned CI jobs.
+ reconcileInterval = 5 * time.Minute
+ // reconcileMaxAge bounds a running job: past this, a runner is presumed dead
+ // and its job is failed. It matches the clone-token TTL — a job that outlives
+ // its credential can't finish anyway.
+ reconcileMaxAge = time.Hour
+)
+
func main() {
// Subcommands run without the server (single-writer admin, per
// docs/notes/bootstrap.md); the default (no subcommand) is the server.
@@ -121,6 +130,10 @@ func run(ctx context.Context, logger *slog.Logger, addr string) error {
Handler: newHandler(gitHandler, ui, reportAPI),
}
+ if reportAPI != nil {
+ go reconcileLoop(ctx, reportAPI, logger)
+ }
+
errCh := make(chan error, 1)
go func() {
logger.Info("listening", "addr", addr, "version", version)
@@ -145,6 +158,24 @@ func run(ctx context.Context, logger *slog.Logger, addr string) error {
return nil
}
+// reconcileLoop periodically sweeps abandoned CI jobs (a runner that died mid-run
+// leaves a job stuck in running). ReconcileStuck is leader-gated internally, so a
+// follower's ticks are no-ops. It stops when ctx is cancelled (shutdown).
+func reconcileLoop(ctx context.Context, reportAPI *ci.ReportAPI, logger *slog.Logger) {
+ t := time.NewTicker(reconcileInterval)
+ defer t.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ if err := reportAPI.ReconcileStuck(ctx, reconcileMaxAge, time.Now()); err != nil {
+ logger.Error("ci: reconcile sweep failed", "error", err)
+ }
+ }
+ }
+}
+
// 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
@@ -219,26 +250,44 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
}
cacheRoot := envOr("GITMOTE_CACHE", filepath.Join(os.TempDir(), "gitmote-repos"))
materializer := repo.New(md, objs, cacheRoot)
- // The Scaleway Jobs client triggers a runner per CI job. It no-ops when
- // SCW_CI_JOB_DEFINITION_ID is unset (local dev / tests / e2e), so the write
- // path runs fully except for the external call. SCW_REGION falls back to the
- // AWS_REGION already set for the object store.
+ guard := auth.NewGuard(md)
+
+ // Select the CI trigger. Cloud and local run the *same* runner code and env
+ // contract; only the substrate differs — Scaleway Serverless Jobs in
+ // production, a local process for dev (tasks/16-ci.md, tasks/21-ci-runner.md).
+ // Both need a reachable GITMOTE_URL (the runner clones + reports back over it)
+ // and a WORKER_SECRET (authenticates the report API). With neither configured,
+ // runs still record but nothing executes.
jobDefID := os.Getenv("SCW_CI_JOB_DEFINITION_ID")
workerSecret := os.Getenv("WORKER_SECRET")
gitmoteURL := os.Getenv("GITMOTE_URL")
- if jobDefID != "" && (workerSecret == "" || gitmoteURL == "") {
- _ = writer.Close()
- _ = md.Close()
- return nil, nil, nil, noop, fmt.Errorf("SCW_CI_JOB_DEFINITION_ID is set but WORKER_SECRET or GITMOTE_URL is missing")
+ var trigger ci.Trigger = ci.NoopTrigger{}
+ switch {
+ case jobDefID != "":
+ if workerSecret == "" || gitmoteURL == "" {
+ _ = writer.Close()
+ _ = md.Close()
+ return nil, nil, nil, noop, fmt.Errorf("SCW_CI_JOB_DEFINITION_ID is set but WORKER_SECRET or GITMOTE_URL is missing")
+ }
+ // SCW_REGION falls back to the AWS_REGION already set for the object store.
+ trigger = scaleway.NewJobsClient(os.Getenv("SCW_SECRET_KEY"), envOr("SCW_REGION", os.Getenv("AWS_REGION")), jobDefID)
+ logger.Info("CI trigger: Scaleway Serverless Jobs", "job_definition_id", jobDefID)
+ case workerSecret != "" && gitmoteURL != "":
+ trigger = ci.NewLocalTrigger(runnerBinaryPath(), logger)
+ logger.Info("CI trigger: local runner", "runner", runnerBinaryPath(), "url", gitmoteURL)
+ default:
+ logger.Warn("CI trigger disabled; runs record but do not execute " +
+ "(set GITMOTE_URL + WORKER_SECRET for local, or SCW_CI_JOB_DEFINITION_ID for cloud)")
}
- jobs := scaleway.NewJobsClient(os.Getenv("SCW_SECRET_KEY"), envOr("SCW_REGION", os.Getenv("AWS_REGION")), jobDefID)
+
// 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).
dispatcher := ci.NewDispatcher(ci.Config{
Runs: md,
Materializer: materializer,
- Trigger: jobs,
+ Trigger: trigger,
+ Minter: guard,
BaseURL: gitmoteURL,
WorkerSecret: workerSecret,
Logger: logger,
@@ -247,7 +296,7 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
// completions (a follower returns a retryable 503), and it authenticates with
// the same WORKER_SECRET injected into the runner env.
reportAPI := ci.NewReportAPI(md, objs, md.IsLeader, workerSecret, logger)
- writer.AfterCommit = func(ctx context.Context, commits []githttp.CommitInfo) {
+ writer.AfterCommit = func(ctx context.Context, pusherID int64, commits []githttp.CommitInfo) {
for _, c := range commits {
dispatcher.Dispatch(ctx, ci.Event{
RepoID: c.RepoID,
@@ -255,6 +304,7 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
Ref: c.Ref,
OldSHA: c.Old,
NewSHA: c.New,
+ PusherID: pusherID,
})
}
}
@@ -272,7 +322,6 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
return err
}
- guard := auth.NewGuard(md)
handler, err := githttp.New(githttp.Config{
Materializer: materializer,
Authorizer: guard,
@@ -317,6 +366,18 @@ func hookBinaryPath() string {
return "gitmote-hook"
}
+// runnerBinaryPath resolves the CI runner executable the local trigger spawns:
+// GITMOTE_RUNNER if set, otherwise gitmote-runner next to this binary.
+func runnerBinaryPath() string {
+ if p := os.Getenv("GITMOTE_RUNNER"); p != "" {
+ return p
+ }
+ if exe, err := os.Executable(); err == nil {
+ return filepath.Join(filepath.Dir(exe), "gitmote-runner")
+ }
+ return "gitmote-runner"
+}
+
// metaConfigFromEnv builds the metadata database config from the environment.
// GITMOTE_DB_REPLICA is an s3:// URL used for both restore-on-cold-start and
// continuous backup (litestream); empty leaves the database local-only (tests,
diff --git a/docs/ops.md b/docs/ops.md
index 4bc5e5e..1063619 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -85,19 +85,27 @@ Setting these on the container does nothing for CI, and vice versa.
| `GITMOTE_DB` | `/tmp/gitmote/meta.sqlite3` — ephemeral; restored from the replica on cold start |
| `GITMOTE_CACHE` | `/tmp/gitmote/cache` — ephemeral; materialized repos rebuild from S3 |
| `GITMOTE_SOCK` | `/tmp/gitmote/gitmote.sock` — pre-receive hook RPC socket |
+| `GITMOTE_HOOK` | pre-receive hook binary (defaults to `gitmote-hook` beside the server) |
+| `GITMOTE_RUNNER` | CI runner binary the local trigger spawns (defaults to `gitmote-runner` beside the server) |
| `GITMOTE_COOKIE_KEY` | secret — signs management-UI session cookies (enables `/ui`) |
| `GITMOTE_URL` | public base URL (`https://gitmote.atmin.net`) — injected into the CI runner's env so it clones and reports back here |
-| `SCW_CI_JOB_DEFINITION_ID` | the Scaleway Serverless Job definition for the CI runner; **unset disables the trigger** (runs/jobs still record). One-time `scw jobs definition create` lands with the runner (stage 4) |
+| `SCW_CI_JOB_DEFINITION_ID` | the Scaleway Serverless Job definition for the CI runner. **Set → cloud trigger** (Scaleway job per CI job) |
| `SCW_SECRET_KEY` | secret — Scaleway API secret key (the UUID) used to start the CI job; also the registry/deploy key |
| `SCW_REGION` | Scaleway region for the CI job start (falls back to `AWS_REGION`) |
-| `WORKER_SECRET` | secret — shared runner-auth secret; injected into the runner env and compared on its report-back (stage 4) |
+| `WORKER_SECRET` | secret — shared runner-auth secret; injected into the runner env and compared on its report-back |
| `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 |
-> `SCW_CI_JOB_DEFINITION_ID` gates CI execution: unset (local dev, tests, e2e) the
-> dispatcher records runs/jobs but the Scaleway trigger no-ops. When it **is** set,
-> `WORKER_SECRET` and `GITMOTE_URL` are required — the server refuses to start
-> otherwise, so a half-configured CI can't silently trigger unreachable runners.
+> **CI trigger selection.** The dispatcher always records runs/jobs; what executes
+> them depends on the env. With `SCW_CI_JOB_DEFINITION_ID` set, the **Scaleway**
+> trigger starts a Serverless Job per CI job (cloud); it then requires
+> `WORKER_SECRET` and `GITMOTE_URL`, and the server refuses to start without them.
+> With that unset but `GITMOTE_URL` + `WORKER_SECRET` present, the **local** trigger
+> spawns `gitmote-runner` as a local process — the *same runner code and env
+> contract*, a local substrate instead of a cloud job (this is what `make dev`
+> uses). With none of them set, runs record but nothing executes. The runner runs
+> `.github/workflows` with `act`, which needs a reachable Docker daemon. A
+> leader-only ticker sweeps jobs stuck `running` past ~1h back to `error`.
`GITMOTE_DB` and `GITMOTE_CACHE` are ephemeral on purpose: the object store +
litestream replica are the durable state, and the local disk is a cache. On a
diff --git a/internal/ci/dispatcher.go b/internal/ci/dispatcher.go
index 996c368..7e9c83b 100644
--- a/internal/ci/dispatcher.go
+++ b/internal/ci/dispatcher.go
@@ -14,6 +14,7 @@ import (
"log/slog"
"strconv"
"strings"
+ "time"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
@@ -26,6 +27,10 @@ const (
branchPrefix = "refs/heads/"
// workflowDir is the fixed, traversal-safe location engine (act) reads.
workflowDir = ".github/workflows"
+ // cloneTokenTTL bounds the per-run clone credential: a run's max duration
+ // plus margin. The token is read-only and repo-scoped, so it grants no more
+ // than a fetch of the one repo, and expires long before it could be reused.
+ cloneTokenTTL = time.Hour
)
// Runs is the slice of meta the dispatcher writes through. *meta.Metadata
@@ -44,11 +49,19 @@ type Materializer interface {
}
// Trigger starts one CI job's runner with the given env. *scaleway.JobsClient
-// satisfies it; it no-ops when unconfigured (local dev / tests).
+// (cloud) and *LocalTrigger (local dev) both satisfy it; the Scaleway client
+// no-ops when unconfigured.
type Trigger interface {
Trigger(ctx context.Context, env map[string]string) error
}
+// Minter mints the per-run clone credential: a read-only, repo-scoped, expiring
+// token the runner uses to fetch the repo over the normal git-HTTP path.
+// *auth.Guard satisfies it. Nil disables minting (the clone token is left empty).
+type Minter interface {
+ MintScoped(ctx context.Context, userID int64, label string, repoScope *int64, readOnly bool, expiresAt time.Time) (string, error)
+}
+
// Event is one updated ref from a completed push.
type Event struct {
RepoID int64
@@ -56,6 +69,10 @@ type Event struct {
Ref string
OldSHA string
NewSHA string
+ // PusherID is the authenticated user that pushed. The clone token is minted
+ // under it (scoped read-only to this repo), since the pusher necessarily holds
+ // repo read — no separate CI service identity or ACL grant is needed.
+ PusherID int64
}
// Config wires the dispatcher's collaborators. BaseURL and WorkerSecret are
@@ -65,6 +82,7 @@ type Config struct {
Runs Runs
Materializer Materializer
Trigger Trigger
+ Minter Minter // mints the per-run clone token; nil leaves it empty
BaseURL string // GITMOTE_URL
WorkerSecret string // WORKER_SECRET
Logger *slog.Logger
@@ -77,9 +95,11 @@ type Dispatcher struct {
runs Runs
mz Materializer
trigger Trigger
+ minter Minter
baseURL string
secret string
logger *slog.Logger
+ now func() time.Time // injectable clock for the clone-token expiry
}
// NewDispatcher returns a Dispatcher from cfg.
@@ -92,9 +112,11 @@ func NewDispatcher(cfg Config) *Dispatcher {
runs: cfg.Runs,
mz: cfg.Materializer,
trigger: cfg.Trigger,
+ minter: cfg.Minter,
baseURL: cfg.BaseURL,
secret: cfg.WorkerSecret,
logger: logger,
+ now: time.Now,
}
}
@@ -158,6 +180,10 @@ func (d *Dispatcher) recordRun(ctx context.Context, ev Event, dir string, files
"repo", ev.RepoName, "ref", ev.Ref, "sha", ev.NewSHA, "error", err)
return
}
+ // Mint one read-only, repo-scoped, expiring clone token for the whole run;
+ // every job of a run clones the same repo at the same SHA. A mint failure is
+ // non-fatal — the run still records, and the runner's clone will fail visibly.
+ cloneToken := d.mintCloneToken(ctx, ev, run.ID)
var (
parseErr error
jobs int
@@ -176,7 +202,7 @@ func (d *Dispatcher) recordRun(ctx context.Context, ev Event, dir string, files
continue
}
jobs++
- if err := d.trigger.Trigger(ctx, d.jobEnv(ev, run.ID, job.ID)); err != nil {
+ if err := d.trigger.Trigger(ctx, d.jobEnv(ev, run.ID, job.ID, cloneToken)); err != nil {
d.logger.Error("ci: trigger failed",
"run", run.ID, "job", job.ID, "name", f.Name, "error", err)
if err := d.runs.SetJobStatus(ctx, job.ID, meta.RunError); err != nil {
@@ -196,9 +222,9 @@ func (d *Dispatcher) recordRun(ctx context.Context, ev Event, dir string, files
}
}
-// jobEnv is the per-job environment injected into the triggered runner. The
-// clone token is a placeholder until scoped tokens land (task 20).
-func (d *Dispatcher) jobEnv(ev Event, runID, jobID int64) map[string]string {
+// jobEnv is the per-job environment injected into the triggered runner: the
+// coordinates it claims and reports against, plus the scoped clone token.
+func (d *Dispatcher) jobEnv(ev Event, runID, jobID int64, cloneToken string) map[string]string {
return map[string]string{
"GITMOTE_CI_RUN_ID": strconv.FormatInt(runID, 10),
"GITMOTE_CI_JOB_ID": strconv.FormatInt(jobID, 10),
@@ -207,8 +233,27 @@ func (d *Dispatcher) jobEnv(ev Event, runID, jobID int64) map[string]string {
"GITMOTE_REPO": ev.RepoName,
"GITMOTE_SHA": ev.NewSHA,
"GITMOTE_REF": ev.Ref,
- "GITMOTE_CI_CLONE_TOKEN": "", // placeholder until task 20
+ "GITMOTE_CI_CLONE_TOKEN": cloneToken,
+ }
+}
+
+// mintCloneToken mints the per-run read-only, repo-scoped, expiring clone token
+// under the pusher's identity. It returns "" (and logs) when no minter is
+// configured or the mint fails — a missing token is non-fatal here; the runner's
+// clone simply fails and the job is reported errored, never a failed push.
+func (d *Dispatcher) mintCloneToken(ctx context.Context, ev Event, runID int64) string {
+ if d.minter == nil {
+ return ""
+ }
+ repoID := ev.RepoID
+ label := "ci-clone run " + strconv.FormatInt(runID, 10)
+ tok, err := d.minter.MintScoped(ctx, ev.PusherID, label, &repoID, true, d.now().Add(cloneTokenTTL))
+ if err != nil {
+ d.logger.Error("ci: mint clone token failed",
+ "repo", ev.RepoName, "run", runID, "error", err)
+ return ""
}
+ return tok
}
// setRunStatus transitions a run, logging (not returning) any error.
diff --git a/internal/ci/dispatcher_test.go b/internal/ci/dispatcher_test.go
index 9d5e721..59d3387 100644
--- a/internal/ci/dispatcher_test.go
+++ b/internal/ci/dispatcher_test.go
@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"testing"
+ "time"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
@@ -311,6 +312,87 @@ func TestDispatchOneTriggerFailsOthersProceed(t *testing.T) {
}
}
+// stubMinter records the MintScoped call and returns a fixed token.
+type stubMinter struct {
+ token string
+ err error
+ userID int64
+ repoScope *int64
+ readOnly bool
+ expiresAt time.Time
+}
+
+func (m *stubMinter) MintScoped(_ context.Context, userID int64, _ string, repoScope *int64, readOnly bool, expiresAt time.Time) (string, error) {
+ m.userID = userID
+ m.repoScope = repoScope
+ m.readOnly = readOnly
+ m.expiresAt = expiresAt
+ return m.token, m.err
+}
+
+func TestDispatchMintsScopedCloneToken(t *testing.T) {
+ ctx := context.Background()
+ md, s, mz := newFixture(t)
+ r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ ".github/workflows/ci.yml": "name: CI\non: push\n",
+ })
+
+ tr := &stubTrigger{}
+ mint := &stubMinter{token: "gmt_scoped.tok"}
+ NewDispatcher(Config{
+ Runs: md, Materializer: mz, Trigger: tr, Minter: mint,
+ BaseURL: "https://gitmote.test", WorkerSecret: "worker-secret",
+ }).Dispatch(ctx, Event{
+ RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/main",
+ OldSHA: meta.ZeroSHA, NewSHA: head, PusherID: 42,
+ })
+
+ if len(tr.calls) != 1 {
+ t.Fatalf("trigger calls = %d, want 1", len(tr.calls))
+ }
+ if got := tr.calls[0]["GITMOTE_CI_CLONE_TOKEN"]; got != "gmt_scoped.tok" {
+ t.Errorf("GITMOTE_CI_CLONE_TOKEN = %q, want the minted token", got)
+ }
+ // Minted under the pusher, read-only, scoped to just this repo, and expiring.
+ if mint.userID != 42 {
+ t.Errorf("mint userID = %d, want the pusher (42)", mint.userID)
+ }
+ if mint.repoScope == nil || *mint.repoScope != r.ID {
+ t.Errorf("mint repoScope = %v, want &%d", mint.repoScope, r.ID)
+ }
+ if !mint.readOnly {
+ t.Error("mint readOnly = false, want true — the clone token must be read-only")
+ }
+ if mint.expiresAt.IsZero() {
+ t.Error("mint expiresAt is zero, want a bounded TTL")
+ }
+}
+
+func TestDispatchMintFailureLeavesRunUsable(t *testing.T) {
+ ctx := context.Background()
+ md, s, mz := newFixture(t)
+ r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ ".github/workflows/ci.yml": "name: CI\non: push\n",
+ })
+
+ // A mint failure is non-fatal: the run and job still record and trigger, with
+ // an empty clone token (the runner's clone fails visibly, not the push).
+ tr := &stubTrigger{}
+ mint := &stubMinter{err: errors.New("mint down")}
+ NewDispatcher(Config{
+ Runs: md, Materializer: mz, Trigger: tr, Minter: mint,
+ BaseURL: "https://gitmote.test", WorkerSecret: "worker-secret",
+ }).Dispatch(ctx, branchEvent(r, head))
+
+ runs, _ := md.ListRuns(ctx, r.ID, 0)
+ if len(runs) != 1 {
+ t.Fatalf("runs = %d, want 1 despite mint failure", len(runs))
+ }
+ if len(tr.calls) != 1 || tr.calls[0]["GITMOTE_CI_CLONE_TOKEN"] != "" {
+ t.Errorf("want one trigger with an empty clone token, got %+v", tr.calls)
+ }
+}
+
// failNthTrigger fails the Nth (1-based) trigger call and succeeds the others.
type failNthTrigger struct {
failOn int
diff --git a/internal/ci/local.go b/internal/ci/local.go
new file mode 100644
index 0000000..1ed413e
--- /dev/null
+++ b/internal/ci/local.go
@@ -0,0 +1,68 @@
+package ci
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/exec"
+)
+
+// NoopTrigger records a run but executes nothing. It is the fallback when no
+// runner substrate is configured (neither local nor Scaleway) — dispatch stays
+// fire-and-forget and the run simply never leaves the queued state.
+type NoopTrigger struct{}
+
+// Trigger implements Trigger.
+func (NoopTrigger) Trigger(context.Context, map[string]string) error { return nil }
+
+// LocalTrigger runs a CI job by spawning the gitmote-runner binary as a local
+// process, mirroring how the Scaleway client starts a container: same runner
+// code, same env contract, a local substrate instead of a cloud job. It is
+// fire-and-forget — Start (not Wait) returns immediately so dispatch never
+// blocks the push; a background goroutine reaps the process and logs its exit.
+type LocalTrigger struct {
+ binary string
+ logger *slog.Logger
+}
+
+// NewLocalTrigger returns a LocalTrigger that spawns the runner at binaryPath.
+func NewLocalTrigger(binaryPath string, logger *slog.Logger) *LocalTrigger {
+ if logger == nil {
+ logger = slog.New(slog.DiscardHandler)
+ }
+ return &LocalTrigger{binary: binaryPath, logger: logger}
+}
+
+// Trigger starts the runner with env layered over the parent environment (so the
+// child inherits PATH, DOCKER_HOST, etc. that act needs). It returns the Start
+// error only; a non-zero runner exit is reported to the report API by the runner
+// itself, and reaped here for logging.
+func (t *LocalTrigger) Trigger(_ context.Context, env map[string]string) error {
+ // Deliberately not tied to the dispatch ctx: the runner outlives the push
+ // request that triggered it. It reports its own outcome and bounds its own
+ // work.
+ cmd := exec.Command(t.binary)
+ cmd.Env = append(os.Environ(), flattenEnv(env)...)
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("start local runner %s: %w", t.binary, err)
+ }
+ jobID := env["GITMOTE_CI_JOB_ID"]
+ go func() {
+ if err := cmd.Wait(); err != nil {
+ t.logger.Warn("local runner exited nonzero", "job", jobID, "pid", cmd.Process.Pid, "error", err)
+ } else {
+ t.logger.Debug("local runner finished", "job", jobID, "pid", cmd.Process.Pid)
+ }
+ }()
+ return nil
+}
+
+// flattenEnv turns a map into the KEY=VALUE slice exec expects.
+func flattenEnv(env map[string]string) []string {
+ out := make([]string, 0, len(env))
+ for k, v := range env {
+ out = append(out, k+"="+v)
+ }
+ return out
+}
diff --git a/internal/ci/local_test.go b/internal/ci/local_test.go
new file mode 100644
index 0000000..1b91598
--- /dev/null
+++ b/internal/ci/local_test.go
@@ -0,0 +1,67 @@
+package ci
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+ "time"
+)
+
+func TestNoopTrigger(t *testing.T) {
+ if err := (NoopTrigger{}).Trigger(context.Background(), map[string]string{"X": "1"}); err != nil {
+ t.Fatalf("NoopTrigger.Trigger = %v, want nil", err)
+ }
+}
+
+func TestFlattenEnv(t *testing.T) {
+ got := flattenEnv(map[string]string{"A": "1", "B": "2"})
+ sort.Strings(got)
+ if len(got) != 2 || got[0] != "A=1" || got[1] != "B=2" {
+ t.Errorf("flattenEnv = %v, want [A=1 B=2]", got)
+ }
+}
+
+// TestLocalTriggerSpawnsRunnerWithEnv proves the local trigger runs the runner
+// binary with the injected env — the local analog of Scaleway starting a job.
+func TestLocalTriggerSpawnsRunnerWithEnv(t *testing.T) {
+ dir := t.TempDir()
+ out := filepath.Join(dir, "out")
+ script := filepath.Join(dir, "fake-runner")
+ // A stand-in runner: record the injected coordinates so the test can assert
+ // they were passed through.
+ body := "#!/bin/sh\nprintf '%s %s' \"$GITMOTE_CI_JOB_ID\" \"$GITMOTE_CI_CLONE_TOKEN\" > \"$OUT_FILE\"\n"
+ if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ tr := NewLocalTrigger(script, nil)
+ if err := tr.Trigger(context.Background(), map[string]string{
+ "GITMOTE_CI_JOB_ID": "7",
+ "GITMOTE_CI_CLONE_TOKEN": "tok",
+ "OUT_FILE": out,
+ }); err != nil {
+ t.Fatalf("Trigger: %v", err)
+ }
+
+ // Fire-and-forget: poll for the spawned runner to finish and write the file.
+ var data []byte
+ for range 200 {
+ if b, err := os.ReadFile(out); err == nil {
+ data = b
+ break
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ if string(data) != "7 tok" {
+ t.Errorf("spawned runner recorded %q, want %q", data, "7 tok")
+ }
+}
+
+func TestLocalTriggerMissingBinaryErrors(t *testing.T) {
+ tr := NewLocalTrigger(filepath.Join(t.TempDir(), "nope"), nil)
+ if err := tr.Trigger(context.Background(), nil); err == nil {
+ t.Error("Trigger with a missing binary = nil, want an error")
+ }
+}
diff --git a/internal/ci/report.go b/internal/ci/report.go
index 9b66e60..161c2a3 100644
--- a/internal/ci/report.go
+++ b/internal/ci/report.go
@@ -212,9 +212,13 @@ func (a *ReportAPI) rollupRun(ctx context.Context, runID int64) {
}
// ReconcileStuck marks running jobs older than maxAge as error and rolls up their
-// runs. The leader calls it periodically so a runner that dies without reporting
-// doesn't leave a job running forever.
+// runs, so a runner that dies without reporting doesn't leave a job running
+// forever. It is leader-gated: only the writer may touch s3lite, so a follower
+// no-ops (the leader's sweep covers the shared state).
func (a *ReportAPI) ReconcileStuck(ctx context.Context, maxAge time.Duration, now time.Time) error {
+ if !a.isLeader() {
+ return nil
+ }
stuck, err := a.meta.SweepStuckJobs(ctx, now.Add(-maxAge))
if err != nil {
return err
diff --git a/internal/githttp/backend.go b/internal/githttp/backend.go
index abb9e2a..8559d73 100644
--- a/internal/githttp/backend.go
+++ b/internal/githttp/backend.go
@@ -111,13 +111,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
- if _, err := h.authz.Authorize(r, repoName, perm); err != nil {
+ user, err := h.authz.Authorize(r, repoName, perm)
+ if err != nil {
h.writeAuthError(w, r, repoName, err)
return
}
if isPush {
- h.serveReceivePack(w, r, repoName)
+ h.serveReceivePack(w, r, repoName, user.ID)
return
}
@@ -145,7 +146,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// from the sources of truth, mint a nonce, and hand off to receive-pack with the
// pre-receive hook wired to call back on the socket. The hook's callback does
// the object PUT + ref CAS (see Writer.handle).
-func (h *Handler) serveReceivePack(w http.ResponseWriter, r *http.Request, repoName string) {
+func (h *Handler) serveReceivePack(w http.ResponseWriter, r *http.Request, repoName string, pusherID int64) {
push, err := h.writer.Begin(r.Context(), repoName)
if err != nil {
if errors.Is(err, meta.ErrNotFound) {
@@ -178,7 +179,7 @@ func (h *Handler) serveReceivePack(w http.ResponseWriter, r *http.Request, repoN
// after-commit hook now, so a CI dispatch reads a warm, consistent repo — never
// racing receive-pack's ref finalization. Fire-and-forget: it never fails the
// already-committed push.
- h.writer.FireAfterCommit(push.Committed())
+ h.writer.FireAfterCommit(pusherID, push.Committed())
}
// materialize builds the on-disk repo, writing the HTTP error itself on failure
diff --git a/internal/githttp/receive.go b/internal/githttp/receive.go
index 5c28126..339b2ab 100644
--- a/internal/githttp/receive.go
+++ b/internal/githttp/receive.go
@@ -51,9 +51,10 @@ type Writer struct {
// HTTP handler after receive-pack has fully finished (see FireAfterCommit), so
// the on-disk repo already reflects the new refs. It is fire-and-forget: the
// push has already committed, so any panic or slowness must not fail the push
- // (content-before-pointer applied to CI — safety.md §3). It receives one
- // CommitInfo per updated ref.
- AfterCommit func(context.Context, []CommitInfo)
+ // (content-before-pointer applied to CI — safety.md §3). It receives the
+ // authenticated pusher's user id (the CI clone token is minted under it, since
+ // the pusher necessarily holds repo read) and one CommitInfo per updated ref.
+ AfterCommit func(ctx context.Context, pusherID int64, commits []CommitInfo)
}
// CommitInfo describes one ref an accepted push advanced. It carries the repo
@@ -295,7 +296,8 @@ func (w *Writer) handle(req hookrpc.Request) hookrpc.Response {
// already reflects the new refs and a dispatch's Materialize is a warm no-op
// rather than a ref update that races receive-pack's own. It is fire-and-forget:
// the push has already committed, so any panic or error must not fail the push.
-func (w *Writer) FireAfterCommit(commits []CommitInfo) {
+// pusherID is the authenticated user that pushed (the CI clone token owner).
+func (w *Writer) FireAfterCommit(pusherID int64, commits []CommitInfo) {
if w.AfterCommit == nil || len(commits) == 0 {
return
}
@@ -306,7 +308,7 @@ func (w *Writer) FireAfterCommit(commits []CommitInfo) {
}()
ctx, cancel := context.WithTimeout(context.Background(), afterCommitTimeout)
defer cancel()
- w.AfterCommit(ctx, commits)
+ w.AfterCommit(ctx, pusherID, commits)
}
// putObjects mirrors the quarantine's git objects into the store under the
diff --git a/internal/githttp/receive_test.go b/internal/githttp/receive_test.go
index 34f96bb..9cf70e5 100644
--- a/internal/githttp/receive_test.go
+++ b/internal/githttp/receive_test.go
@@ -242,7 +242,7 @@ func wireDispatcher(t *testing.T, w *Writer, m *meta.Metadata, s store.Store) {
Trigger: noopTrigger{},
Logger: slog.New(slog.DiscardHandler),
})
- w.AfterCommit = func(ctx context.Context, commits []CommitInfo) {
+ w.AfterCommit = func(ctx context.Context, pusherID int64, commits []CommitInfo) {
for _, c := range commits {
d.Dispatch(ctx, ci.Event{
RepoID: c.RepoID,
@@ -250,6 +250,7 @@ func wireDispatcher(t *testing.T, w *Writer, m *meta.Metadata, s store.Store) {
Ref: c.Ref,
OldSHA: c.Old,
NewSHA: c.New,
+ PusherID: pusherID,
})
}
}
@@ -378,7 +379,7 @@ func TestRejectedPushEnqueuesNoRun(t *testing.T) {
func TestAfterCommitPanicLeavesPushGreen(t *testing.T) {
ctx := context.Background()
srv, m, _, writer := newWriteServer(t)
- writer.AfterCommit = func(context.Context, []CommitInfo) {
+ writer.AfterCommit = func(context.Context, int64, []CommitInfo) {
panic("dispatch blew up")
}
diff --git a/internal/runner/act.go b/internal/runner/act.go
new file mode 100644
index 0000000..27f774d
--- /dev/null
+++ b/internal/runner/act.go
@@ -0,0 +1,41 @@
+package runner
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os/exec"
+)
+
+// ActEngine runs .github/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, so one definition runs both self-hosted and on the
+// GitHub mirror. It requires `act` on PATH and a reachable Docker daemon.
+type ActEngine struct{}
+
+// Run invokes act over workflowDir inside repoDir on the default (push) event,
+// capturing combined stdout+stderr. act's non-zero exit means the workflow
+// failed (passed=false); a failure to start act at all is an engine error.
+func (ActEngine) Run(ctx context.Context, repoDir, workflowDir string) ([]byte, bool, error) {
+ if _, err := exec.LookPath("act"); err != nil {
+ return nil, false, fmt.Errorf("act not found on PATH — install nektos/act (https://github.com/nektos/act): %w", err)
+ }
+ cmd := exec.CommandContext(ctx, "act", "--workflows", workflowDir)
+ cmd.Dir = repoDir
+ var buf bytes.Buffer
+ cmd.Stdout = &buf
+ cmd.Stderr = &buf
+
+ err := cmd.Run()
+ if err == nil {
+ return buf.Bytes(), true, nil
+ }
+ var ee *exec.ExitError
+ if errors.As(err, &ee) {
+ // act ran and the workflow failed — a normal failed build, not an
+ // internal error.
+ return buf.Bytes(), false, nil
+ }
+ return buf.Bytes(), false, err
+}
diff --git a/internal/runner/git.go b/internal/runner/git.go
new file mode 100644
index 0000000..68d8761
--- /dev/null
+++ b/internal/runner/git.go
@@ -0,0 +1,58 @@
+package runner
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "os"
+ "os/exec"
+ "strings"
+)
+
+// GitCloner clones over the normal git-HTTP path with a scoped token — the same
+// authenticated path any client uses, so there is no CI-only backdoor in the git
+// handler (tasks/16-ci.md Stage 0 #4).
+type GitCloner struct{}
+
+// Clone fetches repo into dest and checks out the exact sha. It authenticates by
+// embedding the token as the HTTP password (the username is ignored by the guard
+// when a password is present). When ref names a branch, the SHA is checked out
+// onto that branch so the engine can resolve github.ref; otherwise it is a
+// detached checkout.
+func (GitCloner) Clone(ctx context.Context, baseURL, repo, sha, ref, token, dest string) error {
+ u, err := url.Parse(baseURL)
+ if err != nil {
+ return fmt.Errorf("parse base url %q: %w", baseURL, err)
+ }
+ u.User = url.UserPassword("x-access-token", token)
+ u.Path = "/" + repo
+
+ if out, err := git(ctx, "", "clone", "--quiet", u.String(), dest); err != nil {
+ return fmt.Errorf("git clone: %w: %s", err, out)
+ }
+ // Check out the pushed SHA precisely — the branch tip may have moved on by
+ // the time the runner clones (a newer push), so never trust HEAD. Put it on
+ // the pushed branch (checkout -B) so the workflow sees a real ref, not a
+ // detached HEAD.
+ if branch := strings.TrimPrefix(ref, "refs/heads/"); branch != "" && branch != ref {
+ if out, err := git(ctx, dest, "checkout", "--quiet", "-B", branch, sha); err != nil {
+ return fmt.Errorf("git checkout -B %s %s: %w: %s", branch, sha, err, out)
+ }
+ return nil
+ }
+ if out, err := git(ctx, dest, "-c", "advice.detachedHead=false", "checkout", "--quiet", sha); err != nil {
+ return fmt.Errorf("git checkout %s: %w: %s", sha, err, out)
+ }
+ return nil
+}
+
+// git runs a git command in dir (repo root; "" for none), returning combined
+// output. GIT_TERMINAL_PROMPT=0 makes a bad credential fail fast instead of
+// hanging on a prompt; an empty credential.helper ignores any host helper.
+func git(ctx context.Context, dir string, args ...string) ([]byte, error) {
+ full := append([]string{"-c", "credential.helper="}, args...)
+ cmd := exec.CommandContext(ctx, "git", full...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
+ return cmd.CombinedOutput()
+}
diff --git a/internal/runner/runner.go b/internal/runner/runner.go
new file mode 100644
index 0000000..2a2b9b2
--- /dev/null
+++ b/internal/runner/runner.go
@@ -0,0 +1,196 @@
+// Package runner is the CI job runner: the code that claims a queued job from
+// gitmote's internal report API, checks out the repo at the pushed SHA, runs the
+// workflow engine over .github/workflows, and reports the combined log +
+// pass/fail back. It is deliberately substrate-agnostic — the same code runs as
+// a local process (LocalTrigger, dev) and, later, inside a Scaleway Serverless
+// Job container (cloud). It never touches s3lite or S3: the leader writes the
+// log blob and status rows in response to the report calls (tasks/16-ci.md).
+package runner
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "time"
+)
+
+// errNotClaimable means the job is absent, already claimed, or terminal — the
+// runner has nothing to do and exits cleanly (another runner owns it, or it is
+// already reported).
+var errNotClaimable = errors.New("job not claimable")
+
+// Cloner fetches a repo at an exact SHA (onto branch ref, when it names one)
+// into dest, authenticating with a scoped token over baseURL's git-HTTP
+// endpoint. GitCloner is the real implementation.
+type Cloner interface {
+ Clone(ctx context.Context, baseURL, repo, sha, ref, token, dest string) error
+}
+
+// Engine runs the workflows under workflowDir within repoDir. It returns the
+// combined log and whether the run passed. A non-nil error means the engine
+// could not execute at all (e.g. act missing) — distinct from a workflow that
+// ran and failed (passed=false, err=nil).
+type Engine interface {
+ Run(ctx context.Context, repoDir, workflowDir string) (log []byte, passed bool, err error)
+}
+
+// Config wires one runner invocation.
+type Config struct {
+ BaseURL string // GITMOTE_URL, e.g. http://localhost:8080
+ JobID int64 // GITMOTE_CI_JOB_ID
+ WorkerSecret string // WORKER_SECRET — authenticates the report API
+ CloneToken string // GITMOTE_CI_CLONE_TOKEN — read-only, repo-scoped, expiring
+
+ Cloner Cloner
+ Engine Engine
+ HTTPClient *http.Client
+ WorkDir string // parent dir for the checkout; a temp dir when empty
+ Logger *slog.Logger
+}
+
+// spec is the job coordinates a claim returns (mirrors the report API's jobSpec).
+type spec struct {
+ JobID int64 `json:"job_id"`
+ RunID int64 `json:"run_id"`
+ Repo string `json:"repo"`
+ SHA string `json:"sha"`
+ Ref string `json:"ref"`
+ WorkflowDir string `json:"workflow_dir"`
+}
+
+// Run executes one job end-to-end: claim → clone → engine → complete. It returns
+// an error only for an internal failure that could not be reported (a failed
+// claim, or a failed completion POST); a workflow that runs and fails is a
+// successful run of the runner that reports status=failed. The caller (the
+// runner binary) exits non-zero on a returned error so a crashed runner is
+// distinguishable from a failed build.
+func Run(ctx context.Context, cfg Config) error {
+ if cfg.Logger == nil {
+ cfg.Logger = slog.New(slog.DiscardHandler)
+ }
+ if cfg.HTTPClient == nil {
+ cfg.HTTPClient = &http.Client{Timeout: 30 * time.Second}
+ }
+
+ sp, err := claim(ctx, cfg)
+ if errors.Is(err, errNotClaimable) {
+ cfg.Logger.Info("ci runner: job not claimable, nothing to do", "job", cfg.JobID)
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("claim job %d: %w", cfg.JobID, err)
+ }
+ cfg.Logger.Info("ci runner: claimed job", "job", sp.JobID, "run", sp.RunID, "repo", sp.Repo, "sha", sp.SHA)
+
+ workDir := cfg.WorkDir
+ if workDir == "" {
+ d, err := os.MkdirTemp("", "gitmote-ci-")
+ if err != nil {
+ return fmt.Errorf("work dir: %w", err)
+ }
+ defer func() { _ = os.RemoveAll(d) }()
+ workDir = d
+ }
+ dest := filepath.Join(workDir, "repo")
+
+ if err := cfg.Cloner.Clone(ctx, cfg.BaseURL, sp.Repo, sp.SHA, sp.Ref, cfg.CloneToken, dest); err != nil {
+ cfg.Logger.Error("ci runner: clone failed", "job", sp.JobID, "error", err)
+ return complete(ctx, cfg, sp.JobID, "error", []byte("clone failed: "+err.Error()+"\n"))
+ }
+
+ log, passed, err := cfg.Engine.Run(ctx, dest, sp.WorkflowDir)
+ if err != nil {
+ cfg.Logger.Error("ci runner: engine error", "job", sp.JobID, "error", err)
+ body := append(append([]byte{}, log...), []byte("\nengine error: "+err.Error()+"\n")...)
+ return complete(ctx, cfg, sp.JobID, "error", body)
+ }
+
+ status := "failed"
+ if passed {
+ status = "passed"
+ }
+ cfg.Logger.Info("ci runner: workflow finished", "job", sp.JobID, "status", status)
+ return complete(ctx, cfg, sp.JobID, status, log)
+}
+
+// claim performs GET /internal/ci/jobs/{id}. A 404 maps to errNotClaimable.
+func claim(ctx context.Context, cfg Config) (spec, error) {
+ u := fmt.Sprintf("%s/internal/ci/jobs/%d", cfg.BaseURL, cfg.JobID)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ if err != nil {
+ return spec{}, err
+ }
+ req.Header.Set("X-Worker-Secret", cfg.WorkerSecret)
+ resp, err := cfg.HTTPClient.Do(req)
+ if err != nil {
+ return spec{}, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode == http.StatusNotFound {
+ return spec{}, errNotClaimable
+ }
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
+ return spec{}, fmt.Errorf("claim: status %d: %s", resp.StatusCode, bytes.TrimSpace(body))
+ }
+ var sp spec
+ if err := json.NewDecoder(resp.Body).Decode(&sp); err != nil {
+ return spec{}, fmt.Errorf("claim: decode spec: %w", err)
+ }
+ return sp, nil
+}
+
+// complete performs POST /internal/ci/jobs/{id}/complete?status=... with the log
+// as the body.
+func complete(ctx context.Context, cfg Config, jobID int64, status string, log []byte) error {
+ u := fmt.Sprintf("%s/internal/ci/jobs/%d/complete?status=%s", cfg.BaseURL, jobID, url.QueryEscape(status))
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(log))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("X-Worker-Secret", cfg.WorkerSecret)
+ req.Header.Set("Content-Type", "text/plain; charset=utf-8")
+ resp, err := cfg.HTTPClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("complete job %d: %w", jobID, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
+ return fmt.Errorf("complete job %d: status %d: %s", jobID, resp.StatusCode, bytes.TrimSpace(body))
+ }
+ return nil
+}
+
+// ConfigFromEnv reads the runner's configuration from the environment injected
+// by the trigger (dispatcher.jobEnv). Cloner, Engine, and Logger are left for
+// the caller to set.
+func ConfigFromEnv() (Config, error) {
+ jobID, err := strconv.ParseInt(os.Getenv("GITMOTE_CI_JOB_ID"), 10, 64)
+ if err != nil {
+ return Config{}, fmt.Errorf("GITMOTE_CI_JOB_ID: %w", err)
+ }
+ cfg := Config{
+ BaseURL: os.Getenv("GITMOTE_URL"),
+ JobID: jobID,
+ WorkerSecret: os.Getenv("WORKER_SECRET"),
+ CloneToken: os.Getenv("GITMOTE_CI_CLONE_TOKEN"),
+ }
+ if cfg.BaseURL == "" {
+ return Config{}, errors.New("GITMOTE_URL is not set")
+ }
+ if cfg.WorkerSecret == "" {
+ return Config{}, errors.New("WORKER_SECRET is not set")
+ }
+ return cfg, nil
+}
diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go
new file mode 100644
index 0000000..0b35dcb
--- /dev/null
+++ b/internal/runner/runner_test.go
@@ -0,0 +1,179 @@
+package runner
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+const testSecret = "worker-secret"
+
+var testSpec = spec{
+ JobID: 5, RunID: 1, Repo: "atmin/app", SHA: "deadbeef",
+ Ref: "refs/heads/main", WorkflowDir: ".github/workflows",
+}
+
+// capture records the completion the runner posts.
+type capture struct {
+ calls int
+ status string
+ body string
+}
+
+// newTestServer serves the two report endpoints. claimStatus controls the claim
+// response (200 returns testSpec; anything else is returned verbatim, e.g. 404).
+func newTestServer(t *testing.T, claimStatus int) (*httptest.Server, *capture) {
+ t.Helper()
+ cap := &capture{}
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /internal/ci/jobs/{id}", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-Worker-Secret") != testSecret {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ if claimStatus != http.StatusOK {
+ w.WriteHeader(claimStatus)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(testSpec)
+ })
+ mux.HandleFunc("POST /internal/ci/jobs/{id}/complete", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-Worker-Secret") != testSecret {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ cap.calls++
+ cap.status = r.URL.Query().Get("status")
+ b, _ := io.ReadAll(r.Body)
+ cap.body = string(b)
+ w.WriteHeader(http.StatusOK)
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return srv, cap
+}
+
+type fakeCloner struct {
+ err error
+ called bool
+ repo, sha, ref, token string
+}
+
+func (c *fakeCloner) Clone(_ context.Context, _, repo, sha, ref, token, _ string) error {
+ c.called = true
+ c.repo, c.sha, c.ref, c.token = repo, sha, ref, token
+ return c.err
+}
+
+type fakeEngine struct {
+ log []byte
+ passed bool
+ err error
+ called bool
+}
+
+func (e *fakeEngine) Run(_ context.Context, _, _ string) ([]byte, bool, error) {
+ e.called = true
+ return e.log, e.passed, e.err
+}
+
+func baseConfig(t *testing.T, srvURL string, c Cloner, e Engine) Config {
+ return Config{
+ BaseURL: srvURL, JobID: testSpec.JobID, WorkerSecret: testSecret,
+ CloneToken: "clone-tok", Cloner: c, Engine: e, WorkDir: t.TempDir(),
+ }
+}
+
+func TestRunPassed(t *testing.T) {
+ srv, cap := newTestServer(t, http.StatusOK)
+ cl := &fakeCloner{}
+ en := &fakeEngine{log: []byte("all green\n"), passed: true}
+
+ if err := Run(context.Background(), baseConfig(t, srv.URL, cl, en)); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ // Cloned the claimed SHA/ref with the injected token.
+ if !cl.called || cl.repo != testSpec.Repo || cl.sha != testSpec.SHA ||
+ cl.ref != testSpec.Ref || cl.token != "clone-tok" {
+ t.Errorf("clone = %+v, want repo/sha/ref from the spec and the injected token", cl)
+ }
+ if cap.calls != 1 || cap.status != "passed" || cap.body != "all green\n" {
+ t.Errorf("completion = %+v, want one passed with the engine log", cap)
+ }
+}
+
+func TestRunFailed(t *testing.T) {
+ srv, cap := newTestServer(t, http.StatusOK)
+ en := &fakeEngine{log: []byte("boom\n"), passed: false}
+
+ if err := Run(context.Background(), baseConfig(t, srv.URL, &fakeCloner{}, en)); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if cap.status != "failed" || cap.body != "boom\n" {
+ t.Errorf("completion = %+v, want failed with the engine log", cap)
+ }
+}
+
+func TestRunEngineErrorReportsError(t *testing.T) {
+ srv, cap := newTestServer(t, http.StatusOK)
+ en := &fakeEngine{err: errStub("act missing")}
+
+ if err := Run(context.Background(), baseConfig(t, srv.URL, &fakeCloner{}, en)); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if cap.status != "error" || !strings.Contains(cap.body, "act missing") {
+ t.Errorf("completion = %+v, want status=error mentioning the engine error", cap)
+ }
+}
+
+func TestRunCloneErrorReportsError(t *testing.T) {
+ srv, cap := newTestServer(t, http.StatusOK)
+ cl := &fakeCloner{err: errStub("auth denied")}
+ en := &fakeEngine{}
+
+ if err := Run(context.Background(), baseConfig(t, srv.URL, cl, en)); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if en.called {
+ t.Error("engine ran despite a clone failure")
+ }
+ if cap.status != "error" || !strings.Contains(cap.body, "clone failed") {
+ t.Errorf("completion = %+v, want status=error mentioning the clone failure", cap)
+ }
+}
+
+func TestRunNotClaimableIsNoOp(t *testing.T) {
+ srv, cap := newTestServer(t, http.StatusNotFound)
+ cl := &fakeCloner{}
+
+ if err := Run(context.Background(), baseConfig(t, srv.URL, cl, &fakeEngine{})); err != nil {
+ t.Fatalf("Run on an unclaimable job = %v, want nil", err)
+ }
+ if cl.called {
+ t.Error("cloned an unclaimable job")
+ }
+ if cap.calls != 0 {
+ t.Errorf("completion calls = %d, want 0 (nothing to report)", cap.calls)
+ }
+}
+
+func TestRunClaimUnauthorizedFails(t *testing.T) {
+ srv, cap := newTestServer(t, http.StatusOK)
+ cfg := baseConfig(t, srv.URL, &fakeCloner{}, &fakeEngine{})
+ cfg.WorkerSecret = "wrong" // server rejects the claim
+
+ if err := Run(context.Background(), cfg); err == nil {
+ t.Error("Run with a bad secret = nil, want an error (claim rejected)")
+ }
+ if cap.calls != 0 {
+ t.Errorf("completion calls = %d, want 0", cap.calls)
+ }
+}
+
+type errStub string
+
+func (e errStub) Error() string { return string(e) }
diff --git a/scripts/dev.sh b/scripts/dev.sh
index e6aee44..c77ee12 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -23,8 +23,18 @@ export GITMOTE_DB="$ROOT/data/meta.sqlite3"
export GITMOTE_CACHE="$ROOT/data/cache"
export GITMOTE_SOCK="$ROOT/data/gitmote.sock"
export GITMOTE_HOOK="$ROOT/bin/gitmote-hook"
+export GITMOTE_RUNNER="$ROOT/bin/gitmote-runner"
export GITMOTE_COOKIE_KEY="dev-cookie-key-not-for-production"
+# Enable local CI: on a push, gitmote records a run and spawns the runner
+# (bin/gitmote-runner) as a local process — the same runner code the cloud path
+# uses, driven by the local trigger instead of a Scaleway job. GITMOTE_URL is
+# where the runner clones + reports back; WORKER_SECRET authenticates the report
+# API. The runner executes .github/workflows with `act` (install: brew install
+# act), which needs the Docker/podman daemon MinIO already relies on.
+export GITMOTE_URL="http://localhost:8080"
+export WORKER_SECRET="dev-worker-secret-not-for-production"
+
mkdir -p "$ROOT/data"
# Free :8080 from a stale dev server left running from a previous session.
@@ -70,6 +80,9 @@ cat <<EOF
MinIO console: http://localhost:9101 (minioadmin / minioadmin)
Ctrl-C stops the server; MinIO keeps running. 'make dev-reset' wipes all state.
+ CI: push a repo with .github/workflows and gitmote runs it locally via act
+ ($(command -v act >/dev/null 2>&1 && echo 'act detected' || echo 'act NOT installed — run: brew install act')).
+
EOF
exec bin/gitmote
diff --git a/tasks/21-ci-runner.md b/tasks/21-ci-runner.md
index fbcf230..299aba5 100644
--- a/tasks/21-ci-runner.md
+++ b/tasks/21-ci-runner.md
@@ -1,72 +1,53 @@
-# CI stage 4b — runner image + engine execution
-
-Part of the CI epic ([16-ci.md](16-ci.md)). **Stage 4a (the internal report API)
-has landed**: gitmote exposes the authenticated claim/complete endpoints, the log
-store, run rollup, and the stuck-job reconciliation sweep. What remains is the
-**runner** that actually executes a workflow and drives those endpoints.
+# CI stage 4b(cloud) — Scaleway runner image
+
+Part of the CI epic ([16-ci.md](16-ci.md)). The runner and the whole execution
+path have **landed and run locally**: on a push, gitmote records a run, mints a
+scoped clone token, and triggers `gitmote-runner`, which claims the job, clones
+at the SHA, runs `.github/workflows` with `act`, and reports the log + pass/fail.
+What remains is packaging that exact runner to run on **Scaleway Serverless Jobs**
+instead of a local process.
+
+## What has landed (the whole local path)
+
+- **Runner** — `cmd/gitmote-runner` + `internal/runner` (claim → clone@SHA →
+ engine → complete). Engine is `act` (`internal/runner/act.go`); clone is the
+ normal git-HTTP path with a scoped token (`git.go`). Substrate-agnostic: the
+ same binary runs locally and (once this task lands) in a container.
+- **Local trigger** — `ci.LocalTrigger` spawns the runner as a local process,
+ the dev analog of Scaleway starting a job. `main.go` picks it when
+ `GITMOTE_URL` + `WORKER_SECRET` are set and `SCW_CI_JOB_DEFINITION_ID` is not.
+ `make dev` wires this, so **CI is testable locally** (needs `act` + Docker).
+- **Real clone token** — the dispatcher mints a read-only, repo-scoped, expiring
+ token under the pusher (`auth.Guard.MintScoped`), injected as
+ `GITMOTE_CI_CLONE_TOKEN`. No CI service identity or ACL grant needed.
+- **Report API + reconcile** — `internal/ci/report.go` (claim/complete, idempotent,
+ content-before-pointer log store) plus a leader-only `ReconcileStuck` ticker in
+ `main.go` that sweeps abandoned `running` jobs to `error`.
+
+## The remaining work — cloud packaging
> **Infra spike to resolve first:** `act` runs each job in a Docker container, so
> the runner needs a Docker daemon inside the Scaleway Serverless Job. Confirm
> Jobs expose a usable daemon (privileged / DinD) or pick the fallback: run steps
> in a single provided container without full `act` nesting. Spike this before
-> building the image.
-
-## What 4a already provides (the contract to build against)
-
-- `GET /internal/ci/jobs/{id}` — constant-time `X-Worker-Secret` auth; atomically
- claims (`queued`→`running`) and returns the spec
- `{job_id, run_id, repo, sha, ref, workflow_dir}`. Not claimable → 404; a
- follower → retryable 503. (`internal/ci/report.go`.)
-- `POST /internal/ci/jobs/{id}/complete?status=passed|failed|error` — the request
- **body is the combined log**. Content-before-pointer: the blob is stored under
- `ci/{repoID}/{runID}/{jobID}.log` (10 MiB cap, explicit truncation marker),
- then `SetJobResult` records status + `log_key`, then the run rolls up
- (running > error > failed > passed). Idempotent: a terminal job is a no-op.
-- Per-run clone credential: `auth.Guard.MintScoped` (task 20) mints a read-only,
- repo-scoped, expiring token; the dispatcher already injects
- `GITMOTE_CI_CLONE_TOKEN` (currently a placeholder — **wire the real mint** here).
-- `ReportAPI.ReconcileStuck(ctx, maxAge, now)` exists and is tested; **wire it to
- a leader-only periodic ticker** in `main.go` once runners can produce
- `running` jobs (nothing does until this stage).
-
-## Change
-
-**Wire the real clone token.** In the dispatcher (`internal/ci/dispatcher.go`),
-replace the `GITMOTE_CI_CLONE_TOKEN` placeholder with a `MintScoped` call
-(read-only, scoped to the pushed repo, TTL = run max-duration + margin). Revoke on
-completion (or lean on expiry). Needs the dispatcher to reach the guard/mint and
-a CI service user to own the token.
-
-**Runner** (`cmd/gitmote-runner` + `Dockerfile.runner`):
-- Read env (`GITMOTE_URL`, `GITMOTE_CI_JOB_ID`, `WORKER_SECRET`,
- `GITMOTE_REPO/SHA/REF`, `GITMOTE_CI_CLONE_TOKEN`); `GET` the claim.
-- `git clone https://x-access-token:$TOKEN@$GITMOTE_URL/$REPO`, checkout `$SHA`
- (shallow where possible).
-- Run `act` over `.github/workflows`, capturing combined stdout/stderr.
-- `POST …/complete?status=…` with the captured log as the body (pass/fail from
- `act`'s exit). Exit non-zero on internal failure so a crashed runner is
- distinguishable from a failed build.
-- `Dockerfile.runner`: `act` + git + the daemon strategy from the spike. One-time
- `scw jobs definition create name=gitmote-ci-runner image=… cpu-limit=…
- memory-limit=… local-storage-capacity=…` documented in
- [ops.md](../docs/ops.md); one definition serves all repos (env injected at
- trigger, per stage 3).
+> building the image. (Local dev sidesteps this — the host daemon is real.)
-**Wire the reconciliation ticker.** A leader-only goroutine in `main.go` calling
-`ReportAPI.ReconcileStuck` on an interval (bound ≈ run max-duration), with clean
-shutdown.
+- **`Dockerfile.runner`** — `act` + git + the `gitmote-runner` binary + the daemon
+ strategy from the spike. The runner reads the same env it does locally.
+- **`scw jobs definition create`** — one-time, out of band:
+ `name=gitmote-ci-runner image=… cpu-limit=… memory-limit=…
+ local-storage-capacity=…`, documented in [ops.md](../docs/ops.md). One
+ definition serves all repos (env injected at trigger, per stage 3). Setting
+ `SCW_CI_JOB_DEFINITION_ID` flips `main.go` from the local trigger to Scaleway —
+ no code change.
## Verify
-- **runner test** against a stub gitmote server with a **fake engine** (a script
- that echoes and exits 0/1): full claim→clone→run→complete flow drives the job
- to `passed`/`failed`; the reported log round-trips.
-- **clone-token test:** the minted token is read-only + repo-scoped and clones the
- target repo but is denied a push and denied another repo (leans on task 20's
- guard tests).
-- **security:** the runner env holds only the scoped token + `WORKER_SECRET` — no
- S3/DB credentials.
-- **ticker:** a `running` job past the bound is swept to `error` by the wired
- reconciler (the sweep itself is already unit-tested in 4a).
+- **image smoke:** a container built from `Dockerfile.runner`, given a job's env
+ against a reachable gitmote, drives a real workflow to `passed`/`failed`; the
+ log round-trips (the local path already proves the runner logic).
+- **security:** the runner env holds only the scoped clone token + `WORKER_SECRET`
+ — no S3/DB credentials (already true of the env contract; assert it in the job
+ definition).
- `gofmt`/`golangci-lint`/`go test ./...` clean; `make e2e-local` +
`make e2e-restore` green.
diff --git a/tasks/README.md b/tasks/README.md
index 5710442..7fa029b 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -17,8 +17,10 @@ index [16-ci.md](16-ci.md) (the *why* is in
[../docs/evolution/ci-runner.md](../docs/evolution/ci-runner.md)). Split into
dependency-ordered stages, each implementable from fresh context:
-- [21-ci-runner.md](21-ci-runner.md) — **4b:** runner image + engine execution
- (4a, the internal claim/complete API, has landed; has an infra spike first).
+- [21-ci-runner.md](21-ci-runner.md) — **4b(cloud):** Scaleway runner image only.
+ The runner, local trigger, scoped clone token, and reconcile ticker have landed
+ (CI runs locally via `make dev` + `act`); what remains is packaging the runner
+ for Scaleway (Dockerfile.runner + `scw jobs definition`), gated on a DinD spike.
- [22-ci-secrets.md](22-ci-secrets.md) — encrypted per-repo secrets store
(AES-256-GCM + HKDF + versioned keys), injected at trigger.
- [23-ci-status-ui.md](23-ci-status-ui.md) — runs list, run detail, log viewer,