gitshark

Clone repository

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

← Commits

✨ (ci): Manage repo-scoped runners from repo settings

ece08e6322399f277ace3c21275f667c1d5bb395 · Michael Hainz · 2026-07-23T10:27:20Z

Changes

7 files changed, +152 -12

MODIFY docs/admins/ci-runners.md +4 -3
diff --git a/docs/admins/ci-runners.md b/docs/admins/ci-runners.md
index 9a2da02..e3db9b1 100644
--- a/docs/admins/ci-runners.md
+++ b/docs/admins/ci-runners.md
@@ -46,9 +46,10 @@
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 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.
49 +**scoped to a single repository** (it then only runs that repo's jobs): a repo owner mints a
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.
52 53
53 54 ## Endpoints
54 55
MODIFY docs/maintainers/ci-runners.md +9 -7
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
index 66d17b2..226ab5e 100644
--- a/docs/maintainers/ci-runners.md
+++ b/docs/maintainers/ci-runners.md
@@ -18,7 +18,7 @@
18 18 | Run controls | `ci/ActionRunService.java` | Cancel a run (settle run + unfinished tasks) and re-run a finished run (reset tasks to PENDING, clear logs/outputs). |
19 19 | Commit status | `ci/CommitStatusService.java` | Aggregate a commit's runs into one status; shown on commit/MR pages and via the Gitea commit-status API. |
20 20 | Actions UI | `web/ActionResource.java` + `templates/ActionResource/` | Read-only per-repo run list + run detail (jobs and their log rows); sidebar `Actions` tab. |
21 -| Secrets/variables UI | `web/ActionSettingsResource.java` + `ci/ActionSecretService.java` + `templates/ActionSettingsResource/` | Owner-only CRUD for CI secrets (write-only, encrypted) and variables at `settings/actions`. |
21 +| CI settings UI | `web/ActionSettingsResource.java` + `ci/ActionSecretService.java` + `templates/ActionSettingsResource/` | Owner-only, at `settings/actions`: CRUD for secrets (write-only, encrypted) and variables, plus minting repo-scoped runner tokens and listing/deleting the repo's runners. |
22 22 | Entities | `model/CiRunner.java`, `model/CiRunnerRegistrationToken.java` | Runner state (migration `V19`). |
23 23 | Run entities | `model/ActionRun.java`, `model/ActionTask.java`, `model/ActionLog.java` | Run/job/log-row persistence (migrations `V23`–`V29`). `ActionTask.seq` (`bigserial`) = surrogate int64 `Task.id`; `runs_on` = matching labels; `needs` = job dependencies; `outputs` = reported job outputs (JSON); `job_id` = workflow job key (shared by a matrix job's cells). |
24 24 | Secret/variable entities | `model/ActionSecret.java`, `model/ActionVariable.java` | Per-repo CI secrets (encrypted) and variables (migration `V26`), delivered to runners in FetchTask. |
@@ -103,8 +103,10 @@
103 103 - **Repo-scoped runners:** a registration token (and the runners it creates) may carry a
104 104 `repository_id` (`ci_runner_registration_token`/`ci_runner`, migration `V30`); null = instance-scope
105 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.)
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.
108 110 - **Label matching:** a task carries its job's `runs-on` labels (`action_task.runs_on`, parsed at
109 111 ingest). Dispatch scans PENDING tasks oldest-first and claims the first whose labels are all
110 112 advertised by the fetching runner (empty `runs-on` = any runner); an incompatible task is left for a
@@ -144,7 +146,9 @@
144 146 commit-status API reflects failure, and a commit with no runs stays all-clear),
145 147 `EphemeralRunnerTest` (an ephemeral runner is removed after its task — on completion and on
146 148 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).
149 + other repos' tasks and idles when only they have work; an instance runner claims across repos),
150 + `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).
148 152 - **Ephemeral runners:** a runner registered with `ephemeral=true` is one-shot — once its single task
149 153 reaches a terminal state it is deleted (`ci_runner` row removed; the task's `runner_id` is `ON
150 154 DELETE SET NULL`), so its credentials stop working and it never gets a second task. This holds on
@@ -190,9 +194,7 @@
190 194 not. (`!`-negation within a single pattern list is also not supported.)
191 195 - **Matrix advanced options:** `include`/`exclude` and `fail-fast`/`max-parallel` are not honored
192 196 (plain dimension cross-product only).
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.
197 +- **Org scope:** runners can be scoped to a repository but not to an organisation.
196 198 - **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), non-push events.
197 199
198 200 ## References
MODIFY docs/users/ci-runners.md +3 -0
diff --git a/docs/users/ci-runners.md b/docs/users/ci-runners.md
index 8e7d602..91ad252 100644
--- a/docs/users/ci-runners.md
+++ b/docs/users/ci-runners.md
@@ -76,6 +76,9 @@
76 76 an encryption key configured.
77 77 - **Variables** are plain configuration and their values are visible on the settings page.
78 78
79 +The same page (**Settings → CI secrets & variables**) also lets a repository owner generate a
80 +registration token for a runner dedicated to this repository and remove the repo's runners.
81 +
79 82 ## Job ordering with `needs`
80 83
81 84 A job can depend on others with `needs`. A dependent job runs only after every job it needs has
MODIFY src/main/java/de/workaround/model/CiRunner.java +3 -0
diff --git a/src/main/java/de/workaround/model/CiRunner.java b/src/main/java/de/workaround/model/CiRunner.java
index 00f274c..da80250 100644
--- a/src/main/java/de/workaround/model/CiRunner.java
+++ b/src/main/java/de/workaround/model/CiRunner.java
@@ -78,6 +78,9 @@
78 78
79 79 @HQL("order by createdAt desc")
80 80 List<CiRunner> listNewestFirst();
81 +
82 + @HQL("select r from CiRunner r where r.repository = :repository order by r.createdAt desc")
83 + List<CiRunner> findByRepository(Repository repository);
81 84 }
82 85
83 86 }
MODIFY src/main/java/de/workaround/web/ActionSettingsResource.java +40 -2
diff --git a/src/main/java/de/workaround/web/ActionSettingsResource.java b/src/main/java/de/workaround/web/ActionSettingsResource.java
index 035919e..612e311 100644
--- a/src/main/java/de/workaround/web/ActionSettingsResource.java
+++ b/src/main/java/de/workaround/web/ActionSettingsResource.java
@@ -6,10 +6,12 @@
6 6
7 7 import de.workaround.account.CurrentUser;
8 8 import de.workaround.ci.ActionSecretService;
9 +import de.workaround.ci.RunnerRegistrationService;
9 10 import de.workaround.git.AccessPolicy;
10 11 import de.workaround.git.GitRepositoryService;
11 12 import de.workaround.model.ActionSecret;
12 13 import de.workaround.model.ActionVariable;
14 +import de.workaround.model.CiRunner;
13 15 import de.workaround.model.Repository;
14 16 import io.quarkus.qute.CheckedTemplate;
15 17 import io.quarkus.qute.TemplateInstance;
@@ -38,7 +40,7 @@
38 40 static class Templates
39 41 {
40 42 static native TemplateInstance settings(Repository repo, RepoNav nav, String error,
41 - List<ActionSecret> secrets, List<ActionVariable> variables);
43 + List<ActionSecret> secrets, List<ActionVariable> variables, List<CiRunner> runners, String newToken);
42 44 }
43 45
44 46 @Inject
@@ -56,6 +58,12 @@
56 58 @Inject
57 59 ActionSecretService actionSecrets;
58 60
61 + @Inject
62 + RunnerRegistrationService runnerService;
63 +
64 + @Inject
65 + CiRunner.Repo runners;
66 +
59 67 @Context
60 68 UriInfo uriInfo;
61 69
@@ -122,10 +130,40 @@
122 130 return backToSettings(repo);
123 131 }
124 132
133 + @POST
134 + @jakarta.ws.rs.Path("runners/token")
135 + public Response createRunnerToken(@PathParam("owner") String owner, @PathParam("name") String name)
136 + {
137 + Repository repo = requireOwner(owner, name);
138 + String token = runnerService.createRegistrationToken(currentUser.require(), repo).plaintext();
139 + // show the plaintext once, inline on the settings page (never stored in the clear)
140 + return Response.ok(render(repo, null, token)).build();
141 + }
142 +
143 + @POST
144 + @jakarta.ws.rs.Path("runners/{id}/delete")
145 + public Response deleteRunner(@PathParam("owner") String owner, @PathParam("name") String name,
146 + @PathParam("id") UUID id)
147 + {
148 + Repository repo = requireOwner(owner, name);
149 + CiRunner runner = runners.findById(id);
150 + if (runner != null && runner.repository != null && runner.repository.id.equals(repo.id))
151 + {
152 + runnerService.delete(id);
153 + }
154 + return backToSettings(repo);
155 + }
156 +
125 157 private TemplateInstance render(Repository repo, String error)
126 158 {
159 + return render(repo, error, null);
160 + }
161 +
162 + private TemplateInstance render(Repository repo, String error, String newToken)
163 + {
127 164 return Templates.settings(repo, repoNav.build(repo, uriInfo), error,
128 - actionSecrets.listSecrets(repo), actionSecrets.listVariables(repo));
165 + actionSecrets.listSecrets(repo), actionSecrets.listVariables(repo), runners.findByRepository(repo),
166 + newToken);
129 167 }
130 168
131 169 private Response backToSettings(Repository repo)
MODIFY src/main/resources/templates/ActionSettingsResource/settings.html +33 -0
diff --git a/src/main/resources/templates/ActionSettingsResource/settings.html b/src/main/resources/templates/ActionSettingsResource/settings.html
index 0e156ef..3e2a435 100644
--- a/src/main/resources/templates/ActionSettingsResource/settings.html
+++ b/src/main/resources/templates/ActionSettingsResource/settings.html
@@ -54,6 +54,39 @@
54 54 <button class="btn btn-primary">Add variable</button>
55 55 </form>
56 56 </section>
57 +
58 + <section class="panel settings-card">
59 + <h2>Runners</h2>
60 + <p class="muted">Runners registered with a token from here only run this repository's jobs.</p>
61 + {#if newToken}
62 + <div class="panel token-created">
63 + <p>Registration token (shown once — copy it now):</p>
64 + <div class="clone-url">
65 + <code>{newToken}</code>
66 + <button type="button" class="btn-icon copy-btn" data-copy="{newToken}" aria-label="Copy token" title="Copy">⧉</button>
67 + </div>
68 + <p class="muted">Register a runner with <code>forgejo-runner register --instance &lt;url&gt; --token &lt;token&gt;</code>.</p>
69 + </div>
70 + {/if}
71 + {#if runners}
72 + <ul class="secret-list">
73 + {#for r in runners}
74 + <li class="secret-row">
75 + <code class="secret-name">{r.name}</code>
76 + <span class="muted">{r.status} {#if r.labels}· {r.labels}{/if}</span>
77 + <form class="inline" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/settings/actions/runners/{r.id}/delete">
78 + <button class="btn btn-danger btn-sm">Delete</button>
79 + </form>
80 + </li>
81 + {/for}
82 + </ul>
83 + {#else}
84 + <p class="muted">No runners registered for this repository.</p>
85 + {/if}
86 + <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/settings/actions/runners/token" class="secret-add">
87 + <button class="btn btn-primary">Generate registration token</button>
88 + </form>
89 + </section>
57 90 </section>
58 91 </div>
59 92 {/include}
MODIFY src/test/java/de/workaround/web/SecretsSettingsTest.java +60 -0
diff --git a/src/test/java/de/workaround/web/SecretsSettingsTest.java b/src/test/java/de/workaround/web/SecretsSettingsTest.java
index 3bd4463..aed5fba 100644
--- a/src/test/java/de/workaround/web/SecretsSettingsTest.java
+++ b/src/test/java/de/workaround/web/SecretsSettingsTest.java
@@ -1,12 +1,15 @@
1 1 package de.workaround.web;
2 2
3 +import java.util.List;
3 4 import java.util.UUID;
4 5
5 6 import org.junit.jupiter.api.Test;
6 7
8 +import de.workaround.ci.RunnerRegistrationService;
7 9 import de.workaround.git.GitRepositoryService;
8 10 import de.workaround.model.ActionSecret;
9 11 import de.workaround.model.ActionVariable;
12 +import de.workaround.model.CiRunner;
10 13 import de.workaround.model.Repository;
11 14 import de.workaround.model.User;
12 15 import io.quarkus.test.junit.QuarkusTest;
@@ -47,6 +50,63 @@
47 50 @Inject
48 51 EntityManager em;
49 52
53 + @Inject
54 + RunnerRegistrationService runnerService;
55 +
56 + @Inject
57 + CiRunner.Repo runners;
58 +
59 + @Test
60 + @TestSecurity(user = OWNER)
61 + void ownerGeneratesRepoScopedRunnerToken()
62 + {
63 + Repository repo = repoOwnedBy(OWNER, "sec-tok");
64 +
65 + given().when().post(base(repo) + "/runners/token")
66 + .then().statusCode(200)
67 + .body(containsString("gsr_"));
68 + }
69 +
70 + @Test
71 + @TestSecurity(user = OWNER)
72 + void ownerListsAndDeletesRepoRunner()
73 + {
74 + Repository repo = repoOwnedBy(OWNER, "sec-run");
75 + UUID runnerId = seedScopedRunner(repo);
76 +
77 + given().when().get(base(repo)).then().statusCode(200).body(containsString("scoped-runner"));
78 +
79 + given().redirects().follow(false)
80 + .when().post(base(repo) + "/runners/" + runnerId + "/delete")
81 + .then().statusCode(303);
82 +
83 + assertEquals(0, repoRunnerCount(repo));
84 + }
85 +
86 + @Test
87 + @TestSecurity(user = STRANGER)
88 + void strangerCannotGenerateRunnerToken()
89 + {
90 + Repository repo = repoOwnedBy(OWNER, "sec-tok2");
91 +
92 + given().when().post(base(repo) + "/runners/token").then().statusCode(404);
93 + }
94 +
95 + @Transactional
96 + UUID seedScopedRunner(Repository repo)
97 + {
98 + User owner = users.findByOidcSubOptional(OWNER).orElseThrow();
99 + Repository managed = repositories.find(repo.ownerHandle(), repo.name).orElseThrow();
100 + String token = runnerService.createRegistrationToken(owner, managed).plaintext();
101 + return runnerService.register(token, "scoped-runner", List.of(), "v4.0.0", false).runner().id;
102 + }
103 +
104 + @Transactional
105 + long repoRunnerCount(Repository repo)
106 + {
107 + return runners.findByRepository(repositories.find(repo.ownerHandle(), repo.name).orElseThrow()).size();
108 + }
109 +
50 110 @Test
51 111 @TestSecurity(user = OWNER)
52 112 void ownerAddsSecretStoredEncryptedAndNeverShown()

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog