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 an organisation

5f1ee86e5d5d16af5df4b7d768ec79a44fd2d0da · Michael Hainz · 2026-07-23T10:40:44Z

Changes

9 files changed, +213 -21

MODIFY README.md +2 -2
diff --git a/README.md b/README.md
index 344ffbf..f69def2 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/admins/ci-runners.md b/docs/admins/ci-runners.md
index e3db9b1..65801ac 100644
--- a/docs/admins/ci-runners.md
+++ b/docs/admins/ci-runners.md
@@ -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
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
index 226ab5e..ad5311c 100644
--- a/docs/maintainers/ci-runners.md
+++ b/docs/maintainers/ci-runners.md
@@ -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
diff --git a/src/main/java/de/workaround/ci/RunnerRegistrationService.java b/src/main/java/de/workaround/ci/RunnerRegistrationService.java
index 2830c21..ff191e2 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.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
diff --git a/src/main/java/de/workaround/ci/TaskDispatchService.java b/src/main/java/de/workaround/ci/TaskDispatchService.java
index 7763b1d..a869467 100644
--- a/src/main/java/de/workaround/ci/TaskDispatchService.java
+++ b/src/main/java/de/workaround/ci/TaskDispatchService.java
@@ -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
diff --git a/src/main/java/de/workaround/model/CiRunner.java b/src/main/java/de/workaround/model/CiRunner.java
index da80250..5320510 100644
--- a/src/main/java/de/workaround/model/CiRunner.java
+++ b/src/main/java/de/workaround/model/CiRunner.java
@@ -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
diff --git a/src/main/java/de/workaround/model/CiRunnerRegistrationToken.java b/src/main/java/de/workaround/model/CiRunnerRegistrationToken.java
index 0d08555..a68ead6 100644
--- a/src/main/java/de/workaround/model/CiRunnerRegistrationToken.java
+++ b/src/main/java/de/workaround/model/CiRunnerRegistrationToken.java
@@ -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
diff --git a/src/main/resources/db/migration/V31__ci_runner_org_scope.sql b/src/main/resources/db/migration/V31__ci_runner_org_scope.sql
new file mode 100644
index 0000000..14bd854
--- /dev/null
+++ b/src/main/resources/db/migration/V31__ci_runner_org_scope.sql
@@ -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
diff --git a/src/test/java/de/workaround/ci/OrgScopedRunnerTest.java b/src/test/java/de/workaround/ci/OrgScopedRunnerTest.java
new file mode 100644
index 0000000..5c5e25f
--- /dev/null
+++ b/src/test/java/de/workaround/ci/OrgScopedRunnerTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog