✨ (ci): Match tasks to runners by label
Changes
10 files changed, +290 -27
MODIFY
docs/admins/ci-runners.md
+3 -2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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