gitshark

Clone repository

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

← Commits

✨ (ci): Match tasks to runners by label

605f8b2ca5027e2fd85703f2d5404ce34bb5b2db · Michael Hainz · 2026-07-22T12:28:11Z

Changes

10 files changed, +290 -27

MODIFY docs/admins/ci-runners.md +3 -2
diff --git a/docs/admins/ci-runners.md b/docs/admins/ci-runners.md
index bacd375..262d919 100644
--- a/docs/admins/ci-runners.md
+++ b/docs/admins/ci-runners.md
@@ -89,11 +89,12 @@
89 89 | `ci_runner_registration_token` | Reusable registration tokens: `token_hash`, `created_by_id`, `created_at`, `last_used`. |
90 90 | `ci_runner` | Registered runners: `uuid` (the `x-runner-uuid` value), `token_hash`, `name`, `labels` (comma-joined), `version`, `status` (`IDLE`/`ACTIVE`/`OFFLINE`/`UNSPECIFIED`), `ephemeral`, `last_seen`, `created_at`. |
91 91 | `action_run` | One workflow run per repository: `number` (per-repo sequential), `workflow_name`, `workflow_file`, `event`, `ref`, `commit_sha`, `triggered_by_id`, `status` (`PENDING`/`RUNNING`/`SUCCESS`/`FAILURE`/`CANCELLED`), timestamps. Deleted with its repository. |
92 -| `action_task` | One job within a run: `seq` (surrogate int64 id handed to runners), `run_id`, `name`, `payload`, `runner_id` (the claiming runner, null while pending), `status`, `log_length` (durable log-row count = UpdateLog resume offset), `deadline` (zombie timeout), timestamps. Deleted with its run. |
92 +| `action_task` | One job within a run: `seq` (surrogate int64 id handed to runners), `run_id`, `name`, `runs_on` (comma-joined labels for runner matching, empty = any), `payload`, `runner_id` (the claiming runner, null while pending), `status`, `log_length` (durable log-row count = UpdateLog resume offset), `deadline` (zombie timeout), timestamps. Deleted with its run. |
93 93 | `action_log` | One log row of a task: `task_id`, `line_index` (0-based), `content`, `timestamp`. Deleted with its task. |
94 94
95 95 `ci_runner*` are introduced by migration `V19__ci_runners.sql`; `action_*` by `V23__action_runs.sql`
96 -(and `V24__action_task_seq.sql` adds the `action_task.seq` surrogate id).
96 +(`V24__action_task_seq.sql` adds `action_task.seq`, `V25__action_task_runs_on.sql` adds
97 +`action_task.runs_on`).
97 98 The `ci_runner*` tables hold no repository data (losing them only forces re-registration); the
98 99 `action_*` tables hold run history and logs, tied to their repository by cascade.
99 100
MODIFY docs/maintainers/ci-runners.md +11 -4
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
index 0a1d53e..ed6fd8a 100644
--- a/docs/maintainers/ci-runners.md
+++ b/docs/maintainers/ci-runners.md
@@ -17,7 +17,7 @@
17 17 | Zombie reclaim | `ci/ZombieReclaimService.java` | Scheduled sweep failing RUNNING tasks past their deadline (vanished runner) and rolling up their runs. |
18 18 | Actions UI | `web/ActionResource.java` + `templates/ActionResource/` | Read-only per-repo run list + run detail (jobs and their log rows); sidebar `Actions` tab. |
19 19 | Entities | `model/CiRunner.java`, `model/CiRunnerRegistrationToken.java` | Runner state (migration `V19`). |
20 -| Run entities | `model/ActionRun.java`, `model/ActionTask.java`, `model/ActionLog.java` | Run/job/log-row persistence (migrations `V23`, `V24`). `ActionTask.seq` (`bigserial`) is the surrogate int64 `Task.id` for the wire. |
20 +| Run entities | `model/ActionRun.java`, `model/ActionTask.java`, `model/ActionLog.java` | Run/job/log-row persistence (migrations `V23`–`V25`). `ActionTask.seq` (`bigserial`) is the surrogate int64 `Task.id` for the wire; `ActionTask.runs_on` holds the job's labels for matching. |
21 21 | Workflow ingest | `ci/WorkflowIngestService.java`, `ci/WorkflowRunFactory.java` | Post-receive hook: parse `.forgejo`/`.gitea` workflows at the pushed head, evaluate `on: push`, persist a run + its PENDING tasks (drained by FetchTask). |
22 22 | Admin UI | `ci/AdminRunnerResource.java` + `templates/AdminRunnerResource/` | Token generation, runner list, deletion. |
23 23 | Admin gate | `account/AdminAccess.java` | Config-driven instance-admin check. |
@@ -76,6 +76,11 @@
76 76 context (`job`, `ref`, `sha`, `repository`, `run_id`, …) built in `ConnectRunnerResource.toProto` —
77 77 without it the runner cannot select the job from the workflow and nil-derefs. Auth failures return
78 78 the Connect `unauthenticated` error. No long-poll; `tasks_version` is a coarse max-`seq`.
79 +- **Label matching:** a task carries its job's `runs-on` labels (`action_task.runs_on`, parsed at
80 + ingest). Dispatch scans PENDING tasks oldest-first and claims the first whose labels are all
81 + advertised by the fetching runner (empty `runs-on` = any runner); an incompatible task is left for a
82 + runner that can serve it. The claim still locks the chosen row `FOR UPDATE SKIP LOCKED` and re-checks
83 + PENDING, so label filtering doesn't weaken the no-double-dispatch guarantee.
79 84 - **`UpdateTask` / `UpdateLog` progress (`TaskProgressService`):** UpdateTask records the reported
80 85 result, sends a finished task's runner back to IDLE, and rolls the owning run's status up from all
81 86 its tasks (RUNNING until every task is terminal, then the worst outcome). UpdateLog appends log rows
@@ -91,7 +96,9 @@
91 96 changed files are ignored),
92 97 `FetchTaskTest` (claim oldest pending over the wire, empty queue, bad credentials, and two runners
93 98 racing one task → claimed at most once), `TaskProgressTest` (UpdateTask success rolls up task+run
94 - and frees the runner, UpdateLog append + dedup/resume, cross-runner and bad-credential rejection).
99 + and frees the runner, UpdateLog append + dedup/resume, cross-runner and bad-credential rejection),
100 + `LabelMatchingTest` (runner claims a compatible task and skips an incompatible older one, gets
101 + nothing when none match, unconstrained task runs anywhere).
95 102 - **Zombie reclaim (`ZombieReclaimService`):** a scheduled sweep
96 103 (`gitshark.ci.zombie-reclaim-interval`, default 1m) fails any RUNNING task whose
97 104 `action_task.deadline` has passed — the runner is presumed gone — rolls its run up, and flags the
@@ -118,8 +125,8 @@
118 125 isolated/expanded into its own payload. No `needs`/`matrix` yet.
119 126 - **Non-push events:** only `push` is evaluated; `pull_request`, scheduled and manual triggers are
120 127 not. (`!`-negation within a single pattern list is also not supported.)
121 -- **Later phases:** secrets/variables delivery, label-based matching, concurrency/cancellation,
122 - artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and ephemeral runners, commit/MR status.
128 +- **Later phases:** secrets/variables delivery, concurrency/cancellation, artifacts
129 + (`ACTIONS_RESULTS_URL`), repo/org-scoped and ephemeral runners, commit/MR status.
123 130
124 131 ## References
125 132
MODIFY docs/users/ci-runners.md +3 -0
diff --git a/docs/users/ci-runners.md b/docs/users/ci-runners.md
index f1a7f72..3909931 100644
--- a/docs/users/ci-runners.md
+++ b/docs/users/ci-runners.md
@@ -52,6 +52,9 @@
52 52 tag pushes and not on branch pushes. `paths` runs when any changed file matches; `paths-ignore` runs
53 53 unless every changed file is ignored. Non-push events are not evaluated yet.
54 54
55 +A job runs only on a runner that advertises every label in its `runs-on` (e.g. `runs-on: ubuntu-latest`
56 +needs a runner registered with the `ubuntu-latest` label); a job with no `runs-on` runs on any runner.
57 +
55 58 ## What's coming
56 59
57 60 - Non-push events (`pull_request`, scheduled, manual).
MODIFY src/main/java/de/workaround/ci/TaskDispatchService.java +56 -13
diff --git a/src/main/java/de/workaround/ci/TaskDispatchService.java b/src/main/java/de/workaround/ci/TaskDispatchService.java
index 7d8dd87..f9ab64f 100644
--- a/src/main/java/de/workaround/ci/TaskDispatchService.java
+++ b/src/main/java/de/workaround/ci/TaskDispatchService.java
@@ -2,9 +2,12 @@
2 2
3 3 import java.time.Duration;
4 4 import java.time.Instant;
5 +import java.util.Arrays;
5 6 import java.util.List;
6 7 import java.util.Optional;
8 +import java.util.Set;
7 9 import java.util.UUID;
10 +import java.util.stream.Collectors;
8 11
9 12 import org.eclipse.microprofile.config.inject.ConfigProperty;
10 13
@@ -17,11 +20,12 @@
17 20 import jakarta.transaction.Transactional;
18 21
19 22 /**
20 - * Hands PENDING tasks to runners over FetchTask (issue #2, phase 1). Claiming a task flips it (and its
21 - * run) to RUNNING, records the claiming runner and a {@link #taskTimeout}-based {@link
22 - * ActionTask#deadline} for later zombie reclaim, and marks the runner ACTIVE — all in one transaction
23 - * so a task is never handed to two runners: the candidate row is selected {@code FOR UPDATE SKIP
24 - * LOCKED}, so concurrent fetchers pick distinct rows (or none) rather than racing on the same one.
23 + * Hands PENDING tasks to runners over FetchTask (issue #2). Claiming a task flips it (and its run) to
24 + * RUNNING, records the claiming runner and a {@link #taskTimeout}-based {@link ActionTask#deadline}
25 + * for later zombie reclaim, and marks the runner ACTIVE — all in one transaction so a task is never
26 + * handed to two runners: the chosen row is locked {@code FOR UPDATE SKIP LOCKED} and re-checked for
27 + * PENDING status, so concurrent fetchers pick distinct rows (or none). A runner only claims a task
28 + * whose {@code runs-on} labels it advertises (label matching).
25 29 *
26 30 * <p>Phase-1 scope: no long-poll (returns immediately, empty when the queue is drained) and a coarse
27 31 * {@code tasks_version} = highest task id issued (bumps on task creation, not on state change).
@@ -29,6 +33,9 @@
29 33 @ApplicationScoped
30 34 public class TaskDispatchService
31 35 {
36 + /** How many oldest PENDING tasks to scan for a label-compatible one before giving up this fetch. */
37 + private static final int MAX_CANDIDATES = 100;
38 +
32 39 /** How long a claimed task may run before {@link ZombieReclaimService} may reclaim it. */
33 40 @ConfigProperty(name = "gitshark.ci.task-timeout", defaultValue = "1h")
34 41 Duration taskTimeout;
@@ -57,7 +64,7 @@
57 64 CiRunner runner = runnerService.authenticate(uuid, token);
58 65 runner.lastSeen = Instant.now();
59 66
60 - Optional<ActionTask> next = lockOldestPending().map(id ->
67 + Optional<ActionTask> next = claimOldestCompatible(runner).map(id ->
61 68 {
62 69 ActionTask task = tasks.findById(id);
63 70 claim(task, runner);
@@ -85,17 +92,53 @@
85 92 }
86 93
87 94 /**
88 - * Lock the oldest PENDING task's id with {@code FOR UPDATE SKIP LOCKED} so a concurrent fetcher in
89 - * another transaction cannot claim the same row. Selecting only the id keeps the {@code FOR UPDATE}
90 - * off the nullable {@code runner} association (Postgres rejects it on the nullable side of a join).
95 + * Find and lock the oldest PENDING task the runner's labels satisfy. Candidates are read oldest-first
96 + * (unlocked); the first label-compatible one is then locked by id with {@code FOR UPDATE SKIP LOCKED}
97 + * and re-checked for PENDING status in the same statement, so a task is never handed to two runners:
98 + * if another fetcher already claimed or locked it the lock select returns nothing and we move on.
99 + * Selecting only the id keeps the {@code FOR UPDATE} off the nullable {@code runner} join.
91 100 */
92 - private Optional<UUID> lockOldestPending()
101 + private Optional<UUID> claimOldestCompatible(CiRunner runner)
93 102 {
103 + Set<String> runnerLabels = splitLabels(runner.labels);
94 104 @SuppressWarnings("unchecked")
95 - List<UUID> ids = em.createNativeQuery(
96 - "select id from action_task where status = 'PENDING' order by created_at asc limit 1 for update skip locked")
105 + List<Object[]> candidates = em.createNativeQuery(
106 + "select id, runs_on from action_task where status = 'PENDING' order by created_at asc limit " + MAX_CANDIDATES)
97 107 .getResultList();
98 - return ids.isEmpty() ? Optional.empty() : Optional.of(ids.get(0));
108 + for (Object[] candidate : candidates)
109 + {
110 + if (!labelsSatisfied((String) candidate[1], runnerLabels))
111 + {
112 + continue;
113 + }
114 + UUID id = (UUID) candidate[0];
115 + @SuppressWarnings("unchecked")
116 + List<UUID> locked = em.createNativeQuery(
117 + "select id from action_task where id = :id and status = 'PENDING' for update skip locked")
118 + .setParameter("id", id)
119 + .getResultList();
120 + if (!locked.isEmpty())
121 + {
122 + return Optional.of(id);
123 + }
124 + }
125 + return Optional.empty();
126 + }
127 +
128 + /** Whether the runner advertises every label the task's {@code runs-on} requires (empty = any). */
129 + private static boolean labelsSatisfied(String runsOn, Set<String> runnerLabels)
130 + {
131 + return splitLabels(runsOn).stream().allMatch(runnerLabels::contains);
132 + }
133 +
134 + private static Set<String> splitLabels(String csv)
135 + {
136 + if (csv == null || csv.isBlank())
137 + {
138 + return Set.of();
139 + }
140 + return Arrays.stream(csv.split(",")).map(String::trim).filter(s -> !s.isEmpty())
141 + .collect(Collectors.toSet());
99 142 }
100 143
101 144 }
MODIFY src/main/java/de/workaround/ci/WorkflowIngestService.java +33 -5
diff --git a/src/main/java/de/workaround/ci/WorkflowIngestService.java b/src/main/java/de/workaround/ci/WorkflowIngestService.java
index fb19159..601fde5 100644
--- a/src/main/java/de/workaround/ci/WorkflowIngestService.java
+++ b/src/main/java/de/workaround/ci/WorkflowIngestService.java
@@ -114,7 +114,7 @@
114 114 {
115 115 continue;
116 116 }
117 - List<String> jobs = jobNames(root);
117 + List<WorkflowRunFactory.JobSpec> jobs = jobs(root);
118 118 if (jobs.isEmpty())
119 119 {
120 120 continue;
@@ -392,18 +392,46 @@
392 392 return regex.toString();
393 393 }
394 394
395 - private static List<String> jobNames(JsonNode root)
395 + private static List<WorkflowRunFactory.JobSpec> jobs(JsonNode root)
396 396 {
397 - List<String> names = new ArrayList<>();
397 + List<WorkflowRunFactory.JobSpec> specs = new ArrayList<>();
398 398 JsonNode jobs = root.get("jobs");
399 399 if (jobs != null && jobs.isObject())
400 400 {
401 401 for (Iterator<String> it = jobs.fieldNames(); it.hasNext();)
402 402 {
403 - names.add(it.next());
403 + String name = it.next();
404 + specs.add(new WorkflowRunFactory.JobSpec(name, runsOn(jobs.get(name))));
404 405 }
405 406 }
406 - return names;
407 + return specs;
408 + }
409 +
410 + /** The job's {@code runs-on} as comma-joined labels; string or list, empty when absent. */
411 + private static String runsOn(JsonNode job)
412 + {
413 + JsonNode runsOn = job == null ? null : job.get("runs-on");
414 + if (runsOn == null)
415 + {
416 + return "";
417 + }
418 + if (runsOn.isTextual())
419 + {
420 + return runsOn.asText();
421 + }
422 + if (runsOn.isArray())
423 + {
424 + List<String> labels = new ArrayList<>();
425 + for (JsonNode label : runsOn)
426 + {
427 + if (label.isTextual())
428 + {
429 + labels.add(label.asText());
430 + }
431 + }
432 + return String.join(",", labels);
433 + }
434 + return "";
407 435 }
408 436
409 437 private static String workflowName(JsonNode root, String path)
MODIFY src/main/java/de/workaround/ci/WorkflowRunFactory.java +9 -3
diff --git a/src/main/java/de/workaround/ci/WorkflowRunFactory.java b/src/main/java/de/workaround/ci/WorkflowRunFactory.java
index 478651e..4858d73 100644
--- a/src/main/java/de/workaround/ci/WorkflowRunFactory.java
+++ b/src/main/java/de/workaround/ci/WorkflowRunFactory.java
@@ -35,7 +35,7 @@
35 35
36 36 @Transactional
37 37 public ActionRun create(Repository repository, UUID pusherUserId, String ref, String commitSha,
38 - String workflowName, String workflowFile, List<String> jobNames, String payload)
38 + String workflowName, String workflowFile, List<JobSpec> jobs, String payload)
39 39 {
40 40 Repository repo = repositories.findById(repository.id);
41 41
@@ -50,15 +50,21 @@
50 50 run.triggeredBy = pusherUserId == null ? null : users.findById(pusherUserId);
51 51 run.persist();
52 52
53 - for (String jobName : jobNames)
53 + for (JobSpec job : jobs)
54 54 {
55 55 ActionTask task = new ActionTask();
56 56 task.run = run;
57 - task.name = jobName;
57 + task.name = job.name();
58 + task.runsOn = job.runsOn();
58 59 task.payload = payload;
59 60 task.persist();
60 61 }
61 62 return run;
62 63 }
63 64
65 + /** A job discovered in a workflow: its id and comma-joined {@code runs-on} labels. */
66 + public record JobSpec(String name, String runsOn)
67 + {
68 + }
69 +
64 70 }
MODIFY src/main/java/de/workaround/model/ActionTask.java +3 -0
diff --git a/src/main/java/de/workaround/model/ActionTask.java b/src/main/java/de/workaround/model/ActionTask.java
index 712f620..aa3c853 100644
--- a/src/main/java/de/workaround/model/ActionTask.java
+++ b/src/main/java/de/workaround/model/ActionTask.java
@@ -51,6 +51,9 @@
51 51 /** Job identifier from the workflow file, e.g. {@code build}. */
52 52 public String name;
53 53
54 + /** The job's {@code runs-on} labels, comma-joined; empty means no constraint (any runner). */
55 + public String runsOn = "";
56 +
54 57 /** The expanded single-job workflow payload delivered to the runner in FetchTask; null until materialized. */
55 58 public String payload;
56 59
ADD src/main/resources/db/migration/V25__action_task_runs_on.sql +7 -0
diff --git a/src/main/resources/db/migration/V25__action_task_runs_on.sql b/src/main/resources/db/migration/V25__action_task_runs_on.sql
new file mode 100644
index 0000000..5135399
--- /dev/null
+++ b/src/main/resources/db/migration/V25__action_task_runs_on.sql
@@ -0,0 +1,7 @@
1 +-- Per-task label requirement for runner matching (issue #2, phase 2).
2 +--
3 +-- A job's `runs-on` labels, comma-joined (empty = no constraint, runs on any runner). FetchTask hands
4 +-- a task to a runner only when every one of these labels is advertised by that runner.
5 +
6 +alter table action_task
7 + add column runs_on text not null default '';
ADD src/test/java/de/workaround/ci/LabelMatchingTest.java +136 -0
diff --git a/src/test/java/de/workaround/ci/LabelMatchingTest.java b/src/test/java/de/workaround/ci/LabelMatchingTest.java
new file mode 100644
index 0000000..b806e47
--- /dev/null
+++ b/src/test/java/de/workaround/ci/LabelMatchingTest.java
@@ -0,0 +1,136 @@
1 +package de.workaround.ci;
2 +
3 +import java.util.List;
4 +import java.util.UUID;
5 +
6 +import org.junit.jupiter.api.Test;
7 +
8 +import de.workaround.git.GitRepositoryService;
9 +import de.workaround.model.ActionRun;
10 +import de.workaround.model.ActionTask;
11 +import de.workaround.model.Repository;
12 +import de.workaround.model.User;
13 +import io.quarkus.test.junit.QuarkusTest;
14 +import jakarta.inject.Inject;
15 +import jakarta.transaction.Transactional;
16 +
17 +import static org.junit.jupiter.api.Assertions.assertEquals;
18 +import static org.junit.jupiter.api.Assertions.assertTrue;
19 +
20 +/**
21 + * Label-based task-to-runner matching (issue #2, phase 2): a runner only claims tasks whose
22 + * {@code runs-on} labels it advertises; a task with no label constraint runs on any runner.
23 + */
24 +@QuarkusTest
25 +class LabelMatchingTest
26 +{
27 + @Inject
28 + RunnerRegistrationService runnerService;
29 +
30 + @Inject
31 + TaskDispatchService dispatch;
32 +
33 + @Inject
34 + GitRepositoryService repositories;
35 +
36 + @Inject
37 + ActionRun.Repo runs;
38 +
39 + @Inject
40 + ActionTask.Repo tasks;
41 +
42 + @Test
43 + void runnerClaimsCompatibleTaskAndSkipsIncompatibleOlderOne()
44 + {
45 + RunnerRegistrationService.RegisteredRunner reg = registerRunner("ubuntu-latest");
46 + // windows task is older (would be picked first without label matching); ubuntu task is newer
47 + Ids ids = seedTwoTasks("lm-a", "windows", "ubuntu-latest");
48 +
49 + ActionTask claimed = dispatch.fetch(reg.runner().uuid, reg.plaintext()).task().orElseThrow();
50 + assertEquals("ubuntu-latest", claimed.runsOn);
51 +
52 + assertEquals(ActionRun.Status.PENDING, tasks.findById(ids.first()).status, "incompatible task stays queued");
53 + assertEquals(ActionRun.Status.RUNNING, tasks.findById(ids.second()).status);
54 + }
55 +
56 + @Test
57 + void runnerGetsNothingWhenNoTaskMatchesItsLabels()
58 + {
59 + RunnerRegistrationService.RegisteredRunner reg = registerRunner("arm64");
60 + seedTwoTasks("lm-b", "ubuntu-latest", "windows");
61 +
62 + assertTrue(dispatch.fetch(reg.runner().uuid, reg.plaintext()).task().isEmpty());
63 + }
64 +
65 + @Test
66 + void unconstrainedTaskRunsOnAnyRunner()
67 + {
68 + RunnerRegistrationService.RegisteredRunner reg = registerRunner("whatever");
69 + seedTwoTasks("lm-c", "", "windows"); // first task has no runs-on constraint
70 +
71 + ActionTask claimed = dispatch.fetch(reg.runner().uuid, reg.plaintext()).task().orElseThrow();
72 + assertEquals("", claimed.runsOn);
73 + }
74 +
75 + private record Ids(UUID first, UUID second)
76 + {
77 + }
78 +
79 + private RunnerRegistrationService.RegisteredRunner registerRunner(String label)
80 + {
81 + String token = runnerService.createRegistrationToken(persistUser("lm-admin-" + shortId())).plaintext();
82 + return runnerService.register(token, "lm-runner", List.of(label), "v4.0.0", false);
83 + }
84 +
85 + @Transactional
86 + Ids seedTwoTasks(String repoName, String firstRunsOn, String secondRunsOn)
87 + {
88 + User owner = persistUser(repoName + "-" + shortId());
89 + Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
90 + ActionRun run = newRun(repo);
91 + UUID first = newTask(run, "first", firstRunsOn, 10);
92 + UUID second = newTask(run, "second", secondRunsOn, 5);
93 + return new Ids(first, second);
94 + }
95 +
96 + private ActionRun newRun(Repository repo)
97 + {
98 + ActionRun run = new ActionRun();
99 + run.repository = repo;
100 + run.number = runs.maxNumber(repo) + 1;
101 + run.workflowName = "CI";
102 + run.workflowFile = ".forgejo/workflows/ci.yml";
103 + run.event = "push";
104 + run.ref = "refs/heads/main";
105 + run.commitSha = "0000000000000000000000000000000000000000";
106 + run.persist();
107 + return run;
108 + }
109 +
110 + private UUID newTask(ActionRun run, String name, String runsOn, int secondsAgo)
111 + {
112 + ActionTask task = new ActionTask();
113 + task.run = run;
114 + task.name = name;
115 + task.runsOn = runsOn;
116 + task.payload = "on: push";
117 + task.createdAt = java.time.Instant.now().minusSeconds(secondsAgo);
118 + task.persist();
119 + return task.id;
120 + }
121 +
122 + @Transactional
123 + User persistUser(String name)
124 + {
125 + User user = new User();
126 + user.oidcSub = name;
127 + user.username = name;
128 + user.persist();
129 + return user;
130 + }
131 +
132 + private static String shortId()
133 + {
134 + return UUID.randomUUID().toString().substring(0, 8);
135 + }
136 +}
MODIFY src/test/java/de/workaround/ci/WorkflowIngestServiceTest.java +29 -0
diff --git a/src/test/java/de/workaround/ci/WorkflowIngestServiceTest.java b/src/test/java/de/workaround/ci/WorkflowIngestServiceTest.java
index 2c81f94..6edcfdb 100644
--- a/src/test/java/de/workaround/ci/WorkflowIngestServiceTest.java
+++ b/src/test/java/de/workaround/ci/WorkflowIngestServiceTest.java
@@ -77,6 +77,35 @@
77 77 assertEquals(1, jobs.size());
78 78 assertEquals("build", jobs.get(0).name);
79 79 assertEquals(ActionRun.Status.PENDING, jobs.get(0).status);
80 + assertEquals("ubuntu-latest", jobs.get(0).runsOn, "string runs-on is stored verbatim");
81 + }
82 +
83 + @Test
84 + void parsesRunsOnStringListAndAbsent() throws Exception
85 + {
86 + User owner = persistUser("wf-dave-" + UUID.randomUUID().toString().substring(0, 8));
87 + Repository repo = repositories.create(owner, "wf", Repository.Visibility.PUBLIC, null);
88 +
89 + String yaml = """
90 + on: push
91 + jobs:
92 + single:
93 + runs-on: ubuntu-latest
94 + steps: [{ run: echo hi }]
95 + multi:
96 + runs-on: [self-hosted, linux]
97 + steps: [{ run: echo hi }]
98 + anywhere:
99 + steps: [{ run: echo hi }]
100 + """;
101 + pushWorkflows(repo, Map.of(".forgejo/workflows/ci.yml", yaml));
102 +
103 + ActionRun run = runs.findByRepository(repo).get(0);
104 + java.util.Map<String, String> byJob = tasks.findByRun(run).stream()
105 + .collect(java.util.stream.Collectors.toMap(t -> t.name, t -> t.runsOn));
106 + assertEquals("ubuntu-latest", byJob.get("single"));
107 + assertEquals("self-hosted,linux", byJob.get("multi"), "list runs-on is comma-joined");
108 + assertEquals("", byJob.get("anywhere"), "absent runs-on means no constraint");
80 109 }
81 110
82 111 @Test

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog