✨ (ci): Honor branch and tag trigger filters
Changes
5 files changed, +305 -24
MODIFY
README.md
+4 -4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+}