gitshark

Clone repository

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

← Commits

✨ (ci): Add run cancel and re-run

738d2926399c14e261e83db06557f94166d1c587 · Michael Hainz · 2026-07-23T07:20:24Z

Changes

10 files changed, +537 -9

MODIFY README.md +2 -2
diff --git a/README.md b/README.md
index d4a60ad..4623f9d 100644
--- a/README.md
+++ b/README.md
@@ -90,8 +90,8 @@
90 90 which a runner claims, executes, and streams logs for — visible on the repository's **Actions** tab;
91 91 a vanished runner's task is reclaimed after a timeout. Jobs are matched to runners by `runs-on`
92 92 labels, ordered by `needs` dependencies, and repository owners manage encrypted secrets and
93 - variables that are delivered to runners. Non-push events, `matrix`, and artifacts are follow-up
94 - phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
93 + variables that are delivered to runners, and runs can be cancelled or re-run from the UI.
94 + Non-push events, `matrix`, and artifacts are follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
95 95 [architecture](docs/maintainers/ci-runners.md)
96 96 activities from; local users can in turn follow a remote repository — or a whole remote user, whose
97 97 public repositories are then followed and shown grouped — and read their pushes (see below)
MODIFY docs/maintainers/ci-runners.md +14 -3
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
index 09da34c..a165a30 100644
--- a/docs/maintainers/ci-runners.md
+++ b/docs/maintainers/ci-runners.md
@@ -15,6 +15,7 @@
15 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. |
16 16 | Task progress | `ci/TaskProgressService.java` | UpdateTask (result → task status + run roll-up, runner back to IDLE) and UpdateLog (resume-safe log-row append, `ack_index`). |
17 17 | Zombie reclaim | `ci/ZombieReclaimService.java` | Scheduled sweep failing RUNNING tasks past their deadline (vanished runner) and rolling up their runs. |
18 +| Run controls | `ci/ActionRunService.java` | Cancel a run (settle run + unfinished tasks) and re-run a finished run (reset tasks to PENDING, clear logs/outputs). |
18 19 | Actions UI | `web/ActionResource.java` + `templates/ActionResource/` | Read-only per-repo run list + run detail (jobs and their log rows); sidebar `Actions` tab. |
19 20 | Secrets/variables UI | `web/ActionSettingsResource.java` + `ci/ActionSecretService.java` + `templates/ActionSettingsResource/` | Owner-only CRUD for CI secrets (write-only, encrypted) and variables at `settings/actions`. |
20 21 | Entities | `model/CiRunner.java`, `model/CiRunnerRegistrationToken.java` | Runner state (migration `V19`). |
@@ -120,13 +121,22 @@
120 121 variable, duplicate-name rejected, stranger/anonymous get 404),
121 122 `NeedsOrderingTest` (dependent waits for its need then receives its result; a failed need cancels
122 123 the dependent and ends the run), `NeedsOutputsTest` (dependent receives an upstream job's outputs;
123 - outputs accumulate across incremental UpdateTask calls).
124 + outputs accumulate across incremental UpdateTask calls),
125 + `CancelRerunTest` (cancel settles run+unfinished tasks, re-run resets a finished run, a cancelled
126 + task tells the runner to stop via UpdateTask) and `ActionControlUiTest` (owner cancels/re-runs over
127 + HTTP, a non-writer is refused).
124 128 - **Zombie reclaim (`ZombieReclaimService`):** a scheduled sweep
125 129 (`gitshark.ci.zombie-reclaim-interval`, default 1m) fails any RUNNING task whose
126 130 `action_task.deadline` has passed — the runner is presumed gone — rolls its run up, and flags the
127 131 runner OFFLINE. The deadline is set at claim time from `gitshark.ci.task-timeout` (default 1h). A
128 132 late update from a runner cannot resurrect an already-terminal task. `ZombieReclaimTest` covers both
129 133 the reclaim (overdue → FAILURE, in-deadline left RUNNING) and the anti-resurrection guard.
134 +- **Cancel & re-run:** `ActionRunService.cancel` settles a run and its unfinished tasks; a task still
135 + running on a runner keeps its assignment but is told to stop the next time it calls UpdateTask (the
136 + response's `TaskState.result` is flipped to CANCELLED). `rerun` resets a finished run's tasks to
137 + PENDING (clearing runner, timing, logs and outputs) so they are picked up fresh. Both re-fetch the
138 + run inside the transaction (the entity arrives detached from the resource) and are gated on
139 + repository write access at `POST .../actions/{n}/cancel` and `.../rerun` (buttons on the run page).
130 140 - **Actions UI:** a read-only `Actions` tab on each repository — `ActionResource` renders a run list
131 141 (workflow, run number, status, event, short commit) and a run detail page with each job and its
132 142 streamed log rows. Read-gated like the rest of the repo UI (404 for a hidden repo). Tested by
@@ -149,8 +159,9 @@
149 159 not. (`!`-negation within a single pattern list is also not supported.)
150 160 - **`matrix`:** expansion is not implemented — a job with `strategy.matrix` runs once, not once per
151 161 cell (needs a per-job/per-cell payload expander).
152 -- **Later phases:** concurrency/cancellation, artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and
153 - ephemeral runners, commit/MR status, non-push events.
162 +- **Concurrency:** no auto-cancel of superseded runs on force-push (manual cancel/re-run only).
163 +- **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and ephemeral runners,
164 + commit/MR status, non-push events.
154 165
155 166 ## References
156 167
MODIFY docs/users/ci-runners.md +5 -1
diff --git a/docs/users/ci-runners.md b/docs/users/ci-runners.md
index 451bb64..9eb7b97 100644
--- a/docs/users/ci-runners.md
+++ b/docs/users/ci-runners.md
@@ -36,6 +36,10 @@
36 36 A run whose runner disappears mid-job is marked **Failure** once its time limit passes (configurable
37 37 by the admin), so a run never hangs as Running forever.
38 38
39 +Users with write access see controls on the run page: **Cancel run** (while it is still running —
40 +settles the run and tells the active runner to stop) and **Re-run** (on a finished run — resets its
41 +jobs and runs them again).
42 +
39 43 ## Trigger filters
40 44
41 45 Beyond a bare `on: push` (which runs on every branch push), you can scope runs to specific refs:
@@ -89,5 +93,5 @@
89 93 ## What's coming
90 94
91 95 - Non-push events (`pull_request`, scheduled, manual), `matrix`.
92 -- Run cancellation / re-run.
96 +- Auto-cancelling superseded runs on force-push.
93 97 - Artifacts and commit/merge-request status integration.
ADD src/main/java/de/workaround/ci/ActionRunService.java +83 -0
diff --git a/src/main/java/de/workaround/ci/ActionRunService.java b/src/main/java/de/workaround/ci/ActionRunService.java
new file mode 100644
index 0000000..1fd4a36
--- /dev/null
+++ b/src/main/java/de/workaround/ci/ActionRunService.java
@@ -0,0 +1,83 @@
1 +package de.workaround.ci;
2 +
3 +import java.time.Instant;
4 +
5 +import de.workaround.model.ActionLog;
6 +import de.workaround.model.ActionRun;
7 +import de.workaround.model.ActionTask;
8 +import jakarta.enterprise.context.ApplicationScoped;
9 +import jakarta.inject.Inject;
10 +import jakarta.transaction.Transactional;
11 +
12 +/**
13 + * Run-level controls: cancel and re-run (issue #2, phase 2). Callers must have already gated on
14 + * repository write access.
15 + */
16 +@ApplicationScoped
17 +public class ActionRunService
18 +{
19 + @Inject
20 + ActionRun.Repo runs;
21 +
22 + @Inject
23 + ActionTask.Repo tasks;
24 +
25 + @Inject
26 + ActionLog.Repo logs;
27 +
28 + /**
29 + * Cancel a run: settle the run and every task that has not already finished. A task still RUNNING
30 + * on a runner keeps its assignment but is marked CANCELLED; the runner is told to stop the next
31 + * time it calls UpdateTask (see {@code ConnectRunnerResource}).
32 + */
33 + @Transactional
34 + public void cancel(ActionRun detached)
35 + {
36 + ActionRun run = runs.findById(detached.id);
37 + if (run.status.isTerminal())
38 + {
39 + // already finished (naturally or a double click on a stale page) — don't rewrite history
40 + return;
41 + }
42 + Instant now = Instant.now();
43 + for (ActionTask task : tasks.findByRun(run))
44 + {
45 + if (!task.status.isTerminal())
46 + {
47 + task.status = ActionRun.Status.CANCELLED;
48 + task.finishedAt = now;
49 + }
50 + }
51 + run.status = ActionRun.Status.CANCELLED;
52 + run.finishedAt = now;
53 + }
54 +
55 + /**
56 + * Re-run a finished run: reset every task to PENDING (clearing runner assignment, timing, logs and
57 + * outputs) and the run to PENDING, so runners pick the work up fresh. No-op if the run is not
58 + * terminal.
59 + */
60 + @Transactional
61 + public void rerun(ActionRun detached)
62 + {
63 + ActionRun run = runs.findById(detached.id);
64 + if (!run.status.isTerminal())
65 + {
66 + return;
67 + }
68 + for (ActionTask task : tasks.findByRun(run))
69 + {
70 + logs.deleteByTask(task);
71 + task.status = ActionRun.Status.PENDING;
72 + task.runner = null;
73 + task.startedAt = null;
74 + task.finishedAt = null;
75 + task.deadline = null;
76 + task.logLength = 0;
77 + task.outputs = "{}";
78 + }
79 + run.status = ActionRun.Status.PENDING;
80 + run.startedAt = null;
81 + run.finishedAt = null;
82 + }
83 +}
MODIFY src/main/java/de/workaround/ci/ConnectRunnerResource.java +6 -1
diff --git a/src/main/java/de/workaround/ci/ConnectRunnerResource.java b/src/main/java/de/workaround/ci/ConnectRunnerResource.java
index ed6aef4..c922e5a 100644
--- a/src/main/java/de/workaround/ci/ConnectRunnerResource.java
+++ b/src/main/java/de/workaround/ci/ConnectRunnerResource.java
@@ -18,6 +18,7 @@
18 18 import de.workaround.ci.proto.runner.v1.FetchTaskResponse;
19 19 import de.workaround.ci.proto.runner.v1.RegisterRequest;
20 20 import de.workaround.ci.proto.runner.v1.RegisterResponse;
21 +import de.workaround.ci.proto.runner.v1.Result;
21 22 import de.workaround.ci.proto.runner.v1.Runner;
22 23 import de.workaround.ci.proto.runner.v1.RunnerStatus;
23 24 import de.workaround.ci.proto.runner.v1.Task;
@@ -147,8 +148,12 @@
147 148 {
148 149 ActionTask task = progressService.updateTask(uuid, token, state.getId(), state.getResult(), stoppedAt,
149 150 request.getOutputsMap());
151 + // If the task was cancelled server-side (e.g. the run was cancelled), tell the runner to stop.
152 + TaskState responseState = task.status == ActionRun.Status.CANCELLED
153 + ? state.toBuilder().setResult(Result.RESULT_CANCELLED).build()
154 + : state;
150 155 return ok(UpdateTaskResponse.newBuilder()
151 - .setState(state)
156 + .setState(responseState)
152 157 .addAllSentOutputs(ActionOutputs.parse(task.outputs).keySet())
153 158 .build().toByteArray());
154 159 }
MODIFY src/main/java/de/workaround/model/ActionLog.java +3 -0
diff --git a/src/main/java/de/workaround/model/ActionLog.java b/src/main/java/de/workaround/model/ActionLog.java
index 81cd998..e3ceb1a 100644
--- a/src/main/java/de/workaround/model/ActionLog.java
+++ b/src/main/java/de/workaround/model/ActionLog.java
@@ -45,6 +45,9 @@
45 45
46 46 @HQL("select count(l) from ActionLog l where l.task = :task")
47 47 long countByTask(ActionTask task);
48 +
49 + @HQL("delete from ActionLog l where l.task = :task")
50 + void deleteByTask(ActionTask task);
48 51 }
49 52
50 53 }
MODIFY src/main/java/de/workaround/web/ActionResource.java +48 -2
diff --git a/src/main/java/de/workaround/web/ActionResource.java b/src/main/java/de/workaround/web/ActionResource.java
index a0b5756..dc74a1a 100644
--- a/src/main/java/de/workaround/web/ActionResource.java
+++ b/src/main/java/de/workaround/web/ActionResource.java
@@ -1,9 +1,12 @@
1 1 package de.workaround.web;
2 2
3 +import java.net.URI;
3 4 import java.util.List;
4 5
5 6 import de.workaround.account.CurrentUser;
7 +import de.workaround.ci.ActionRunService;
6 8 import de.workaround.git.AccessPolicy;
9 +import de.workaround.git.ForbiddenOperationException;
7 10 import de.workaround.git.GitRepositoryService;
8 11 import de.workaround.model.ActionLog;
9 12 import de.workaround.model.ActionRun;
@@ -14,10 +17,12 @@
14 17 import jakarta.inject.Inject;
15 18 import jakarta.ws.rs.GET;
16 19 import jakarta.ws.rs.NotFoundException;
20 +import jakarta.ws.rs.POST;
17 21 import jakarta.ws.rs.PathParam;
18 22 import jakarta.ws.rs.Produces;
19 23 import jakarta.ws.rs.core.Context;
20 24 import jakarta.ws.rs.core.MediaType;
25 +import jakarta.ws.rs.core.Response;
21 26 import jakarta.ws.rs.core.UriInfo;
22 27
23 28 /**
@@ -34,7 +39,8 @@
34 39 {
35 40 static native TemplateInstance runs(Repository repo, RepoNav nav, List<ActionRun> runs);
36 41
37 - static native TemplateInstance run(Repository repo, RepoNav nav, ActionRun run, List<TaskLogs> tasks);
42 + static native TemplateInstance run(Repository repo, RepoNav nav, ActionRun run, List<TaskLogs> tasks,
43 + boolean canWrite);
38 44 }
39 45
40 46 /** A task paired with its log rows, for the run detail page. */
@@ -63,6 +69,9 @@
63 69 @Inject
64 70 ActionLog.Repo logs;
65 71
72 + @Inject
73 + ActionRunService runService;
74 +
66 75 @Context
67 76 UriInfo uriInfo;
68 77
@@ -83,7 +92,44 @@
83 92 List<TaskLogs> taskLogs = tasks.findByRun(run).stream()
84 93 .map(task -> new TaskLogs(task, logs.findByTask(task)))
85 94 .toList();
86 - return Templates.run(repo, repoNav.build(repo, uriInfo), run, taskLogs);
95 + boolean canWrite = accessPolicy.canWrite(currentUser.get(), repo);
96 + return Templates.run(repo, repoNav.build(repo, uriInfo), run, taskLogs, canWrite);
97 + }
98 +
99 + @POST
100 + @jakarta.ws.rs.Path("{number:\\d+}/cancel")
101 + public Response cancel(@PathParam("owner") String owner, @PathParam("name") String name,
102 + @PathParam("number") int number)
103 + {
104 + ActionRun run = requireWritableRun(owner, name, number);
105 + runService.cancel(run);
106 + return backToRun(run, number);
107 + }
108 +
109 + @POST
110 + @jakarta.ws.rs.Path("{number:\\d+}/rerun")
111 + public Response rerun(@PathParam("owner") String owner, @PathParam("name") String name,
112 + @PathParam("number") int number)
113 + {
114 + ActionRun run = requireWritableRun(owner, name, number);
115 + runService.rerun(run);
116 + return backToRun(run, number);
117 + }
118 +
119 + private ActionRun requireWritableRun(String owner, String name, int number)
120 + {
121 + Repository repo = requireReadable(owner, name);
122 + if (!accessPolicy.canWrite(currentUser.get(), repo))
123 + {
124 + throw new ForbiddenOperationException("Only users with write access can control runs");
125 + }
126 + return runs.findByRepositoryAndNumber(repo, number).orElseThrow(NotFoundException::new);
127 + }
128 +
129 + private static Response backToRun(ActionRun run, int number)
130 + {
131 + return Response.seeOther(URI.create(
132 + "/repos/" + run.repository.ownerHandle() + "/" + run.repository.name + "/actions/" + number)).build();
87 133 }
88 134
89 135 private Repository requireReadable(String owner, String name)
MODIFY src/main/resources/templates/ActionResource/run.html +14 -0
diff --git a/src/main/resources/templates/ActionResource/run.html b/src/main/resources/templates/ActionResource/run.html
index eee42be..3d6e096 100644
--- a/src/main/resources/templates/ActionResource/run.html
+++ b/src/main/resources/templates/ActionResource/run.html
@@ -6,6 +6,20 @@
6 6 <p class="issue-back"><a href="/repos/{repo.ownerHandle}/{repo.name}/actions">← Actions</a></p>
7 7 <header class="issue-header">
8 8 <h2 class="issue-heading">{run.workflowName} <span class="issue-no">#{run.number}</span></h2>
9 + {#if canWrite}
10 + <div class="issue-header-actions">
11 + {#if run.status.terminal}
12 + <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/actions/{run.number}/rerun"
13 + onsubmit="return confirm('Re-run this workflow? Its previous logs will be cleared.')">
14 + <button type="submit" class="btn btn-secondary btn-sm">Re-run</button>
15 + </form>
16 + {#else}
17 + <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/actions/{run.number}/cancel">
18 + <button type="submit" class="btn btn-danger btn-sm">Cancel run</button>
19 + </form>
20 + {/if}
21 + </div>
22 + {/if}
9 23 </header>
10 24 <div class="issue-byline">
11 25 <span class="badge status-{run.status}">{run.status.label}</span>
ADD src/test/java/de/workaround/ci/CancelRerunTest.java +237 -0
diff --git a/src/test/java/de/workaround/ci/CancelRerunTest.java b/src/test/java/de/workaround/ci/CancelRerunTest.java
new file mode 100644
index 0000000..daf2d72
--- /dev/null
+++ b/src/test/java/de/workaround/ci/CancelRerunTest.java
@@ -0,0 +1,237 @@
1 +package de.workaround.ci;
2 +
3 +import java.time.Instant;
4 +import java.util.List;
5 +import java.util.UUID;
6 +
7 +import org.junit.jupiter.api.Test;
8 +
9 +import com.google.protobuf.InvalidProtocolBufferException;
10 +
11 +import de.workaround.ci.proto.runner.v1.Result;
12 +import de.workaround.ci.proto.runner.v1.TaskState;
13 +import de.workaround.ci.proto.runner.v1.UpdateTaskRequest;
14 +import de.workaround.ci.proto.runner.v1.UpdateTaskResponse;
15 +import de.workaround.git.GitRepositoryService;
16 +import de.workaround.model.ActionLog;
17 +import de.workaround.model.ActionRun;
18 +import de.workaround.model.ActionTask;
19 +import de.workaround.model.CiRunner;
20 +import de.workaround.model.Repository;
21 +import de.workaround.model.User;
22 +import io.quarkus.test.TestTransaction;
23 +import io.quarkus.test.junit.QuarkusTest;
24 +import jakarta.inject.Inject;
25 +import jakarta.persistence.EntityManager;
26 +import jakarta.transaction.Transactional;
27 +
28 +import static io.restassured.RestAssured.given;
29 +import static org.junit.jupiter.api.Assertions.assertEquals;
30 +import static org.junit.jupiter.api.Assertions.assertNull;
31 +import static org.junit.jupiter.api.Assertions.assertTrue;
32 +
33 +/**
34 + * Run cancellation and re-run (issue #2, phase 2). Cancelling settles a run and its unfinished tasks,
35 + * and a still-running task's runner learns of it via the UpdateTask response. Re-run resets a finished
36 + * run's tasks back to PENDING.
37 + */
38 +@QuarkusTest
39 +class CancelRerunTest
40 +{
41 + private static final String PROTO = "application/proto";
42 +
43 + @Inject
44 + ActionRunService runService;
45 +
46 + @Inject
47 + RunnerRegistrationService runnerService;
48 +
49 + @Inject
50 + GitRepositoryService repositories;
51 +
52 + @Inject
53 + ActionRun.Repo runs;
54 +
55 + @Inject
56 + ActionTask.Repo tasks;
57 +
58 + @Inject
59 + ActionLog.Repo logs;
60 +
61 + @Inject
62 + CiRunner.Repo runners;
63 +
64 + @Inject
65 + EntityManager em;
66 +
67 + @Test
68 + @TestTransaction
69 + void cancelSettlesRunAndUnfinishedTasks()
70 + {
71 + Repository repo = repo("cr-a");
72 + ActionRun run = newRun(repo, ActionRun.Status.RUNNING);
73 + UUID running = newTask(run, "build", ActionRun.Status.RUNNING);
74 + UUID pending = newTask(run, "deploy", ActionRun.Status.PENDING);
75 +
76 + runService.cancel(run);
77 +
78 + assertEquals(ActionRun.Status.CANCELLED, runs.findById(run.id).status);
79 + assertEquals(ActionRun.Status.CANCELLED, tasks.findById(running).status);
80 + assertEquals(ActionRun.Status.CANCELLED, tasks.findById(pending).status);
81 + }
82 +
83 + @Test
84 + @TestTransaction
85 + void rerunResetsFinishedRunToPending()
86 + {
87 + Repository repo = repo("cr-b");
88 + ActionRun run = newRun(repo, ActionRun.Status.SUCCESS);
89 + run.finishedAt = Instant.now();
90 + UUID taskId = newTask(run, "build", ActionRun.Status.SUCCESS);
91 + ActionTask task = tasks.findById(taskId);
92 + task.finishedAt = Instant.now();
93 + task.logLength = 2;
94 + task.outputs = "{\"image\":\"x\"}";
95 + ActionLog log = new ActionLog();
96 + log.task = task;
97 + log.lineIndex = 0;
98 + log.content = "hi";
99 + log.persist();
100 +
101 + runService.rerun(run);
102 +
103 + ActionRun reloaded = runs.findById(run.id);
104 + ActionTask reloadedTask = tasks.findById(taskId);
105 + assertEquals(ActionRun.Status.PENDING, reloaded.status);
106 + assertNull(reloaded.finishedAt);
107 + assertEquals(ActionRun.Status.PENDING, reloadedTask.status);
108 + assertNull(reloadedTask.runner);
109 + assertNull(reloadedTask.finishedAt);
110 + assertEquals(0, reloadedTask.logLength);
111 + assertEquals("{}", reloadedTask.outputs);
112 + assertTrue(logs.findByTask(reloadedTask).isEmpty(), "old logs cleared");
113 + }
114 +
115 + @Test
116 + @TestTransaction
117 + void cancelIsNoOpOnAlreadyFinishedRun()
118 + {
119 + Repository repo = repo("cr-d");
120 + ActionRun run = newRun(repo, ActionRun.Status.SUCCESS);
121 + Instant finished = Instant.now().minusSeconds(60);
122 + run.finishedAt = finished;
123 +
124 + runService.cancel(run);
125 +
126 + ActionRun reloaded = runs.findById(run.id);
127 + assertEquals(ActionRun.Status.SUCCESS, reloaded.status, "a finished run is not rewritten to CANCELLED");
128 + assertEquals(finished, reloaded.finishedAt);
129 + }
130 +
131 + @Test
132 + @TestTransaction
133 + void rerunIsNoOpWhileRunStillRunning()
134 + {
135 + Repository repo = repo("cr-e");
136 + ActionRun run = newRun(repo, ActionRun.Status.RUNNING);
137 + UUID taskId = newTask(run, "build", ActionRun.Status.RUNNING);
138 +
139 + runService.rerun(run);
140 +
141 + assertEquals(ActionRun.Status.RUNNING, runs.findById(run.id).status, "a running run is not reset");
142 + assertEquals(ActionRun.Status.RUNNING, tasks.findById(taskId).status);
143 + }
144 +
145 + @Test
146 + void cancelledTaskTellsRunnerToStopOnUpdate() throws InvalidProtocolBufferException
147 + {
148 + RunnerRegistrationService.RegisteredRunner reg = registerRunner();
149 + long seq = seedClaimedThenCancelled("cr-c", reg.runner().uuid);
150 +
151 + byte[] bytes = given()
152 + .contentType(PROTO)
153 + .header("x-runner-uuid", reg.runner().uuid)
154 + .header("x-runner-token", reg.plaintext())
155 + .body(UpdateTaskRequest.newBuilder()
156 + .setState(TaskState.newBuilder().setId(seq).setResult(Result.RESULT_UNSPECIFIED))
157 + .build().toByteArray())
158 + .when().post("/api/actions/runner.v1.RunnerService/UpdateTask")
159 + .then().statusCode(200)
160 + .extract().asByteArray();
161 +
162 + UpdateTaskResponse response = UpdateTaskResponse.parseFrom(bytes);
163 + assertEquals(Result.RESULT_CANCELLED, response.getState().getResult(),
164 + "the runner is told the task was cancelled");
165 + }
166 +
167 + @Transactional
168 + long seedClaimedThenCancelled(String repoName, String runnerUuid)
169 + {
170 + Repository repo = repo(repoName);
171 + CiRunner runner = runners.findByUuid(runnerUuid).orElseThrow();
172 + ActionRun run = newRun(repo, ActionRun.Status.RUNNING);
173 + ActionTask task = new ActionTask();
174 + task.run = run;
175 + task.name = "build";
176 + task.payload = "on: push";
177 + task.runner = runner;
178 + task.status = ActionRun.Status.RUNNING;
179 + task.persist();
180 + em.flush();
181 + runService.cancel(run);
182 + return task.seq;
183 + }
184 +
185 + private RunnerRegistrationService.RegisteredRunner registerRunner()
186 + {
187 + String token = runnerService.createRegistrationToken(persistUser("cr-admin-" + shortId())).plaintext();
188 + return runnerService.register(token, "cr-runner", List.of(), "v4.0.0", false);
189 + }
190 +
191 + private Repository repo(String name)
192 + {
193 + User owner = persistUser(name + "-" + shortId());
194 + return repositories.create(owner, name, Repository.Visibility.PUBLIC, null);
195 + }
196 +
197 + private ActionRun newRun(Repository repo, ActionRun.Status status)
198 + {
199 + ActionRun run = new ActionRun();
200 + run.repository = repo;
201 + run.number = runs.maxNumber(repo) + 1;
202 + run.workflowName = "CI";
203 + run.workflowFile = ".forgejo/workflows/ci.yml";
204 + run.event = "push";
205 + run.ref = "refs/heads/main";
206 + run.commitSha = "0000000000000000000000000000000000000000";
207 + run.status = status;
208 + run.persist();
209 + return run;
210 + }
211 +
212 + private UUID newTask(ActionRun run, String name, ActionRun.Status status)
213 + {
214 + ActionTask task = new ActionTask();
215 + task.run = run;
216 + task.name = name;
217 + task.payload = "on: push";
218 + task.status = status;
219 + task.persist();
220 + return task.id;
221 + }
222 +
223 + @Transactional
224 + User persistUser(String name)
225 + {
226 + User user = new User();
227 + user.oidcSub = name;
228 + user.username = name;
229 + user.persist();
230 + return user;
231 + }
232 +
233 + private static String shortId()
234 + {
235 + return UUID.randomUUID().toString().substring(0, 8);
236 + }
237 +}
ADD src/test/java/de/workaround/web/ActionControlUiTest.java +125 -0
diff --git a/src/test/java/de/workaround/web/ActionControlUiTest.java b/src/test/java/de/workaround/web/ActionControlUiTest.java
new file mode 100644
index 0000000..afa2ee7
--- /dev/null
+++ b/src/test/java/de/workaround/web/ActionControlUiTest.java
@@ -0,0 +1,125 @@
1 +package de.workaround.web;
2 +
3 +import java.util.UUID;
4 +
5 +import org.junit.jupiter.api.Test;
6 +
7 +import de.workaround.git.GitRepositoryService;
8 +import de.workaround.model.ActionRun;
9 +import de.workaround.model.Repository;
10 +import de.workaround.model.User;
11 +import io.quarkus.test.junit.QuarkusTest;
12 +import io.quarkus.test.security.TestSecurity;
13 +import jakarta.inject.Inject;
14 +import jakarta.transaction.Transactional;
15 +
16 +import static io.restassured.RestAssured.given;
17 +import static org.junit.jupiter.api.Assertions.assertEquals;
18 +
19 +/**
20 + * The run cancel/re-run controls on the Actions run page (issue #2, phase 2): a writer can drive them
21 + * over HTTP; a non-writer cannot.
22 + */
23 +@QuarkusTest
24 +class ActionControlUiTest
25 +{
26 + private static final String OWNER = "arc-owner";
27 +
28 + private static final String STRANGER = "arc-stranger";
29 +
30 + @Inject
31 + GitRepositoryService repositories;
32 +
33 + @Inject
34 + User.Repo users;
35 +
36 + @Inject
37 + ActionRun.Repo runs;
38 +
39 + @Test
40 + @TestSecurity(user = OWNER)
41 + void ownerCancelsRunningRun()
42 + {
43 + Repository repo = repoWithRun("arc-a", ActionRun.Status.RUNNING);
44 +
45 + given().redirects().follow(false)
46 + .when().post(base(repo) + "/1/cancel")
47 + .then().statusCode(303);
48 +
49 + assertEquals(ActionRun.Status.CANCELLED, runStatus(repo));
50 + }
51 +
52 + @Test
53 + @TestSecurity(user = OWNER)
54 + void ownerRerunsFinishedRun()
55 + {
56 + Repository repo = repoWithRun("arc-b", ActionRun.Status.FAILURE);
57 +
58 + given().redirects().follow(false)
59 + .when().post(base(repo) + "/1/rerun")
60 + .then().statusCode(303);
61 +
62 + assertEquals(ActionRun.Status.PENDING, runStatus(repo));
63 + }
64 +
65 + @Test
66 + @TestSecurity(user = STRANGER)
67 + void nonWriterCannotCancel()
68 + {
69 + Repository repo = repoWithRun("arc-c", ActionRun.Status.RUNNING);
70 +
71 + given().redirects().follow(false)
72 + .when().post(base(repo) + "/1/cancel")
73 + .then().statusCode(403);
74 +
75 + assertEquals(ActionRun.Status.RUNNING, runStatus(repo));
76 + }
77 +
78 + private static String base(Repository repo)
79 + {
80 + return "/repos/" + repo.ownerHandle() + "/" + repo.name + "/actions";
81 + }
82 +
83 + @Transactional
84 + ActionRun.Status runStatus(Repository repo)
85 + {
86 + return runs.findByRepository(repo).get(0).status;
87 + }
88 +
89 + private Repository repoWithRun(String repoName, ActionRun.Status status)
90 + {
91 + User owner = persistUser(OWNER);
92 + persistUser(STRANGER);
93 + return createRepoWithRun(owner, repoName, status);
94 + }
95 +
96 + @Transactional
97 + Repository createRepoWithRun(User owner, String repoName, ActionRun.Status status)
98 + {
99 + Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
100 + ActionRun run = new ActionRun();
101 + run.repository = repo;
102 + run.number = 1;
103 + run.workflowName = "CI";
104 + run.workflowFile = ".forgejo/workflows/ci.yml";
105 + run.event = "push";
106 + run.ref = "refs/heads/main";
107 + run.commitSha = "0000000000000000000000000000000000000000";
108 + run.status = status;
109 + run.persist();
110 + return repo;
111 + }
112 +
113 + @Transactional
114 + User persistUser(String name)
115 + {
116 + return users.findByOidcSubOptional(name).orElseGet(() ->
117 + {
118 + User user = new User();
119 + user.oidcSub = name;
120 + user.username = name;
121 + user.persist();
122 + return user;
123 + });
124 + }
125 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog