diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 353c50e..64269bb 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -213,9 +213,11 @@ 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)
+ cacheRoot := envOr("GITMOTE_CACHE", filepath.Join(os.TempDir(), "gitmote-repos"))
+ materializer := repo.New(md, objs, cacheRoot)
+ // A successful, branch-advancing push discovers its workflows and enqueues a
+ // CI run — fire-and-forget, so dispatch never fails the push (tasks/16-ci.md).
+ dispatcher := ci.NewDispatcher(md, materializer, logger)
writer.AfterCommit = func(ctx context.Context, commits []githttp.CommitInfo) {
for _, c := range commits {
dispatcher.Dispatch(ctx, ci.Event{
@@ -242,8 +244,6 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
}
guard := auth.NewGuard(md)
- cacheRoot := envOr("GITMOTE_CACHE", filepath.Join(os.TempDir(), "gitmote-repos"))
- materializer := repo.New(md, objs, cacheRoot)
handler, err := githttp.New(githttp.Config{
Materializer: materializer,
Authorizer: guard,
diff --git a/go.mod b/go.mod
index 0c00c4b..c8aad1b 100644
--- a/go.mod
+++ b/go.mod
@@ -12,6 +12,7 @@ require (
github.com/microcosm-cc/bluemonday v1.0.27
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
+ gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -43,6 +44,7 @@ require (
github.com/hablullah/go-juliandays v1.0.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
+ github.com/kr/text v0.2.0 // indirect
github.com/lmittmann/tint v1.1.3 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/markusmobius/go-dateparser v1.2.4 // indirect
diff --git a/go.sum b/go.sum
index c0f6503..8853eb8 100644
--- a/go.sum
+++ b/go.sum
@@ -94,6 +94,7 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -154,6 +155,10 @@ github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBF
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
@@ -227,6 +232,8 @@ github.com/psanford/sqlite3vfs v0.0.0-20251127171934-4e34e03a991a h1:r4YWl0uVObC
github.com/psanford/sqlite3vfs v0.0.0-20251127171934-4e34e03a991a/go.mod h1:iW4cSew5PAb1sMZiTEkVJAIBNrepaB6jTYjeP47WtI0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
@@ -309,6 +316,8 @@ google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/ci/dispatcher.go b/internal/ci/dispatcher.go
index 85cc322..53a705b 100644
--- a/internal/ci/dispatcher.go
+++ b/internal/ci/dispatcher.go
@@ -1,28 +1,44 @@
// 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.
+// the write path hands each updated ref to Dispatcher.Dispatch, which reads the
+// pushed commit's .github/workflows/ directory and — when there is work —
+// records a run with one job per workflow file. 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 add the Scaleway
+// trigger.
package ci
import (
"context"
+ "errors"
"log/slog"
"strings"
"github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/repo"
+ "gopkg.in/yaml.v3"
)
-// branchPrefix is the ref namespace CI reacts to; tags and other refs are
-// ignored.
-const branchPrefix = "refs/heads/"
+const (
+ // branchPrefix is the ref namespace CI reacts to; tags and other refs are
+ // ignored.
+ branchPrefix = "refs/heads/"
+ // workflowDir is the fixed, traversal-safe location engine (act) reads.
+ workflowDir = ".github/workflows"
+)
-// Runs is the slice of meta the dispatcher needs to enqueue runs. *meta.Metadata
-// satisfies it; a fake stands in for unit tests.
+// Runs is the slice of meta the dispatcher writes through. *meta.Metadata
+// satisfies it.
type Runs interface {
CreateRun(ctx context.Context, repoID int64, ref, sha string) (*meta.Run, error)
+ SetRunStatus(ctx context.Context, runID int64, status meta.RunStatus) error
+ CreateJob(ctx context.Context, runID int64, name string) (*meta.Job, error)
+}
+
+// Materializer turns a repo name into an on-disk bare repo the browse reader can
+// query. *repo.Materializer satisfies it.
+type Materializer interface {
+ Materialize(ctx context.Context, name string) (string, error)
}
// Event is one updated ref from a completed push.
@@ -38,20 +54,23 @@ type Event struct {
// calls it (it is the only instance that processes receive-pack).
type Dispatcher struct {
runs Runs
+ mz Materializer
logger *slog.Logger
}
-// NewDispatcher returns a Dispatcher writing runs through runs.
-func NewDispatcher(runs Runs, logger *slog.Logger) *Dispatcher {
+// NewDispatcher returns a Dispatcher writing runs through runs and reading
+// workflow config via mz.
+func NewDispatcher(runs Runs, mz Materializer, logger *slog.Logger) *Dispatcher {
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
- return &Dispatcher{runs: runs, logger: logger}
+ return &Dispatcher{runs: runs, mz: mz, 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.
+// Dispatch reads the pushed commit's workflows and records a run for a branch
+// create/update that has any. Tag pushes and branch deletions do nothing; a repo
+// with no .github/workflows creates no run. Everything here is non-fatal: the
+// push has already committed, so errors are logged, never returned.
func (d *Dispatcher) Dispatch(ctx context.Context, ev Event) {
if !strings.HasPrefix(ev.Ref, branchPrefix) {
return // not a branch — no CI
@@ -59,10 +78,96 @@ func (d *Dispatcher) Dispatch(ctx context.Context, ev Event) {
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",
+
+ dir, err := d.mz.Materialize(ctx, ev.RepoName)
+ if err != nil {
+ // An unexpected materialize error: record a visible errored run rather
+ // than silently dropping the push's CI.
+ d.logger.Error("ci: materialize failed",
+ "repo", ev.RepoName, "ref", ev.Ref, "sha", ev.NewSHA, "error", err)
+ d.recordErrorRun(ctx, ev)
+ return
+ }
+
+ entries, err := repo.Tree(ctx, dir, ev.NewSHA, workflowDir)
+ if errors.Is(err, repo.ErrNotFound) {
+ d.logger.Debug("ci: no workflows", "repo", ev.RepoName, "ref", ev.Ref)
+ return // no .github/workflows → no run
+ }
+ if err != nil {
+ d.logger.Error("ci: read workflows failed",
"repo", ev.RepoName, "ref", ev.Ref, "sha", ev.NewSHA, "error", err)
+ d.recordErrorRun(ctx, ev)
+ return
+ }
+
+ var files []repo.TreeEntry
+ for _, e := range entries {
+ if e.Type == "blob" && isYAML(e.Name) {
+ files = append(files, e)
+ }
+ }
+ if len(files) == 0 {
+ return // a workflows dir with no *.yml|*.yaml → nothing to run
}
+
+ d.recordRun(ctx, ev, dir, files)
+}
+
+// recordRun creates a queued run and one job per workflow file. A file that
+// fails to parse as YAML downgrades the run to failed (the first parse error is
+// logged), but the parsable files still get job rows, so the failure is visible
+// in the UI.
+func (d *Dispatcher) recordRun(ctx context.Context, ev Event, dir string, files []repo.TreeEntry) {
+ run, err := d.runs.CreateRun(ctx, ev.RepoID, ev.Ref, ev.NewSHA)
+ if err != nil {
+ d.logger.Error("ci: create run failed",
+ "repo", ev.RepoName, "ref", ev.Ref, "sha", ev.NewSHA, "error", err)
+ return
+ }
+ var parseErr error
+ for _, f := range files {
+ if err := d.validate(ctx, dir, ev.NewSHA, f.Path); err != nil {
+ if parseErr == nil {
+ parseErr = err
+ }
+ continue // a malformed file gets no job row
+ }
+ if _, err := d.runs.CreateJob(ctx, run.ID, f.Name); err != nil {
+ d.logger.Error("ci: create job failed", "run", run.ID, "job", f.Name, "error", err)
+ }
+ }
+ if parseErr != nil {
+ d.logger.Error("ci: malformed workflow",
+ "repo", ev.RepoName, "ref", ev.Ref, "error", parseErr)
+ if err := d.runs.SetRunStatus(ctx, run.ID, meta.RunFailed); err != nil {
+ d.logger.Error("ci: set run failed", "run", run.ID, "error", err)
+ }
+ }
+}
+
+// recordErrorRun records a run in the error state for a discovery failure, so a
+// broken push-CI surfaces instead of vanishing.
+func (d *Dispatcher) recordErrorRun(ctx context.Context, ev Event) {
+ run, err := d.runs.CreateRun(ctx, ev.RepoID, ev.Ref, ev.NewSHA)
+ if err != nil {
+ d.logger.Error("ci: create error run failed", "repo", ev.RepoName, "error", err)
+ return
+ }
+ if err := d.runs.SetRunStatus(ctx, run.ID, meta.RunError); err != nil {
+ d.logger.Error("ci: set run error failed", "run", run.ID, "error", err)
+ }
+}
+
+// validate cheaply gates a workflow file: it must read and parse as YAML. Deep
+// validation (does the engine accept it) is the runner's job at execution time.
+func (d *Dispatcher) validate(ctx context.Context, dir, sha, path string) error {
+ content, _, _, err := repo.Blob(ctx, dir, sha, path)
+ if err != nil {
+ return err
+ }
+ var doc map[string]any
+ return yaml.Unmarshal(content, &doc)
}
// isAbsent reports whether a wire SHA denotes "no object" — the empty string or
@@ -70,3 +175,8 @@ func (d *Dispatcher) Dispatch(ctx context.Context, ev Event) {
func isAbsent(sha string) bool {
return sha == "" || sha == meta.ZeroSHA
}
+
+// isYAML reports whether name has a workflow file extension.
+func isYAML(name string) bool {
+ return strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml")
+}
diff --git a/internal/ci/dispatcher_test.go b/internal/ci/dispatcher_test.go
index f6ea6c8..8ce2c88 100644
--- a/internal/ci/dispatcher_test.go
+++ b/internal/ci/dispatcher_test.go
@@ -1,87 +1,212 @@
package ci
import (
+ "bytes"
"context"
- "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
"testing"
"github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/repo"
+ "github.com/atmin/gitmote/internal/store"
)
-// fakeRuns records CreateRun calls, optionally returning err.
-type fakeRuns struct {
- created []meta.Run
- err error
+// newFixture wires a local meta, an in-memory store, and a materializer — the
+// real collaborators the dispatcher reads workflow config through.
+func newFixture(t *testing.T) (*meta.Metadata, store.Store, *repo.Materializer) {
+ t.Helper()
+ md, err := meta.Open(context.Background(), meta.Config{
+ LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3"),
+ })
+ if err != nil {
+ t.Fatalf("meta.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = md.Close() })
+ s := store.NewMem()
+ mz := repo.New(md, s, t.TempDir())
+ return md, s, mz
}
-func (f *fakeRuns) CreateRun(_ context.Context, repoID int64, ref, sha string) (*meta.Run, error) {
- if f.err != nil {
- return nil, f.err
+// gitFixture runs a git command hermetically, failing the test on nonzero exit.
+func gitFixture(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
+ "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
- 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
+ return strings.TrimSpace(string(out))
}
-const (
- shaA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- shaB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
-)
+// seedRepo builds a real repo with the given files, loads its objects into the
+// store the materializer reads, records the repo + branch ref in meta, and
+// returns the repo row and the head SHA. An empty files map makes a repo with a
+// single placeholder file (so the commit is non-empty) and no workflows.
+func seedRepo(t *testing.T, md *meta.Metadata, s store.Store, name string, files map[string]string) (*meta.Repo, string) {
+ t.Helper()
+ ctx := context.Background()
+
+ src := t.TempDir()
+ gitFixture(t, src, "init", "-b", "main", ".")
+ if len(files) == 0 {
+ files = map[string]string{"README.md": "# repo\n"}
+ }
+ for rel, content := range files {
+ full := filepath.Join(src, filepath.FromSlash(rel))
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ gitFixture(t, src, "add", "-A")
+ gitFixture(t, src, "commit", "-m", "seed")
+ head := gitFixture(t, src, "rev-parse", "HEAD")
-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,
+ objRoot := filepath.Join(src, ".git", "objects")
+ err := filepath.WalkDir(objRoot, func(p string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ rel, err := filepath.Rel(objRoot, p)
+ if err != nil {
+ return err
+ }
+ data, err := os.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ return s.Put(ctx, name+"/objects/"+filepath.ToSlash(rel), bytes.NewReader(data))
})
- if len(f.created) != 1 {
- t.Fatalf("created %d runs, want 1", len(f.created))
+ if err != nil {
+ t.Fatalf("seed objects: %v", err)
+ }
+
+ r, err := md.CreateRepo(ctx, name, "main")
+ if err != nil {
+ t.Fatalf("CreateRepo: %v", err)
}
- 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)
+ if err := md.CASRef(ctx, r.ID, "refs/heads/main", meta.ZeroSHA, head); err != nil {
+ t.Fatalf("CASRef: %v", err)
}
+ return r, head
}
-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 branchEvent(r *meta.Repo, sha string) Event {
+ return Event{RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/main", OldSHA: meta.ZeroSHA, NewSHA: sha}
+}
+
+func TestDispatchOneWorkflowCreatesRunAndJob(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\njobs:\n build:\n runs-on: ubuntu-latest\n",
+ })
+
+ NewDispatcher(md, mz, nil).Dispatch(ctx, branchEvent(r, head))
+
+ runs, _ := md.ListRuns(ctx, r.ID, 0)
+ if len(runs) != 1 {
+ t.Fatalf("runs = %d, want 1", len(runs))
+ }
+ if runs[0].Status != meta.RunQueued || runs[0].SHA != head {
+ t.Errorf("run = %+v, want queued at %s", runs[0], head)
+ }
+ jobs, _ := md.ListJobs(ctx, runs[0].ID)
+ if len(jobs) != 1 || jobs[0].Name != "ci.yml" || jobs[0].Status != meta.RunQueued {
+ t.Errorf("jobs = %+v, want one queued job named ci.yml", jobs)
}
}
-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,
+func TestDispatchTwoWorkflowsCreateTwoJobs(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",
+ ".github/workflows/deploy.yaml": "name: Deploy\non: push\n",
})
- if len(f.created) != 1 {
- t.Fatalf("created %d runs, want 1", len(f.created))
+
+ NewDispatcher(md, mz, nil).Dispatch(ctx, branchEvent(r, head))
+
+ runs, _ := md.ListRuns(ctx, r.ID, 0)
+ if len(runs) != 1 {
+ t.Fatalf("runs = %d, want 1", len(runs))
+ }
+ jobs, _ := md.ListJobs(ctx, runs[0].ID)
+ if len(jobs) != 2 {
+ t.Fatalf("jobs = %d, want 2", len(jobs))
+ }
+ names := map[string]bool{jobs[0].Name: true, jobs[1].Name: true}
+ if !names["ci.yml"] || !names["deploy.yaml"] {
+ t.Errorf("job names = %v, want ci.yml + deploy.yaml", names)
+ }
+}
+
+func TestDispatchNoWorkflowsNoRun(t *testing.T) {
+ ctx := context.Background()
+ md, s, mz := newFixture(t)
+ r, head := seedRepo(t, md, s, "atmin/app", nil) // no .github/workflows
+
+ NewDispatcher(md, mz, nil).Dispatch(ctx, branchEvent(r, head))
+
+ if runs, _ := md.ListRuns(ctx, r.ID, 0); len(runs) != 0 {
+ t.Errorf("runs = %d, want 0 (no workflows)", len(runs))
+ }
+}
+
+func TestDispatchMalformedWorkflowFailsRun(t *testing.T) {
+ ctx := context.Background()
+ md, s, mz := newFixture(t)
+ r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ ".github/workflows/good.yml": "name: Good\non: push\n",
+ ".github/workflows/bad.yml": "name: Bad\n : : not: valid: yaml\n\t- broken\n",
+ })
+
+ // Must not panic; the push (simulated by the event) is unaffected.
+ NewDispatcher(md, mz, nil).Dispatch(ctx, branchEvent(r, head))
+
+ runs, _ := md.ListRuns(ctx, r.ID, 0)
+ if len(runs) != 1 {
+ t.Fatalf("runs = %d, want 1", len(runs))
+ }
+ if runs[0].Status != meta.RunFailed {
+ t.Errorf("run status = %q, want failed", runs[0].Status)
+ }
+ // The parsable file still got a job row, so the failure is visible in the UI.
+ jobs, _ := md.ListJobs(ctx, runs[0].ID)
+ if len(jobs) != 1 || jobs[0].Name != "good.yml" {
+ t.Errorf("jobs = %+v, want just the parsable good.yml", jobs)
}
}
-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,
+func TestDispatchIgnoresTagsAndDeletes(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",
})
- if len(f.created) != 0 {
- t.Errorf("created %d runs, want 0", len(f.created))
+ d := NewDispatcher(md, mz, nil)
+
+ cases := []Event{
+ {RepoID: r.ID, RepoName: r.Name, Ref: "refs/tags/v1", OldSHA: meta.ZeroSHA, NewSHA: head},
+ {RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/main", OldSHA: head, NewSHA: meta.ZeroSHA},
+ {RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/main", OldSHA: head, NewSHA: ""},
+ {RepoID: r.ID, RepoName: r.Name, Ref: "refs/notes/commits", OldSHA: meta.ZeroSHA, NewSHA: head},
+ }
+ for _, ev := range cases {
+ d.Dispatch(ctx, ev)
+ }
+ if runs, _ := md.ListRuns(ctx, r.ID, 0); len(runs) != 0 {
+ t.Errorf("runs = %d, want 0 (tags/deletes/non-branch create none)", len(runs))
}
}
diff --git a/internal/githttp/backend.go b/internal/githttp/backend.go
index 6c4186a..abb9e2a 100644
--- a/internal/githttp/backend.go
+++ b/internal/githttp/backend.go
@@ -173,6 +173,12 @@ func (h *Handler) serveReceivePack(w http.ResponseWriter, r *http.Request, repoN
"GITMOTE_NONCE="+push.Nonce,
)
h.serveCGI(w, r, extra)
+
+ // receive-pack has finished (response flushed, on-disk refs updated). Fire the
+ // 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())
}
// 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 cef2a1d..5c28126 100644
--- a/internal/githttp/receive.go
+++ b/internal/githttp/receive.go
@@ -47,10 +47,12 @@ type Writer struct {
// 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, when set, runs once per push that advanced refs — fired by the
+ // 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)
}
@@ -73,6 +75,28 @@ const afterCommitTimeout = 30 * time.Second
type pushOp struct {
repoID int64
repoName string
+ outcome *pushOutcome
+}
+
+// pushOutcome collects the refs a push committed. The hook callback (Writer.handle)
+// fills it after a successful CAS; the HTTP handler reads it after receive-pack
+// finishes, to fire the after-commit hook. Guarded by its mutex so the write in
+// the socket goroutine is visible to the read in the request goroutine.
+type pushOutcome struct {
+ mu sync.Mutex
+ commits []CommitInfo
+}
+
+func (o *pushOutcome) record(commits []CommitInfo) {
+ o.mu.Lock()
+ o.commits = commits
+ o.mu.Unlock()
+}
+
+func (o *pushOutcome) committed() []CommitInfo {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ return o.commits
}
// NewWriter starts the write coordinator: it installs the pre-receive hook
@@ -129,12 +153,17 @@ func (w *Writer) SockPath() string { return w.sockPath }
// and Release drops the per-repo lock and burns the nonce.
type Push struct {
Nonce string
+ outcome *pushOutcome
release func()
}
// Release ends the push, unlocking the repo and discarding the nonce.
func (p *Push) Release() { p.release() }
+// Committed returns the refs this push advanced, available once receive-pack has
+// run. It is empty for a rejected or no-op push.
+func (p *Push) Committed() []CommitInfo { return p.outcome.committed() }
+
// Begin starts a push: it resolves the repo, takes the per-repo write lock (so
// pushes to one repo serialize), and mints a nonce bound to this operation. The
// caller must Release the returned Push. Begin returns meta.ErrNotFound for an
@@ -146,13 +175,15 @@ func (w *Writer) Begin(ctx context.Context, repoName string) (*Push, error) {
}
lk := w.lockRepo(repoName)
lk.Lock()
- nonce, err := w.register(pushOp{repoID: repo.ID, repoName: repoName})
+ outcome := &pushOutcome{}
+ nonce, err := w.register(pushOp{repoID: repo.ID, repoName: repoName, outcome: outcome})
if err != nil {
lk.Unlock()
return nil, err
}
return &Push{
- Nonce: nonce,
+ Nonce: nonce,
+ outcome: outcome,
release: func() {
w.unregister(nonce)
lk.Unlock()
@@ -243,34 +274,36 @@ func (w *Writer) handle(req hookrpc.Request) hookrpc.Response {
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)
+ // The push is committed. Record what advanced so the HTTP handler can fire the
+ // after-commit hook once receive-pack has finished (see FireAfterCommit).
+ commits := make([]CommitInfo, len(req.Commands))
+ for i, c := range req.Commands {
+ commits[i] = CommitInfo{
+ RepoID: op.repoID,
+ RepoName: op.repoName,
+ Ref: c.Ref,
+ Old: c.Old,
+ New: c.New,
+ }
+ }
+ op.outcome.record(commits)
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 {
+// FireAfterCommit runs the after-commit hook for a completed push. The HTTP
+// handler calls it after receive-pack has fully finished, so the on-disk repo
+// 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) {
+ if w.AfterCommit == nil || len(commits) == 0 {
return
}
defer func() {
if r := recover(); r != nil {
- w.logger.Error("after-commit hook panicked", "repo", op.repoName, "panic", r)
+ w.logger.Error("after-commit hook panicked", "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)
diff --git a/internal/githttp/receive_test.go b/internal/githttp/receive_test.go
index ae333fb..72129bb 100644
--- a/internal/githttp/receive_test.go
+++ b/internal/githttp/receive_test.go
@@ -226,9 +226,11 @@ 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))
+// reading workflow config through a materializer over store s, mirroring the
+// main.go wiring.
+func wireDispatcher(t *testing.T, w *Writer, m *meta.Metadata, s store.Store) {
+ t.Helper()
+ d := ci.NewDispatcher(m, repo.New(m, s, t.TempDir()), slog.New(slog.DiscardHandler))
w.AfterCommit = func(ctx context.Context, commits []CommitInfo) {
for _, c := range commits {
d.Dispatch(ctx, ci.Event{
@@ -242,12 +244,34 @@ func wireDispatcher(w *Writer, m *meta.Metadata) {
}
}
-// TestAfterCommitEnqueuesRun drives a real git push and asserts the after-commit
-// seam records exactly one queued run for the advanced branch tip.
+// initWorkflowCommit makes a source repo whose first commit carries a valid
+// workflow, so a push through the dispatcher discovers work and enqueues a run.
+func initWorkflowCommit(t *testing.T) string {
+ t.Helper()
+ src := t.TempDir()
+ git(t, src, "init", "-b", "main", ".")
+ if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(src, ".github", "workflows"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(src, ".github", "workflows", "ci.yml"),
+ []byte("name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, src, "add", "-A")
+ git(t, src, "commit", "-m", "commit")
+ return src
+}
+
+// TestAfterCommitEnqueuesRun drives a real git push of a repo with a workflow
+// and asserts the after-commit seam discovers it and records one queued run and
+// job for the advanced branch tip.
func TestAfterCommitEnqueuesRun(t *testing.T) {
ctx := context.Background()
- srv, m, _, writer := newWriteServer(t)
- wireDispatcher(writer, m)
+ srv, m, s, writer := newWriteServer(t)
+ wireDispatcher(t, writer, m, s)
r, err := m.CreateRepo(ctx, repoName, "main")
if err != nil {
@@ -256,7 +280,7 @@ func TestAfterCommitEnqueuesRun(t *testing.T) {
raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
remote := authedURL(srv.URL, raw) + "/" + repoName
- src := initCommit(t, "hello\n")
+ src := initWorkflowCommit(t)
head := git(t, src, "rev-parse", "HEAD")
git(t, src, "push", remote, "main")
@@ -270,21 +294,44 @@ func TestAfterCommitEnqueuesRun(t *testing.T) {
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)
}
+ jobs, _ := m.ListJobs(ctx, runs[0].ID)
+ if len(jobs) != 1 || jobs[0].Name != "ci.yml" {
+ t.Errorf("jobs = %+v, want one job named ci.yml", jobs)
+ }
+}
+
+// TestAfterCommitNoWorkflowNoRun confirms the discovery gate end-to-end: a real
+// push of a repo without .github/workflows records no run.
+func TestAfterCommitNoWorkflowNoRun(t *testing.T) {
+ ctx := context.Background()
+ srv, m, s, writer := newWriteServer(t)
+ wireDispatcher(t, writer, m, s)
+
+ 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") // no workflow file
+ git(t, src, "push", remote, "main")
+
+ if runs, _ := m.ListRuns(ctx, r.ID, 0); len(runs) != 0 {
+ t.Errorf("runs = %d, want 0 (no workflows)", len(runs))
+ }
}
// 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)
+ srv, m, s, writer := newWriteServer(t)
+ wireDispatcher(t, writer, m, s)
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")
+ // Establish main with a workflow (one accepted push → one run).
+ src := initWorkflowCommit(t)
git(t, src, "push", remote, "main")
// A stale clone diverges and pushes a non-fast-forward, which is rejected.
diff --git a/internal/meta/ci.go b/internal/meta/ci.go
index b979767..7fbe543 100644
--- a/internal/meta/ci.go
+++ b/internal/meta/ci.go
@@ -2,6 +2,7 @@ package meta
import (
"context"
+ "database/sql"
"time"
)
@@ -132,3 +133,89 @@ func (m *Metadata) ListRuns(ctx context.Context, repoID int64, limit int) ([]Run
}
return runs, rows.Err()
}
+
+// Job is one unit of a run: a single workflow file. Name is the workflow file's
+// base name (stage 2); LogKey is the ci/ object key, set on completion (stage 4).
+type Job struct {
+ ID int64
+ RunID int64
+ Name string
+ Status RunStatus
+ LogKey string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// CreateJob records a queued job under a run and returns it.
+func (m *Metadata) CreateJob(ctx context.Context, runID int64, name string) (*Job, error) {
+ ts := now()
+ res, err := m.db.ExecContext(ctx,
+ `INSERT INTO ci_jobs (run_id, name, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?)`,
+ runID, name, string(RunQueued), ts, ts)
+ if err != nil {
+ return nil, err
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return nil, err
+ }
+ return &Job{
+ ID: id,
+ RunID: runID,
+ Name: name,
+ Status: RunQueued,
+ CreatedAt: parseTime(ts),
+ UpdatedAt: parseTime(ts),
+ }, nil
+}
+
+// SetJobStatus transitions a job to status. It returns ErrNotFound when no job
+// has the given id.
+func (m *Metadata) SetJobStatus(ctx context.Context, jobID int64, status RunStatus) error {
+ res, err := m.db.ExecContext(ctx,
+ `UPDATE ci_jobs SET status = ?, updated_at = ? WHERE id = ?`,
+ string(status), now(), jobID)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+// ListJobs returns a run's jobs ordered by creation (id).
+func (m *Metadata) ListJobs(ctx context.Context, runID int64) ([]Job, error) {
+ rows, err := m.db.QueryContext(ctx,
+ `SELECT id, run_id, name, status, log_key, created_at, updated_at
+ FROM ci_jobs WHERE run_id = ? ORDER BY id`, runID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var jobs []Job
+ for rows.Next() {
+ var (
+ j Job
+ status string
+ logKey sql.NullString
+ cts string
+ uts string
+ )
+ if err := rows.Scan(&j.ID, &j.RunID, &j.Name, &status, &logKey, &cts, &uts); err != nil {
+ return nil, err
+ }
+ j.Status = RunStatus(status)
+ j.LogKey = logKey.String
+ j.CreatedAt = parseTime(cts)
+ j.UpdatedAt = parseTime(uts)
+ jobs = append(jobs, j)
+ }
+ return jobs, rows.Err()
+}
diff --git a/tasks/18-ci-config-discovery.md b/tasks/18-ci-config-discovery.md
deleted file mode 100644
index d9abe69..0000000
--- a/tasks/18-ci-config-discovery.md
+++ /dev/null
@@ -1,72 +0,0 @@
-# CI stage 2 — workflow config discovery
-
-Part of the CI epic ([16-ci.md](16-ci.md)). Depends on **stage 1
-([17](17-ci-data-model.md))** having landed: `ci_runs`/`ci_jobs` tables, the
-`ci.Dispatcher`, and the after-commit seam. Engine is locked to **`act`**, so the
-workflow location/format is `.github/workflows/*.yml|*.yaml`, GitHub Actions YAML
-(epic §Stage 0). Still no execution — this stage decides *whether* there is work
-and records the jobs.
-
-## Spec
-
-When the dispatcher fires for a branch push, read the pushed commit's
-`.github/workflows/` directory at the new SHA:
-
-- **No workflow files** → create **no run** (nothing to do; log at debug).
-- **Workflow file(s) present** → the run gets one `ci_job` per workflow file.
-- **Malformed workflow** (not valid YAML) → the run is recorded **failed** with a
- clear message, never a crash or panic in the write path.
-
-Deep validation (does `act` accept it, do the jobs make sense) is the runner's
-job at execution time; this stage only does cheap syntactic gating.
-
-## Current
-
-- Stage 1's `ci.Dispatcher.Dispatch` creates a `queued` run for a branch update
- and stops there.
-- The browse reader ([internal/repo/browse.go](../internal/repo/browse.go)) reads
- a tree/blob at an arbitrary SHA over a materialized dir: `repo.Tree(ctx, dir,
- sha, path)` and `repo.Blob(ctx, dir, sha, path)`, with `safePath` already
- rejecting traversal. `ErrNotFound` distinguishes "no such path".
-- The shared `*repo.Materializer` (built in [main.go](../cmd/gitmote/main.go),
- passed to githttp and webui) turns a repo name into an on-disk dir via
- `Materialize(ctx, name)` — idempotent and warm right after a push.
-- No YAML dependency in [go.mod](../go.mod) yet.
-
-## Change
-
-**1. Give the `Dispatcher` the `Materializer`** (constructor dep). After
-`CreateRun`, `Materialize(repoName)` to get the dir (the push just materialized
-it, so this is warm), then `repo.Tree(ctx, dir, sha, ".github/workflows")`.
-
-- `repo.ErrNotFound` (dir absent) → treat as "no workflows": delete/skip the run
- (prefer **not creating the run until after** discovery, so an empty result
- leaves no row — reorder stage 1's `CreateRun` to follow discovery).
-- Filter entries to `type == blob` with a `.yml`/`.yaml` suffix.
-
-**2. Minimal YAML validation** — add `gopkg.in/yaml.v3`; for each workflow blob,
-`repo.Blob` then `yaml.Unmarshal` into a loose `map[string]any`. Syntactically
-valid → create a `ci_job` (name = the file's base name) in `queued`. Any file
-failing to parse → set the **run** `failed` with the first parse error as the
-message and still create the job rows you can, so the failure is visible in the
-UI (stage 6). No file parses / none present → no run.
-
-**3. `meta` job helpers** — `CreateJob(ctx, runID, name)`, `ListJobs(ctx,
-runID)`, `SetJobStatus`. (Run status setter exists from stage 1.)
-
-Keep discovery **non-fatal** to the push, exactly like stage 1: a materialize or
-read error marks the run `error` and is logged; it never propagates to the client.
-
-## Verify
-
-- **dispatcher unit tests** (with a seeded materialized fixture repo, cf.
- [webui/browse_test.go](../internal/webui/browse_test.go) `seedBrowseRepo`):
- - repo with one workflow → run + one `queued` job named after the file.
- - repo with **no** `.github/workflows` → **no run** created.
- - two workflow files → two jobs under one run.
- - a **malformed** workflow → run recorded `failed` with the parse message, no
- panic; the push (simulated event) is unaffected.
-- **path safety:** the fixed `.github/workflows` path plus `safePath` means no
- traversal; add a test that a repo without the dir 404s cleanly rather than
- erroring the run as a crash.
-- `gofmt`/`golangci-lint`/`go test ./...` clean; e2e stays green.
diff --git a/tasks/README.md b/tasks/README.md
index c07ffbc..491e9a9 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:
-- [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
client + per-job trigger (no-op when unconfigured).
- [20-ci-scoped-tokens.md](20-ci-scoped-tokens.md) — expiring, repo-scoped,