✨ (ci): Manage repo-scoped runners from repo settings
Changes
7 files changed, +152 -12
MODIFY
docs/admins/ci-runners.md
+4 -3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 <url> --token <token></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
@@ -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()