gitmote

gitmote  ·  history  ·  branches

feat: honor on.push branch filters so a non-matching push starts no run

676a933febf5a8c082bb1d5585cff133d8b1a381
Atanas Minev <atmin@protonmail.com> · 2026-07-18 16:10

diff --git a/docs/architecture/ci.md b/docs/architecture/ci.md
index bab2919..0ecd19a 100644
--- a/docs/architecture/ci.md
+++ b/docs/architecture/ci.md
@@ -46,6 +46,19 @@ The runner ([`internal/runner`](../../internal/runner)) is one linear pass:
injected secrets, reports over the authenticated API, and **never touches s3lite
or S3 directly**.
+**Branch filters gate dispatch, GitHub-style.** The dispatcher parses each
+workflow's `on.push` and records a run *only* for workflows whose branch filter
+selects the pushed branch — so a push that matches nothing produces **no run at
+all**, exactly as GitHub shows no workflow run when a push triggers nothing (a
+green no-op run, its log full of engine start-up chatter, would otherwise be pure
+noise — the concrete case: gitmote's own `deploy.yml` gated on `self-deploy`,
+which must stay silent on every `master` push). It models the common filter
+subset — `branches` / `branches-ignore` with `*` and `**` globs, the ~80%;
+`paths`, tag filters, and the rarer `?`/`+`/`[]`/`!` patterns are not evaluated
+(a workflow using them is kept, erring toward running). A workflow file that
+can't be read or parsed is also kept, so a malformed workflow surfaces as a
+*failed* run rather than vanishing.
+
## One runner, three substrates
The runner code and env contract are identical everywhere; only what *starts* a
diff --git a/internal/ci/dispatcher.go b/internal/ci/dispatcher.go
index b8cde23..8df3ffd 100644
--- a/internal/ci/dispatcher.go
+++ b/internal/ci/dispatcher.go
@@ -175,19 +175,40 @@ func (d *Dispatcher) Dispatch(ctx context.Context, ev Event) {
return
}
+ branch := strings.TrimPrefix(ev.Ref, branchPrefix)
var files []repo.TreeEntry
for _, e := range entries {
- if e.Type == "blob" && isYAML(e.Name) {
+ if e.Type == "blob" && isYAML(e.Name) && d.triggeredBy(ctx, dir, ev.NewSHA, e.Path, branch) {
files = append(files, e)
}
}
if len(files) == 0 {
- return // a workflows dir with no *.yml|*.yaml → nothing to run
+ // No workflow is configured to run for this branch (none present, or their
+ // `on.push` branch filters all exclude it). Like GitHub, record no run —
+ // a run appears only when something is actually meant to execute.
+ return
}
d.recordRun(ctx, ev, dir, files)
}
+// triggeredBy reports whether the workflow at path should run for a push to
+// branch, per its `on.push` branch filter. A file that can't be read or parsed
+// is kept (returns true) so recordRun still surfaces it as a failed run rather
+// than silently dropping it — only a well-formed workflow that genuinely does
+// not select this branch (or doesn't react to push) is filtered out.
+func (d *Dispatcher) triggeredBy(ctx context.Context, dir, sha, path, branch string) bool {
+ content, _, _, err := repo.Blob(ctx, dir, sha, path)
+ if err != nil {
+ return true
+ }
+ trig, err := parsePushTrigger(content)
+ if err != nil {
+ return true
+ }
+ return trig.matches(branch)
+}
+
// recordRun creates a queued run and one job per workflow file, then triggers a
// runner per job. 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,
diff --git a/internal/ci/dispatcher_test.go b/internal/ci/dispatcher_test.go
index a143c91..767d40d 100644
--- a/internal/ci/dispatcher_test.go
+++ b/internal/ci/dispatcher_test.go
@@ -195,6 +195,36 @@ func TestDispatchTwoWorkflowsCreateTwoJobs(t *testing.T) {
}
}
+// TestDispatchBranchFilterSkipsNonMatchingPush: a workflow gated on a branch
+// filter (gitmote's own deploy.yml, `on.push.branches: [self-deploy]`) creates no
+// run on a push to any other branch — like GitHub, a run appears only when the
+// push actually triggers something.
+func TestDispatchBranchFilterSkipsNonMatchingPush(t *testing.T) {
+ ctx := context.Background()
+ md, s, mz := newFixture(t)
+ r, head := seedRepo(t, md, s, "app", map[string]string{
+ ".gitmote/workflows/deploy.yml": "name: Deploy\non:\n push:\n branches: [self-deploy]\n",
+ })
+ d := newDispatcher(md, mz, &stubTrigger{})
+
+ // A push to main matches nothing → no run at all.
+ d.Dispatch(ctx, Event{RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/main", OldSHA: meta.ZeroSHA, NewSHA: head})
+ if runs, _ := md.ListRuns(ctx, r.ID, 0); len(runs) != 0 {
+ t.Fatalf("main push runs = %d, want 0 (branch filter excludes main)", len(runs))
+ }
+
+ // A push to self-deploy matches → one run with the deploy job.
+ d.Dispatch(ctx, Event{RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/self-deploy", OldSHA: meta.ZeroSHA, NewSHA: head})
+ runs, _ := md.ListRuns(ctx, r.ID, 0)
+ if len(runs) != 1 {
+ t.Fatalf("self-deploy push runs = %d, want 1", len(runs))
+ }
+ jobs, _ := md.ListJobs(ctx, runs[0].ID)
+ if len(jobs) != 1 || jobs[0].Name != "deploy.yml" {
+ t.Errorf("jobs = %+v, want one deploy.yml job", jobs)
+ }
+}
+
func TestDispatchNoWorkflowsNoRun(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
diff --git a/internal/ci/triggers.go b/internal/ci/triggers.go
new file mode 100644
index 0000000..c152f5d
--- /dev/null
+++ b/internal/ci/triggers.go
@@ -0,0 +1,154 @@
+package ci
+
+import (
+ "regexp"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// pushTrigger is the parsed push-event filter of a workflow's `on:` — the subset
+// gitmote evaluates to decide whether a branch push starts a run. reacts is false
+// when the workflow doesn't react to push at all (e.g. workflow_dispatch only),
+// so a push never triggers it. branches / branchesIgnore are GitHub's branch
+// globs; empty means "no branch constraint".
+type pushTrigger struct {
+ reacts bool
+ branches []string
+ branchesIgnore []string
+}
+
+// workflowOn decodes just a workflow's `on:` key. The explicit tag matters: YAML
+// resolves a bare `on` scalar as the boolean true (YAML 1.1), so keying off a
+// decoded map would miss it — a tagged struct field matches the literal `on`.
+type workflowOn struct {
+ On yaml.Node `yaml:"on"`
+}
+
+// parsePushTrigger extracts the push filter from a workflow's YAML. A file that
+// doesn't parse returns an error (the caller keeps it so the run still surfaces
+// the failure). A well-formed file that doesn't react to push returns
+// reacts=false with a nil error.
+func parsePushTrigger(content []byte) (pushTrigger, error) {
+ var w workflowOn
+ if err := yaml.Unmarshal(content, &w); err != nil {
+ return pushTrigger{}, err
+ }
+ return interpretOn(&w.On), nil
+}
+
+// interpretOn reads the three shapes `on:` takes — a scalar (`on: push`), a
+// sequence (`on: [push, …]`), or a mapping (`on: {push: {branches: […]}}`).
+func interpretOn(on *yaml.Node) pushTrigger {
+ switch on.Kind {
+ case yaml.ScalarNode:
+ return pushTrigger{reacts: on.Value == "push"}
+ case yaml.SequenceNode:
+ for _, n := range on.Content {
+ if n.Kind == yaml.ScalarNode && n.Value == "push" {
+ return pushTrigger{reacts: true}
+ }
+ }
+ return pushTrigger{}
+ case yaml.MappingNode:
+ for i := 0; i+1 < len(on.Content); i += 2 {
+ if on.Content[i].Value != "push" {
+ continue
+ }
+ t := pushTrigger{reacts: true}
+ if v := on.Content[i+1]; v.Kind == yaml.MappingNode {
+ t.branches = seqStrings(mapValue(v, "branches"))
+ t.branchesIgnore = seqStrings(mapValue(v, "branches-ignore"))
+ }
+ return t
+ }
+ return pushTrigger{}
+ default:
+ return pushTrigger{} // null or absent `on:` — nothing to react to
+ }
+}
+
+// matches reports whether a push to branch should trigger this workflow: it must
+// react to push, pass any `branches` allow-list, and clear any `branches-ignore`
+// deny-list.
+func (t pushTrigger) matches(branch string) bool {
+ if !t.reacts {
+ return false
+ }
+ if len(t.branches) > 0 && !anyGlob(t.branches, branch) {
+ return false
+ }
+ if len(t.branchesIgnore) > 0 && anyGlob(t.branchesIgnore, branch) {
+ return false
+ }
+ return true
+}
+
+// mapValue returns the value node for key in a mapping node, or nil.
+func mapValue(m *yaml.Node, key string) *yaml.Node {
+ for i := 0; i+1 < len(m.Content); i += 2 {
+ if m.Content[i].Value == key {
+ return m.Content[i+1]
+ }
+ }
+ return nil
+}
+
+// seqStrings reads a scalar or a sequence of scalars into a string slice (a
+// branch filter may be written `branches: main` or `branches: [main, dev]`).
+func seqStrings(n *yaml.Node) []string {
+ if n == nil {
+ return nil
+ }
+ switch n.Kind {
+ case yaml.ScalarNode:
+ return []string{n.Value}
+ case yaml.SequenceNode:
+ out := make([]string, 0, len(n.Content))
+ for _, e := range n.Content {
+ if e.Kind == yaml.ScalarNode {
+ out = append(out, e.Value)
+ }
+ }
+ return out
+ default:
+ return nil
+ }
+}
+
+func anyGlob(patterns []string, name string) bool {
+ for _, p := range patterns {
+ if globMatch(p, name) {
+ return true
+ }
+ }
+ return false
+}
+
+// globMatch reports whether name matches a GitHub branch-filter glob. It models
+// the two wildcards that cover real branch filters — `*` (a run of any character
+// except `/`) and `**` (any character including `/`) — and treats everything
+// else literally. GitHub's rarer pattern features (`?`, `+`, `[]`, `!`) are not
+// modeled: a pattern using them matches only its literal form.
+func globMatch(pattern, name string) bool {
+ var b strings.Builder
+ b.WriteByte('^')
+ for i := 0; i < len(pattern); i++ {
+ if pattern[i] == '*' {
+ if i+1 < len(pattern) && pattern[i+1] == '*' {
+ b.WriteString(".*")
+ i++
+ } else {
+ b.WriteString("[^/]*")
+ }
+ continue
+ }
+ b.WriteString(regexp.QuoteMeta(pattern[i : i+1]))
+ }
+ b.WriteByte('$')
+ re, err := regexp.Compile(b.String())
+ if err != nil {
+ return pattern == name
+ }
+ return re.MatchString(name)
+}
diff --git a/internal/ci/triggers_test.go b/internal/ci/triggers_test.go
new file mode 100644
index 0000000..d07fb5e
--- /dev/null
+++ b/internal/ci/triggers_test.go
@@ -0,0 +1,66 @@
+package ci
+
+import "testing"
+
+func TestParsePushTriggerMatches(t *testing.T) {
+ cases := []struct {
+ name string
+ yaml string
+ branch string
+ want bool
+ wantErr bool
+ }{
+ {"bare push scalar", "on: push\n", "main", true, false},
+ {"push in sequence", "on: [push, workflow_dispatch]\n", "anything", true, false},
+ {"no push in sequence", "on: [workflow_dispatch]\n", "main", false, false},
+ {"push map no filter", "on:\n push:\n", "main", true, false},
+ {"branch allow match", "on:\n push:\n branches: [self-deploy]\n", "self-deploy", true, false},
+ {"branch allow miss", "on:\n push:\n branches: [self-deploy]\n", "main", false, false},
+ {"branch scalar form", "on:\n push:\n branches: main\n", "main", true, false},
+ {"glob star matches segment", "on:\n push:\n branches: ['feature/*']\n", "feature/x", true, false},
+ {"glob star not across slash", "on:\n push:\n branches: ['feature/*']\n", "feature/x/y", false, false},
+ {"glob doublestar across slash", "on:\n push:\n branches: ['release/**']\n", "release/a/b", true, false},
+ {"branches-ignore excludes", "on:\n push:\n branches-ignore: [wip]\n", "wip", false, false},
+ {"branches-ignore allows other", "on:\n push:\n branches-ignore: [wip]\n", "main", true, false},
+ {"dispatch only, no push", "on:\n workflow_dispatch:\n", "main", false, false},
+ {"malformed yaml errors", "name: x\n\t- : broken:\n", "main", false, true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ trig, err := parsePushTrigger([]byte(tc.yaml))
+ if tc.wantErr {
+ if err == nil {
+ t.Fatalf("parse %q: want error, got none", tc.yaml)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("parse %q: %v", tc.yaml, err)
+ }
+ if got := trig.matches(tc.branch); got != tc.want {
+ t.Errorf("matches(%q) = %v, want %v", tc.branch, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestGlobMatch(t *testing.T) {
+ cases := []struct {
+ pattern, name string
+ want bool
+ }{
+ {"main", "main", true},
+ {"main", "master", false},
+ {"self-deploy", "self-deploy", true}, // literal hyphen, not a regex range
+ {"feature/*", "feature/login", true},
+ {"feature/*", "feature/login/deep", false},
+ {"**", "any/deep/branch", true},
+ {"release/**", "release/1.2/rc", true},
+ {"v*.*", "v1.2", true},
+ }
+ for _, tc := range cases {
+ if got := globMatch(tc.pattern, tc.name); got != tc.want {
+ t.Errorf("globMatch(%q, %q) = %v, want %v", tc.pattern, tc.name, got, tc.want)
+ }
+ }
+}