gitshark

Clone repository

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

← Commits

✨ (ci): Honor branch and tag trigger filters

e8b29aa1cb82f71c11db61bd2404209bdda5baef · Michael Hainz · 2026-07-22T11:54:38Z

Changes

5 files changed, +305 -24

MODIFY README.md +4 -4
diff --git a/README.md b/README.md
index cdb59af..201ca66 100644
--- a/README.md
+++ b/README.md
@@ -86,10 +86,10 @@
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/` (plain `on: push`) creates a run, which a runner claims, executes, and
90 - streams logs for — visible on the repository's **Actions** tab; a vanished runner's task is
91 - reclaimed after a timeout. Richer triggers, secrets/variables, `needs`/`matrix`, and artifacts are
92 - follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
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,
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
95 95 public repositories are then followed and shown grouped — and read their pushes (see below)
MODIFY docs/maintainers/ci-runners.md +10 -4
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
index 6606d17..19d2a99 100644
--- a/docs/maintainers/ci-runners.md
+++ b/docs/maintainers/ci-runners.md
@@ -58,9 +58,13 @@
58 58 UpdateLog resume/ack offset; `action_task.deadline` is the zombie-timeout anchor.
59 59 - **Workflow ingest on push:** the post-receive hooks (HTTP + SSH) call `WorkflowIngestService`,
60 60 which reads `.forgejo/workflows/*.{yml,yaml}` and `.gitea/workflows/*` at the new commit of each
61 - updated branch, parses them (Jackson `YAMLMapper`), and for those triggered by `push` persists one
62 - `action_run` (per-repo `number`, PENDING) with one PENDING `action_task` per job via
61 + updated ref, parses them (Jackson `YAMLMapper`), and for those whose `push` trigger matches the ref
62 + persists one `action_run` (per-repo `number`, PENDING) with one PENDING `action_task` per job via
63 63 `WorkflowRunFactory` (`@Transactional`). Handles the YAML-1.1 `on:`→boolean-`true` key coercion.
64 +- **Ref-based trigger filters:** a bare/list `on: push` triggers on any branch push (never tags); an
65 + `on: { push: {...} }` object honors `branches`/`branches-ignore` (branch pushes) and
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.
64 68 - **`FetchTask` dispatch:** a registered runner claims the oldest PENDING task (`TaskDispatchService`,
65 69 one transaction) — task+run flip to RUNNING, the runner goes ACTIVE, `action_task.deadline` is set,
66 70 and the task is delivered with its surrogate int64 `seq` id and `workflow_payload`. The candidate
@@ -79,6 +83,7 @@
79 83 round-trip for Ping/Register/Declare + auth failures), `AdminAccessTest` (admin gate),
80 84 `ActionRunPersistenceTest` (run/task/log persistence, per-repo run numbering, pending-task lookup),
81 85 `WorkflowIngestServiceTest` (push → run/task creation, non-push trigger and no-workflow are no-ops),
86 + `WorkflowTriggerFilterTest` (branch/tag include+ignore globs, bare push branches-only, tag pushes),
82 87 `FetchTaskTest` (claim oldest pending over the wire, empty queue, bad credentials, and two runners
83 88 racing one task → claimed at most once), `TaskProgressTest` (UpdateTask success rolls up task+run
84 89 and frees the runner, UpdateLog append + dedup/resume, cross-runner and bad-credential rejection).
@@ -106,8 +111,9 @@
106 111 - **Per-job payload expansion:** `workflow_payload` is the raw workflow YAML (fine while a workflow
107 112 has a single job, which the `github.job` context selects); a multi-job workflow needs each job
108 113 isolated/expanded into its own payload. No `needs`/`matrix` yet.
109 -- **Trigger refinement:** only bare `on: push` is honored; branch/tag/path filters and other events
110 - (tag push, `pull_request`) are not evaluated.
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).
111 117 - **Later phases:** secrets/variables delivery, label-based matching, concurrency/cancellation,
112 118 artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and ephemeral runners, commit/MR status.
113 119
MODIFY docs/users/ci-runners.md +15 -1
diff --git a/docs/users/ci-runners.md b/docs/users/ci-runners.md
index eeb16bb..56e52c2 100644
--- a/docs/users/ci-runners.md
+++ b/docs/users/ci-runners.md
@@ -36,8 +36,22 @@
36 36 A run whose runner disappears mid-job is marked **Failure** once its time limit passes (configurable
37 37 by the admin), so a run never hangs as Running forever.
38 38
39 +## Trigger filters
40 +
41 +Beyond a bare `on: push` (which runs on every branch push), you can scope runs to specific refs:
42 +
43 +```yaml
44 +on:
45 + push:
46 + branches: [main, 'release/*'] # only these branches (globs: * within a segment, ** across)
47 + tags: ['v*'] # and pushes of matching tags
48 +```
49 +
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.
52 +
39 53 ## What's coming
40 54
41 -- Richer triggers (branch/tag/path filters, tag pushes, merge-request events).
55 +- `paths`/`paths-ignore` filters and non-push events (`pull_request`, scheduled, manual).
42 56 - Repository-level secrets and variables, `needs`/`matrix`, and run cancellation/re-run.
43 57 - Artifacts and commit/merge-request status integration.
MODIFY src/main/java/de/workaround/ci/WorkflowIngestService.java +140 -15
diff --git a/src/main/java/de/workaround/ci/WorkflowIngestService.java b/src/main/java/de/workaround/ci/WorkflowIngestService.java
index 35cd261..1f60631 100644
--- a/src/main/java/de/workaround/ci/WorkflowIngestService.java
+++ b/src/main/java/de/workaround/ci/WorkflowIngestService.java
@@ -26,14 +26,15 @@
26 26 import jakarta.inject.Inject;
27 27
28 28 /**
29 - * Detects workflow files pushed to a repository and materializes CI runs (issue #2, phase 1). For
30 - * every branch update, workflow files under {@code .forgejo/workflows/} and {@code .gitea/workflows/}
31 - * at the new commit are parsed; those triggered by {@code push} produce one {@link
29 + * Detects workflow files pushed to a repository and materializes CI runs (issue #2). For every
30 + * branch or tag update, workflow files under {@code .forgejo/workflows/} and {@code .gitea/workflows/}
31 + * at the new commit are parsed; those whose {@code push} trigger matches the ref produce one {@link
32 32 * de.workaround.model.ActionRun} with one {@link de.workaround.model.ActionTask} per job.
33 33 *
34 - * <p>Phase-1 scope: {@code on: push} only (branch/tag/path filters, other events, {@code needs} and
35 - * {@code matrix} are phase 2). Invoked from the transports' post-receive hooks on a Git worker thread
36 - * with no CDI request context, so it activates one and never throws into the Git path.
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 + * Invoked from the transports' post-receive hooks on a Git worker thread with no CDI request context,
37 + * so it activates one and never throws into the Git path.
37 38 */
38 39 @ApplicationScoped
39 40 public class WorkflowIngestService
@@ -89,16 +90,17 @@
89 90 }
90 91 for (ReceiveCommand command : commands)
91 92 {
93 + RefTarget target = classify(command.getRefName());
92 94 if (command.getResult() != ReceiveCommand.Result.OK
93 - || !command.getRefName().startsWith("refs/heads/")
94 - || command.getType() == ReceiveCommand.Type.DELETE)
95 + || command.getType() == ReceiveCommand.Type.DELETE
96 + || target == null)
95 97 {
96 98 continue;
97 99 }
98 100 for (WorkflowFile workflow : readWorkflows(db, command.getNewId()))
99 101 {
100 102 JsonNode root = parse(workflow.content());
101 - if (root == null || !triggeredByPush(root))
103 + if (root == null || !pushMatches(root, target))
102 104 {
103 105 continue;
104 106 }
@@ -173,11 +175,37 @@
173 175 }
174 176 }
175 177
178 + private enum RefKind
179 + {
180 + BRANCH,
181 + TAG
182 + }
183 +
184 + private record RefTarget(RefKind kind, String name)
185 + {
186 + }
187 +
188 + private static RefTarget classify(String ref)
189 + {
190 + if (ref.startsWith("refs/heads/"))
191 + {
192 + return new RefTarget(RefKind.BRANCH, ref.substring("refs/heads/".length()));
193 + }
194 + if (ref.startsWith("refs/tags/"))
195 + {
196 + return new RefTarget(RefKind.TAG, ref.substring("refs/tags/".length()));
197 + }
198 + return null;
199 + }
200 +
176 201 /**
177 - * Reads the {@code on:} trigger. YAML 1.1 coerces the bare key {@code on} to boolean true, so
178 - * SnakeYAML/Jackson may surface it under the field name {@code "true"} — both keys are checked.
202 + * Whether the workflow's {@code on:} triggers a run for this ref. YAML 1.1 coerces the bare key
203 + * {@code on} to boolean true, so SnakeYAML/Jackson may surface it under {@code "true"} — both are
204 + * checked. A bare/list {@code on: push} triggers on any branch push (never tags); an
205 + * {@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.
179 207 */
180 - static boolean triggeredByPush(JsonNode root)
208 + static boolean pushMatches(JsonNode root, RefTarget target)
181 209 {
182 210 JsonNode on = root.has("on") ? root.get("on") : root.get("true");
183 211 if (on == null)
@@ -186,7 +214,7 @@
186 214 }
187 215 if (on.isTextual())
188 216 {
189 - return "push".equals(on.asText());
217 + return "push".equals(on.asText()) && target.kind() == RefKind.BRANCH;
190 218 }
191 219 if (on.isArray())
192 220 {
@@ -194,12 +222,109 @@
194 222 {
195 223 if (event.isTextual() && "push".equals(event.asText()))
196 224 {
197 - return true;
225 + return target.kind() == RefKind.BRANCH;
198 226 }
199 227 }
200 228 return false;
201 229 }
202 - return on.isObject() && on.has("push");
230 + if (!on.isObject())
231 + {
232 + return false;
233 + }
234 + JsonNode push = on.get("push");
235 + if (push == null)
236 + {
237 + return false;
238 + }
239 + if (!push.isObject())
240 + {
241 + // `push:` with no filter block triggers on any branch push (never tags)
242 + return target.kind() == RefKind.BRANCH;
243 + }
244 + return refMatchesFilters(push, target);
245 + }
246 +
247 + private static boolean refMatchesFilters(JsonNode push, RefTarget target)
248 + {
249 + if (target.kind() == RefKind.BRANCH)
250 + {
251 + if (push.has("branches"))
252 + {
253 + return matchesAnyGlob(push.get("branches"), target.name());
254 + }
255 + if (push.has("branches-ignore"))
256 + {
257 + return !matchesAnyGlob(push.get("branches-ignore"), target.name());
258 + }
259 + // a tag-only filter block excludes branch pushes; anything else (e.g. paths only) allows them
260 + return !push.has("tags") && !push.has("tags-ignore");
261 + }
262 + if (push.has("tags"))
263 + {
264 + return matchesAnyGlob(push.get("tags"), target.name());
265 + }
266 + if (push.has("tags-ignore"))
267 + {
268 + return !matchesAnyGlob(push.get("tags-ignore"), target.name());
269 + }
270 + // tags must be opted into explicitly
271 + return false;
272 + }
273 +
274 + private static boolean matchesAnyGlob(JsonNode patterns, String name)
275 + {
276 + if (patterns == null)
277 + {
278 + return false;
279 + }
280 + if (patterns.isTextual())
281 + {
282 + return name.matches(globToRegex(patterns.asText()));
283 + }
284 + if (patterns.isArray())
285 + {
286 + for (JsonNode pattern : patterns)
287 + {
288 + if (pattern.isTextual() && name.matches(globToRegex(pattern.asText())))
289 + {
290 + return true;
291 + }
292 + }
293 + }
294 + return false;
295 + }
296 +
297 + /** GitHub ref-filter glob → regex: {@code **} spans {@code /}, {@code *} and {@code ?} do not. */
298 + static String globToRegex(String glob)
299 + {
300 + StringBuilder regex = new StringBuilder();
301 + for (int i = 0; i < glob.length(); i++)
302 + {
303 + char c = glob.charAt(i);
304 + switch (c)
305 + {
306 + case '*':
307 + if (i + 1 < glob.length() && glob.charAt(i + 1) == '*')
308 + {
309 + regex.append(".*");
310 + i++;
311 + }
312 + else
313 + {
314 + regex.append("[^/]*");
315 + }
316 + break;
317 + case '?':
318 + regex.append("[^/]");
319 + break;
320 + case '.', '(', ')', '+', '|', '^', '$', '{', '}', '[', ']', '\\':
321 + regex.append('\\').append(c);
322 + break;
323 + default:
324 + regex.append(c);
325 + }
326 + }
327 + return regex.toString();
203 328 }
204 329
205 330 private static List<String> jobNames(JsonNode root)
ADD src/test/java/de/workaround/ci/WorkflowTriggerFilterTest.java +136 -0
diff --git a/src/test/java/de/workaround/ci/WorkflowTriggerFilterTest.java b/src/test/java/de/workaround/ci/WorkflowTriggerFilterTest.java
new file mode 100644
index 0000000..7bace78
--- /dev/null
+++ b/src/test/java/de/workaround/ci/WorkflowTriggerFilterTest.java
@@ -0,0 +1,136 @@
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.storage.file.FileRepositoryBuilder;
11 +import org.eclipse.jgit.transport.ReceiveCommand;
12 +import org.junit.jupiter.api.Test;
13 +
14 +import de.workaround.git.GitRepositoryService;
15 +import de.workaround.git.GitTestSeeder;
16 +import de.workaround.model.ActionRun;
17 +import de.workaround.model.Repository;
18 +import de.workaround.model.User;
19 +import io.quarkus.test.junit.QuarkusTest;
20 +import jakarta.inject.Inject;
21 +import jakarta.transaction.Transactional;
22 +
23 +import static org.junit.jupiter.api.Assertions.assertEquals;
24 +
25 +/**
26 + * Ref-based trigger filters (issue #2, phase 2): {@code on.push.branches}/{@code branches-ignore} and
27 + * {@code tags}/{@code tags-ignore} with GitHub-style globs, plus tag-push handling. A bare
28 + * {@code on: push} keeps triggering on any branch push (and never on tags).
29 + */
30 +@QuarkusTest
31 +class WorkflowTriggerFilterTest
32 +{
33 + @Inject
34 + WorkflowIngestService ingest;
35 +
36 + @Inject
37 + GitRepositoryService repositories;
38 +
39 + @Inject
40 + ActionRun.Repo runs;
41 +
42 + @Test
43 + void branchesFilterRunsOnlyMatchingBranch()
44 + {
45 + String on = "on:\n push:\n branches: [main]\n";
46 + assertEquals(1, runCount("tf-a", "main", "refs/heads/main", on));
47 + assertEquals(0, runCount("tf-b", "feature", "refs/heads/feature", on));
48 + }
49 +
50 + @Test
51 + void branchGlobMatchesNestedName()
52 + {
53 + String on = "on:\n push:\n branches: ['release/*']\n";
54 + assertEquals(1, runCount("tf-c", "release/1", "refs/heads/release/1", on));
55 + }
56 +
57 + @Test
58 + void branchesIgnoreSkipsListedBranch()
59 + {
60 + String on = "on:\n push:\n branches-ignore: [main]\n";
61 + assertEquals(0, runCount("tf-d", "main", "refs/heads/main", on));
62 + assertEquals(1, runCount("tf-e", "dev", "refs/heads/dev", on));
63 + }
64 +
65 + @Test
66 + void bareOnPushRunsOnAnyBranchButNotTags()
67 + {
68 + String on = "on: push\n";
69 + assertEquals(1, runCount("tf-f", "main", "refs/heads/main", on));
70 + assertEquals(0, runCount("tf-g", "main", "refs/tags/v1", on));
71 + }
72 +
73 + @Test
74 + void tagsFilterRunsOnMatchingTagPush()
75 + {
76 + String on = "on:\n push:\n tags: ['v*']\n";
77 + assertEquals(1, runCount("tf-h", "main", "refs/tags/v1.2.0", on));
78 + // a plain branch push does not match a tag-only push filter
79 + assertEquals(0, runCount("tf-i", "main", "refs/heads/main", on));
80 + }
81 +
82 + @Test
83 + void tagPushWithoutTagFilterDoesNotRun()
84 + {
85 + // a branches-only block never opts tags in
86 + String on = "on:\n push:\n branches: [main]\n";
87 + assertEquals(0, runCount("tf-j", "main", "refs/tags/v1", on));
88 + }
89 +
90 + @Test
91 + void doubleStarGlobSpansSlashes()
92 + {
93 + String on = "on:\n push:\n branches: ['release/**']\n";
94 + assertEquals(1, runCount("tf-k", "release/1/beta", "refs/heads/release/1/beta", on));
95 + // a single star does not span the extra segment
96 + String single = "on:\n push:\n branches: ['release/*']\n";
97 + assertEquals(0, runCount("tf-l", "release/1/beta", "refs/heads/release/1/beta", single));
98 + }
99 +
100 + private int runCount(String repoName, String seedBranch, String pushRef, String onBlock)
101 + {
102 + String yaml = onBlock + "jobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n";
103 + Repository repo = seedAndIngest(repoName, seedBranch, pushRef, yaml);
104 + return runs.findByRepository(repo).size();
105 + }
106 +
107 + @Transactional
108 + Repository seedAndIngest(String repoName, String seedBranch, String pushRef, String yaml)
109 + {
110 + String username = repoName + "-" + UUID.randomUUID().toString().substring(0, 8);
111 + User owner = new User();
112 + owner.oidcSub = username;
113 + owner.username = username;
114 + owner.persist();
115 + Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
116 +
117 + try
118 + {
119 + Path bare = repositories.repositoryPath(repo);
120 + GitTestSeeder.seedBranch(bare, seedBranch,
121 + Map.of(".forgejo/workflows/ci.yml", yaml.getBytes(StandardCharsets.UTF_8)));
122 + try (org.eclipse.jgit.lib.Repository db = new FileRepositoryBuilder().setGitDir(bare.toFile()).build())
123 + {
124 + ObjectId head = db.resolve("refs/heads/" + seedBranch);
125 + ReceiveCommand command = new ReceiveCommand(ObjectId.zeroId(), head, pushRef);
126 + command.setResult(ReceiveCommand.Result.OK);
127 + ingest.onPush(repo.ownerHandle(), repo.name, owner.id, db, List.of(command));
128 + }
129 + }
130 + catch (Exception e)
131 + {
132 + throw new RuntimeException(e);
133 + }
134 + return repo;
135 + }
136 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog