✨ (ci): Serve FetchTask so runners claim pending tasks
Changes
7 files changed, +387 -8
MODIFY
docs/admins/ci-runners.md
+3 -2
@@ -89,10 +89,11 @@
89
89
| `ci_runner_registration_token` | Reusable registration tokens: `token_hash`, `created_by_id`, `created_at`, `last_used`. |
90
90
| `ci_runner` | Registered runners: `uuid` (the `x-runner-uuid` value), `token_hash`, `name`, `labels` (comma-joined), `version`, `status` (`IDLE`/`ACTIVE`/`OFFLINE`/`UNSPECIFIED`), `ephemeral`, `last_seen`, `created_at`. |
91
91
| `action_run` | One workflow run per repository: `number` (per-repo sequential), `workflow_name`, `workflow_file`, `event`, `ref`, `commit_sha`, `triggered_by_id`, `status` (`PENDING`/`RUNNING`/`SUCCESS`/`FAILURE`/`CANCELLED`), timestamps. Deleted with its repository. |
92
-| `action_task` | One job within a run: `run_id`, `name`, `payload`, `runner_id` (the claiming runner, null while pending), `status`, `log_length` (durable log-row count = UpdateLog resume offset), `deadline` (zombie timeout), timestamps. Deleted with its run. |
92
+| `action_task` | One job within a run: `seq` (surrogate int64 id handed to runners), `run_id`, `name`, `payload`, `runner_id` (the claiming runner, null while pending), `status`, `log_length` (durable log-row count = UpdateLog resume offset), `deadline` (zombie timeout), timestamps. Deleted with its run. |
93
93
| `action_log` | One log row of a task: `task_id`, `line_index` (0-based), `content`, `timestamp`. Deleted with its task. |
94
94
95
-`ci_runner*` are introduced by migration `V19__ci_runners.sql`; `action_*` by `V23__action_runs.sql`.
95
+`ci_runner*` are introduced by migration `V19__ci_runners.sql`; `action_*` by `V23__action_runs.sql`
96
+(and `V24__action_task_seq.sql` adds the `action_task.seq` surrogate id).
96
97
The `ci_runner*` tables hold no repository data (losing them only forces re-registration); the
97
98
`action_*` tables hold run history and logs, tied to their repository by cascade.
98
99
MODIFY
docs/maintainers/ci-runners.md
+16 -6
@@ -12,8 +12,9 @@
12
12
| Generated messages | `de.workaround.ci.proto.*` (build output) | Protobuf message classes generated by `protobuf-maven-plugin` at `generate-sources`. |
13
13
| Connect endpoint | `ci/ConnectRunnerResource.java` | JAX-RS resource serving the Connect unary RPCs under `/api/actions`. |
14
14
| Registration/presence | `ci/RunnerRegistrationService.java` | Token issue, runner register/declare/authenticate, list/delete. |
15
+| Task dispatch | `ci/TaskDispatchService.java` | FetchTask: authenticate, claim the oldest PENDING task, flip task+run to RUNNING, set the runner ACTIVE and the task deadline — atomically. |
15
16
| Entities | `model/CiRunner.java`, `model/CiRunnerRegistrationToken.java` | Runner state (migration `V19`). |
16
-| Run entities | `model/ActionRun.java`, `model/ActionTask.java`, `model/ActionLog.java` | Run/job/log-row persistence (migration `V23`). |
17
+| Run entities | `model/ActionRun.java`, `model/ActionTask.java`, `model/ActionLog.java` | Run/job/log-row persistence (migrations `V23`, `V24`). `ActionTask.seq` (`bigserial`) is the surrogate int64 `Task.id` for the wire. |
17
18
| Workflow ingest | `ci/WorkflowIngestService.java`, `ci/WorkflowRunFactory.java` | Post-receive hook: parse `.forgejo`/`.gitea` workflows at the pushed head, evaluate `on: push`, persist a run + its tasks. Rows are created but not yet dispatched. |
18
19
| Admin UI | `ci/AdminRunnerResource.java` + `templates/AdminRunnerResource/` | Token generation, runner list, deletion. |
19
20
| Admin gate | `account/AdminAccess.java` | Config-driven instance-admin check. |
@@ -57,17 +58,26 @@
57
58
updated branch, parses them (Jackson `YAMLMapper`), and for those triggered by `push` persists one
58
59
`action_run` (per-repo `number`, PENDING) with one PENDING `action_task` per job via
59
60
`WorkflowRunFactory` (`@Transactional`). Handles the YAML-1.1 `on:`→boolean-`true` key coercion.
61
+- **`FetchTask` dispatch:** a registered runner claims the oldest PENDING task (`TaskDispatchService`,
62
+ one transaction) — task+run flip to RUNNING, the runner goes ACTIVE, `action_task.deadline` is set,
63
+ and the task is delivered with its surrogate int64 `seq` id and `workflow_payload`. The candidate
64
+ row is locked `FOR UPDATE SKIP LOCKED` (id-only select, to keep the lock off the nullable `runner`
65
+ join) so concurrent fetchers never claim the same task. Auth failures return the Connect
66
+ `unauthenticated` error. No long-poll; `tasks_version` is a coarse max-`seq`.
60
67
- Tests: `RunnerRegistrationServiceTest` (service), `ConnectRunnerResourceTest` (protobuf-over-HTTP
61
68
round-trip for Ping/Register/Declare + auth failures), `AdminAccessTest` (admin gate),
62
69
`ActionRunPersistenceTest` (run/task/log persistence, per-repo run numbering, pending-task lookup),
63
- `WorkflowIngestServiceTest` (push → run/task creation, non-push trigger and no-workflow are no-ops).
70
+ `WorkflowIngestServiceTest` (push → run/task creation, non-push trigger and no-workflow are no-ops),
71
+ `FetchTaskTest` (claim oldest pending over the wire, empty queue, bad credentials, and two runners
72
+ racing one task → claimed at most once).
64
73
65
74
## What still needs to be implemented
66
75
67
-- **Run loop:** `FetchTask` (long-poll with `tasks_version`), `UpdateTask`, `UpdateLog` (offset /
68
- `ack_index` resume). Not served yet — the ingested PENDING tasks are the queue it will drain.
69
-- **Per-job payload expansion:** ingest stores the raw workflow YAML in `action_task.payload`;
70
- `Task.workflow_payload` needs the single job isolated/expanded. No `needs`/`matrix` yet.
76
+- **Run loop (remaining):** `UpdateTask` (step/task state) and `UpdateLog` (offset / `ack_index`
77
+ resume). `FetchTask` is served; these two report progress back. Add long-poll and a real
78
+ state-driven `tasks_version` when they land.
79
+- **Per-job payload expansion:** ingest/dispatch deliver the raw workflow YAML as `workflow_payload`;
80
+ it needs the single job isolated/expanded. No `needs`/`matrix` yet.
71
81
- **Trigger refinement:** only bare `on: push` is honored; branch/tag/path filters and other events
72
82
(tag push, `pull_request`) are not evaluated.
73
83
- **Run UI:** per-repository run list + run detail with live per-step status and logs.
MODIFY
src/main/java/de/workaround/ci/ConnectRunnerResource.java
+38 -0
@@ -3,16 +3,21 @@
3
3
import java.util.Arrays;
4
4
import java.util.List;
5
5
6
+import com.google.protobuf.ByteString;
6
7
import com.google.protobuf.InvalidProtocolBufferException;
7
8
8
9
import de.workaround.ci.proto.ping.v1.PingRequest;
9
10
import de.workaround.ci.proto.ping.v1.PingResponse;
10
11
import de.workaround.ci.proto.runner.v1.DeclareRequest;
11
12
import de.workaround.ci.proto.runner.v1.DeclareResponse;
13
+import de.workaround.ci.proto.runner.v1.FetchTaskRequest;
14
+import de.workaround.ci.proto.runner.v1.FetchTaskResponse;
12
15
import de.workaround.ci.proto.runner.v1.RegisterRequest;
13
16
import de.workaround.ci.proto.runner.v1.RegisterResponse;
14
17
import de.workaround.ci.proto.runner.v1.Runner;
15
18
import de.workaround.ci.proto.runner.v1.RunnerStatus;
19
+import de.workaround.ci.proto.runner.v1.Task;
20
+import de.workaround.model.ActionTask;
16
21
import de.workaround.model.CiRunner;
17
22
import jakarta.inject.Inject;
18
23
import jakarta.ws.rs.Consumes;
@@ -43,6 +48,9 @@
43
48
@Inject
44
49
RunnerRegistrationService runnerService;
45
50
51
+ @Inject
52
+ TaskDispatchService dispatchService;
53
+
46
54
@POST
47
55
@Path("/ping.v1.PingService/Ping")
48
56
public Response ping(byte[] body) throws InvalidProtocolBufferException
@@ -89,6 +97,36 @@
89
97
}
90
98
}
91
99
100
+ @POST
101
+ @Path("/runner.v1.RunnerService/FetchTask")
102
+ public Response fetchTask(@HeaderParam("x-runner-uuid") String uuid, @HeaderParam("x-runner-token") String token,
103
+ byte[] body) throws InvalidProtocolBufferException
104
+ {
105
+ FetchTaskRequest.parseFrom(body); // tasks_version is advisory; phase 1 always checks the queue
106
+ try
107
+ {
108
+ TaskDispatchService.Fetched fetched = dispatchService.fetch(uuid, token);
109
+ FetchTaskResponse.Builder response = FetchTaskResponse.newBuilder()
110
+ .setTasksVersion(fetched.tasksVersion());
111
+ fetched.task().ifPresent(task -> response.setTask(toProto(task)));
112
+ return ok(response.build().toByteArray());
113
+ }
114
+ catch (RunnerAuthenticationException e)
115
+ {
116
+ return connectError(Response.Status.UNAUTHORIZED, "unauthenticated", e.getMessage());
117
+ }
118
+ }
119
+
120
+ private static Task toProto(ActionTask task)
121
+ {
122
+ Task.Builder builder = Task.newBuilder().setId(task.seq);
123
+ if (task.payload != null)
124
+ {
125
+ builder.setWorkflowPayload(ByteString.copyFromUtf8(task.payload));
126
+ }
127
+ return builder.build();
128
+ }
129
+
92
130
private static Runner toProto(CiRunner runner, String plaintextSecret)
93
131
{
94
132
Runner.Builder builder = Runner.newBuilder()
ADD
src/main/java/de/workaround/ci/TaskDispatchService.java
+98 -0
@@ -0,0 +1,98 @@
1
+package de.workaround.ci;
2
+
3
+import java.time.Duration;
4
+import java.time.Instant;
5
+import java.util.List;
6
+import java.util.Optional;
7
+import java.util.UUID;
8
+
9
+import de.workaround.model.ActionRun;
10
+import de.workaround.model.ActionTask;
11
+import de.workaround.model.CiRunner;
12
+import jakarta.enterprise.context.ApplicationScoped;
13
+import jakarta.inject.Inject;
14
+import jakarta.persistence.EntityManager;
15
+import jakarta.transaction.Transactional;
16
+
17
+/**
18
+ * Hands PENDING tasks to runners over FetchTask (issue #2, phase 1). Claiming a task flips it (and its
19
+ * run) to RUNNING, records the claiming runner and a {@link #TASK_TIMEOUT}-based {@link
20
+ * ActionTask#deadline} for later zombie reclaim, and marks the runner ACTIVE — all in one transaction
21
+ * so a task is never handed to two runners: the candidate row is selected {@code FOR UPDATE SKIP
22
+ * LOCKED}, so concurrent fetchers pick distinct rows (or none) rather than racing on the same one.
23
+ *
24
+ * <p>Phase-1 scope: no long-poll (returns immediately, empty when the queue is drained) and a coarse
25
+ * {@code tasks_version} = highest task id issued (bumps on task creation, not on state change).
26
+ */
27
+@ApplicationScoped
28
+public class TaskDispatchService
29
+{
30
+ /** How long a claimed task may run before it is eligible for zombie reclaim (reclaim itself is a later slice). */
31
+ static final Duration TASK_TIMEOUT = Duration.ofHours(1);
32
+
33
+ @Inject
34
+ RunnerRegistrationService runnerService;
35
+
36
+ @Inject
37
+ ActionTask.Repo tasks;
38
+
39
+ @Inject
40
+ EntityManager em;
41
+
42
+ public record Fetched(Optional<ActionTask> task, long tasksVersion)
43
+ {
44
+ }
45
+
46
+ /**
47
+ * Authenticate the runner and claim the oldest PENDING task, if any.
48
+ *
49
+ * @throws RunnerAuthenticationException if the uuid/token pair is unknown
50
+ */
51
+ @Transactional
52
+ public Fetched fetch(String uuid, String token)
53
+ {
54
+ CiRunner runner = runnerService.authenticate(uuid, token);
55
+ runner.lastSeen = Instant.now();
56
+
57
+ Optional<ActionTask> next = lockOldestPending().map(id ->
58
+ {
59
+ ActionTask task = tasks.findById(id);
60
+ claim(task, runner);
61
+ return task;
62
+ });
63
+ return new Fetched(next, tasks.maxSeq());
64
+ }
65
+
66
+ /**
67
+ * Lock the oldest PENDING task's id with {@code FOR UPDATE SKIP LOCKED} so a concurrent fetcher in
68
+ * another transaction cannot claim the same row. Selecting only the id keeps the {@code FOR UPDATE}
69
+ * off the nullable {@code runner} association (Postgres rejects it on the nullable side of a join).
70
+ */
71
+ private Optional<UUID> lockOldestPending()
72
+ {
73
+ @SuppressWarnings("unchecked")
74
+ List<UUID> ids = em.createNativeQuery(
75
+ "select id from action_task where status = 'PENDING' order by created_at asc limit 1 for update skip locked")
76
+ .getResultList();
77
+ return ids.isEmpty() ? Optional.empty() : Optional.of(ids.get(0));
78
+ }
79
+
80
+ private static void claim(ActionTask task, CiRunner runner)
81
+ {
82
+ Instant now = Instant.now();
83
+ task.runner = runner;
84
+ task.status = ActionRun.Status.RUNNING;
85
+ task.startedAt = now;
86
+ task.deadline = now.plus(TASK_TIMEOUT);
87
+
88
+ runner.status = CiRunner.Status.ACTIVE;
89
+
90
+ ActionRun run = task.run;
91
+ if (run.status == ActionRun.Status.PENDING)
92
+ {
93
+ run.status = ActionRun.Status.RUNNING;
94
+ run.startedAt = now;
95
+ }
96
+ }
97
+
98
+}
MODIFY
src/main/java/de/workaround/model/ActionTask.java
+15 -0
@@ -5,11 +5,14 @@
5
5
import java.util.Optional;
6
6
import java.util.UUID;
7
7
8
+import org.hibernate.annotations.Generated;
8
9
import org.hibernate.annotations.processing.Find;
9
10
import org.hibernate.annotations.processing.HQL;
11
+import org.hibernate.generator.EventType;
10
12
11
13
import io.quarkus.hibernate.panache.PanacheEntity;
12
14
import io.quarkus.hibernate.panache.PanacheRepository;
15
+import jakarta.persistence.Column;
13
16
import jakarta.persistence.Entity;
14
17
import jakarta.persistence.EnumType;
15
18
import jakarta.persistence.Enumerated;
@@ -37,6 +40,14 @@
37
40
@ManyToOne(optional = false)
38
41
public ActionRun run;
39
42
43
+ /**
44
+ * Surrogate, globally-unique int64 exposed as the runner.v1 {@code Task.id}; the runner echoes it
45
+ * in UpdateTask/UpdateLog. DB-generated ({@code bigserial}); read back after insert.
46
+ */
47
+ @Column(insertable = false, updatable = false)
48
+ @Generated(event = EventType.INSERT)
49
+ public long seq;
50
+
40
51
/** Job identifier from the workflow file, e.g. {@code build}. */
41
52
public String name;
42
53
@@ -77,6 +88,10 @@
77
88
78
89
@Find
79
90
Optional<ActionTask> findByIdAndRunner(UUID id, CiRunner runner);
91
+
92
+ /** The highest surrogate id issued so far; the coarse {@code tasks_version} handed to runners. */
93
+ @HQL("select coalesce(max(t.seq), 0) from ActionTask t")
94
+ long maxSeq();
80
95
}
81
96
82
97
}
ADD
src/main/resources/db/migration/V24__action_task_seq.sql
+11 -0
@@ -0,0 +1,11 @@
1
+-- Surrogate int64 task id for the runner.v1 protocol (issue #2, phase 1).
2
+--
3
+-- The Forgejo/Gitea Task message identifies a task by an int64 `id` that the runner echoes back in
4
+-- UpdateTask/UpdateLog. action_task uses a UUID primary key, so a separate stable, globally-unique
5
+-- int64 is exposed to the wire. bigserial gives us a NOT NULL sequence-backed column; existing rows
6
+-- (none in practice) are backfilled by the default.
7
+
8
+alter table action_task
9
+ add column seq bigserial;
10
+
11
+create unique index idx_action_task_seq on action_task (seq);
ADD
src/test/java/de/workaround/ci/FetchTaskTest.java
+206 -0
@@ -0,0 +1,206 @@
1
+package de.workaround.ci;
2
+
3
+import java.util.List;
4
+import java.util.UUID;
5
+import java.util.concurrent.Callable;
6
+import java.util.concurrent.CyclicBarrier;
7
+import java.util.concurrent.ExecutorService;
8
+import java.util.concurrent.Executors;
9
+import java.util.concurrent.Future;
10
+
11
+import org.junit.jupiter.api.Test;
12
+
13
+import com.google.protobuf.InvalidProtocolBufferException;
14
+
15
+import de.workaround.ci.proto.runner.v1.FetchTaskRequest;
16
+import de.workaround.ci.proto.runner.v1.FetchTaskResponse;
17
+import de.workaround.git.GitRepositoryService;
18
+import de.workaround.model.ActionRun;
19
+import de.workaround.model.ActionTask;
20
+import de.workaround.model.CiRunner;
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 io.restassured.RestAssured.given;
28
+import static org.junit.jupiter.api.Assertions.assertEquals;
29
+import static org.junit.jupiter.api.Assertions.assertFalse;
30
+import static org.junit.jupiter.api.Assertions.assertTrue;
31
+
32
+/**
33
+ * Drives FetchTask over the Connect wire (issue #2, phase 1): a registered runner claims the oldest
34
+ * PENDING task, which flips to RUNNING and is delivered with its surrogate int64 id and payload;
35
+ * an empty queue yields no task; bad credentials are rejected.
36
+ */
37
+@QuarkusTest
38
+class FetchTaskTest
39
+{
40
+ private static final String PROTO = "application/proto";
41
+
42
+ @Inject
43
+ RunnerRegistrationService runnerService;
44
+
45
+ @Inject
46
+ GitRepositoryService repositories;
47
+
48
+ @Inject
49
+ User.Repo users;
50
+
51
+ @Inject
52
+ ActionRun.Repo runs;
53
+
54
+ @Inject
55
+ ActionTask.Repo tasks;
56
+
57
+ @Test
58
+ void fetchClaimsOldestPendingTask() throws InvalidProtocolBufferException
59
+ {
60
+ RunnerRegistrationService.RegisteredRunner reg = registerRunner();
61
+ Repository repo = newRepo("ft-a");
62
+ UUID taskId = seedPendingTask(repo, "build", "on: push\njobs:\n build:\n runs-on: ubuntu-latest");
63
+
64
+ byte[] bytes = given()
65
+ .contentType(PROTO)
66
+ .header("x-runner-uuid", reg.runner().uuid)
67
+ .header("x-runner-token", reg.plaintext())
68
+ .body(FetchTaskRequest.newBuilder().setTasksVersion(0).build().toByteArray())
69
+ .when().post("/api/actions/runner.v1.RunnerService/FetchTask")
70
+ .then().statusCode(200)
71
+ .extract().asByteArray();
72
+
73
+ FetchTaskResponse response = FetchTaskResponse.parseFrom(bytes);
74
+ assertTrue(response.hasTask(), "a pending task must be handed out");
75
+ assertTrue(response.getTask().getId() > 0, "task carries its surrogate int64 id");
76
+ assertEquals("on: push\njobs:\n build:\n runs-on: ubuntu-latest",
77
+ response.getTask().getWorkflowPayload().toStringUtf8());
78
+
79
+ ActionTask claimed = tasks.findById(taskId);
80
+ assertEquals(ActionRun.Status.RUNNING, claimed.status);
81
+ assertEquals(response.getTask().getId(), claimed.seq);
82
+ assertEquals(reg.runner().uuid, claimed.runner.uuid);
83
+ assertEquals(ActionRun.Status.RUNNING, claimed.run.status);
84
+ assertEquals(CiRunner.Status.ACTIVE, claimed.runner.status);
85
+ }
86
+
87
+ @Test
88
+ void fetchWithEmptyQueueReturnsNoTask() throws InvalidProtocolBufferException
89
+ {
90
+ RunnerRegistrationService.RegisteredRunner reg = registerRunner();
91
+
92
+ byte[] bytes = given()
93
+ .contentType(PROTO)
94
+ .header("x-runner-uuid", reg.runner().uuid)
95
+ .header("x-runner-token", reg.plaintext())
96
+ .body(FetchTaskRequest.newBuilder().setTasksVersion(0).build().toByteArray())
97
+ .when().post("/api/actions/runner.v1.RunnerService/FetchTask")
98
+ .then().statusCode(200)
99
+ .extract().asByteArray();
100
+
101
+ FetchTaskResponse response = FetchTaskResponse.parseFrom(bytes);
102
+ assertFalse(response.hasTask(), "no pending task means an empty response");
103
+ }
104
+
105
+ @Test
106
+ void concurrentFetchesClaimTheSameTaskAtMostOnce() throws Exception
107
+ {
108
+ RunnerRegistrationService.RegisteredRunner a = registerRunner();
109
+ RunnerRegistrationService.RegisteredRunner b = registerRunner();
110
+ Repository repo = newRepo("ft-race");
111
+ seedPendingTask(repo, "build", "on: push\njobs:\n build:\n runs-on: ubuntu-latest");
112
+
113
+ CyclicBarrier start = new CyclicBarrier(2);
114
+ ExecutorService pool = Executors.newFixedThreadPool(2);
115
+ try
116
+ {
117
+ Future<Boolean> f1 = pool.submit(fetchGotTask(a, start));
118
+ Future<Boolean> f2 = pool.submit(fetchGotTask(b, start));
119
+ int handedOut = (f1.get() ? 1 : 0) + (f2.get() ? 1 : 0);
120
+ assertEquals(1, handedOut, "exactly one runner may claim the single pending task");
121
+ }
122
+ finally
123
+ {
124
+ pool.shutdownNow();
125
+ }
126
+ }
127
+
128
+ private Callable<Boolean> fetchGotTask(RunnerRegistrationService.RegisteredRunner runner, CyclicBarrier start)
129
+ {
130
+ return () ->
131
+ {
132
+ start.await();
133
+ byte[] bytes = given()
134
+ .contentType(PROTO)
135
+ .header("x-runner-uuid", runner.runner().uuid)
136
+ .header("x-runner-token", runner.plaintext())
137
+ .body(FetchTaskRequest.newBuilder().setTasksVersion(0).build().toByteArray())
138
+ .when().post("/api/actions/runner.v1.RunnerService/FetchTask")
139
+ .then().statusCode(200)
140
+ .extract().asByteArray();
141
+ return FetchTaskResponse.parseFrom(bytes).hasTask();
142
+ };
143
+ }
144
+
145
+ @Test
146
+ void fetchWithBadCredentialsIsUnauthenticated()
147
+ {
148
+ given()
149
+ .contentType(PROTO)
150
+ .header("x-runner-uuid", "nope")
151
+ .header("x-runner-token", "gsrt_bogus")
152
+ .body(FetchTaskRequest.newBuilder().setTasksVersion(0).build().toByteArray())
153
+ .when().post("/api/actions/runner.v1.RunnerService/FetchTask")
154
+ .then().statusCode(401);
155
+ }
156
+
157
+ private RunnerRegistrationService.RegisteredRunner registerRunner()
158
+ {
159
+ String regToken = runnerService.createRegistrationToken(persistUser("ft-admin-" + shortId())).plaintext();
160
+ return runnerService.register(regToken, "ft-runner", List.of("ubuntu-latest"), "v4.0.0", false);
161
+ }
162
+
163
+ private Repository newRepo(String name)
164
+ {
165
+ User owner = persistUser(name + "-" + shortId());
166
+ return repositories.create(owner, name, Repository.Visibility.PUBLIC, null);
167
+ }
168
+
169
+ @Transactional
170
+ UUID seedPendingTask(Repository repository, String jobName, String payload)
171
+ {
172
+ Repository repo = repositories.find(repository.ownerHandle(), repository.name).orElseThrow();
173
+ ActionRun run = new ActionRun();
174
+ run.repository = repo;
175
+ run.number = runs.maxNumber(repo) + 1;
176
+ run.workflowName = "CI";
177
+ run.workflowFile = ".forgejo/workflows/ci.yml";
178
+ run.event = "push";
179
+ run.ref = "refs/heads/main";
180
+ run.commitSha = "0000000000000000000000000000000000000000";
181
+ run.persist();
182
+
183
+ ActionTask task = new ActionTask();
184
+ task.run = run;
185
+ task.name = jobName;
186
+ task.payload = payload;
187
+ task.persist();
188
+ return task.id;
189
+ }
190
+
191
+ @Transactional
192
+ User persistUser(String name)
193
+ {
194
+ User user = new User();
195
+ user.oidcSub = name;
196
+ user.username = name;
197
+ user.persist();
198
+ return user;
199
+ }
200
+
201
+ private static String shortId()
202
+ {
203
+ return UUID.randomUUID().toString().substring(0, 8);
204
+ }
205
+
206
+}