Workflows: honour paths and paths-ignore on push

34b2c06 · Gabriel Morell · 2026-10-17 04:22

26 files +904 -21
Message
{commit_body(@commit)}

Files changed

modified PROGRESS.md
+5 −0
@@ -32,6 +32,11 @@ each gap below was a wall that pipeline hit.
32 32 - **Job-token grants** (repo settings → Job token access): a repository lets another repository's — or every repository in an organization's — job tokens read its images and packages and/or dispatch its workflows. GitLab's job-token allowlist; the repository reached into decides.
33 33 - **`workflow_dispatch`**: `POST /api/v1/repos/:owner/:name/actions/workflows/:workflow/dispatches`, Gitea/GitHub-compatible, with a job token (own repo or granted) or a `ci:write` personal token. The workflow must declare `on: workflow_dispatch`. `inputs` are accepted and not yet passed through.
34 34
35 +### Workflow triggers
36 +
37 +- **`paths:` / `paths-ignore:`** are honoured for `push`, with GitHub's semantics and pattern syntax (`GitGud.Workflows.Glob`). Every push used to run every workflow: bigtrippr's Android workflow, filtered to `android/**`, ran its tests and re-published an identical APK under a new versionCode on each server-only push. The push worker now passes the ref's old SHA; dispatch lists the changed files once (`Git.changed_paths/3`, `git diff --name-only --no-renames -z`), only when some workflow filters on paths. A new branch is diffed from its merge-base with the default branch. Tags and manual runs ignore path filters; anything that can't be determined (or > 3000 files) fails open.
38 +- Follow-up: `branches:` / `tags:` still use the old matcher, whose `*` crosses `/` (GitHub's doesn't). Left alone because changing it changes which existing branch filters fire.
39 +
35 40 ### Runner protocol
36 41
37 42 - **Field numbers now match forgejo-runner's** (`actions-proto-def`) for `Runner`, `RegisterRequest` and `TaskNeed`. A runner read our `version` as its labels and our `token` as its name ("runner: , with version: , with labels: [v13.2.0]"); a binary-protobuf registration would have crossed version and labels. Tests check against bytes laid out by upstream's numbering.
modified README.md
+2 −0
@@ -88,6 +88,8 @@ reinvent them.
88 88 - Repo-scoped + org-scoped runner registration with one-shot tokens minted from the settings UI
89 89 - **Matrix builds**, with `runs-on` as a real label set — `strategy.matrix` expands into one job per combination, each claimed independently, so `runs-on: [self-hosted, "${{ matrix.arch }}"]` sends the arm64 leg to an arm64 machine and the amd64 leg to an amd64 one. `include`/`exclude` follow GitHub's semantics, `fail-fast` defaults on, and `needs:` names the group rather than a leg
90 90 - **Five deployment styles** offered in the registration UI: Native binary, Docker, Podman, Podman Quadlet, Kubernetes (Deployment + dind sidecar) — copy-pasteable `register` + `daemon` snippets per style
91 + - **Triggers**: `push` (with `branches:` / `tags:`, GitHub's rule that naming one excludes the other kind of ref) and `workflow_dispatch`
92 + - **`paths:` / `paths-ignore:`** with GitHub's pattern rules (`*` stays within a directory, `**` crosses them, `?`, `[a-z]`; in `paths:`, `!pattern` un-matches and a later positive pattern re-matches). A push runs if any changed file is selected by `paths:`, or if not every changed file is matched by `paths-ignore:`; both set → `paths:` wins, with a warning. Changed files are the ref's old → new diff (renames count as both paths); a new branch is diffed from where it left the default branch. Tag pushes and manual runs ignore path filters, and when the changed files can't be worked out (no default branch to compare with, an unknown base, a git error, more than 3000 files) the filter passes — a run is never skipped because the forge couldn't look
91 93 - Re-run / cancel buttons on the workflow show page
92 94 - Per-task HS256 JWT minted on `FetchTask`, gates artifact + cache endpoints
93 95 - **GitHub Actions cache server v1** with hit matching (primary exact → fallback prefix, current ref → default ref) + age/size eviction worker
added docs/plans/workflow-path-filters.md
+192 −0
@@ -0,0 +1,192 @@
1 +# Plan: `paths` / `paths-ignore` filters for workflow triggers
2 +
3 +Status: **executed** (2026-10-16). Written to be carried out start to finish by
4 +one agent without further questions. Execute it before
5 +[workflow-pull-request.md](workflow-pull-request.md), which builds on it.
6 +
7 +## Why
8 +
9 +A workflow can say which files it cares about:
10 +
11 +```yaml
12 +on:
13 + push:
14 + branches: [master]
15 + paths: ["android/**", ".forgejo/workflows/android.yml"]
16 +```
17 +
18 +GitHub and Forgejo only start the run when the push changed a matching
19 +file. gitgud ignores `paths:` and `paths-ignore:` today
20 +(`GitGud.Workflows.Yaml.matches?/3` looks at `branches:` and `tags:` only),
21 +so every push runs every workflow. In bigtrippr that means each server-only
22 +push runs the Android tests and re-publishes an identical APK to F-Droid
23 +under a new versionCode.
24 +
25 +## What the semantics are (GitHub's — workflows are written against its docs)
26 +
27 +1. `paths` and `paths-ignore` sit under an event (`push`, `pull_request`),
28 + next to `branches` / `tags`. Ref filters and path filters must **both**
29 + pass.
30 +2. `paths: [...]`: run if **at least one** changed file matches. Patterns
31 + are evaluated in order; a pattern starting with `!` un-matches files a
32 + previous pattern matched (and a later positive pattern can re-match
33 + them). A file counts as matched if the **last** pattern that applies to
34 + it is positive.
35 +3. `paths-ignore: [...]`: run unless **every** changed file matches some
36 + pattern.
37 +4. Both under one event is an error on GitHub. Here: honour `paths` and
38 + ignore `paths-ignore`, and log a warning once per dispatch. (Do not
39 + silently run nothing.)
40 +5. Pattern syntax (GitHub's filter pattern cheat sheet), relative to the
41 + repository root, no leading `/`:
42 + - `*` matches any run of characters **except** `/`
43 + - `**` matches any run of characters **including** `/` (`docs/**`,
44 + `**/*.md`, `**.js`)
45 + - `?` matches one character except `/`
46 + - `[abc]`, `[a-z]` character classes
47 + - `!` at the start negates (only meaningful in `paths`)
48 + - everything else literal (`.` is a dot)
49 +6. **Tag pushes**: path filters are not evaluated; a tag push is decided by
50 + `tags:` alone.
51 +7. **Deleted branches**: already produce no run (new SHA all zeros); no
52 + change.
53 +8. **New branch** (old SHA all zeros): diff the new head against the
54 + merge-base with the repository's default branch. No default branch, no
55 + merge-base, or pushing the default branch itself as new → treat as
56 + "every file changed" (run).
57 +9. **Fail open**: if the changed-file list can't be computed (missing
58 + object, git error), or has more than 3000 entries, the path filter
59 + passes. Never skip a run because we couldn't look.
60 +10. **Manual runs** (`workflow_dispatch`, re-runs): no path filtering.
61 +11. Renames count as both the old and the new path (`--no-renames`).
62 +
63 +## Where things are
64 +
65 +| | |
66 +|---|---|
67 +| Trigger matching | `lib/git_gud/workflows/yaml.ex` — `matches?/3`, `ref_match?/2`, `glob_match?/2` |
68 +| Dispatch | `lib/git_gud/workflows.ex` — `dispatch/2` (calls `discover_workflows/2`, then `Yaml.matches?/3` per workflow) |
69 +| The only push caller | `lib/git_gud/repositories/workers/commit_backfill.ex` — `perform/1` has `old_sha` and `new_sha`; `dispatch_workflows/4` passes only `new_sha` today |
70 +| Git facade | `lib/git_gud/git.ex` (`dispatch/2` tries the NIF backend, falls back to `GitGud.Git.Cli` on `{:error, :unsupported}`), behaviour in `lib/git_gud/git/backend.ex`, CLI in `lib/git_gud/git/cli.ex`, NIF in `lib/git_gud/git/nif.ex` + `native/gitgud_gix` |
71 +| Existing helpers | `Git.diff_stats/3` (`--numstat`, rename detection on: a rename shows as `{a => b}`, unusable here), `Git.merge_base/3` |
72 +| Pure filter tests | `test/git_gud/workflows/trigger_filters_test.exs` |
73 +| On-disk dispatch tests | `test/git_gud/workflows/tag_dispatch_test.exs` (builds a real repo with `GitGud.ForgeFixtures`; copy its approach) |
74 +
75 +## Steps
76 +
77 +### 1. A glob that follows GitHub's rules
78 +
79 +New module `GitGud.Workflows.Glob` (`lib/git_gud/workflows/glob.ex`):
80 +
81 +- `compile(pattern) :: Regex.t()` translating the syntax in point 5, anchored
82 + `^…$`. Handle `**/` (zero or more directories, so `**/*.md` matches
83 + `README.md`), `/**` at the end, and bare `**`.
84 +- `match?(pattern_or_regex, path) :: boolean()`.
85 +
86 +Tests `test/git_gud/workflows/glob_test.exs`, at least: `*.md` matches
87 +`a.md` not `docs/a.md`; `**/*.md` matches both; `docs/**` matches
88 +`docs/a/b.txt` not `docs`… (GitHub: `docs/**` does match files under docs
89 +only); `android/**` vs `androidx/a`; `?`; `[ab]`; dots literal;
90 +`**.js` matches `a/b/c.js`.
91 +
92 +Do **not** change `Yaml.glob_match?/2` for branches in this work (its `*`
93 +crosses `/`; GitHub's doesn't — note it as a follow-up in the PR/commit
94 +message, don't fix it here: it would change which existing branch filters
95 +fire).
96 +
97 +### 2. Path matching in `Yaml`
98 +
99 +In `Yaml.matches?/3`, after the ref check passes, apply a new
100 +`paths_match?(config, ctx)`:
101 +
102 +- `ctx[:tag]` set → `true` (point 6).
103 +- Neither `paths` nor `paths-ignore` in config → `true`.
104 +- `ctx[:changed_paths]` is `:unknown` or absent → `true` (fail open).
105 +- Otherwise the rules in points 2–4.
106 +
107 +Expose `Yaml.path_filtered?(doc, event) :: boolean()` — whether the event's
108 +config has `paths` or `paths-ignore` — so dispatch can skip computing the
109 +diff when no workflow needs it.
110 +
111 +Extend `trigger_filters_test.exs` (pure, `changed_paths` passed in ctx):
112 +`paths` hit / miss; negation order (`["src/**", "!src/gen/**"]` with only
113 +`src/gen/x` changed → no run; with `src/gen/x` and `src/a` → run;
114 +`["!a/**", "a/b"]` re-match); `paths-ignore` all-ignored → no run, one
115 +non-ignored → run; both keys → `paths` wins; tag push ignores paths;
116 +branch filter fails → no run even if paths match; `:unknown` → run.
117 +
118 +### 3. Listing changed files
119 +
120 +Add `@callback changed_paths(repo_path, base_sha, head_sha)` to
121 +`GitGud.Git.Backend`, `Git.changed_paths/3` in the facade, and the CLI
122 +implementation:
123 +
124 +```
125 +git diff --name-only --no-renames -z <base> <head>
126 +```
127 +
128 +split on NUL (paths can contain newlines/quotes; `-z` disables quoting).
129 +Return `{:ok, [String.t()]}` / `{:error, term}`. The NIF backend returns
130 +`{:error, :unsupported}` (add the clause in `nif.ex` if the module defines
131 +every callback explicitly) so the facade falls back to the CLI. Test it in
132 +whatever file covers `Git.Cli` (find it under `test/git_gud/git*`): add,
133 +modify, delete, rename (both paths), a path with a space.
134 +
135 +### 4. Dispatch computes the changed files once, only if needed
136 +
137 +`Workflows.dispatch/2`'s event gains an optional `:base_sha` (20 bytes,
138 +or `nil` / all zeros for "new ref").
139 +
140 +In `dispatch/2`, after `discover_workflows/2`:
141 +
142 +- If no workflow `Yaml.path_filtered?/2` for this trigger, don't diff.
143 +- Otherwise compute once:
144 + - tag ref → skip (ctx gets no `:changed_paths`; tags ignore paths anyway)
145 + - `base_sha` present and non-zero → `Git.changed_paths(path, base, head)`
146 + - else (new branch) → merge-base of `head` with
147 + `refs/heads/<default_branch>` (use `Git.merge_base/3`; resolve the
148 + default branch via the same helpers `resolve_ref/2` uses); if found
149 + and != head → diff from it; otherwise `:unknown`
150 + - any `{:error, _}` → `:unknown`; more than 3000 paths → `:unknown`
151 +- Put the result in `ctx` as `:changed_paths` and pass that ctx to every
152 + `Yaml.matches?/3` call.
153 +
154 +`dispatch_manual/4` passes no `:base_sha` and must not filter on paths
155 +(point 10): make sure its ctx never carries `:changed_paths` (it already
156 +builds its own ctx — keep it that way).
157 +
158 +### 5. Pass the old SHA from the push
159 +
160 +`CommitBackfill.dispatch_workflows/4` → `/5` taking `old_sha`, and put it in
161 +the event as `base_sha`. `old_sha` is `nil` when the hook sent all zeros
162 +(see `maybe_hex/1` / `zero?/1` in the same file); pass it through as is.
163 +
164 +### 6. On-disk tests
165 +
166 +New `test/git_gud/workflows/path_dispatch_test.exs`, modelled on
167 +`tag_dispatch_test.exs` (real repo on disk, real commits):
168 +
169 +- workflow `paths: ["android/**"]`: push changing only `README.md` → no
170 + run; push changing `android/x` → one run.
171 +- `paths-ignore: ["docs/**"]`: docs-only push → no run; docs + `lib/a` → run.
172 +- new branch (base zero) off the default branch changing only `docs/a` with
173 + `paths: ["lib/**"]` → no run; same with no default branch → run (fail open).
174 +- tag push with `paths: ["nothing/**"]` and `tags: ['v*']` → run.
175 +- an unresolvable base SHA (random 20 bytes) → run.
176 +- a workflow with no path filters still runs on every push, and no git diff
177 + is executed for it (if that's observable cheaply; otherwise skip this
178 + assertion).
179 +
180 +### 7. Docs
181 +
182 +Find where gitgud documents workflow support (grep `branches:` /
183 +`workflow_dispatch` under `docs/`, `README.md`, `PROGRESS.md`,
184 +`ROADMAP.md`) and add `paths` / `paths-ignore` with the rules above,
185 +including the fail-open behaviour and that tags ignore them.
186 +
187 +## Done when
188 +
189 +- `mix precommit` passes (compile with warnings as errors, format, tests).
190 +- One commit on the current branch, message in the repository's style
191 + (see `git log`): what changed and why, mentioning the bigtrippr case.
192 +- **Not pushed.** Pushing gitgud deploys the forge; leave that to a human.
modified lib/git_gud/git.ex
+9 −0
@@ -195,6 +195,15 @@ defmodule GitGud.Git do
195 195 def diff(path, base_sha, head_sha, opts \\ []),
196 196 do: dispatch(:diff, [path, base_sha, head_sha, opts])
197 197
198 + @doc """
199 + Every path that differs between two commits, renames counted as both
200 + their old and their new path (`--no-renames`). What `paths:` workflow
201 + filters are matched against.
202 + """
203 + @spec changed_paths(repo_path(), sha(), sha()) :: {:ok, [String.t()]} | {:error, term()}
204 + def changed_paths(path, base_sha, head_sha),
205 + do: dispatch(:changed_paths, [path, base_sha, head_sha])
206 +
198 207 @doc """
199 208 Resolve a ref name or partial SHA into a full commit SHA.
200 209 Accepts: "main", "refs/heads/main", "abc1234", full SHA as hex or binary.
modified lib/git_gud/git/backend.ex
+2 −0
@@ -23,6 +23,8 @@ defmodule GitGud.Git.Backend do
23 23 {:ok, Git.diff_stat()} | {:error, term()}
24 24 @callback diff(Git.repo_path(), Git.sha() | nil, Git.sha(), keyword()) ::
25 25 {:ok, String.t()} | {:error, term()}
26 + @callback changed_paths(Git.repo_path(), Git.sha(), Git.sha()) ::
27 + {:ok, [String.t()]} | {:error, term()}
26 28 @callback resolve(Git.repo_path(), String.t() | Git.sha()) ::
27 29 {:ok, Git.sha()} | {:error, term()}
28 30 @callback merge_base(Git.repo_path(), Git.sha(), Git.sha()) ::
modified lib/git_gud/git/cli.ex
+23 −0

Click to load diff…

modified lib/git_gud/git/nif.ex
+5 −0

Click to load diff…

modified lib/git_gud/repositories/workers/commit_backfill.ex
+20 −14

Click to load diff…

modified lib/git_gud/workflows.ex
+58 −2

Click to load diff…

added lib/git_gud/workflows/glob.ex
+76 −0

Click to load diff…

modified lib/git_gud/workflows/yaml.ex
+84 −5

Click to load diff…

modified test/git_gud/git_test.exs
+75 −0

Click to load diff…

added test/git_gud/workflows/glob_test.exs
+79 −0

Click to load diff…

added test/git_gud/workflows/path_dispatch_test.exs
+189 −0

Click to load diff…

modified test/git_gud/workflows/trigger_filters_test.exs
+85 −0

Click to load diff…

Parents: 69f26e0