✨ (ci): Verify the run loop against a real act_runner
Changes
5 files changed, +240 -10
MODIFY
docs/maintainers/ci-runners.md
+12 -6
@@ -65,8 +65,10 @@
65
65
one transaction) — task+run flip to RUNNING, the runner goes ACTIVE, `action_task.deadline` is set,
66
66
and the task is delivered with its surrogate int64 `seq` id and `workflow_payload`. The candidate
67
67
row is locked `FOR UPDATE SKIP LOCKED` (id-only select, to keep the lock off the nullable `runner`
68
- join) so concurrent fetchers never claim the same task. Auth failures return the Connect
69
- `unauthenticated` error. No long-poll; `tasks_version` is a coarse max-`seq`.
68
+ join) so concurrent fetchers never claim the same task. The delivered `Task` carries a `github.*`
69
+ context (`job`, `ref`, `sha`, `repository`, `run_id`, …) built in `ConnectRunnerResource.toProto` —
70
+ without it the runner cannot select the job from the workflow and nil-derefs. Auth failures return
71
+ the Connect `unauthenticated` error. No long-poll; `tasks_version` is a coarse max-`seq`.
70
72
- **`UpdateTask` / `UpdateLog` progress (`TaskProgressService`):** UpdateTask records the reported
71
73
result, sends a finished task's runner back to IDLE, and rolls the owning run's status up from all
72
74
its tasks (RUNNING until every task is terminal, then the worst outcome). UpdateLog appends log rows
@@ -90,18 +92,22 @@
90
92
(workflow, run number, status, event, short commit) and a run detail page with each job and its
91
93
streamed log rows. Read-gated like the rest of the repo UI (404 for a hidden repo). Tested by
92
94
`ActionUiTest` (list shows runs + tab, detail shows jobs and logs, unknown run number → 404).
95
+- **Real-runner round-trip:** `ForgejoRunnerRoundTripTest` starts an actual `gitea/act_runner`
96
+ container (Testcontainers, `:host` execution so no docker-in-docker), which registers, fetches a
97
+ queued task over the Connect protocol, runs its `run:` step, and reports SUCCESS with streamed
98
+ logs — exercising Register/Declare/FetchTask/UpdateTask/UpdateLog against the genuine client. Needs
99
+ a Docker daemon; self-skips otherwise.
93
100
94
101
## What still needs to be implemented
95
102
96
103
- **Long-poll & real `tasks_version`:** `FetchTask` returns immediately and `tasks_version` is a
97
104
coarse max-`seq` (bumps on creation, not state change), so with several simultaneous PENDING tasks a
98
105
runner may under-poll. Add server-side long-poll and a state-driven version counter.
99
-- **Per-job payload expansion:** ingest/dispatch deliver the raw workflow YAML as `workflow_payload`;
100
- it needs the single job isolated/expanded. No `needs`/`matrix` yet.
106
+- **Per-job payload expansion:** `workflow_payload` is the raw workflow YAML (fine while a workflow
107
+ has a single job, which the `github.job` context selects); a multi-job workflow needs each job
108
+ isolated/expanded into its own payload. No `needs`/`matrix` yet.
101
109
- **Trigger refinement:** only bare `on: push` is honored; branch/tag/path filters and other events
102
110
(tag push, `pull_request`) are not evaluated.
103
-- **Real-runner integration test:** protocol round-trip against an actual `forgejo-runner` container
104
- (the current endpoint test uses a hand-built protobuf client, not the binary).
105
111
- **Later phases:** secrets/variables delivery, label-based matching, concurrency/cancellation,
106
112
artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and ephemeral runners, commit/MR status.
107
113
MODIFY
pom.xml
+5 -0
@@ -174,6 +174,11 @@
174
174
<scope>test</scope>
175
175
</dependency>
176
176
<dependency>
177
+ <groupId>org.testcontainers</groupId>
178
+ <artifactId>testcontainers</artifactId>
179
+ <scope>test</scope>
180
+ </dependency>
181
+ <dependency>
177
182
<groupId>io.rest-assured</groupId>
178
183
<artifactId>rest-assured</artifactId>
179
184
<scope>test</scope>
MODIFY
src/main/java/de/workaround/ci/ConnectRunnerResource.java
+41 -0
@@ -6,6 +6,8 @@
6
6
7
7
import com.google.protobuf.ByteString;
8
8
import com.google.protobuf.InvalidProtocolBufferException;
9
+import com.google.protobuf.Struct;
10
+import com.google.protobuf.Value;
9
11
10
12
import de.workaround.ci.proto.ping.v1.PingRequest;
11
13
import de.workaround.ci.proto.ping.v1.PingResponse;
@@ -23,8 +25,10 @@
23
25
import de.workaround.ci.proto.runner.v1.UpdateLogResponse;
24
26
import de.workaround.ci.proto.runner.v1.UpdateTaskRequest;
25
27
import de.workaround.ci.proto.runner.v1.UpdateTaskResponse;
28
+import de.workaround.model.ActionRun;
26
29
import de.workaround.model.ActionTask;
27
30
import de.workaround.model.CiRunner;
31
+import de.workaround.model.Repository;
28
32
import jakarta.inject.Inject;
29
33
import jakarta.ws.rs.Consumes;
30
34
import jakarta.ws.rs.HeaderParam;
@@ -180,9 +184,46 @@
180
184
{
181
185
builder.setWorkflowPayload(ByteString.copyFromUtf8(task.payload));
182
186
}
187
+ builder.setContext(githubContext(task));
183
188
return builder.build();
184
189
}
185
190
191
+ /**
192
+ * The GitHub-Actions {@code github.*} context the runner needs to execute the task. Without it the
193
+ * runner cannot pick the job from the workflow ({@code github.job}) and dereferences a nil context.
194
+ * Phase 1 supplies the identity/ref fields a plain {@code run:} job needs; richer fields (tokens,
195
+ * event payloads) arrive with secrets/variables support.
196
+ */
197
+ private static Struct githubContext(ActionTask task)
198
+ {
199
+ ActionRun run = task.run;
200
+ Repository repo = run.repository;
201
+ String prefix = "refs/heads/";
202
+ String refName = run.ref.startsWith(prefix) ? run.ref.substring(prefix.length()) : run.ref;
203
+ String fullName = repo.ownerHandle() + "/" + repo.name;
204
+ Struct.Builder context = Struct.newBuilder();
205
+ putString(context, "token", "");
206
+ putString(context, "actor", run.triggeredBy != null ? run.triggeredBy.username : "ghost");
207
+ putString(context, "run_id", String.valueOf(task.seq));
208
+ putString(context, "run_number", String.valueOf(run.number));
209
+ putString(context, "run_attempt", "1");
210
+ putString(context, "job", task.name);
211
+ putString(context, "ref", run.ref);
212
+ putString(context, "ref_name", refName);
213
+ putString(context, "ref_type", "branch");
214
+ putString(context, "sha", run.commitSha);
215
+ putString(context, "repository", fullName);
216
+ putString(context, "repository_owner", repo.ownerHandle());
217
+ putString(context, "event_name", run.event);
218
+ context.putFields("event", Value.newBuilder().setStructValue(Struct.getDefaultInstance()).build());
219
+ return context.build();
220
+ }
221
+
222
+ private static void putString(Struct.Builder context, String key, String value)
223
+ {
224
+ context.putFields(key, Value.newBuilder().setStringValue(value == null ? "" : value).build());
225
+ }
226
+
186
227
private static Runner toProto(CiRunner runner, String plaintextSecret)
187
228
{
188
229
Runner.Builder builder = Runner.newBuilder()
ADD
src/test/java/de/workaround/ci/ForgejoRunnerRoundTripTest.java
+174 -0
@@ -0,0 +1,174 @@
1
+package de.workaround.ci;
2
+
3
+import java.time.Duration;
4
+import java.util.List;
5
+import java.util.UUID;
6
+
7
+import org.junit.jupiter.api.Assumptions;
8
+import org.junit.jupiter.api.Test;
9
+import org.testcontainers.DockerClientFactory;
10
+import org.testcontainers.Testcontainers;
11
+import org.testcontainers.containers.GenericContainer;
12
+import org.testcontainers.containers.output.Slf4jLogConsumer;
13
+import org.testcontainers.containers.wait.strategy.Wait;
14
+
15
+import org.jboss.logging.Logger;
16
+
17
+import de.workaround.git.GitRepositoryService;
18
+import de.workaround.model.ActionLog;
19
+import de.workaround.model.ActionRun;
20
+import de.workaround.model.ActionTask;
21
+import de.workaround.model.Repository;
22
+import de.workaround.model.User;
23
+import io.quarkus.test.junit.QuarkusTest;
24
+import jakarta.inject.Inject;
25
+import jakarta.transaction.Transactional;
26
+
27
+import static org.junit.jupiter.api.Assertions.assertEquals;
28
+import static org.junit.jupiter.api.Assertions.assertTrue;
29
+
30
+/**
31
+ * End-to-end round-trip against a real {@code gitea/act_runner} binary (issue #2, phase 1 acceptance).
32
+ * A run is queued, an actual runner container registers against this instance, fetches the task over
33
+ * the Connect protocol, executes it, and reports state + logs back — exercising Register / FetchTask /
34
+ * UpdateTask / UpdateLog against the genuine client rather than a hand-built protobuf stub.
35
+ *
36
+ * <p>Runs the job in the runner's own container ({@code :host} label) so no docker-in-docker is
37
+ * needed. Self-skips when no Docker daemon is available.
38
+ */
39
+@QuarkusTest
40
+class ForgejoRunnerRoundTripTest
41
+{
42
+ private static final Logger LOG = Logger.getLogger(ForgejoRunnerRoundTripTest.class);
43
+
44
+ private static final org.slf4j.Logger RUNNER_LOG = org.slf4j.LoggerFactory.getLogger("act_runner");
45
+
46
+ private static final String RUNNER_IMAGE = "gitea/act_runner:0.2.11";
47
+
48
+ private static final int APP_PORT = 8081;
49
+
50
+ private static final String WORKFLOW = """
51
+ name: CI
52
+ on: push
53
+ jobs:
54
+ build:
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - run: echo "hello from git-shark ci"
58
+ """;
59
+
60
+ @Inject
61
+ RunnerRegistrationService runnerService;
62
+
63
+ @Inject
64
+ GitRepositoryService repositories;
65
+
66
+ @Inject
67
+ ActionRun.Repo runs;
68
+
69
+ @Inject
70
+ ActionTask.Repo tasks;
71
+
72
+ @Inject
73
+ ActionLog.Repo logs;
74
+
75
+ @Test
76
+ void realRunnerFetchesRunsAndReportsBack() throws Exception
77
+ {
78
+ Assumptions.assumeTrue(DockerClientFactory.instance().isDockerAvailable(), "Docker required for runner round-trip");
79
+
80
+ UUID runId = seedRun();
81
+ String registrationToken = mintRegistrationToken();
82
+
83
+ Testcontainers.exposeHostPorts(APP_PORT);
84
+ try (GenericContainer<?> runner = new GenericContainer<>(RUNNER_IMAGE)
85
+ .withEnv("GITEA_INSTANCE_URL", "http://host.testcontainers.internal:" + APP_PORT)
86
+ .withEnv("GITEA_RUNNER_REGISTRATION_TOKEN", registrationToken)
87
+ .withEnv("GITEA_RUNNER_NAME", "it-runner")
88
+ .withEnv("GITEA_RUNNER_LABELS", "ubuntu-latest:host")
89
+ .withLogConsumer(new Slf4jLogConsumer(RUNNER_LOG))
90
+ .waitingFor(Wait.forLogMessage(".*(Starting runner daemon|Runner registered).*", 1))
91
+ .withStartupTimeout(Duration.ofMinutes(3)))
92
+ {
93
+ runner.start();
94
+
95
+ Status status = pollUntilTerminal(runId, Duration.ofMinutes(2));
96
+ LOG.infof("Final run status: %s (%d log rows)", status.status(), status.logRows());
97
+
98
+ assertTrue(status.status().isTerminal(),
99
+ "the real runner should drive the run to a terminal state, was " + status.status());
100
+ assertEquals(ActionRun.Status.SUCCESS, status.status(), "the echo job should succeed");
101
+ assertTrue(status.logRows() > 0, "the runner should have streamed log rows via UpdateLog");
102
+ }
103
+ }
104
+
105
+ private Status pollUntilTerminal(UUID runId, Duration timeout) throws InterruptedException
106
+ {
107
+ long deadline = System.nanoTime() + timeout.toNanos();
108
+ Status last = status(runId);
109
+ while (!last.status().isTerminal() && System.nanoTime() < deadline)
110
+ {
111
+ Thread.sleep(2000);
112
+ last = status(runId);
113
+ }
114
+ return last;
115
+ }
116
+
117
+ private record Status(ActionRun.Status status, long logRows)
118
+ {
119
+ }
120
+
121
+ @Transactional
122
+ Status status(UUID runId)
123
+ {
124
+ ActionRun run = runs.findById(runId);
125
+ List<ActionTask> runTasks = tasks.findByRun(run);
126
+ long rows = runTasks.stream().mapToLong(t -> logs.findByTask(t).size()).sum();
127
+ return new Status(run.status, rows);
128
+ }
129
+
130
+ @Transactional
131
+ String mintRegistrationToken()
132
+ {
133
+ String name = "it-admin-" + shortId();
134
+ User admin = new User();
135
+ admin.oidcSub = name;
136
+ admin.username = name;
137
+ admin.persist();
138
+ return runnerService.createRegistrationToken(admin).plaintext();
139
+ }
140
+
141
+ @Transactional
142
+ UUID seedRun()
143
+ {
144
+ String name = "it-owner-" + shortId();
145
+ User owner = new User();
146
+ owner.oidcSub = name;
147
+ owner.username = name;
148
+ owner.persist();
149
+ Repository repo = repositories.create(owner, "it-repo", Repository.Visibility.PUBLIC, null);
150
+
151
+ ActionRun run = new ActionRun();
152
+ run.repository = repo;
153
+ run.number = 1;
154
+ run.workflowName = "CI";
155
+ run.workflowFile = ".forgejo/workflows/ci.yml";
156
+ run.event = "push";
157
+ run.ref = "refs/heads/main";
158
+ run.commitSha = "0000000000000000000000000000000000000000";
159
+ run.persist();
160
+
161
+ ActionTask task = new ActionTask();
162
+ task.run = run;
163
+ task.name = "build";
164
+ task.payload = WORKFLOW;
165
+ task.persist();
166
+ return run.id;
167
+ }
168
+
169
+ private static String shortId()
170
+ {
171
+ return UUID.randomUUID().toString().substring(0, 8);
172
+ }
173
+
174
+}
MODIFY
src/test/java/de/workaround/model/ActionRunPersistenceTest.java
+8 -4
@@ -96,7 +96,7 @@
96
96
97
97
@Test
98
98
@TestTransaction
99
- void findsOldestPendingTaskForDispatch()
99
+ void listPendingReturnsOnlyPendingTasks()
100
100
{
101
101
Repository repo = newRepo("delta");
102
102
ActionRun run = newRun(repo, 1);
@@ -112,9 +112,13 @@
112
112
pending.name = "new";
113
113
pending.persist();
114
114
115
- ActionTask next = tasks.findOldestPending().orElseThrow();
116
- assertEquals("new", next.name);
117
- assertTrue(next.status == ActionRun.Status.PENDING);
115
+ // listPending() is global; assert on membership by id so other tests' committed rows don't matter
116
+ List<ActionTask> queued = tasks.listPending();
117
+ assertTrue(queued.stream().anyMatch(t -> t.id.equals(pending.id)), "the PENDING task is queued");
118
+ assertTrue(queued.stream().noneMatch(t -> t.id.equals(done.id)), "the finished task is not queued");
119
+
120
+ // within a run, tasks come back oldest-first
121
+ assertEquals(List.of("old", "new"), tasks.findByRun(run).stream().map(t -> t.name).toList());
118
122
}
119
123
120
124
private ActionRun newRun(Repository repo, int number)