✨ (ci): Deliver repo secrets and variables to runners
Changes
9 files changed, +392 -10
MODIFY
docs/admins/ci-runners.md
+5 -1
@@ -91,10 +91,14 @@
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
92
| `action_task` | One job within a run: `seq` (surrogate int64 id handed to runners), `run_id`, `name`, `runs_on` (comma-joined labels for runner matching, empty = any), `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
+| `action_secret` | Per-repo CI secret: `repository_id`, `name`, `value_encrypted` (SecretCrypto envelope), decrypted only when delivered to a runner. Deleted with its repository. |
95
+| `action_variable` | Per-repo CI variable: `repository_id`, `name`, `value` (plaintext config). Deleted with its repository. |
94
96
95
97
`ci_runner*` are introduced by migration `V19__ci_runners.sql`; `action_*` by `V23__action_runs.sql`
96
98
(`V24__action_task_seq.sql` adds `action_task.seq`, `V25__action_task_runs_on.sql` adds
97
-`action_task.runs_on`).
99
+`action_task.runs_on`, `V26__action_secrets_variables.sql` adds `action_secret`/`action_variable`).
100
+Secrets are stored encrypted and require `GITSHARK_SECRET_KEY` to be set (same key as push mirrors);
101
+without it, secrets cannot be decrypted and are omitted from what a runner receives.
98
102
The `ci_runner*` tables hold no repository data (losing them only forces re-registration); the
99
103
`action_*` tables hold run history and logs, tied to their repository by cascade.
100
104
MODIFY
docs/admins/getting-started.md
+1 -1
@@ -357,7 +357,7 @@
357
357
| `GITSHARK_SSH_HOST_KEY` | — | `data/ssh/host-key` | Persistent SSH host key path |
358
358
| `GITSHARK_SSH_PORT` | — | `2222` | Port the embedded SSH server **binds inside the container**; keep >1024 so it needs no root |
359
359
| `GITSHARK_SSH_EXTERNAL_PORT` | — | `22` | Port advertised in clone/push URLs (display only, no runtime effect). Must match the published host port; `22` is omitted from the printed URL |
360
-| `GITSHARK_SECRET_KEY` | — | — | Encrypts push-mirror secrets at rest; required to create mirrors (see [Push mirrors](mirrors.md)) |
360
+| `GITSHARK_SECRET_KEY` | — | — | Encrypts push-mirror secrets and CI secrets at rest; required to create mirrors and to deliver CI secrets to runners (see [Push mirrors](mirrors.md), [CI runners](ci-runners.md)) |
361
361
| `GITSHARK_MIRROR_MAX_ATTEMPTS` | — | `8` | Mirror-sync retry cap before dead-letter |
362
362
| `GITSHARK_MIRROR_ALLOW_INSECURE` | — | `false` | Dev only: allow http/loopback mirror targets |
363
363
| `GITSHARK_MIRROR_DRAIN_INTERVAL` | — | `10s` | How often the async mirror-sync drain worker runs |
MODIFY
docs/maintainers/ci-runners.md
+13 -3
@@ -18,6 +18,7 @@
18
18
| Actions UI | `web/ActionResource.java` + `templates/ActionResource/` | Read-only per-repo run list + run detail (jobs and their log rows); sidebar `Actions` tab. |
19
19
| Entities | `model/CiRunner.java`, `model/CiRunnerRegistrationToken.java` | Runner state (migration `V19`). |
20
20
| Run entities | `model/ActionRun.java`, `model/ActionTask.java`, `model/ActionLog.java` | Run/job/log-row persistence (migrations `V23`–`V25`). `ActionTask.seq` (`bigserial`) is the surrogate int64 `Task.id` for the wire; `ActionTask.runs_on` holds the job's labels for matching. |
21
+| Secret/variable entities | `model/ActionSecret.java`, `model/ActionVariable.java` | Per-repo CI secrets (encrypted) and variables (migration `V26`), delivered to runners in FetchTask. |
21
22
| 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 PENDING tasks (drained by FetchTask). |
22
23
| Admin UI | `ci/AdminRunnerResource.java` + `templates/AdminRunnerResource/` | Token generation, runner list, deletion. |
23
24
| Admin gate | `account/AdminAccess.java` | Config-driven instance-admin check. |
@@ -76,6 +77,11 @@
76
77
context (`job`, `ref`, `sha`, `repository`, `run_id`, …) built in `ConnectRunnerResource.toProto` —
77
78
without it the runner cannot select the job from the workflow and nil-derefs. Auth failures return
78
79
the Connect `unauthenticated` error. No long-poll; `tasks_version` is a coarse max-`seq`.
80
+- **Secret & variable delivery:** a claimed task is handed its repository's variables (plaintext) and
81
+ secrets (`action_secret`, stored with the `SecretCrypto` envelope, decrypted at delivery — a value
82
+ that fails to decrypt is dropped, never sent as ciphertext) in the FetchTask `Task.secrets`/`vars`
83
+ maps. Same trust model as GitHub self-hosted runners: secrets go to whatever runner claims the task
84
+ (over TLS). No repo/org scoping of secrets and no fork-PR guard yet (no PR triggers exist).
79
85
- **Label matching:** a task carries its job's `runs-on` labels (`action_task.runs_on`, parsed at
80
86
ingest). Dispatch scans PENDING tasks oldest-first and claims the first whose labels are all
81
87
advertised by the fetching runner (empty `runs-on` = any runner); an incompatible task is left for a
@@ -98,7 +104,9 @@
98
104
racing one task → claimed at most once), `TaskProgressTest` (UpdateTask success rolls up task+run
99
105
and frees the runner, UpdateLog append + dedup/resume, cross-runner and bad-credential rejection),
100
106
`LabelMatchingTest` (runner claims a compatible task and skips an incompatible older one, gets
101
- nothing when none match, unconstrained task runs anywhere).
107
+ nothing when none match, unconstrained task runs anywhere),
108
+ `SecretDeliveryTest` (claimed task receives repo secrets decrypted + variables; empty fetch carries
109
+ none).
102
110
- **Zombie reclaim (`ZombieReclaimService`):** a scheduled sweep
103
111
(`gitshark.ci.zombie-reclaim-interval`, default 1m) fails any RUNNING task whose
104
112
`action_task.deadline` has passed — the runner is presumed gone — rolls its run up, and flags the
@@ -125,8 +133,10 @@
125
133
isolated/expanded into its own payload. No `needs`/`matrix` yet.
126
134
- **Non-push events:** only `push` is evaluated; `pull_request`, scheduled and manual triggers are
127
135
not. (`!`-negation within a single pattern list is also not supported.)
128
-- **Later phases:** secrets/variables delivery, concurrency/cancellation, artifacts
129
- (`ACTIONS_RESULTS_URL`), repo/org-scoped and ephemeral runners, commit/MR status.
136
+- **Secrets/variables management UI:** delivery works, but there is no page yet to create/edit/delete
137
+ them (tests seed the rows directly).
138
+- **Later phases:** concurrency/cancellation, artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and
139
+ ephemeral runners, commit/MR status.
130
140
131
141
## References
132
142
MODIFY
src/main/java/de/workaround/ci/ConnectRunnerResource.java
+5 -2
@@ -3,6 +3,7 @@
3
3
import java.time.Instant;
4
4
import java.util.Arrays;
5
5
import java.util.List;
6
+import java.util.Map;
6
7
7
8
import com.google.protobuf.ByteString;
8
9
import com.google.protobuf.InvalidProtocolBufferException;
@@ -121,7 +122,7 @@
121
122
TaskDispatchService.Fetched fetched = dispatchService.fetch(uuid, token);
122
123
FetchTaskResponse.Builder response = FetchTaskResponse.newBuilder()
123
124
.setTasksVersion(fetched.tasksVersion());
124
- fetched.task().ifPresent(task -> response.setTask(toProto(task)));
125
+ fetched.task().ifPresent(task -> response.setTask(toProto(task, fetched.secrets(), fetched.vars())));
125
126
return ok(response.build().toByteArray());
126
127
}
127
128
catch (RunnerAuthenticationException e)
@@ -177,7 +178,7 @@
177
178
}
178
179
}
179
180
180
- private static Task toProto(ActionTask task)
181
+ private static Task toProto(ActionTask task, Map<String, String> secrets, Map<String, String> vars)
181
182
{
182
183
Task.Builder builder = Task.newBuilder().setId(task.seq);
183
184
if (task.payload != null)
@@ -185,6 +186,8 @@
185
186
builder.setWorkflowPayload(ByteString.copyFromUtf8(task.payload));
186
187
}
187
188
builder.setContext(githubContext(task));
189
+ builder.putAllSecrets(secrets);
190
+ builder.putAllVars(vars);
188
191
return builder.build();
189
192
}
190
193
MODIFY
src/main/java/de/workaround/ci/TaskDispatchService.java
+54 -3
@@ -3,7 +3,9 @@
3
3
import java.time.Duration;
4
4
import java.time.Instant;
5
5
import java.util.Arrays;
6
+import java.util.HashMap;
6
7
import java.util.List;
8
+import java.util.Map;
7
9
import java.util.Optional;
8
10
import java.util.Set;
9
11
import java.util.UUID;
@@ -11,9 +13,13 @@
11
13
12
14
import org.eclipse.microprofile.config.inject.ConfigProperty;
13
15
16
+import de.workaround.mirror.SecretCrypto;
14
17
import de.workaround.model.ActionRun;
18
+import de.workaround.model.ActionSecret;
15
19
import de.workaround.model.ActionTask;
20
+import de.workaround.model.ActionVariable;
16
21
import de.workaround.model.CiRunner;
22
+import de.workaround.model.Repository;
17
23
import jakarta.enterprise.context.ApplicationScoped;
18
24
import jakarta.inject.Inject;
19
25
import jakarta.persistence.EntityManager;
@@ -47,14 +53,25 @@
47
53
ActionTask.Repo tasks;
48
54
49
55
@Inject
56
+ ActionSecret.Repo secrets;
57
+
58
+ @Inject
59
+ ActionVariable.Repo variables;
60
+
61
+ @Inject
62
+ SecretCrypto crypto;
63
+
64
+ @Inject
50
65
EntityManager em;
51
66
52
- public record Fetched(Optional<ActionTask> task, long tasksVersion)
67
+ public record Fetched(Optional<ActionTask> task, long tasksVersion, Map<String, String> secrets,
68
+ Map<String, String> vars)
53
69
{
54
70
}
55
71
56
72
/**
57
- * Authenticate the runner and claim the oldest PENDING task, if any.
73
+ * Authenticate the runner and claim the oldest label-compatible PENDING task, if any, delivering
74
+ * the owning repository's variables and (decrypted) secrets alongside it.
58
75
*
59
76
* @throws RunnerAuthenticationException if the uuid/token pair is unknown
60
77
*/
@@ -70,7 +87,41 @@
70
87
claim(task, runner);
71
88
return task;
72
89
});
73
- return new Fetched(next, tasks.maxSeq());
90
+ Map<String, String> secretMap = next.map(task -> secretsFor(task.run.repository)).orElse(Map.of());
91
+ Map<String, String> varMap = next.map(task -> variablesFor(task.run.repository)).orElse(Map.of());
92
+ return new Fetched(next, tasks.maxSeq(), secretMap, varMap);
93
+ }
94
+
95
+ /** Decrypted repository secrets by name; a secret that cannot be decrypted is dropped, never leaked as ciphertext. */
96
+ private Map<String, String> secretsFor(Repository repository)
97
+ {
98
+ Map<String, String> result = new HashMap<>();
99
+ if (!crypto.available())
100
+ {
101
+ return result;
102
+ }
103
+ for (ActionSecret secret : secrets.findByRepository(repository))
104
+ {
105
+ try
106
+ {
107
+ result.put(secret.name, crypto.decrypt(secret.valueEncrypted));
108
+ }
109
+ catch (RuntimeException undecryptable)
110
+ {
111
+ // key rotated or value corrupt: omit rather than hand the runner unusable/ciphertext data
112
+ }
113
+ }
114
+ return result;
115
+ }
116
+
117
+ private Map<String, String> variablesFor(Repository repository)
118
+ {
119
+ Map<String, String> result = new HashMap<>();
120
+ for (ActionVariable variable : variables.findByRepository(repository))
121
+ {
122
+ result.put(variable.name, variable.value);
123
+ }
124
+ return result;
74
125
}
75
126
76
127
private void claim(ActionTask task, CiRunner runner)
ADD
src/main/java/de/workaround/model/ActionSecret.java
+51 -0
@@ -0,0 +1,51 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Optional;
6
+import java.util.UUID;
7
+
8
+import org.hibernate.annotations.processing.Find;
9
+import org.hibernate.annotations.processing.HQL;
10
+
11
+import io.quarkus.hibernate.panache.PanacheEntity;
12
+import io.quarkus.hibernate.panache.PanacheRepository;
13
+import jakarta.persistence.Entity;
14
+import jakarta.persistence.GeneratedValue;
15
+import jakarta.persistence.GenerationType;
16
+import jakarta.persistence.Id;
17
+import jakarta.persistence.ManyToOne;
18
+import jakarta.persistence.Table;
19
+
20
+/**
21
+ * A repository-scoped CI secret (issue #2, phase 2). The value is stored encrypted (the same
22
+ * {@code SecretCrypto} envelope as push-mirror credentials) and decrypted only when delivered to a
23
+ * runner in FetchTask. Unique by {@link #name} within a repository; removed with it (cascade).
24
+ */
25
+@Entity
26
+@Table(name = "action_secret")
27
+public class ActionSecret implements PanacheEntity.Managed
28
+{
29
+ @Id
30
+ @GeneratedValue(strategy = GenerationType.UUID)
31
+ public UUID id;
32
+
33
+ @ManyToOne(optional = false)
34
+ public Repository repository;
35
+
36
+ public String name;
37
+
38
+ public String valueEncrypted;
39
+
40
+ public Instant createdAt = Instant.now();
41
+
42
+ public interface Repo extends PanacheRepository.Managed<ActionSecret, UUID>
43
+ {
44
+ @HQL("select s from ActionSecret s where s.repository = :repository order by s.name asc")
45
+ List<ActionSecret> findByRepository(Repository repository);
46
+
47
+ @Find
48
+ Optional<ActionSecret> findByRepositoryAndName(Repository repository, String name);
49
+ }
50
+
51
+}
ADD
src/main/java/de/workaround/model/ActionVariable.java
+50 -0
@@ -0,0 +1,50 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Optional;
6
+import java.util.UUID;
7
+
8
+import org.hibernate.annotations.processing.Find;
9
+import org.hibernate.annotations.processing.HQL;
10
+
11
+import io.quarkus.hibernate.panache.PanacheEntity;
12
+import io.quarkus.hibernate.panache.PanacheRepository;
13
+import jakarta.persistence.Entity;
14
+import jakarta.persistence.GeneratedValue;
15
+import jakarta.persistence.GenerationType;
16
+import jakarta.persistence.Id;
17
+import jakarta.persistence.ManyToOne;
18
+import jakarta.persistence.Table;
19
+
20
+/**
21
+ * A repository-scoped CI variable (issue #2, phase 2): plain (non-secret) configuration delivered to
22
+ * runners in FetchTask. Unique by {@link #name} within a repository; removed with it (cascade).
23
+ */
24
+@Entity
25
+@Table(name = "action_variable")
26
+public class ActionVariable implements PanacheEntity.Managed
27
+{
28
+ @Id
29
+ @GeneratedValue(strategy = GenerationType.UUID)
30
+ public UUID id;
31
+
32
+ @ManyToOne(optional = false)
33
+ public Repository repository;
34
+
35
+ public String name;
36
+
37
+ public String value;
38
+
39
+ public Instant createdAt = Instant.now();
40
+
41
+ public interface Repo extends PanacheRepository.Managed<ActionVariable, UUID>
42
+ {
43
+ @HQL("select v from ActionVariable v where v.repository = :repository order by v.name asc")
44
+ List<ActionVariable> findByRepository(Repository repository);
45
+
46
+ @Find
47
+ Optional<ActionVariable> findByRepositoryAndName(Repository repository, String name);
48
+ }
49
+
50
+}
ADD
src/main/resources/db/migration/V26__action_secrets_variables.sql
+29 -0
@@ -0,0 +1,29 @@
1
+-- Repository-level CI secrets and variables (issue #2, phase 2).
2
+--
3
+-- Secrets are stored encrypted (same SecretCrypto envelope as push-mirror credentials) and decrypted
4
+-- only when delivered to a runner in FetchTask. Variables are plain configuration. Both are unique by
5
+-- name within a repository and removed with it.
6
+
7
+create table action_secret
8
+(
9
+ id uuid primary key,
10
+ repository_id uuid not null references repositories (id) on delete cascade,
11
+ name varchar(255) not null,
12
+ value_encrypted text not null,
13
+ created_at timestamptz not null default now(),
14
+ unique (repository_id, name)
15
+);
16
+
17
+create index idx_action_secret_repository on action_secret (repository_id);
18
+
19
+create table action_variable
20
+(
21
+ id uuid primary key,
22
+ repository_id uuid not null references repositories (id) on delete cascade,
23
+ name varchar(255) not null,
24
+ value text not null,
25
+ created_at timestamptz not null default now(),
26
+ unique (repository_id, name)
27
+);
28
+
29
+create index idx_action_variable_repository on action_variable (repository_id);
ADD
src/test/java/de/workaround/ci/SecretDeliveryTest.java
+184 -0
@@ -0,0 +1,184 @@
1
+package de.workaround.ci;
2
+
3
+import java.util.List;
4
+import java.util.Map;
5
+import java.util.UUID;
6
+
7
+import org.junit.jupiter.api.Test;
8
+
9
+import de.workaround.git.GitRepositoryService;
10
+import de.workaround.mirror.SecretCrypto;
11
+import de.workaround.model.ActionRun;
12
+import de.workaround.model.ActionSecret;
13
+import de.workaround.model.ActionTask;
14
+import de.workaround.model.ActionVariable;
15
+import de.workaround.model.Repository;
16
+import de.workaround.model.User;
17
+import io.quarkus.test.junit.QuarkusTest;
18
+import jakarta.inject.Inject;
19
+import jakarta.transaction.Transactional;
20
+
21
+import static org.junit.jupiter.api.Assertions.assertEquals;
22
+import static org.junit.jupiter.api.Assertions.assertTrue;
23
+
24
+/**
25
+ * Secret and variable delivery (issue #2, phase 2): a claimed task is handed its repository's
26
+ * variables (plaintext) and secrets (decrypted from storage) in the FetchTask response.
27
+ */
28
+@QuarkusTest
29
+class SecretDeliveryTest
30
+{
31
+ @Inject
32
+ RunnerRegistrationService runnerService;
33
+
34
+ @Inject
35
+ TaskDispatchService dispatch;
36
+
37
+ @Inject
38
+ GitRepositoryService repositories;
39
+
40
+ @Inject
41
+ SecretCrypto crypto;
42
+
43
+ @Inject
44
+ ActionRun.Repo runs;
45
+
46
+ @Inject
47
+ ActionTask.Repo tasks;
48
+
49
+ @Inject
50
+ ActionSecret.Repo secrets;
51
+
52
+ @Inject
53
+ ActionVariable.Repo variables;
54
+
55
+ @Test
56
+ void claimedTaskReceivesRepoSecretsAndVariables()
57
+ {
58
+ RunnerRegistrationService.RegisteredRunner reg = registerRunner();
59
+ seed("sd-a");
60
+
61
+ TaskDispatchService.Fetched fetched = dispatch.fetch(reg.runner().uuid, reg.plaintext());
62
+
63
+ assertTrue(fetched.task().isPresent());
64
+ assertEquals("s3cr3t", fetched.secrets().get("API_TOKEN"), "secret delivered decrypted");
65
+ assertEquals("production", fetched.vars().get("DEPLOY_ENV"), "variable delivered as-is");
66
+ }
67
+
68
+ @Test
69
+ void undecryptableSecretIsDroppedNotLeaked()
70
+ {
71
+ RunnerRegistrationService.RegisteredRunner reg = registerRunner();
72
+ seedWithCorruptSecret("sd-c");
73
+
74
+ TaskDispatchService.Fetched fetched = dispatch.fetch(reg.runner().uuid, reg.plaintext());
75
+
76
+ assertTrue(fetched.task().isPresent());
77
+ assertEquals("s3cr3t", fetched.secrets().get("API_TOKEN"), "valid secret still delivered");
78
+ assertTrue(fetched.secrets().containsKey("API_TOKEN"));
79
+ assertTrue(!fetched.secrets().containsKey("BROKEN"), "undecryptable secret is dropped, never leaked");
80
+ }
81
+
82
+ @Test
83
+ void emptyFetchCarriesNoSecrets()
84
+ {
85
+ RunnerRegistrationService.RegisteredRunner reg = registerRunner();
86
+
87
+ TaskDispatchService.Fetched fetched = dispatch.fetch(reg.runner().uuid, reg.plaintext());
88
+
89
+ assertTrue(fetched.task().isEmpty());
90
+ assertTrue(fetched.secrets().isEmpty());
91
+ assertTrue(fetched.vars().isEmpty());
92
+ }
93
+
94
+ private RunnerRegistrationService.RegisteredRunner registerRunner()
95
+ {
96
+ String token = runnerService.createRegistrationToken(persistUser("sd-admin-" + shortId())).plaintext();
97
+ return runnerService.register(token, "sd-runner", List.of(), "v4.0.0", false);
98
+ }
99
+
100
+ @Transactional
101
+ void seed(String repoName)
102
+ {
103
+ User owner = persistUser(repoName + "-" + shortId());
104
+ Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
105
+
106
+ ActionSecret secret = new ActionSecret();
107
+ secret.repository = repo;
108
+ secret.name = "API_TOKEN";
109
+ secret.valueEncrypted = crypto.encrypt("s3cr3t");
110
+ secret.persist();
111
+
112
+ ActionVariable variable = new ActionVariable();
113
+ variable.repository = repo;
114
+ variable.name = "DEPLOY_ENV";
115
+ variable.value = "production";
116
+ variable.persist();
117
+
118
+ ActionRun run = new ActionRun();
119
+ run.repository = repo;
120
+ run.number = runs.maxNumber(repo) + 1;
121
+ run.workflowName = "CI";
122
+ run.workflowFile = ".forgejo/workflows/ci.yml";
123
+ run.event = "push";
124
+ run.ref = "refs/heads/main";
125
+ run.commitSha = "0000000000000000000000000000000000000000";
126
+ run.persist();
127
+
128
+ ActionTask task = new ActionTask();
129
+ task.run = run;
130
+ task.name = "build";
131
+ task.payload = "on: push";
132
+ task.persist();
133
+ }
134
+
135
+ @Transactional
136
+ void seedWithCorruptSecret(String repoName)
137
+ {
138
+ User owner = persistUser(repoName + "-" + shortId());
139
+ Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
140
+
141
+ ActionSecret good = new ActionSecret();
142
+ good.repository = repo;
143
+ good.name = "API_TOKEN";
144
+ good.valueEncrypted = crypto.encrypt("s3cr3t");
145
+ good.persist();
146
+
147
+ ActionSecret broken = new ActionSecret();
148
+ broken.repository = repo;
149
+ broken.name = "BROKEN";
150
+ broken.valueEncrypted = "enc1:not-valid-ciphertext";
151
+ broken.persist();
152
+
153
+ ActionRun run = new ActionRun();
154
+ run.repository = repo;
155
+ run.number = runs.maxNumber(repo) + 1;
156
+ run.workflowName = "CI";
157
+ run.workflowFile = ".forgejo/workflows/ci.yml";
158
+ run.event = "push";
159
+ run.ref = "refs/heads/main";
160
+ run.commitSha = "0000000000000000000000000000000000000000";
161
+ run.persist();
162
+
163
+ ActionTask task = new ActionTask();
164
+ task.run = run;
165
+ task.name = "build";
166
+ task.payload = "on: push";
167
+ task.persist();
168
+ }
169
+
170
+ @Transactional
171
+ User persistUser(String name)
172
+ {
173
+ User user = new User();
174
+ user.oidcSub = name;
175
+ user.username = name;
176
+ user.persist();
177
+ return user;
178
+ }
179
+
180
+ private static String shortId()
181
+ {
182
+ return UUID.randomUUID().toString().substring(0, 8);
183
+ }
184
+}