neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Workflows: honour paths and paths-ignore on push
34b2c06 · Gabriel Morell · 2026-10-17 04:22
Message
{commit_body(@commit)}
Files changed
modified
PROGRESS.md
+5
−0
| 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 | 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
| 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 | 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 | 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