gitshark

Clone repository

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

← Commits

✨ (ci): Retire ephemeral runners after one task

e54f19bb400aacb42394bc7fb077a054529324ab · Michael Hainz · 2026-07-23T10:06:09Z

Changes

6 files changed, +208 -10

MODIFY README.md +2 -1
diff --git a/README.md b/README.md
index 5db64c1..344ffbf 100644
--- a/README.md
+++ b/README.md
@@ -92,7 +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. Non-push events, artifacts, and scoped/ephemeral runners are follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
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),
96 97 [architecture](docs/maintainers/ci-runners.md)
97 98 activities from; local users can in turn follow a remote repository — or a whole remote user, whose
98 99 public repositories are then followed and shown grouped — and read their pushes (see below)
MODIFY docs/admins/ci-runners.md +3 -1
diff --git a/docs/admins/ci-runners.md b/docs/admins/ci-runners.md
index e84109b..dacd03a 100644
--- a/docs/admins/ci-runners.md
+++ b/docs/admins/ci-runners.md
@@ -44,7 +44,9 @@
44 44 Registration tokens are **reusable** and **instance-scoped** (matching Gitea's global tokens): one
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 -registration token). Repo/org-scoped and ephemeral runners are later phases.
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 50
49 51 ## Endpoints
50 52
MODIFY docs/maintainers/ci-runners.md +9 -3
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
index af09463..a033123 100644
--- a/docs/maintainers/ci-runners.md
+++ b/docs/maintainers/ci-runners.md
@@ -136,7 +136,14 @@
136 136 matrices expand to one task per cell with a reduced payload; a non-matrix job stays single),
137 137 `MatrixNeedsTest` (a dependent waits for every cell of a needed matrix job, and one failed cell
138 138 cancels the dependent), `CommitCiStatusTest` (commit page + MR page show the aggregate badge, the
139 - commit-status API reflects failure, and a commit with no runs stays all-clear).
139 + commit-status API reflects failure, and a commit with no runs stays all-clear),
140 + `EphemeralRunnerTest` (an ephemeral runner is removed after its task — on completion and on
141 + zombie-reclaim — and its credentials stop working).
142 +- **Ephemeral runners:** a runner registered with `ephemeral=true` is one-shot — once its single task
143 + reaches a terminal state it is deleted (`ci_runner` row removed; the task's `runner_id` is `ON
144 + DELETE SET NULL`), so its credentials stop working and it never gets a second task. This holds on
145 + both completion (`TaskProgressService`) and timeout (`ZombieReclaimService` deletes rather than just
146 + flagging OFFLINE). The stock `act_runner --ephemeral` client exits on its own after the job.
140 147 - **Zombie reclaim (`ZombieReclaimService`):** a scheduled sweep
141 148 (`gitshark.ci.zombie-reclaim-interval`, default 1m) fails any RUNNING task whose
142 149 `action_task.deadline` has passed — the runner is presumed gone — rolls its run up, and flags the
@@ -177,8 +184,7 @@
177 184 not. (`!`-negation within a single pattern list is also not supported.)
178 185 - **Matrix advanced options:** `include`/`exclude` and `fail-fast`/`max-parallel` are not honored
179 186 (plain dimension cross-product only).
180 -- **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and ephemeral runners,
181 - non-push events.
187 +- **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped runners, non-push events.
182 188
183 189 ## References
184 190
MODIFY src/main/java/de/workaround/ci/TaskProgressService.java +13 -1
diff --git a/src/main/java/de/workaround/ci/TaskProgressService.java b/src/main/java/de/workaround/ci/TaskProgressService.java
index e620cfc..a92dd41 100644
--- a/src/main/java/de/workaround/ci/TaskProgressService.java
+++ b/src/main/java/de/workaround/ci/TaskProgressService.java
@@ -37,6 +37,9 @@
37 37 @Inject
38 38 ActionLog.Repo logs;
39 39
40 + @Inject
41 + CiRunner.Repo runners;
42 +
40 43 /**
41 44 * Record the result the runner reports for a task and roll the owning run's status up.
42 45 *
@@ -67,7 +70,16 @@
67 70 if (status.isTerminal())
68 71 {
69 72 task.finishedAt = stoppedAt != null ? stoppedAt : Instant.now();
70 - runner.status = CiRunner.Status.IDLE;
73 + if (runner.ephemeral)
74 + {
75 + // an ephemeral runner is one-shot: retire it after its single task (the client also exits)
76 + task.runner = null;
77 + runners.delete(runner);
78 + }
79 + else
80 + {
81 + runner.status = CiRunner.Status.IDLE;
82 + }
71 83 }
72 84 rollUpRun(task.run);
73 85 return task;
MODIFY src/main/java/de/workaround/ci/ZombieReclaimService.java +18 -4
diff --git a/src/main/java/de/workaround/ci/ZombieReclaimService.java b/src/main/java/de/workaround/ci/ZombieReclaimService.java
index 75fcdf8..d30d630 100644
--- a/src/main/java/de/workaround/ci/ZombieReclaimService.java
+++ b/src/main/java/de/workaround/ci/ZombieReclaimService.java
@@ -16,8 +16,9 @@
16 16 /**
17 17 * Fails tasks whose runner vanished mid-run (issue #2, phase 1). A claimed task carries a {@link
18 18 * ActionTask#deadline}; once it passes with the task still RUNNING, the runner is presumed gone, so
19 - * the task is marked FAILURE, its run rolled up, and the runner flagged OFFLINE. Runs on a schedule
20 - * ({@code gitshark.ci.zombie-reclaim-interval}); {@link #reclaim(Instant)} is the testable core.
19 + * the task is marked FAILURE, its run rolled up, and the runner flagged OFFLINE (or deleted, if it is
20 + * ephemeral). Runs on a schedule ({@code gitshark.ci.zombie-reclaim-interval}); {@link
21 + * #reclaim(Instant)} is the testable core.
21 22 */
22 23 @ApplicationScoped
23 24 public class ZombieReclaimService
@@ -28,6 +29,9 @@
28 29 ActionTask.Repo tasks;
29 30
30 31 @Inject
32 + CiRunner.Repo runners;
33 +
34 + @Inject
31 35 TaskProgressService progress;
32 36
33 37 @Scheduled(every = "{gitshark.ci.zombie-reclaim-interval}", concurrentExecution = Scheduled.ConcurrentExecution.SKIP)
@@ -49,9 +53,19 @@
49 53 {
50 54 task.status = ActionRun.Status.FAILURE;
51 55 task.finishedAt = now;
52 - if (task.runner != null)
56 + CiRunner runner = task.runner;
57 + if (runner != null)
53 58 {
54 - task.runner.status = CiRunner.Status.OFFLINE;
59 + if (runner.ephemeral)
60 + {
61 + // an ephemeral runner is one-shot even when it dies mid-task — retire it, don't just flag it
62 + task.runner = null;
63 + runners.delete(runner);
64 + }
65 + else
66 + {
67 + runner.status = CiRunner.Status.OFFLINE;
68 + }
55 69 }
56 70 progress.rollUpRun(task.run);
57 71 }
ADD src/test/java/de/workaround/ci/EphemeralRunnerTest.java +163 -0
diff --git a/src/test/java/de/workaround/ci/EphemeralRunnerTest.java b/src/test/java/de/workaround/ci/EphemeralRunnerTest.java
new file mode 100644
index 0000000..9c72f5f
--- /dev/null
+++ b/src/test/java/de/workaround/ci/EphemeralRunnerTest.java
@@ -0,0 +1,163 @@
1 +package de.workaround.ci;
2 +
3 +import java.util.List;
4 +import java.util.UUID;
5 +
6 +import org.junit.jupiter.api.Test;
7 +
8 +import de.workaround.ci.proto.runner.v1.Result;
9 +import de.workaround.git.GitRepositoryService;
10 +import de.workaround.model.ActionRun;
11 +import de.workaround.model.ActionTask;
12 +import de.workaround.model.CiRunner;
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.persistence.EntityManager;
18 +import jakarta.transaction.Transactional;
19 +
20 +import static org.junit.jupiter.api.Assertions.assertThrows;
21 +import static org.junit.jupiter.api.Assertions.assertTrue;
22 +
23 +/**
24 + * Ephemeral runners (issue #2, phase 3): a runner registered {@code ephemeral} is retired after it
25 + * finishes its single task, and its credentials no longer work.
26 + */
27 +@QuarkusTest
28 +class EphemeralRunnerTest
29 +{
30 + @Inject
31 + RunnerRegistrationService runnerService;
32 +
33 + @Inject
34 + TaskDispatchService dispatch;
35 +
36 + @Inject
37 + TaskProgressService progress;
38 +
39 + @Inject
40 + GitRepositoryService repositories;
41 +
42 + @Inject
43 + ActionRun.Repo runs;
44 +
45 + @Inject
46 + ActionTask.Repo tasks;
47 +
48 + @Inject
49 + CiRunner.Repo runners;
50 +
51 + @Inject
52 + ZombieReclaimService reclaim;
53 +
54 + @Inject
55 + EntityManager em;
56 +
57 + @Test
58 + void ephemeralRunnerIsRetiredAfterItsTask()
59 + {
60 + RunnerRegistrationService.RegisteredRunner reg = registerEphemeral();
61 + long seq = seedClaimedTask("ep-a", reg.runner().uuid);
62 +
63 + progress.updateTask(reg.runner().uuid, reg.plaintext(), seq, Result.RESULT_SUCCESS, null, java.util.Map.of());
64 +
65 + assertTrue(runners.findByUuid(reg.runner().uuid).isEmpty(), "ephemeral runner removed after its task");
66 +
67 + // its credentials no longer authenticate
68 + assertThrows(RunnerAuthenticationException.class,
69 + () -> dispatch.fetch(reg.runner().uuid, reg.plaintext()));
70 + }
71 +
72 + @Test
73 + void ephemeralRunnerIsRetiredWhenItsTaskIsReclaimed()
74 + {
75 + RunnerRegistrationService.RegisteredRunner reg = registerEphemeral();
76 + seedOverdueClaimedTask("ep-b", reg.runner().uuid);
77 +
78 + reclaim.reclaim(java.time.Instant.now());
79 +
80 + assertTrue(runners.findByUuid(reg.runner().uuid).isEmpty(),
81 + "ephemeral runner removed even when its task is reclaimed as a zombie");
82 + }
83 +
84 + private RunnerRegistrationService.RegisteredRunner registerEphemeral()
85 + {
86 + String token = runnerService.createRegistrationToken(persistUser("ep-admin-" + shortId())).plaintext();
87 + return runnerService.register(token, "ep-runner", List.of(), "v4.0.0", true);
88 + }
89 +
90 + @Transactional
91 + long seedClaimedTask(String repoName, String runnerUuid)
92 + {
93 + User owner = persistUser(repoName + "-" + shortId());
94 + Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
95 + CiRunner runner = runners.findByUuid(runnerUuid).orElseThrow();
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.status = ActionRun.Status.RUNNING;
106 + run.persist();
107 +
108 + ActionTask task = new ActionTask();
109 + task.run = run;
110 + task.name = "build";
111 + task.jobId = "build";
112 + task.payload = "on: push";
113 + task.runner = runner;
114 + task.status = ActionRun.Status.RUNNING;
115 + task.persist();
116 + em.flush();
117 + return task.seq;
118 + }
119 +
120 + @Transactional
121 + void seedOverdueClaimedTask(String repoName, String runnerUuid)
122 + {
123 + User owner = persistUser(repoName + "-" + shortId());
124 + Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
125 + CiRunner runner = runners.findByUuid(runnerUuid).orElseThrow();
126 +
127 + ActionRun run = new ActionRun();
128 + run.repository = repo;
129 + run.number = runs.maxNumber(repo) + 1;
130 + run.workflowName = "CI";
131 + run.workflowFile = ".forgejo/workflows/ci.yml";
132 + run.event = "push";
133 + run.ref = "refs/heads/main";
134 + run.commitSha = "0000000000000000000000000000000000000000";
135 + run.status = ActionRun.Status.RUNNING;
136 + run.persist();
137 +
138 + ActionTask task = new ActionTask();
139 + task.run = run;
140 + task.name = "build";
141 + task.jobId = "build";
142 + task.payload = "on: push";
143 + task.runner = runner;
144 + task.status = ActionRun.Status.RUNNING;
145 + task.deadline = java.time.Instant.now().minusSeconds(300);
146 + task.persist();
147 + }
148 +
149 + @Transactional
150 + User persistUser(String name)
151 + {
152 + User user = new User();
153 + user.oidcSub = name;
154 + user.username = name;
155 + user.persist();
156 + return user;
157 + }
158 +
159 + private static String shortId()
160 + {
161 + return UUID.randomUUID().toString().substring(0, 8);
162 + }
163 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog