✨ (ci): Honor path trigger filters
Changes
5 files changed, +198 -17
MODIFY
README.md
+3 -3
@@ -86,9 +86,9 @@
86
86
protocol under `/api/actions`, so a stock `forgejo-runner` / `act_runner` registers and runs jobs
87
87
unchanged. Instance admins (handles in `GITSHARK_ADMIN_HANDLES`) generate reusable registration
88
88
tokens and manage runners at `/admin/runners`; secrets are stored hashed. A push adding a workflow
89
- to `.forgejo/workflows/` (`on: push`, with `branches`/`tags` glob filters) creates a run, which a
90
- runner claims, executes, and streams logs for — visible on the repository's **Actions** tab; a
91
- vanished runner's task is reclaimed after a timeout. Path filters, other events, secrets/variables,
89
+ to `.forgejo/workflows/` (`on: push`, with `branches`/`tags`/`paths` glob filters) creates a run,
90
+ which a runner claims, executes, and streams logs for — visible on the repository's **Actions** tab;
91
+ a vanished runner's task is reclaimed after a timeout. Non-push events, secrets/variables,
92
92
`needs`/`matrix`, and artifacts are follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
93
93
[architecture](docs/maintainers/ci-runners.md)
94
94
activities from; local users can in turn follow a remote repository — or a whole remote user, whose
MODIFY
docs/maintainers/ci-runners.md
+8 -4
@@ -64,7 +64,10 @@
64
64
- **Ref-based trigger filters:** a bare/list `on: push` triggers on any branch push (never tags); an
65
65
`on: { push: {...} }` object honors `branches`/`branches-ignore` (branch pushes) and
66
66
`tags`/`tags-ignore` (tag pushes) with GitHub-style globs (`globToRegex`: `**` spans `/`, `*`/`?`
67
- do not). A tag-only filter block excludes branch pushes. Path filters are not evaluated yet.
67
+ do not). A tag-only filter block excludes branch pushes.
68
+- **Path filters:** `paths`/`paths-ignore` are matched against the files changed by the push
69
+ (`changedPaths` diffs old→new, or the empty tree for a new ref, capped at 5000 paths). `paths` runs
70
+ when any changed file matches; `paths-ignore` runs unless every changed file is ignored.
68
71
- **`FetchTask` dispatch:** a registered runner claims the oldest PENDING task (`TaskDispatchService`,
69
72
one transaction) — task+run flip to RUNNING, the runner goes ACTIVE, `action_task.deadline` is set,
70
73
and the task is delivered with its surrogate int64 `seq` id and `workflow_payload`. The candidate
@@ -84,6 +87,8 @@
84
87
`ActionRunPersistenceTest` (run/task/log persistence, per-repo run numbering, pending-task lookup),
85
88
`WorkflowIngestServiceTest` (push → run/task creation, non-push trigger and no-workflow are no-ops),
86
89
`WorkflowTriggerFilterTest` (branch/tag include+ignore globs, bare push branches-only, tag pushes),
90
+ `WorkflowPathFilterTest` (paths runs on a matching changed file, paths-ignore skips only when all
91
+ changed files are ignored),
87
92
`FetchTaskTest` (claim oldest pending over the wire, empty queue, bad credentials, and two runners
88
93
racing one task → claimed at most once), `TaskProgressTest` (UpdateTask success rolls up task+run
89
94
and frees the runner, UpdateLog append + dedup/resume, cross-runner and bad-credential rejection).
@@ -111,9 +116,8 @@
111
116
- **Per-job payload expansion:** `workflow_payload` is the raw workflow YAML (fine while a workflow
112
117
has a single job, which the `github.job` context selects); a multi-job workflow needs each job
113
118
isolated/expanded into its own payload. No `needs`/`matrix` yet.
114
-- **Trigger refinement (remaining):** `branches`/`tags` (+ `-ignore`) filters and tag pushes work;
115
- still missing are `paths`/`paths-ignore` filters (needs an old→new diff) and non-push events
116
- (`pull_request`, scheduled, manual).
119
+- **Non-push events:** only `push` is evaluated; `pull_request`, scheduled and manual triggers are
120
+ not. (`!`-negation within a single pattern list is also not supported.)
117
121
- **Later phases:** secrets/variables delivery, label-based matching, concurrency/cancellation,
118
122
artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and ephemeral runners, commit/MR status.
119
123
MODIFY
docs/users/ci-runners.md
+5 -3
@@ -45,13 +45,15 @@
45
45
push:
46
46
branches: [main, 'release/*'] # only these branches (globs: * within a segment, ** across)
47
47
tags: ['v*'] # and pushes of matching tags
48
+ paths: ['src/**'] # only when a changed file matches
48
49
```
49
50
50
-Use `branches-ignore` / `tags-ignore` to invert. A block with only `tags:` runs on tag pushes and
51
-not on branch pushes. `paths:` filters and non-push events are not evaluated yet.
51
+Use `branches-ignore` / `tags-ignore` / `paths-ignore` to invert. A block with only `tags:` runs on
52
+tag pushes and not on branch pushes. `paths` runs when any changed file matches; `paths-ignore` runs
53
+unless every changed file is ignored. Non-push events are not evaluated yet.
52
54
53
55
## What's coming
54
56
55
-- `paths`/`paths-ignore` filters and non-push events (`pull_request`, scheduled, manual).
57
+- Non-push events (`pull_request`, scheduled, manual).
56
58
- Repository-level secrets and variables, `needs`/`matrix`, and run cancellation/re-run.
57
59
- Artifacts and commit/merge-request status integration.
MODIFY
src/main/java/de/workaround/ci/WorkflowIngestService.java
+72 -7
@@ -12,8 +12,10 @@
12
12
import org.eclipse.jgit.revwalk.RevCommit;
13
13
import org.eclipse.jgit.revwalk.RevWalk;
14
14
import org.eclipse.jgit.transport.ReceiveCommand;
15
+import org.eclipse.jgit.treewalk.EmptyTreeIterator;
15
16
import org.eclipse.jgit.treewalk.TreeWalk;
16
17
import org.eclipse.jgit.treewalk.filter.PathFilter;
18
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
17
19
import org.jboss.logging.Logger;
18
20
19
21
import com.fasterxml.jackson.databind.JsonNode;
@@ -31,8 +33,8 @@
31
33
* at the new commit are parsed; those whose {@code push} trigger matches the ref produce one {@link
32
34
* de.workaround.model.ActionRun} with one {@link de.workaround.model.ActionTask} per job.
33
35
*
34
- * <p>Supported today: {@code on: push} with {@code branches}/{@code tags} (and {@code -ignore})
35
- * glob filters. Not yet: {@code paths} filters, non-push events, {@code needs} and {@code matrix}.
36
+ * <p>Supported today: {@code on: push} with {@code branches}/{@code tags}/{@code paths} (and their
37
+ * {@code -ignore} variants) filters. Not yet: non-push events, {@code needs} and {@code matrix}.
36
38
* Invoked from the transports' post-receive hooks on a Git worker thread with no CDI request context,
37
39
* so it activates one and never throws into the Git path.
38
40
*/
@@ -45,6 +47,8 @@
45
47
46
48
private static final int MAX_WORKFLOW_BYTES = 512 * 1024;
47
49
50
+ private static final int MAX_CHANGED_PATHS = 5000;
51
+
48
52
private static final YAMLMapper YAML = new YAMLMapper();
49
53
50
54
@Inject
@@ -97,10 +101,16 @@
97
101
{
98
102
continue;
99
103
}
100
- for (WorkflowFile workflow : readWorkflows(db, command.getNewId()))
104
+ List<WorkflowFile> workflows = readWorkflows(db, command.getNewId());
105
+ if (workflows.isEmpty())
106
+ {
107
+ continue;
108
+ }
109
+ List<String> changedPaths = changedPaths(db, command.getOldId(), command.getNewId());
110
+ for (WorkflowFile workflow : workflows)
101
111
{
102
112
JsonNode root = parse(workflow.content());
103
- if (root == null || !pushMatches(root, target))
113
+ if (root == null || !pushMatches(root, target, changedPaths))
104
114
{
105
115
continue;
106
116
}
@@ -162,6 +172,40 @@
162
172
}
163
173
}
164
174
175
+ /**
176
+ * The repository-relative paths changed between the old and new commit of a push (added, modified
177
+ * or deleted). A brand-new ref (old = zero) is diffed against the empty tree. Capped at {@link
178
+ * #MAX_CHANGED_PATHS} so a huge push cannot exhaust memory; best-effort (empty on error).
179
+ */
180
+ private static List<String> changedPaths(org.eclipse.jgit.lib.Repository db, ObjectId oldId, ObjectId newId)
181
+ {
182
+ List<String> paths = new ArrayList<>();
183
+ try (RevWalk walk = new RevWalk(db); TreeWalk treeWalk = new TreeWalk(db))
184
+ {
185
+ if (oldId != null && !oldId.equals(ObjectId.zeroId()))
186
+ {
187
+ treeWalk.addTree(walk.parseCommit(oldId).getTree());
188
+ }
189
+ else
190
+ {
191
+ treeWalk.addTree(new EmptyTreeIterator());
192
+ }
193
+ treeWalk.addTree(walk.parseCommit(newId).getTree());
194
+ treeWalk.setRecursive(true);
195
+ treeWalk.setFilter(TreeFilter.ANY_DIFF);
196
+ while (treeWalk.next() && paths.size() < MAX_CHANGED_PATHS)
197
+ {
198
+ paths.add(treeWalk.getPathString());
199
+ }
200
+ }
201
+ catch (Exception e)
202
+ {
203
+ // best-effort: without a diff, path filters simply won't match (no run)
204
+ LOG.debugf(e, "Could not diff %s..%s for path filters", oldId, newId);
205
+ }
206
+ return paths;
207
+ }
208
+
165
209
private static JsonNode parse(String yaml)
166
210
{
167
211
try
@@ -203,9 +247,10 @@
203
247
* {@code on} to boolean true, so SnakeYAML/Jackson may surface it under {@code "true"} — both are
204
248
* checked. A bare/list {@code on: push} triggers on any branch push (never tags); an
205
249
* {@code on: { push: {...} }} object honors {@code branches}/{@code branches-ignore} and
206
- * {@code tags}/{@code tags-ignore} with GitHub-style globs. Path filters are not evaluated yet.
250
+ * {@code tags}/{@code tags-ignore} with GitHub-style globs, plus {@code paths}/{@code paths-ignore}
251
+ * against the changed files.
207
252
*/
208
- static boolean pushMatches(JsonNode root, RefTarget target)
253
+ static boolean pushMatches(JsonNode root, RefTarget target, List<String> changedPaths)
209
254
{
210
255
JsonNode on = root.has("on") ? root.get("on") : root.get("true");
211
256
if (on == null)
@@ -241,7 +286,7 @@
241
286
// `push:` with no filter block triggers on any branch push (never tags)
242
287
return target.kind() == RefKind.BRANCH;
243
288
}
244
- return refMatchesFilters(push, target);
289
+ return refMatchesFilters(push, target) && pathMatches(push, changedPaths);
245
290
}
246
291
247
292
private static boolean refMatchesFilters(JsonNode push, RefTarget target)
@@ -271,6 +316,26 @@
271
316
return false;
272
317
}
273
318
319
+ /**
320
+ * Evaluates {@code paths}/{@code paths-ignore} against the files changed by the push. {@code paths}
321
+ * runs when any changed file matches; {@code paths-ignore} runs unless every changed file is
322
+ * ignored. Neither key means no path constraint.
323
+ */
324
+ private static boolean pathMatches(JsonNode push, List<String> changedPaths)
325
+ {
326
+ if (push.has("paths"))
327
+ {
328
+ JsonNode paths = push.get("paths");
329
+ return changedPaths.stream().anyMatch(file -> matchesAnyGlob(paths, file));
330
+ }
331
+ if (push.has("paths-ignore"))
332
+ {
333
+ JsonNode ignore = push.get("paths-ignore");
334
+ return !changedPaths.stream().allMatch(file -> matchesAnyGlob(ignore, file));
335
+ }
336
+ return true;
337
+ }
338
+
274
339
private static boolean matchesAnyGlob(JsonNode patterns, String name)
275
340
{
276
341
if (patterns == null)
ADD
src/test/java/de/workaround/ci/WorkflowPathFilterTest.java
+110 -0
@@ -0,0 +1,110 @@
1
+package de.workaround.ci;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.nio.file.Path;
5
+import java.util.List;
6
+import java.util.Map;
7
+import java.util.UUID;
8
+
9
+import org.eclipse.jgit.lib.ObjectId;
10
+import org.eclipse.jgit.revwalk.RevWalk;
11
+import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
12
+import org.eclipse.jgit.transport.ReceiveCommand;
13
+import org.junit.jupiter.api.Test;
14
+
15
+import de.workaround.git.GitRepositoryService;
16
+import de.workaround.git.GitTestSeeder;
17
+import de.workaround.model.ActionRun;
18
+import de.workaround.model.Repository;
19
+import de.workaround.model.User;
20
+import io.quarkus.test.junit.QuarkusTest;
21
+import jakarta.inject.Inject;
22
+import jakarta.transaction.Transactional;
23
+
24
+import static org.junit.jupiter.api.Assertions.assertEquals;
25
+
26
+/**
27
+ * Path-based trigger filters (issue #2, phase 2): {@code on.push.paths}/{@code paths-ignore} matched
28
+ * against the files changed by the pushed commits (old→new diff). {@code paths} runs when any changed
29
+ * file matches; {@code paths-ignore} runs unless every changed file is ignored.
30
+ */
31
+@QuarkusTest
32
+class WorkflowPathFilterTest
33
+{
34
+ @Inject
35
+ WorkflowIngestService ingest;
36
+
37
+ @Inject
38
+ GitRepositoryService repositories;
39
+
40
+ @Inject
41
+ ActionRun.Repo runs;
42
+
43
+ @Test
44
+ void pathsRunsOnlyWhenAChangedFileMatches()
45
+ {
46
+ String matches = "on:\n push:\n paths: ['src/**']\n";
47
+ assertEquals(1, runCount("pf-a", matches, "src/app.js"));
48
+
49
+ String noMatch = "on:\n push:\n paths: ['docs/**']\n";
50
+ assertEquals(0, runCount("pf-b", noMatch, "src/app.js"));
51
+ }
52
+
53
+ @Test
54
+ void pathsIgnoreSkipsOnlyWhenAllChangedFilesAreIgnored()
55
+ {
56
+ String ignore = "on:\n push:\n paths-ignore: ['docs/**']\n";
57
+ // changed file is outside the ignore set → still runs
58
+ assertEquals(1, runCount("pf-c", ignore, "src/app.js"));
59
+
60
+ String ignoreAll = "on:\n push:\n paths-ignore: ['src/**']\n";
61
+ // the only changed file is ignored → skipped
62
+ assertEquals(0, runCount("pf-d", ignoreAll, "src/app.js"));
63
+ }
64
+
65
+ private int runCount(String repoName, String onBlock, String changedFile)
66
+ {
67
+ String yaml = onBlock + "jobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n";
68
+ Repository repo = seedTwoCommitsAndIngest(repoName, yaml, changedFile);
69
+ return runs.findByRepository(repo).size();
70
+ }
71
+
72
+ @Transactional
73
+ Repository seedTwoCommitsAndIngest(String repoName, String workflowYaml, String changedFile)
74
+ {
75
+ String username = repoName + "-" + UUID.randomUUID().toString().substring(0, 8);
76
+ User owner = new User();
77
+ owner.oidcSub = username;
78
+ owner.username = username;
79
+ owner.persist();
80
+ Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
81
+
82
+ try
83
+ {
84
+ Path bare = repositories.repositoryPath(repo);
85
+ // commit 1: the workflow + a baseline file; commit 2: change only `changedFile`
86
+ GitTestSeeder.seed(bare, Map.of(
87
+ ".forgejo/workflows/ci.yml", workflowYaml.getBytes(StandardCharsets.UTF_8),
88
+ "baseline.txt", "base".getBytes(StandardCharsets.UTF_8)));
89
+ GitTestSeeder.seed(bare, Map.of(changedFile, "changed".getBytes(StandardCharsets.UTF_8)));
90
+
91
+ try (org.eclipse.jgit.lib.Repository db = new FileRepositoryBuilder().setGitDir(bare.toFile()).build())
92
+ {
93
+ ObjectId newId = db.resolve("refs/heads/main");
94
+ ObjectId oldId;
95
+ try (RevWalk walk = new RevWalk(db))
96
+ {
97
+ oldId = walk.parseCommit(newId).getParent(0).getId();
98
+ }
99
+ ReceiveCommand command = new ReceiveCommand(oldId, newId, "refs/heads/main");
100
+ command.setResult(ReceiveCommand.Result.OK);
101
+ ingest.onPush(repo.ownerHandle(), repo.name, owner.id, db, List.of(command));
102
+ }
103
+ }
104
+ catch (Exception e)
105
+ {
106
+ throw new RuntimeException(e);
107
+ }
108
+ return repo;
109
+ }
110
+}