gitshark

Clone repository

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

← Commits

✨ (ci): Verify the run loop against a real act_runner

7b384736d76c0ddf9c131c53e1348408934fe9d2 · Michael Hainz · 2026-07-22T11:06:43Z

Changes

5 files changed, +240 -10

MODIFY docs/maintainers/ci-runners.md +12 -6
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
index 929beec..6606d17 100644
--- a/docs/maintainers/ci-runners.md
+++ b/docs/maintainers/ci-runners.md
@@ -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
diff --git a/pom.xml b/pom.xml
index 10e0679..25cdd86 100644
--- a/pom.xml
+++ b/pom.xml
@@ -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
diff --git a/src/main/java/de/workaround/ci/ConnectRunnerResource.java b/src/main/java/de/workaround/ci/ConnectRunnerResource.java
index 62a325f..46b51b6 100644
--- a/src/main/java/de/workaround/ci/ConnectRunnerResource.java
+++ b/src/main/java/de/workaround/ci/ConnectRunnerResource.java
@@ -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
diff --git a/src/test/java/de/workaround/ci/ForgejoRunnerRoundTripTest.java b/src/test/java/de/workaround/ci/ForgejoRunnerRoundTripTest.java
new file mode 100644
index 0000000..8bee5b5
--- /dev/null
+++ b/src/test/java/de/workaround/ci/ForgejoRunnerRoundTripTest.java
@@ -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
diff --git a/src/test/java/de/workaround/model/ActionRunPersistenceTest.java b/src/test/java/de/workaround/model/ActionRunPersistenceTest.java
index 30fc394..4df8a71 100644
--- a/src/test/java/de/workaround/model/ActionRunPersistenceTest.java
+++ b/src/test/java/de/workaround/model/ActionRunPersistenceTest.java
@@ -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)

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog