gitshark

Clone repository

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

← Commits

✨ (ci): Scope runners to a repository

c6fff3839987bf4bf427245ecf89fdb42663808c · Michael Hainz · 2026-07-23T10:20:49Z

Changes

8 files changed, +206 -8

MODIFY docs/admins/ci-runners.md +7 -4
diff --git a/docs/admins/ci-runners.md b/docs/admins/ci-runners.md
index dacd03a..9a2da02 100644
--- a/docs/admins/ci-runners.md
+++ b/docs/admins/ci-runners.md
@@ -45,8 +45,10 @@
45 45 token can register any number of runners. Delete a token to stop it registering new runners; runners
46 46 already registered keep working (they authenticate with their own per-runner secret, not the
47 47 registration token). An **ephemeral** runner (registered with `--ephemeral`) runs a single task and
48 -is then removed automatically — its credentials stop working after that one job. Repo/org-scoped
49 -runners are a later phase.
48 +is then removed automatically — its credentials stop working after that one job. A runner can also be
49 +**scoped to a single repository** (it then only runs that repo's jobs); the enforcement is in place,
50 +though minting a repo-scoped registration token from the UI is still to come (the admin page mints
51 +instance-wide tokens today). Org scope is a later phase.
50 52
51 53 ## Endpoints
52 54
@@ -89,7 +91,7 @@
89 91 | Table | Contents |
90 92 |---|---|
91 93 | `ci_runner_registration_token` | Reusable registration tokens: `token_hash`, `created_by_id`, `created_at`, `last_used`. |
92 -| `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`. |
94 +| `ci_runner` | Registered runners: `uuid` (the `x-runner-uuid` value), `token_hash`, `name`, `labels` (comma-joined), `version`, `status` (`IDLE`/`ACTIVE`/`OFFLINE`/`UNSPECIFIED`), `ephemeral`, `repository_id` (scope; null = instance-wide), `last_seen`, `created_at`. |
93 95 | `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. |
94 96 | `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), `needs` (comma-joined dependency job names), `outputs` (JSON of the job's reported outputs), `job_id` (workflow job key; a matrix job's cells share it), `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. |
95 97 | `action_log` | One log row of a task: `task_id`, `line_index` (0-based), `content`, `timestamp`. Deleted with its task. |
@@ -100,7 +102,8 @@
100 102 (`V24__action_task_seq.sql` adds `action_task.seq`, `V25__action_task_runs_on.sql` adds
101 103 `action_task.runs_on`, `V26__action_secrets_variables.sql` adds `action_secret`/`action_variable`,
102 104 `V27__action_task_needs.sql` adds `action_task.needs`, `V28__action_task_outputs.sql` adds
103 -`action_task.outputs`, `V29__action_task_job_id.sql` adds `action_task.job_id`).
105 +`action_task.outputs`, `V29__action_task_job_id.sql` adds `action_task.job_id`,
106 +`V30__ci_runner_scope.sql` adds the `repository_id` scope to `ci_runner`/`ci_runner_registration_token`).
104 107 Secrets are stored encrypted and require `GITSHARK_SECRET_KEY` to be set (same key as push mirrors);
105 108 without it, secrets cannot be decrypted and are omitted from what a runner receives.
106 109 The `ci_runner*` tables hold no repository data (losing them only forces re-registration); the
MODIFY docs/maintainers/ci-runners.md +11 -2
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
index a033123..66d17b2 100644
--- a/docs/maintainers/ci-runners.md
+++ b/docs/maintainers/ci-runners.md
@@ -100,6 +100,11 @@
100 100 accumulated into `action_task.outputs` (JSON) and echoed back as `sent_outputs`; dispatch delivers a
101 101 needed job's outputs to its dependents as `needs.<job>.outputs`. `ActionOutputs` (de)serializes the
102 102 JSON, fail-safe to an empty map on a bad value.
103 +- **Repo-scoped runners:** a registration token (and the runners it creates) may carry a
104 + `repository_id` (`ci_runner_registration_token`/`ci_runner`, migration `V30`); null = instance-scope
105 + (any repository). Dispatch's `scopeAllows` check hands a scoped runner only its repository's tasks,
106 + while an instance runner still serves any. Scoped rows cascade-delete with the repository.
107 + (Generating a scoped token still needs a UI — the admin page only mints instance tokens today.)
103 108 - **Label matching:** a task carries its job's `runs-on` labels (`action_task.runs_on`, parsed at
104 109 ingest). Dispatch scans PENDING tasks oldest-first and claims the first whose labels are all
105 110 advertised by the fetching runner (empty `runs-on` = any runner); an incompatible task is left for a
@@ -138,7 +143,8 @@
138 143 cancels the dependent), `CommitCiStatusTest` (commit page + MR page show the aggregate badge, the
139 144 commit-status API reflects failure, and a commit with no runs stays all-clear),
140 145 `EphemeralRunnerTest` (an ephemeral runner is removed after its task — on completion and on
141 - zombie-reclaim — and its credentials stop working).
146 + zombie-reclaim — and its credentials stop working), `ScopedRunnerTest` (a repo-scoped runner skips
147 + other repos' tasks and idles when only they have work; an instance runner claims across repos).
142 148 - **Ephemeral runners:** a runner registered with `ephemeral=true` is one-shot — once its single task
143 149 reaches a terminal state it is deleted (`ci_runner` row removed; the task's `runner_id` is `ON
144 150 DELETE SET NULL`), so its credentials stop working and it never gets a second task. This holds on
@@ -184,7 +190,10 @@
184 190 not. (`!`-negation within a single pattern list is also not supported.)
185 191 - **Matrix advanced options:** `include`/`exclude` and `fail-fast`/`max-parallel` are not honored
186 192 (plain dimension cross-product only).
187 -- **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped runners, non-push events.
193 +- **Scoped-runner token UI & org scope:** dispatch enforces repo scope, but there's no page yet to
194 + mint a repo-scoped registration token (admin UI mints instance tokens only), and org scope isn't
195 + modelled.
196 +- **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), non-push events.
188 197
189 198 ## References
190 199
MODIFY src/main/java/de/workaround/ci/RunnerRegistrationService.java +10 -0
diff --git a/src/main/java/de/workaround/ci/RunnerRegistrationService.java b/src/main/java/de/workaround/ci/RunnerRegistrationService.java
index 83e8786..2830c21 100644
--- a/src/main/java/de/workaround/ci/RunnerRegistrationService.java
+++ b/src/main/java/de/workaround/ci/RunnerRegistrationService.java
@@ -12,6 +12,7 @@
12 12
13 13 import de.workaround.model.CiRunner;
14 14 import de.workaround.model.CiRunnerRegistrationToken;
15 +import de.workaround.model.Repository;
15 16 import de.workaround.model.User;
16 17 import jakarta.enterprise.context.ApplicationScoped;
17 18 import jakarta.inject.Inject;
@@ -48,10 +49,18 @@
48 49 @Transactional
49 50 public CreatedRegistrationToken createRegistrationToken(User admin)
50 51 {
52 + return createRegistrationToken(admin, null);
53 + }
54 +
55 + /** Create a registration token scoped to {@code repository} (null = instance-scope, any repository). */
56 + @Transactional
57 + public CreatedRegistrationToken createRegistrationToken(User admin, Repository repository)
58 + {
51 59 String plaintext = REGISTRATION_PREFIX + randomSecret();
52 60 CiRunnerRegistrationToken token = new CiRunnerRegistrationToken();
53 61 token.tokenHash = hash(plaintext);
54 62 token.createdBy = admin;
63 + token.repository = repository;
55 64 token.persist();
56 65 return new CreatedRegistrationToken(token, plaintext);
57 66 }
@@ -72,6 +81,7 @@
72 81 runner.labels = joinLabels(labels);
73 82 runner.version = version;
74 83 runner.ephemeral = ephemeral;
84 + runner.repository = registration.repository;
75 85 runner.status = CiRunner.Status.IDLE;
76 86 runner.lastSeen = Instant.now();
77 87 runner.persist();
MODIFY src/main/java/de/workaround/ci/TaskDispatchService.java +8 -1
diff --git a/src/main/java/de/workaround/ci/TaskDispatchService.java b/src/main/java/de/workaround/ci/TaskDispatchService.java
index 3186798..7763b1d 100644
--- a/src/main/java/de/workaround/ci/TaskDispatchService.java
+++ b/src/main/java/de/workaround/ci/TaskDispatchService.java
@@ -170,7 +170,8 @@
170 170 continue;
171 171 }
172 172 UUID id = (UUID) candidate[0];
173 - if (!needsSatisfied(tasks.findById(id)))
173 + ActionTask candidateTask = tasks.findById(id);
174 + if (!scopeAllows(runner, candidateTask) || !needsSatisfied(candidateTask))
174 175 {
175 176 continue;
176 177 }
@@ -187,6 +188,12 @@
187 188 return Optional.empty();
188 189 }
189 190
191 + /** An instance-scoped runner (no repository) serves any task; a repo-scoped runner only its repository's. */
192 + private static boolean scopeAllows(CiRunner runner, ActionTask task)
193 + {
194 + return runner.repository == null || runner.repository.id.equals(task.run.repository.id);
195 + }
196 +
190 197 /** A task is dispatchable only once every job it needs (in the same run) has succeeded. */
191 198 private boolean needsSatisfied(ActionTask task)
192 199 {
MODIFY src/main/java/de/workaround/model/CiRunner.java +7 -1
diff --git a/src/main/java/de/workaround/model/CiRunner.java b/src/main/java/de/workaround/model/CiRunner.java
index 5ca38f4..00f274c 100644
--- a/src/main/java/de/workaround/model/CiRunner.java
+++ b/src/main/java/de/workaround/model/CiRunner.java
@@ -16,13 +16,15 @@
16 16 import jakarta.persistence.GeneratedValue;
17 17 import jakarta.persistence.GenerationType;
18 18 import jakarta.persistence.Id;
19 +import jakarta.persistence.ManyToOne;
19 20 import jakarta.persistence.Table;
20 21
21 22 /**
22 23 * A CI/CD runner registered against this instance via the Forgejo/Gitea runner.v1 Connect protocol.
23 24 * The runner authenticates every post-registration call with {@link #uuid} + a secret whose SHA-256
24 25 * hash is kept in {@link #tokenHash}; the plaintext secret is returned to the runner only once, at
25 - * registration. Instance-scope only in phase 1 (no repo/org scoping yet).
26 + * registration. A runner may be scoped to a single {@link #repository} (null means instance-scope,
27 + * serving any repository).
26 28 */
27 29 @Entity
28 30 @Table(name = "ci_runner")
@@ -50,6 +52,10 @@
50 52
51 53 public boolean ephemeral;
52 54
55 + /** Repository this runner is scoped to; null means instance-scope (any repository). */
56 + @ManyToOne
57 + public Repository repository;
58 +
53 59 public Instant lastSeen;
54 60
55 61 public Instant createdAt = Instant.now();
MODIFY src/main/java/de/workaround/model/CiRunnerRegistrationToken.java +4 -0
diff --git a/src/main/java/de/workaround/model/CiRunnerRegistrationToken.java b/src/main/java/de/workaround/model/CiRunnerRegistrationToken.java
index d22e7eb..0d08555 100644
--- a/src/main/java/de/workaround/model/CiRunnerRegistrationToken.java
+++ b/src/main/java/de/workaround/model/CiRunnerRegistrationToken.java
@@ -37,6 +37,10 @@
37 37 @ManyToOne
38 38 public User createdBy;
39 39
40 + /** Repository this token (and the runners it creates) is scoped to; null means instance-scope. */
41 + @ManyToOne
42 + public Repository repository;
43 +
40 44 public Instant createdAt = Instant.now();
41 45
42 46 public Instant lastUsed;
ADD src/main/resources/db/migration/V30__ci_runner_scope.sql +11 -0
diff --git a/src/main/resources/db/migration/V30__ci_runner_scope.sql b/src/main/resources/db/migration/V30__ci_runner_scope.sql
new file mode 100644
index 0000000..40f92fc
--- /dev/null
+++ b/src/main/resources/db/migration/V30__ci_runner_scope.sql
@@ -0,0 +1,11 @@
1 +-- Repo-scoped runners (issue #2, phase 3).
2 +--
3 +-- A registration token and the runners it creates may be scoped to a single repository; a NULL
4 +-- repository means instance scope (any repository), the existing behaviour. A scoped runner only
5 +-- receives tasks from its repository. Scoped rows are removed with the repository.
6 +
7 +alter table ci_runner_registration_token
8 + add column repository_id uuid references repositories (id) on delete cascade;
9 +
10 +alter table ci_runner
11 + add column repository_id uuid references repositories (id) on delete cascade;
ADD src/test/java/de/workaround/ci/ScopedRunnerTest.java +148 -0
diff --git a/src/test/java/de/workaround/ci/ScopedRunnerTest.java b/src/test/java/de/workaround/ci/ScopedRunnerTest.java
new file mode 100644
index 0000000..bf70f81
--- /dev/null
+++ b/src/test/java/de/workaround/ci/ScopedRunnerTest.java
@@ -0,0 +1,148 @@
1 +package de.workaround.ci;
2 +
3 +import java.time.Instant;
4 +import java.util.List;
5 +import java.util.UUID;
6 +
7 +import org.junit.jupiter.api.Test;
8 +
9 +import de.workaround.git.GitRepositoryService;
10 +import de.workaround.model.ActionRun;
11 +import de.workaround.model.ActionTask;
12 +import de.workaround.model.Repository;
13 +import de.workaround.model.User;
14 +import io.quarkus.test.junit.QuarkusTest;
15 +import jakarta.inject.Inject;
16 +import jakarta.transaction.Transactional;
17 +
18 +import static org.junit.jupiter.api.Assertions.assertEquals;
19 +import static org.junit.jupiter.api.Assertions.assertTrue;
20 +
21 +/**
22 + * Repo-scoped runners (issue #2, phase 3): a runner registered for one repository only claims that
23 + * repository's tasks; an instance-scoped runner claims any.
24 + */
25 +@QuarkusTest
26 +class ScopedRunnerTest
27 +{
28 + @Inject
29 + RunnerRegistrationService runnerService;
30 +
31 + @Inject
32 + TaskDispatchService dispatch;
33 +
34 + @Inject
35 + GitRepositoryService repositories;
36 +
37 + @Inject
38 + ActionRun.Repo runs;
39 +
40 + @Inject
41 + ActionTask.Repo tasks;
42 +
43 + @Test
44 + void repoScopedRunnerSkipsOtherReposTasks()
45 + {
46 + User admin = persistUser("sr-admin-" + shortId());
47 + Repository repoA = newRepo("sr-a");
48 + Repository repoB = newRepo("sr-b");
49 + // repoB's task is older, so without scoping it would be picked first
50 + seedPendingTask(repoB, "b-build", 20);
51 + UUID aTask = seedPendingTask(repoA, "a-build", 10);
52 +
53 + RunnerRegistrationService.RegisteredRunner scoped = registerScoped(admin, repoA);
54 + ActionTask claimed = dispatch.fetch(scoped.runner().uuid, scoped.plaintext()).task().orElseThrow();
55 +
56 + assertEquals(aTask, claimed.id, "scoped runner claims its own repo's task, not the older other-repo one");
57 + }
58 +
59 + @Test
60 + void repoScopedRunnerGetsNothingWhenOnlyOtherReposHaveWork()
61 + {
62 + User admin = persistUser("sr-admin2-" + shortId());
63 + Repository repoA = newRepo("sr-c");
64 + Repository repoB = newRepo("sr-d");
65 + seedPendingTask(repoB, "b-build", 10);
66 +
67 + RunnerRegistrationService.RegisteredRunner scoped = registerScoped(admin, repoA);
68 + assertTrue(dispatch.fetch(scoped.runner().uuid, scoped.plaintext()).task().isEmpty());
69 + }
70 +
71 + @Test
72 + void instanceRunnerClaimsAnyRepo()
73 + {
74 + User admin = persistUser("sr-admin3-" + shortId());
75 + Repository repoB = newRepo("sr-e");
76 + // dispatch's queue is global; backdate far so this is the oldest pending task and is claimed
77 + // deterministically regardless of other tests' committed rows
78 + UUID bTask = seedPendingTask(repoB, "b-build", 86_400);
79 +
80 + String token = runnerService.createRegistrationToken(admin).plaintext();
81 + RunnerRegistrationService.RegisteredRunner instance = runnerService.register(token, "inst", List.of(),
82 + "v4.0.0", false);
83 + assertEquals(bTask, dispatch.fetch(instance.runner().uuid, instance.plaintext()).task().orElseThrow().id,
84 + "instance runner claims across repos");
85 + }
86 +
87 + private RunnerRegistrationService.RegisteredRunner registerScoped(User admin, Repository repo)
88 + {
89 + String token = runnerService.createRegistrationToken(admin, repo).plaintext();
90 + return runnerService.register(token, "scoped", List.of(), "v4.0.0", false);
91 + }
92 +
93 + private Repository newRepo(String name)
94 + {
95 + return createRepo(persistUser(name + "-owner-" + shortId()), name);
96 + }
97 +
98 + @Transactional
99 + Repository createRepo(User owner, String name)
100 + {
101 + return repositories.create(owner, name, Repository.Visibility.PUBLIC, null);
102 + }
103 +
104 + private UUID seedPendingTask(Repository repository, String jobName, int secondsAgo)
105 + {
106 + return seedPendingTask(repository, jobName, secondsAgo, "");
107 + }
108 +
109 + @Transactional
110 + UUID seedPendingTask(Repository repository, String jobName, int secondsAgo, String runsOn)
111 + {
112 + Repository repo = repositories.find(repository.ownerHandle(), repository.name).orElseThrow();
113 + ActionRun run = new ActionRun();
114 + run.repository = repo;
115 + run.number = runs.maxNumber(repo) + 1;
116 + run.workflowName = "CI";
117 + run.workflowFile = ".forgejo/workflows/ci.yml";
118 + run.event = "push";
119 + run.ref = "refs/heads/main";
120 + run.commitSha = "0000000000000000000000000000000000000000";
121 + run.persist();
122 +
123 + ActionTask task = new ActionTask();
124 + task.run = run;
125 + task.name = jobName;
126 + task.jobId = jobName;
127 + task.runsOn = runsOn;
128 + task.payload = "on: push";
129 + task.createdAt = Instant.now().minusSeconds(secondsAgo);
130 + task.persist();
131 + return task.id;
132 + }
133 +
134 + @Transactional
135 + User persistUser(String name)
136 + {
137 + User user = new User();
138 + user.oidcSub = name;
139 + user.username = name;
140 + user.persist();
141 + return user;
142 + }
143 +
144 + private static String shortId()
145 + {
146 + return UUID.randomUUID().toString().substring(0, 8);
147 + }
148 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog