gitmote

gitmote  ·  history  ·  branches

feat: CI stage 1 — run/job data model + after-commit dispatch seam

091da01a674c619e32f754b4b5bbc067251ab40a
Atanas Minev <atmin@protonmail.com> · 2026-07-03 18:12

diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 7835203..353c50e 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -17,6 +17,7 @@ import (
"github.com/atmin/gitmote/internal/auth"
"github.com/atmin/gitmote/internal/bootstrap"
+ "github.com/atmin/gitmote/internal/ci"
"github.com/atmin/gitmote/internal/githttp"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
@@ -212,6 +213,20 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
_ = md.Close()
return nil, nil, noop, fmt.Errorf("write path: %w", err)
}
+ // A successful, branch-advancing push enqueues a CI run — fire-and-forget, so
+ // dispatch never fails the push (tasks/16-ci.md).
+ dispatcher := ci.NewDispatcher(md, logger)
+ writer.AfterCommit = func(ctx context.Context, commits []githttp.CommitInfo) {
+ for _, c := range commits {
+ dispatcher.Dispatch(ctx, ci.Event{
+ RepoID: c.RepoID,
+ RepoName: c.RepoName,
+ Ref: c.Ref,
+ OldSHA: c.Old,
+ NewSHA: c.New,
+ })
+ }
+ }
cleanup := func() error {
err := writer.Close()
// Close durably flushes replication on clean shutdown so a redeploy/restart
diff --git a/internal/ci/dispatcher.go b/internal/ci/dispatcher.go
new file mode 100644
index 0000000..85cc322
--- /dev/null
+++ b/internal/ci/dispatcher.go
@@ -0,0 +1,72 @@
+// Package ci is the continuous-integration dispatch seam. On a successful push
+// the write path hands each updated ref to Dispatcher.Dispatch, which — for a
+// branch create/update — records a queued run in meta. It is fire-and-forget:
+// dispatch must never block or fail a push (a missed run is a missed deploy, not
+// a failed push — the content-before-pointer discipline applied to CI; see
+// docs/architecture/safety.md and tasks/16-ci.md). Later stages extend Dispatch
+// with config discovery and the Scaleway trigger.
+package ci
+
+import (
+ "context"
+ "log/slog"
+ "strings"
+
+ "github.com/atmin/gitmote/internal/meta"
+)
+
+// branchPrefix is the ref namespace CI reacts to; tags and other refs are
+// ignored.
+const branchPrefix = "refs/heads/"
+
+// Runs is the slice of meta the dispatcher needs to enqueue runs. *meta.Metadata
+// satisfies it; a fake stands in for unit tests.
+type Runs interface {
+ CreateRun(ctx context.Context, repoID int64, ref, sha string) (*meta.Run, error)
+}
+
+// Event is one updated ref from a completed push.
+type Event struct {
+ RepoID int64
+ RepoName string
+ Ref string
+ OldSHA string
+ NewSHA string
+}
+
+// Dispatcher enqueues CI runs for ref advances. Only the leader constructs and
+// calls it (it is the only instance that processes receive-pack).
+type Dispatcher struct {
+ runs Runs
+ logger *slog.Logger
+}
+
+// NewDispatcher returns a Dispatcher writing runs through runs.
+func NewDispatcher(runs Runs, logger *slog.Logger) *Dispatcher {
+ if logger == nil {
+ logger = slog.New(slog.DiscardHandler)
+ }
+ return &Dispatcher{runs: runs, logger: logger}
+}
+
+// Dispatch records a queued run for a branch create/update. Tag pushes and
+// branch deletions create no run. An error creating the run is logged, not
+// returned — the push has already committed.
+func (d *Dispatcher) Dispatch(ctx context.Context, ev Event) {
+ if !strings.HasPrefix(ev.Ref, branchPrefix) {
+ return // not a branch — no CI
+ }
+ if isAbsent(ev.NewSHA) {
+ return // a branch deletion — nothing to run
+ }
+ if _, err := d.runs.CreateRun(ctx, ev.RepoID, ev.Ref, ev.NewSHA); err != nil {
+ d.logger.Error("ci: enqueue run failed",
+ "repo", ev.RepoName, "ref", ev.Ref, "sha", ev.NewSHA, "error", err)
+ }
+}
+
+// isAbsent reports whether a wire SHA denotes "no object" — the empty string or
+// git's all-zero id (a ref deletion).
+func isAbsent(sha string) bool {
+ return sha == "" || sha == meta.ZeroSHA
+}
diff --git a/internal/ci/dispatcher_test.go b/internal/ci/dispatcher_test.go
new file mode 100644
index 0000000..f6ea6c8
--- /dev/null
+++ b/internal/ci/dispatcher_test.go
@@ -0,0 +1,87 @@
+package ci
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/atmin/gitmote/internal/meta"
+)
+
+// fakeRuns records CreateRun calls, optionally returning err.
+type fakeRuns struct {
+ created []meta.Run
+ err error
+}
+
+func (f *fakeRuns) CreateRun(_ context.Context, repoID int64, ref, sha string) (*meta.Run, error) {
+ if f.err != nil {
+ return nil, f.err
+ }
+ run := meta.Run{ID: int64(len(f.created) + 1), RepoID: repoID, Ref: ref, SHA: sha, Status: meta.RunQueued}
+ f.created = append(f.created, run)
+ return &run, nil
+}
+
+const (
+ shaA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ shaB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+)
+
+func TestDispatchBranchUpdateCreatesRun(t *testing.T) {
+ f := &fakeRuns{}
+ d := NewDispatcher(f, nil)
+ d.Dispatch(context.Background(), Event{
+ RepoID: 7, RepoName: "atmin/repo", Ref: "refs/heads/main", OldSHA: shaA, NewSHA: shaB,
+ })
+ if len(f.created) != 1 {
+ t.Fatalf("created %d runs, want 1", len(f.created))
+ }
+ got := f.created[0]
+ if got.RepoID != 7 || got.Ref != "refs/heads/main" || got.SHA != shaB {
+ t.Errorf("run = %+v, want repo 7 ref refs/heads/main sha %q", got, shaB)
+ }
+}
+
+func TestDispatchIgnoresTagsAndDeletes(t *testing.T) {
+ cases := []struct {
+ name string
+ ev Event
+ }{
+ {"tag push", Event{RepoID: 1, Ref: "refs/tags/v1", OldSHA: "", NewSHA: shaA}},
+ {"branch delete (zero sha)", Event{RepoID: 1, Ref: "refs/heads/main", OldSHA: shaA, NewSHA: meta.ZeroSHA}},
+ {"branch delete (empty sha)", Event{RepoID: 1, Ref: "refs/heads/main", OldSHA: shaA, NewSHA: ""}},
+ {"non-branch ref", Event{RepoID: 1, Ref: "refs/notes/commits", OldSHA: "", NewSHA: shaA}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ f := &fakeRuns{}
+ NewDispatcher(f, nil).Dispatch(context.Background(), tc.ev)
+ if len(f.created) != 0 {
+ t.Errorf("created %d runs, want 0", len(f.created))
+ }
+ })
+ }
+}
+
+func TestDispatchBranchCreateCreatesRun(t *testing.T) {
+ f := &fakeRuns{}
+ // A new branch: old is zero, new is a real sha — one run.
+ NewDispatcher(f, nil).Dispatch(context.Background(), Event{
+ RepoID: 1, Ref: "refs/heads/feature", OldSHA: meta.ZeroSHA, NewSHA: shaA,
+ })
+ if len(f.created) != 1 {
+ t.Fatalf("created %d runs, want 1", len(f.created))
+ }
+}
+
+func TestDispatchSwallowsCreateError(t *testing.T) {
+ // A failed enqueue must not panic or propagate — the push already committed.
+ f := &fakeRuns{err: errors.New("db down")}
+ NewDispatcher(f, nil).Dispatch(context.Background(), Event{
+ RepoID: 1, Ref: "refs/heads/main", OldSHA: shaA, NewSHA: shaB,
+ })
+ if len(f.created) != 0 {
+ t.Errorf("created %d runs, want 0", len(f.created))
+ }
+}
diff --git a/internal/githttp/receive.go b/internal/githttp/receive.go
index b71439e..cef2a1d 100644
--- a/internal/githttp/receive.go
+++ b/internal/githttp/receive.go
@@ -46,8 +46,29 @@ type Writer struct {
// is a test seam for the ordering invariant; a non-nil error rejects the
// push after the objects are already durable.
beforeCAS func() error
+
+ // AfterCommit, when set, runs once after the ref CAS succeeds, before the
+ // push returns OK. 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)
+}
+
+// CommitInfo describes one ref an accepted push advanced. It carries the repo
+// identity so the after-commit hook is self-contained (a push targets one repo,
+// so RepoID/RepoName repeat across a multi-ref push's entries).
+type CommitInfo struct {
+ RepoID int64
+ RepoName string
+ Ref string
+ Old string
+ New string
}
+// afterCommitTimeout bounds the fire-and-forget after-commit hook so a slow
+// dispatch can't hold the push handler open indefinitely.
+const afterCommitTimeout = 30 * time.Second
+
// pushOp is the parent-side context a nonce resolves to.
type pushOp struct {
repoID int64
@@ -221,9 +242,40 @@ func (w *Writer) handle(req hookrpc.Request) hookrpc.Response {
w.logger.Error("ref cas failed", "repo", op.repoName, "error", err)
return hookrpc.Response{Reason: "ref update failed"}
}
+
+ // The push is committed. Fire the after-commit hook fire-and-forget: it must
+ // never turn a green push red.
+ w.fireAfterCommit(op, req.Commands)
return hookrpc.Response{OK: true}
}
+// fireAfterCommit invokes the after-commit hook (if set) under its own bounded
+// context, recovering any panic. The ref CAS has already committed, so a failure
+// here is a missed dispatch, never a failed push.
+func (w *Writer) fireAfterCommit(op pushOp, cmds []hookrpc.RefCommand) {
+ if w.AfterCommit == nil {
+ return
+ }
+ defer func() {
+ if r := recover(); r != nil {
+ w.logger.Error("after-commit hook panicked", "repo", op.repoName, "panic", r)
+ }
+ }()
+ commits := make([]CommitInfo, len(cmds))
+ for i, c := range cmds {
+ commits[i] = CommitInfo{
+ RepoID: op.repoID,
+ RepoName: op.repoName,
+ Ref: c.Ref,
+ Old: c.Old,
+ New: c.New,
+ }
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), afterCommitTimeout)
+ defer cancel()
+ w.AfterCommit(ctx, commits)
+}
+
// putObjects mirrors the quarantine's git objects into the store under the
// repo's prefix. Only true object entries (fan-out dirs and pack/) are copied,
// never bookkeeping like objects/info/alternates.
diff --git a/internal/githttp/receive_test.go b/internal/githttp/receive_test.go
index cd3e0a0..ae333fb 100644
--- a/internal/githttp/receive_test.go
+++ b/internal/githttp/receive_test.go
@@ -15,6 +15,7 @@ import (
"testing"
"github.com/atmin/gitmote/internal/auth"
+ "github.com/atmin/gitmote/internal/ci"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
"github.com/atmin/gitmote/internal/store"
@@ -224,6 +225,119 @@ func TestPushChunkedLargePack(t *testing.T) {
}
}
+// wireDispatcher points the writer's after-commit hook at a real ci.Dispatcher
+// backed by m, mirroring the main.go wiring.
+func wireDispatcher(w *Writer, m *meta.Metadata) {
+ d := ci.NewDispatcher(m, slog.New(slog.DiscardHandler))
+ w.AfterCommit = func(ctx context.Context, commits []CommitInfo) {
+ for _, c := range commits {
+ d.Dispatch(ctx, ci.Event{
+ RepoID: c.RepoID,
+ RepoName: c.RepoName,
+ Ref: c.Ref,
+ OldSHA: c.Old,
+ NewSHA: c.New,
+ })
+ }
+ }
+}
+
+// TestAfterCommitEnqueuesRun drives a real git push and asserts the after-commit
+// seam records exactly one queued run for the advanced branch tip.
+func TestAfterCommitEnqueuesRun(t *testing.T) {
+ ctx := context.Background()
+ srv, m, _, writer := newWriteServer(t)
+ wireDispatcher(writer, m)
+
+ r, err := m.CreateRepo(ctx, repoName, "main")
+ if err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+ raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
+ remote := authedURL(srv.URL, raw) + "/" + repoName
+
+ src := initCommit(t, "hello\n")
+ head := git(t, src, "rev-parse", "HEAD")
+ git(t, src, "push", remote, "main")
+
+ runs, err := m.ListRuns(ctx, r.ID, 0)
+ if err != nil {
+ t.Fatalf("ListRuns: %v", err)
+ }
+ if len(runs) != 1 {
+ t.Fatalf("after push, runs = %d, want exactly 1", len(runs))
+ }
+ if runs[0].Ref != "refs/heads/main" || runs[0].SHA != head || runs[0].Status != meta.RunQueued {
+ t.Errorf("run = %+v, want ref refs/heads/main sha %q queued", runs[0], head)
+ }
+}
+
+// TestRejectedPushEnqueuesNoRun asserts a non-fast-forward push — refused at the
+// CAS — never fires the after-commit seam, so no run is recorded for it.
+func TestRejectedPushEnqueuesNoRun(t *testing.T) {
+ ctx := context.Background()
+ srv, m, _, writer := newWriteServer(t)
+ wireDispatcher(writer, m)
+
+ r, _ := m.CreateRepo(ctx, repoName, "main")
+ raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
+ remote := authedURL(srv.URL, raw) + "/" + repoName
+
+ // Establish main (one accepted push → one run).
+ src := initCommit(t, "one\n")
+ git(t, src, "push", remote, "main")
+
+ // A stale clone diverges and pushes a non-fast-forward, which is rejected.
+ stale := filepath.Join(t.TempDir(), "stale")
+ git(t, "", "clone", remote, stale)
+ if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("one\ntwo\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, src, "commit", "-am", "advance")
+ git(t, src, "push", remote, "main")
+
+ if err := os.WriteFile(filepath.Join(stale, "file.txt"), []byte("one\ndivergent\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, stale, "commit", "-am", "divergent")
+ if out, err := tryGit(stale, "push", remote, "main"); err == nil {
+ t.Fatalf("stale push succeeded, want rejection\n%s", out)
+ }
+
+ // Two accepted pushes → two runs; the rejected push adds none.
+ runs, err := m.ListRuns(ctx, r.ID, 0)
+ if err != nil {
+ t.Fatalf("ListRuns: %v", err)
+ }
+ if len(runs) != 2 {
+ t.Errorf("runs = %d, want 2 (rejected push enqueues none)", len(runs))
+ }
+}
+
+// TestAfterCommitPanicLeavesPushGreen is the safety invariant: an after-commit
+// hook that panics must not fail the push — the ref still advances and the
+// client sees a green push (content-before-pointer applied to CI).
+func TestAfterCommitPanicLeavesPushGreen(t *testing.T) {
+ ctx := context.Background()
+ srv, m, _, writer := newWriteServer(t)
+ writer.AfterCommit = func(context.Context, []CommitInfo) {
+ panic("dispatch blew up")
+ }
+
+ r, _ := m.CreateRepo(ctx, repoName, "main")
+ raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
+ remote := authedURL(srv.URL, raw) + "/" + repoName
+
+ src := initCommit(t, "hello\n")
+ head := git(t, src, "rev-parse", "HEAD")
+ if out, err := tryGit(src, "push", remote, "main"); err != nil {
+ t.Fatalf("push failed despite panicking after-commit hook: %v\n%s", err, out)
+ }
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head {
+ t.Errorf("main = %q, want %q — ref must advance even when the hook panics", got, head)
+ }
+}
+
// tryGit runs a git command hermetically and returns its combined output and
// error without failing the test — for operations expected to be rejected.
func tryGit(dir string, args ...string) (string, error) {
diff --git a/internal/meta/ci.go b/internal/meta/ci.go
new file mode 100644
index 0000000..b979767
--- /dev/null
+++ b/internal/meta/ci.go
@@ -0,0 +1,134 @@
+package meta
+
+import (
+ "context"
+ "time"
+)
+
+// RunStatus is the lifecycle state of a CI run. A run starts queued and moves
+// to a terminal state as later stages execute it.
+type RunStatus string
+
+const (
+ RunQueued RunStatus = "queued"
+ RunRunning RunStatus = "running"
+ RunPassed RunStatus = "passed"
+ RunFailed RunStatus = "failed"
+ RunError RunStatus = "error"
+ RunSuperseded RunStatus = "superseded"
+)
+
+// Run is one CI run: a workflow triggered by a ref advancing to SHA. Rows are
+// written only by the leader, like refs.
+type Run struct {
+ ID int64
+ RepoID int64
+ Ref string
+ SHA string
+ Status RunStatus
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// CreateRun records a queued run for a ref advance and returns it.
+func (m *Metadata) CreateRun(ctx context.Context, repoID int64, ref, sha string) (*Run, error) {
+ ts := now()
+ res, err := m.db.ExecContext(ctx,
+ `INSERT INTO ci_runs (repo_id, ref, sha, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ repoID, ref, sha, string(RunQueued), ts, ts)
+ if err != nil {
+ return nil, err
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return nil, err
+ }
+ return &Run{
+ ID: id,
+ RepoID: repoID,
+ Ref: ref,
+ SHA: sha,
+ Status: RunQueued,
+ CreatedAt: parseTime(ts),
+ UpdatedAt: parseTime(ts),
+ }, nil
+}
+
+// SetRunStatus transitions a run to status. It returns ErrNotFound when no run
+// has the given id.
+func (m *Metadata) SetRunStatus(ctx context.Context, runID int64, status RunStatus) error {
+ res, err := m.db.ExecContext(ctx,
+ `UPDATE ci_runs SET status = ?, updated_at = ? WHERE id = ?`,
+ string(status), now(), runID)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+// GetRun returns the run with the given id, or ErrNotFound.
+func (m *Metadata) GetRun(ctx context.Context, runID int64) (*Run, error) {
+ var (
+ r Run
+ status string
+ cts string
+ uts string
+ )
+ err := m.db.QueryRowContext(ctx,
+ `SELECT id, repo_id, ref, sha, status, created_at, updated_at
+ FROM ci_runs WHERE id = ?`, runID).
+ Scan(&r.ID, &r.RepoID, &r.Ref, &r.SHA, &status, &cts, &uts)
+ if isNoRows(err) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ r.Status = RunStatus(status)
+ r.CreatedAt = parseTime(cts)
+ r.UpdatedAt = parseTime(uts)
+ return &r, nil
+}
+
+// ListRuns returns a repository's runs, newest first, capped at limit (<= 0
+// means no limit).
+func (m *Metadata) ListRuns(ctx context.Context, repoID int64, limit int) ([]Run, error) {
+ query := `SELECT id, repo_id, ref, sha, status, created_at, updated_at
+ FROM ci_runs WHERE repo_id = ? ORDER BY id DESC`
+ args := []any{repoID}
+ if limit > 0 {
+ query += ` LIMIT ?`
+ args = append(args, limit)
+ }
+ rows, err := m.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var runs []Run
+ for rows.Next() {
+ var (
+ r Run
+ status string
+ cts string
+ uts string
+ )
+ if err := rows.Scan(&r.ID, &r.RepoID, &r.Ref, &r.SHA, &status, &cts, &uts); err != nil {
+ return nil, err
+ }
+ r.Status = RunStatus(status)
+ r.CreatedAt = parseTime(cts)
+ r.UpdatedAt = parseTime(uts)
+ runs = append(runs, r)
+ }
+ return runs, rows.Err()
+}
diff --git a/internal/meta/ci_test.go b/internal/meta/ci_test.go
new file mode 100644
index 0000000..7393c06
--- /dev/null
+++ b/internal/meta/ci_test.go
@@ -0,0 +1,96 @@
+package meta
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestCreateRun(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+ r := seedRepo(t, m, "atmin/repo")
+ const main = "refs/heads/main"
+
+ run, err := m.CreateRun(ctx, r.ID, main, shaA)
+ if err != nil {
+ t.Fatalf("CreateRun: %v", err)
+ }
+ if run.RepoID != r.ID || run.Ref != main || run.SHA != shaA {
+ t.Errorf("run = %+v, want repo %d ref %q sha %q", run, r.ID, main, shaA)
+ }
+ if run.Status != RunQueued {
+ t.Errorf("status = %q, want %q", run.Status, RunQueued)
+ }
+
+ // Round-trips through GetRun.
+ got, err := m.GetRun(ctx, run.ID)
+ if err != nil {
+ t.Fatalf("GetRun: %v", err)
+ }
+ if got.ID != run.ID || got.Status != RunQueued || got.SHA != shaA {
+ t.Errorf("GetRun = %+v, want id %d queued sha %q", got, run.ID, shaA)
+ }
+}
+
+func TestSetRunStatus(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+ r := seedRepo(t, m, "atmin/repo")
+
+ run, err := m.CreateRun(ctx, r.ID, "refs/heads/main", shaA)
+ if err != nil {
+ t.Fatalf("CreateRun: %v", err)
+ }
+ if err := m.SetRunStatus(ctx, run.ID, RunRunning); err != nil {
+ t.Fatalf("SetRunStatus: %v", err)
+ }
+ got, _ := m.GetRun(ctx, run.ID)
+ if got.Status != RunRunning {
+ t.Errorf("status = %q, want %q", got.Status, RunRunning)
+ }
+
+ // An unknown run is ErrNotFound.
+ if err := m.SetRunStatus(ctx, 9999, RunPassed); !errors.Is(err, ErrNotFound) {
+ t.Errorf("SetRunStatus(missing) = %v, want ErrNotFound", err)
+ }
+ // GetRun on an unknown id is ErrNotFound.
+ if _, err := m.GetRun(ctx, 9999); !errors.Is(err, ErrNotFound) {
+ t.Errorf("GetRun(missing) = %v, want ErrNotFound", err)
+ }
+}
+
+func TestListRunsNewestFirst(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+ r := seedRepo(t, m, "atmin/repo")
+ other := seedRepo(t, m, "atmin/other")
+
+ first, _ := m.CreateRun(ctx, r.ID, "refs/heads/main", shaA)
+ second, _ := m.CreateRun(ctx, r.ID, "refs/heads/main", shaB)
+ // A run for a different repo must not appear in r's list.
+ if _, err := m.CreateRun(ctx, other.ID, "refs/heads/main", shaC); err != nil {
+ t.Fatalf("CreateRun(other): %v", err)
+ }
+
+ runs, err := m.ListRuns(ctx, r.ID, 0)
+ if err != nil {
+ t.Fatalf("ListRuns: %v", err)
+ }
+ if len(runs) != 2 {
+ t.Fatalf("ListRuns = %d runs, want 2", len(runs))
+ }
+ if runs[0].ID != second.ID || runs[1].ID != first.ID {
+ t.Errorf("ListRuns order = [%d %d], want newest-first [%d %d]",
+ runs[0].ID, runs[1].ID, second.ID, first.ID)
+ }
+
+ // limit caps the result.
+ limited, err := m.ListRuns(ctx, r.ID, 1)
+ if err != nil {
+ t.Fatalf("ListRuns(limit): %v", err)
+ }
+ if len(limited) != 1 || limited[0].ID != second.ID {
+ t.Errorf("ListRuns(limit 1) = %+v, want just newest %d", limited, second.ID)
+ }
+}
diff --git a/internal/meta/schema.sql b/internal/meta/schema.sql
index 5b1bba1..6bb05bf 100644
--- a/internal/meta/schema.sql
+++ b/internal/meta/schema.sql
@@ -49,3 +49,24 @@ CREATE TABLE IF NOT EXISTS acls (
perm TEXT NOT NULL CHECK (perm IN ('read','write','admin')),
PRIMARY KEY (repo_id, user_id)
);
+
+-- CI: a queued/running/finished workflow run per ref advance, written only by
+-- the leader (like refs). See docs/evolution/ci-runner.md and tasks 16/17.
+CREATE TABLE IF NOT EXISTS ci_runs (
+ id INTEGER PRIMARY KEY,
+ repo_id INTEGER NOT NULL REFERENCES repos(id),
+ ref TEXT NOT NULL, -- "refs/heads/main"
+ sha TEXT NOT NULL, -- the new tip
+ status TEXT NOT NULL, -- queued|running|passed|failed|error|superseded
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS ci_jobs (
+ id INTEGER PRIMARY KEY,
+ run_id INTEGER NOT NULL REFERENCES ci_runs(id),
+ name TEXT NOT NULL, -- workflow file / job name (filled in stage 2)
+ status TEXT NOT NULL, -- queued|running|passed|failed|error
+ log_key TEXT, -- ci/ object key, set on completion (stage 4)
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
diff --git a/tasks/17-ci-data-model.md b/tasks/17-ci-data-model.md
deleted file mode 100644
index d6e23f5..0000000
--- a/tasks/17-ci-data-model.md
+++ /dev/null
@@ -1,89 +0,0 @@
-# CI stage 1 — run/job data model + after-commit dispatch seam
-
-Part of the CI epic ([16-ci.md](16-ci.md)); rationale + locked decisions live
-there and in [../docs/evolution/ci-runner.md](../docs/evolution/ci-runner.md).
-This stage adds **no execution** — it makes "a branch advanced" durably enqueue a
-CI run, and nothing more. Non-breaking on its own.
-
-## Spec
-
-On a successful push that advances a branch, record a **queued CI run** in the
-metadata DB. The dispatch is **fire-and-forget**: it must never block or fail the
-push (a missed run is not a failed push — the content-before-pointer discipline,
-[safety.md §3](../docs/architecture/safety.md), applied to CI). Only the leader
-dispatches (it is the only instance that processes receive-pack).
-
-## Current
-
-- The one durable "a ref advanced" moment is the ref CAS in
- [`Writer.handle`](../internal/githttp/receive.go#L189): after
- `w.meta.CASRefs(...)` succeeds it returns `Response{OK:true}`. There is already
- a `beforeCAS func() error` hook field on `Writer` (used before the CAS); there
- is **no after-commit hook**.
-- `Writer` is built by [`NewWriter`](../internal/githttp/receive.go#L60) and wired
- in [cmd/gitmote/main.go](../cmd/gitmote/main.go#L210).
-- The schema ([internal/meta/schema.sql](../internal/meta/schema.sql)) is applied
- on every `Open` and must stay idempotent (`CREATE TABLE IF NOT EXISTS`; s3lite
- has no version table). No CI tables exist.
-- `meta` write helpers (e.g. [refs.go](../internal/meta/refs.go),
- [repos.go](../internal/meta/repos.go)) are the pattern for new CRUD.
-
-## Change
-
-**1. Schema** ([schema.sql](../internal/meta/schema.sql)) — two idempotent tables:
-
-```sql
-CREATE TABLE IF NOT EXISTS ci_runs (
- id INTEGER PRIMARY KEY,
- repo_id INTEGER NOT NULL REFERENCES repos(id),
- ref TEXT NOT NULL, -- "refs/heads/main"
- sha TEXT NOT NULL, -- the new tip
- status TEXT NOT NULL, -- queued|running|passed|failed|error|superseded
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
-);
-CREATE TABLE IF NOT EXISTS ci_jobs (
- id INTEGER PRIMARY KEY,
- run_id INTEGER NOT NULL REFERENCES ci_runs(id),
- name TEXT NOT NULL, -- workflow file / job name (filled in stage 2)
- status TEXT NOT NULL, -- queued|running|passed|failed|error
- log_key TEXT, -- ci/ object key, set on completion (stage 4)
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
-);
-```
-
-**2. `meta/ci.go`** — CRUD, written **only by the leader**: `CreateRun(ctx,
-repoID, ref, sha) (Run, error)` (status `queued`), `SetRunStatus`, `GetRun`,
-`ListRuns(ctx, repoID, limit)`; job helpers arrive in later stages (stage 2
-creates job rows). Mirror the existing `now()`/`parseTime` conventions.
-
-**3. A `ci` package with a `Dispatcher`** (`internal/ci`) so the write path stays
-thin and this is unit-testable. Stage 1's `Dispatcher.Dispatch(ctx, ev)` takes a
-small event `{RepoID, RepoName, Ref, OldSHA, NewSHA}` and, **only for a branch
-create/update** (`refs/heads/*`, new ≠ zero), calls `meta.CreateRun`. Tag pushes
-and deletions create no run. Later stages extend `Dispatch` (config read, trigger).
-
-**4. The after-commit seam** — add an `AfterCommit func(context.Context,
-[]CommitInfo)` field to `Writer`, invoked once **after** `CASRefs` succeeds,
-before returning `OK`. It must be non-fatal: wrap it in its own bounded context
-and **recover-and-log** any panic/error — the push has already committed and must
-return green regardless. `CommitInfo` carries `{Ref, Old, New}` per updated ref;
-the handler already has `op.repoID`/`op.repoName` and `req.Commands`. Wire it in
-[main.go](../cmd/gitmote/main.go) to `ci.Dispatcher.Dispatch`.
-
-## Verify
-
-- **meta unit tests:** `CreateRun` inserts a `queued` row with the right
- repo/ref/sha; `SetRunStatus` transitions; `ListRuns` orders newest-first.
-- **dispatcher unit tests:** a branch update → one run; a tag push → none; a
- branch **delete** (new = zero) → none; a multi-ref push → one run per updated
- branch.
-- **seam integration test** (githttp, real `git push` like
- [receive_test.go](../internal/githttp/receive_test.go)): a fast-forward push
- produces exactly one `queued` run for the branch tip; a **rejected**
- non-fast-forward push produces **none**; an `AfterCommit` that panics or errors
- leaves the push **successful** (the client sees a green push, the ref is
- advanced) — this is the safety invariant and gets an explicit test.
-- `gofmt`/`golangci-lint`/`go test ./...` clean; `make e2e-local` +
- `make e2e-restore` stay green (the write path's success contract is unchanged).
diff --git a/tasks/README.md b/tasks/README.md
index 23be88c..c07ffbc 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -17,8 +17,6 @@ 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:
-- [17-ci-data-model.md](17-ci-data-model.md) — run/job tables + the fire-and-forget
- after-commit dispatch seam (no execution).
- [18-ci-config-discovery.md](18-ci-config-discovery.md) — read `.github/workflows`
at the pushed SHA; create jobs, or no run / a failed run.
- [19-ci-scaleway-trigger.md](19-ci-scaleway-trigger.md) — Scaleway Serverless Jobs