gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (ci): Honor path trigger filters

9b0468c434e1f2ba63bb4538a8aa7132d9fcd2aa · Michael Hainz · 2026-07-22T12:09:18Z

Changes

5 files changed, +198 -17

MODIFY README.md +3 -3
diff --git a/README.md b/README.md
index 201ca66..7a07f29 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
index 19d2a99..0a1d53e 100644
--- a/docs/maintainers/ci-runners.md
+++ b/docs/maintainers/ci-runners.md
@@ -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
diff --git a/docs/users/ci-runners.md b/docs/users/ci-runners.md
index 56e52c2..f1a7f72 100644
--- a/docs/users/ci-runners.md
+++ b/docs/users/ci-runners.md
@@ -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
diff --git a/src/main/java/de/workaround/ci/WorkflowIngestService.java b/src/main/java/de/workaround/ci/WorkflowIngestService.java
index 1f60631..fb19159 100644
--- a/src/main/java/de/workaround/ci/WorkflowIngestService.java
+++ b/src/main/java/de/workaround/ci/WorkflowIngestService.java
@@ -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
diff --git a/src/test/java/de/workaround/ci/WorkflowPathFilterTest.java b/src/test/java/de/workaround/ci/WorkflowPathFilterTest.java
new file mode 100644
index 0000000..7fa4537
--- /dev/null
+++ b/src/test/java/de/workaround/ci/WorkflowPathFilterTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog