✨ (ci): Scope runners to an organisation
Changes
9 files changed, +213 -21
MODIFY
README.md
+2 -2
@@ -92,8 +92,8 @@
92
92
labels, ordered by `needs` dependencies, and repository owners manage encrypted secrets and
93
93
variables that are delivered to runners, jobs support `needs` ordering and `strategy.matrix`, runs
94
94
can be cancelled or re-run from the UI, and a commit's CI result shows on its commit and merge-request
95
- pages, and ephemeral runners are retired after one job. Non-push events, artifacts, and
96
- repo/org-scoped runners are follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
95
+ pages, ephemeral runners are retired after one job, and runners can be scoped to a repository (from
96
+ its settings) or an organisation. Non-push events and artifacts are follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
97
97
[architecture](docs/maintainers/ci-runners.md)
98
98
activities from; local users can in turn follow a remote repository — or a whole remote user, whose
99
99
public repositories are then followed and shown grouped — and read their pushes (see below)
MODIFY
docs/admins/ci-runners.md
+5 -3
@@ -48,8 +48,9 @@
48
48
is then removed automatically — its credentials stop working after that one job. A runner can also be
49
49
**scoped to a single repository** (it then only runs that repo's jobs): a repo owner mints a
50
50
repo-scoped registration token and manages that repo's runners from the repository's **Settings → CI
51
-secrets & variables** page, while this admin page mints instance-wide tokens. Org scope is a later
52
-phase.
51
+secrets & variables** page, while this admin page mints instance-wide tokens. Runners can also be
52
+scoped to an organisation (they then serve all of that org's repositories); org scope is enforced,
53
+though a UI to mint an org-scoped token is still to come.
53
54
54
55
## Endpoints
55
56
@@ -104,7 +105,8 @@
104
105
`action_task.runs_on`, `V26__action_secrets_variables.sql` adds `action_secret`/`action_variable`,
105
106
`V27__action_task_needs.sql` adds `action_task.needs`, `V28__action_task_outputs.sql` adds
106
107
`action_task.outputs`, `V29__action_task_job_id.sql` adds `action_task.job_id`,
107
-`V30__ci_runner_scope.sql` adds the `repository_id` scope to `ci_runner`/`ci_runner_registration_token`).
108
+`V30__ci_runner_scope.sql` adds the `repository_id` scope to `ci_runner`/`ci_runner_registration_token`,
109
+`V31__ci_runner_org_scope.sql` adds the `organisation_id` scope).
108
110
Secrets are stored encrypted and require `GITSHARK_SECRET_KEY` to be set (same key as push mirrors);
109
111
without it, secrets cannot be decrypted and are omitted from what a runner receives.
110
112
The `ci_runner*` tables hold no repository data (losing them only forces re-registration); the
MODIFY
docs/maintainers/ci-runners.md
+12 -9
@@ -100,13 +100,14 @@
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. Repo
107
- owners mint a repo-scoped registration token and list/delete the repo's runners under **Settings →
108
- CI** (`ActionSettingsResource`); the instance-wide admin page (`AdminRunnerResource`) still mints
109
- unscoped tokens.
103
+- **Scoped runners:** a registration token (and the runners it creates) may carry a `repository_id`
104
+ (`V30`) or an `organisation_id` (`V31`) on `ci_runner_registration_token`/`ci_runner`; neither set =
105
+ instance-scope. Dispatch's `scopeAllows` hands a repo-scoped runner only its repository's tasks, an
106
+ org-scoped runner any task in a repo owned by that org, and an instance runner any task. Scoped rows
107
+ cascade-delete with their repository/organisation. Repo owners mint a repo-scoped token and
108
+ list/delete the repo's runners under **Settings → CI** (`ActionSettingsResource`); the instance-wide
109
+ admin page (`AdminRunnerResource`) mints unscoped tokens. Org-scoped tokens exist in the model and
110
+ are honored by dispatch, but there's no UI to mint one yet (service-only).
110
111
- **Label matching:** a task carries its job's `runs-on` labels (`action_task.runs_on`, parsed at
111
112
ingest). Dispatch scans PENDING tasks oldest-first and claims the first whose labels are all
112
113
advertised by the fetching runner (empty `runs-on` = any runner); an incompatible task is left for a
@@ -148,7 +149,8 @@
148
149
zombie-reclaim — and its credentials stop working), `ScopedRunnerTest` (a repo-scoped runner skips
149
150
other repos' tasks and idles when only they have work; an instance runner claims across repos),
150
151
`SecretsSettingsTest` also covers the repo Settings → CI runner UI (owner mints a scoped token,
151
- lists/deletes the repo's runners; a stranger is refused).
152
+ lists/deletes the repo's runners; a stranger is refused), `OrgScopedRunnerTest` (an org-scoped
153
+ runner serves its org's repos and idles otherwise).
152
154
- **Ephemeral runners:** a runner registered with `ephemeral=true` is one-shot — once its single task
153
155
reaches a terminal state it is deleted (`ci_runner` row removed; the task's `runner_id` is `ON
154
156
DELETE SET NULL`), so its credentials stop working and it never gets a second task. This holds on
@@ -194,7 +196,8 @@
194
196
not. (`!`-negation within a single pattern list is also not supported.)
195
197
- **Matrix advanced options:** `include`/`exclude` and `fail-fast`/`max-parallel` are not honored
196
198
(plain dimension cross-product only).
197
-- **Org scope:** runners can be scoped to a repository but not to an organisation.
199
+- **Org-scoped token UI:** dispatch enforces org scope, but there's no page yet to mint an
200
+ org-scoped registration token (repo-scoped tokens have one; org-scoped are service-only).
198
201
- **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), non-push events.
199
202
200
203
## References
MODIFY
src/main/java/de/workaround/ci/RunnerRegistrationService.java
+16 -1
@@ -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.Organisation;
15
16
import de.workaround.model.Repository;
16
17
import de.workaround.model.User;
17
18
import jakarta.enterprise.context.ApplicationScoped;
@@ -49,18 +50,31 @@
49
50
@Transactional
50
51
public CreatedRegistrationToken createRegistrationToken(User admin)
51
52
{
52
- return createRegistrationToken(admin, null);
53
+ return createScopedToken(admin, null, null);
53
54
}
54
55
55
56
/** Create a registration token scoped to {@code repository} (null = instance-scope, any repository). */
56
57
@Transactional
57
58
public CreatedRegistrationToken createRegistrationToken(User admin, Repository repository)
58
59
{
60
+ return createScopedToken(admin, repository, null);
61
+ }
62
+
63
+ /** Create a registration token scoped to {@code organisation} (serves that org's repositories). */
64
+ @Transactional
65
+ public CreatedRegistrationToken createRegistrationToken(User admin, Organisation organisation)
66
+ {
67
+ return createScopedToken(admin, null, organisation);
68
+ }
69
+
70
+ private CreatedRegistrationToken createScopedToken(User admin, Repository repository, Organisation organisation)
71
+ {
59
72
String plaintext = REGISTRATION_PREFIX + randomSecret();
60
73
CiRunnerRegistrationToken token = new CiRunnerRegistrationToken();
61
74
token.tokenHash = hash(plaintext);
62
75
token.createdBy = admin;
63
76
token.repository = repository;
77
+ token.organisation = organisation;
64
78
token.persist();
65
79
return new CreatedRegistrationToken(token, plaintext);
66
80
}
@@ -82,6 +96,7 @@
82
96
runner.version = version;
83
97
runner.ephemeral = ephemeral;
84
98
runner.repository = registration.repository;
99
+ runner.organisation = registration.organisation;
85
100
runner.status = CiRunner.Status.IDLE;
86
101
runner.lastSeen = Instant.now();
87
102
runner.persist();
MODIFY
src/main/java/de/workaround/ci/TaskDispatchService.java
+14 -2
@@ -188,10 +188,22 @@
188
188
return Optional.empty();
189
189
}
190
190
191
- /** An instance-scoped runner (no repository) serves any task; a repo-scoped runner only its repository's. */
191
+ /**
192
+ * Whether the runner's scope permits this task: a repo-scoped runner only its repository's tasks; an
193
+ * org-scoped runner any of that organisation's repositories'; an unscoped (instance) runner any task.
194
+ */
192
195
private static boolean scopeAllows(CiRunner runner, ActionTask task)
193
196
{
194
- return runner.repository == null || runner.repository.id.equals(task.run.repository.id);
197
+ Repository repo = task.run.repository;
198
+ if (runner.repository != null)
199
+ {
200
+ return runner.repository.id.equals(repo.id);
201
+ }
202
+ if (runner.organisation != null)
203
+ {
204
+ return repo.ownerOrg != null && runner.organisation.id.equals(repo.ownerOrg.id);
205
+ }
206
+ return true;
195
207
}
196
208
197
209
/** A task is dispatchable only once every job it needs (in the same run) has succeeded. */
MODIFY
src/main/java/de/workaround/model/CiRunner.java
+7 -3
@@ -23,8 +23,8 @@
23
23
* A CI/CD runner registered against this instance via the Forgejo/Gitea runner.v1 Connect protocol.
24
24
* The runner authenticates every post-registration call with {@link #uuid} + a secret whose SHA-256
25
25
* hash is kept in {@link #tokenHash}; the plaintext secret is returned to the runner only once, at
26
- * registration. A runner may be scoped to a single {@link #repository} (null means instance-scope,
27
- * serving any repository).
26
+ * registration. A runner may be scoped to a single {@link #repository} or an {@link #organisation};
27
+ * with neither set it is instance-scope, serving any repository.
28
28
*/
29
29
@Entity
30
30
@Table(name = "ci_runner")
@@ -52,10 +52,14 @@
52
52
53
53
public boolean ephemeral;
54
54
55
- /** Repository this runner is scoped to; null means instance-scope (any repository). */
55
+ /** Repository this runner is scoped to; null means not repo-scoped. */
56
56
@ManyToOne
57
57
public Repository repository;
58
58
59
+ /** Organisation this runner is scoped to (serves its repos); null means not org-scoped. */
60
+ @ManyToOne
61
+ public Organisation organisation;
62
+
59
63
public Instant lastSeen;
60
64
61
65
public Instant createdAt = Instant.now();
MODIFY
src/main/java/de/workaround/model/CiRunnerRegistrationToken.java
+5 -1
@@ -37,10 +37,14 @@
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. */
40
+ /** Repository this token (and its runners) is scoped to; null means not repo-scoped. */
41
41
@ManyToOne
42
42
public Repository repository;
43
43
44
+ /** Organisation this token (and its runners) is scoped to; null means not org-scoped. */
45
+ @ManyToOne
46
+ public Organisation organisation;
47
+
44
48
public Instant createdAt = Instant.now();
45
49
46
50
public Instant lastUsed;
ADD
src/main/resources/db/migration/V31__ci_runner_org_scope.sql
+11 -0
@@ -0,0 +1,11 @@
1
+-- Org-scoped runners (issue #2, phase 3).
2
+--
3
+-- In addition to the repository scope (V30), a registration token and its runners may be scoped to an
4
+-- organisation: the runner then serves any repository owned by that organisation. NULL organisation
5
+-- (and NULL repository) means instance scope. Scoped rows are removed with the organisation.
6
+
7
+alter table ci_runner_registration_token
8
+ add column organisation_id uuid references organisations (id) on delete cascade;
9
+
10
+alter table ci_runner
11
+ add column organisation_id uuid references organisations (id) on delete cascade;
ADD
src/test/java/de/workaround/ci/OrgScopedRunnerTest.java
+141 -0
@@ -0,0 +1,141 @@
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.Organisation;
13
+import de.workaround.model.Repository;
14
+import de.workaround.model.User;
15
+import io.quarkus.test.junit.QuarkusTest;
16
+import jakarta.inject.Inject;
17
+import jakarta.transaction.Transactional;
18
+
19
+import static org.junit.jupiter.api.Assertions.assertEquals;
20
+import static org.junit.jupiter.api.Assertions.assertTrue;
21
+
22
+/**
23
+ * Org-scoped runners (issue #2, phase 3): a runner scoped to an organisation runs any of that org's
24
+ * repositories' tasks, but nothing outside it.
25
+ */
26
+@QuarkusTest
27
+class OrgScopedRunnerTest
28
+{
29
+ @Inject
30
+ RunnerRegistrationService runnerService;
31
+
32
+ @Inject
33
+ TaskDispatchService dispatch;
34
+
35
+ @Inject
36
+ GitRepositoryService repositories;
37
+
38
+ @Inject
39
+ ActionRun.Repo runs;
40
+
41
+ @Inject
42
+ Organisation.Repo organisations;
43
+
44
+ @Test
45
+ void orgScopedRunnerRunsItsOrgReposButNotOthers()
46
+ {
47
+ User admin = persistUser("org-admin-" + shortId());
48
+ Organisation org = persistOrg("org-" + shortId());
49
+ UUID orgTask = seedOrgRepoTask(org, "org-repo", 10);
50
+ seedUserRepoTask("org-outsider", 20); // older, different owner — must be skipped
51
+
52
+ RunnerRegistrationService.RegisteredRunner scoped = registerForOrg(admin, org);
53
+ ActionTask claimed = dispatch.fetch(scoped.runner().uuid, scoped.plaintext()).task().orElseThrow();
54
+
55
+ assertEquals(orgTask, claimed.id, "org runner claims its org's repo task, not the older outside one");
56
+ }
57
+
58
+ @Test
59
+ void orgScopedRunnerIdlesWhenOnlyOtherOwnersHaveWork()
60
+ {
61
+ User admin = persistUser("org-admin2-" + shortId());
62
+ Organisation org = persistOrg("org2-" + shortId());
63
+ seedUserRepoTask("org-outsider2", 10);
64
+
65
+ RunnerRegistrationService.RegisteredRunner scoped = registerForOrg(admin, org);
66
+ assertTrue(dispatch.fetch(scoped.runner().uuid, scoped.plaintext()).task().isEmpty());
67
+ }
68
+
69
+ private RunnerRegistrationService.RegisteredRunner registerForOrg(User admin, Organisation org)
70
+ {
71
+ String token = runnerService.createRegistrationToken(admin, org).plaintext();
72
+ return runnerService.register(token, "org-runner", List.of(), "v4.0.0", false);
73
+ }
74
+
75
+ @Transactional
76
+ UUID seedOrgRepoTask(Organisation organisation, String repoName, int secondsAgo)
77
+ {
78
+ Organisation org = organisations.findById(organisation.id);
79
+ Repository repo = new Repository();
80
+ repo.name = repoName;
81
+ repo.ownerOrg = org;
82
+ repo.visibility = Repository.Visibility.PUBLIC;
83
+ repo.persist();
84
+ return seedTask(repo, secondsAgo);
85
+ }
86
+
87
+ @Transactional
88
+ UUID seedUserRepoTask(String name, int secondsAgo)
89
+ {
90
+ User owner = persistUser(name + "-" + shortId());
91
+ Repository repo = repositories.create(owner, name, Repository.Visibility.PUBLIC, null);
92
+ return seedTask(repositories.find(repo.ownerHandle(), repo.name).orElseThrow(), secondsAgo);
93
+ }
94
+
95
+ private UUID seedTask(Repository repo, int secondsAgo)
96
+ {
97
+ ActionRun run = new ActionRun();
98
+ run.repository = repo;
99
+ run.number = runs.maxNumber(repo) + 1;
100
+ run.workflowName = "CI";
101
+ run.workflowFile = ".forgejo/workflows/ci.yml";
102
+ run.event = "push";
103
+ run.ref = "refs/heads/main";
104
+ run.commitSha = "0000000000000000000000000000000000000000";
105
+ run.persist();
106
+
107
+ ActionTask task = new ActionTask();
108
+ task.run = run;
109
+ task.name = "build";
110
+ task.jobId = "build";
111
+ task.payload = "on: push";
112
+ task.createdAt = Instant.now().minusSeconds(secondsAgo);
113
+ task.persist();
114
+ return task.id;
115
+ }
116
+
117
+ @Transactional
118
+ Organisation persistOrg(String name)
119
+ {
120
+ Organisation org = new Organisation();
121
+ org.name = name;
122
+ org.displayName = name;
123
+ org.persist();
124
+ return org;
125
+ }
126
+
127
+ @Transactional
128
+ User persistUser(String name)
129
+ {
130
+ User user = new User();
131
+ user.oidcSub = name;
132
+ user.username = name;
133
+ user.persist();
134
+ return user;
135
+ }
136
+
137
+ private static String shortId()
138
+ {
139
+ return UUID.randomUUID().toString().substring(0, 8);
140
+ }
141
+}