✨ (ci): Cancel superseded runs on a new push
Changes
6 files changed, +171 -4
MODIFY
docs/maintainers/ci-runners.md
+5 -2
@@ -124,7 +124,8 @@
124
124
outputs accumulate across incremental UpdateTask calls),
125
125
`CancelRerunTest` (cancel settles run+unfinished tasks, re-run resets a finished run, a cancelled
126
126
task tells the runner to stop via UpdateTask) and `ActionControlUiTest` (owner cancels/re-runs over
127
- HTTP, a non-writer is refused).
127
+ HTTP, a non-writer is refused), `SupersededRunsTest` (a new push cancels the branch's earlier
128
+ running run but leaves other branches alone).
128
129
- **Zombie reclaim (`ZombieReclaimService`):** a scheduled sweep
129
130
(`gitshark.ci.zombie-reclaim-interval`, default 1m) fails any RUNNING task whose
130
131
`action_task.deadline` has passed — the runner is presumed gone — rolls its run up, and flags the
@@ -137,6 +138,9 @@
137
138
PENDING (clearing runner, timing, logs and outputs) so they are picked up fresh. Both re-fetch the
138
139
run inside the transaction (the entity arrives detached from the resource) and are gated on
139
140
repository write access at `POST .../actions/{n}/cancel` and `.../rerun` (buttons on the run page).
141
+- **Superseded runs:** after ingest creates the run(s) for a push, `ActionRunService.cancelSuperseded`
142
+ cancels that branch's other still-active runs (keeping the just-created ones), so an in-flight run
143
+ is abandoned when a newer commit lands on the same ref. Other branches are unaffected.
140
144
- **Actions UI:** a read-only `Actions` tab on each repository — `ActionResource` renders a run list
141
145
(workflow, run number, status, event, short commit) and a run detail page with each job and its
142
146
streamed log rows. Read-gated like the rest of the repo UI (404 for a hidden repo). Tested by
@@ -159,7 +163,6 @@
159
163
not. (`!`-negation within a single pattern list is also not supported.)
160
164
- **`matrix`:** expansion is not implemented — a job with `strategy.matrix` runs once, not once per
161
165
cell (needs a per-job/per-cell payload expander).
162
-- **Concurrency:** no auto-cancel of superseded runs on force-push (manual cancel/re-run only).
163
166
- **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and ephemeral runners,
164
167
commit/MR status, non-push events.
165
168
MODIFY
docs/users/ci-runners.md
+3 -1
@@ -40,6 +40,9 @@
40
40
settles the run and tells the active runner to stop) and **Re-run** (on a finished run — resets its
41
41
jobs and runs them again).
42
42
43
+Pushing a new commit to a branch automatically cancels that branch's earlier still-running run, so
44
+only the latest push keeps running.
45
+
43
46
## Trigger filters
44
47
45
48
Beyond a bare `on: push` (which runs on every branch push), you can scope runs to specific refs:
@@ -93,5 +96,4 @@
93
96
## What's coming
94
97
95
98
- Non-push events (`pull_request`, scheduled, manual), `matrix`.
96
-- Auto-cancelling superseded runs on force-push.
97
99
- Artifacts and commit/merge-request status integration.
MODIFY
src/main/java/de/workaround/ci/ActionRunService.java
+24 -0
@@ -1,10 +1,13 @@
1
1
package de.workaround.ci;
2
2
3
3
import java.time.Instant;
4
+import java.util.Set;
5
+import java.util.UUID;
4
6
5
7
import de.workaround.model.ActionLog;
6
8
import de.workaround.model.ActionRun;
7
9
import de.workaround.model.ActionTask;
10
+import de.workaround.model.Repository;
8
11
import jakarta.enterprise.context.ApplicationScoped;
9
12
import jakarta.inject.Inject;
10
13
import jakarta.transaction.Transactional;
@@ -39,6 +42,27 @@
39
42
// already finished (naturally or a double click on a stale page) — don't rewrite history
40
43
return;
41
44
}
45
+ settle(run);
46
+ }
47
+
48
+ /**
49
+ * Cancel a branch's still-active runs that a newer push has superseded, keeping the runs just
50
+ * created for that push. Called from workflow ingest.
51
+ */
52
+ @Transactional
53
+ public void cancelSuperseded(Repository repository, String ref, Set<UUID> keep)
54
+ {
55
+ for (ActionRun run : runs.findActiveByRepositoryAndRef(repository, ref))
56
+ {
57
+ if (!keep.contains(run.id))
58
+ {
59
+ settle(run);
60
+ }
61
+ }
62
+ }
63
+
64
+ private void settle(ActionRun run)
65
+ {
42
66
Instant now = Instant.now();
43
67
for (ActionTask task : tasks.findByRun(run))
44
68
{
MODIFY
src/main/java/de/workaround/ci/WorkflowIngestService.java
+14 -1
@@ -2,8 +2,10 @@
2
2
3
3
import java.nio.charset.StandardCharsets;
4
4
import java.util.ArrayList;
5
+import java.util.HashSet;
5
6
import java.util.Iterator;
6
7
import java.util.List;
8
+import java.util.Set;
7
9
import java.util.UUID;
8
10
9
11
import org.eclipse.jgit.errors.LargeObjectException;
@@ -22,6 +24,7 @@
22
24
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
23
25
24
26
import de.workaround.git.GitRepositoryService;
27
+import de.workaround.model.ActionRun;
25
28
import de.workaround.model.Repository;
26
29
import io.quarkus.arc.Arc;
27
30
import jakarta.enterprise.context.ApplicationScoped;
@@ -57,6 +60,9 @@
57
60
@Inject
58
61
WorkflowRunFactory factory;
59
62
63
+ @Inject
64
+ ActionRunService runControl;
65
+
60
66
/** Entry point from the transports' post-receive hooks. */
61
67
public void onPush(String ownerName, String repoName, UUID pusherUserId,
62
68
org.eclipse.jgit.lib.Repository db, java.util.Collection<ReceiveCommand> commands)
@@ -107,6 +113,7 @@
107
113
continue;
108
114
}
109
115
List<String> changedPaths = changedPaths(db, command.getOldId(), command.getNewId());
116
+ Set<UUID> createdRuns = new HashSet<>();
110
117
for (WorkflowFile workflow : workflows)
111
118
{
112
119
JsonNode root = parse(workflow.content());
@@ -119,8 +126,14 @@
119
126
{
120
127
continue;
121
128
}
122
- factory.create(repo, pusherUserId, command.getRefName(), command.getNewId().name(),
129
+ ActionRun run = factory.create(repo, pusherUserId, command.getRefName(), command.getNewId().name(),
123
130
workflowName(root, workflow.path()), workflow.path(), jobs, workflow.content());
131
+ createdRuns.add(run.id);
132
+ }
133
+ if (!createdRuns.isEmpty())
134
+ {
135
+ // a new push to this branch supersedes its still-active earlier runs
136
+ runControl.cancelSuperseded(repo, command.getRefName(), createdRuns);
124
137
}
125
138
}
126
139
}
MODIFY
src/main/java/de/workaround/model/ActionRun.java
+4 -0
@@ -100,6 +100,10 @@
100
100
101
101
@HQL("select coalesce(max(r.number), 0) from ActionRun r where r.repository = :repository")
102
102
int maxNumber(Repository repository);
103
+
104
+ @HQL("select r from ActionRun r where r.repository = :repository and r.ref = :ref "
105
+ + "and r.status in (PENDING, RUNNING)")
106
+ List<ActionRun> findActiveByRepositoryAndRef(Repository repository, String ref);
103
107
}
104
108
105
109
}
ADD
src/test/java/de/workaround/ci/SupersededRunsTest.java
+121 -0
@@ -0,0 +1,121 @@
1
+package de.workaround.ci;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.nio.file.Path;
5
+import java.util.List;
6
+import java.util.Map;
7
+import java.util.UUID;
8
+
9
+import org.eclipse.jgit.lib.ObjectId;
10
+import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
11
+import org.eclipse.jgit.transport.ReceiveCommand;
12
+import org.junit.jupiter.api.Test;
13
+
14
+import de.workaround.git.GitRepositoryService;
15
+import de.workaround.git.GitTestSeeder;
16
+import de.workaround.model.ActionRun;
17
+import de.workaround.model.Repository;
18
+import de.workaround.model.User;
19
+import io.quarkus.test.junit.QuarkusTest;
20
+import jakarta.inject.Inject;
21
+import jakarta.transaction.Transactional;
22
+
23
+import static org.junit.jupiter.api.Assertions.assertEquals;
24
+
25
+/**
26
+ * Superseded-run cancellation (issue #2, phase 2): a new push to a branch cancels the branch's
27
+ * still-running earlier runs, but leaves runs on other branches alone.
28
+ */
29
+@QuarkusTest
30
+class SupersededRunsTest
31
+{
32
+ @Inject
33
+ WorkflowIngestService ingest;
34
+
35
+ @Inject
36
+ GitRepositoryService repositories;
37
+
38
+ @Inject
39
+ ActionRun.Repo runs;
40
+
41
+ private static final String WORKFLOW = "on: push\njobs:\n build:\n runs-on: ubuntu-latest\n"
42
+ + " steps:\n - run: echo hi\n";
43
+
44
+ @Test
45
+ void newPushCancelsEarlierRunningRunOnSameBranch()
46
+ {
47
+ Repository repo = seedRepo("sup-a");
48
+ UUID oldRun = seedActiveRun(repo, "refs/heads/main", ActionRun.Status.RUNNING);
49
+ UUID otherBranch = seedActiveRun(repo, "refs/heads/dev", ActionRun.Status.RUNNING);
50
+
51
+ pushWorkflowToMain(repo);
52
+
53
+ assertEquals(ActionRun.Status.CANCELLED, statusOf(oldRun), "earlier run on the pushed branch is superseded");
54
+ assertEquals(ActionRun.Status.RUNNING, statusOf(otherBranch), "a run on another branch is untouched");
55
+ assertEquals(1, activeCount(repo, "refs/heads/main"), "exactly the new run stays active on main");
56
+ }
57
+
58
+ @Transactional
59
+ long activeCount(Repository repo, String ref)
60
+ {
61
+ return runs.findActiveByRepositoryAndRef(repositories.find(repo.ownerHandle(), repo.name).orElseThrow(), ref)
62
+ .size();
63
+ }
64
+
65
+ private void pushWorkflowToMain(Repository repo)
66
+ {
67
+ try
68
+ {
69
+ Path bare = repositories.repositoryPath(repo);
70
+ GitTestSeeder.seed(bare, Map.of(".forgejo/workflows/ci.yml", WORKFLOW.getBytes(StandardCharsets.UTF_8)));
71
+ try (org.eclipse.jgit.lib.Repository db = new FileRepositoryBuilder().setGitDir(bare.toFile()).build())
72
+ {
73
+ ObjectId head = db.resolve("refs/heads/main");
74
+ ReceiveCommand command = new ReceiveCommand(ObjectId.zeroId(), head, "refs/heads/main");
75
+ command.setResult(ReceiveCommand.Result.OK);
76
+ ingest.onPush(repo.ownerHandle(), repo.name, null, db, List.of(command));
77
+ }
78
+ }
79
+ catch (Exception e)
80
+ {
81
+ throw new RuntimeException(e);
82
+ }
83
+ }
84
+
85
+ @Transactional
86
+ Repository seedRepo(String name)
87
+ {
88
+ User owner = new User();
89
+ owner.oidcSub = name + "-" + shortId();
90
+ owner.username = owner.oidcSub;
91
+ owner.persist();
92
+ return repositories.create(owner, name, Repository.Visibility.PUBLIC, null);
93
+ }
94
+
95
+ @Transactional
96
+ UUID seedActiveRun(Repository repo, String ref, ActionRun.Status status)
97
+ {
98
+ ActionRun run = new ActionRun();
99
+ run.repository = repositories.find(repo.ownerHandle(), repo.name).orElseThrow();
100
+ run.number = runs.maxNumber(run.repository) + 1;
101
+ run.workflowName = "CI";
102
+ run.workflowFile = ".forgejo/workflows/ci.yml";
103
+ run.event = "push";
104
+ run.ref = ref;
105
+ run.commitSha = "0000000000000000000000000000000000000000";
106
+ run.status = status;
107
+ run.persist();
108
+ return run.id;
109
+ }
110
+
111
+ @Transactional
112
+ ActionRun.Status statusOf(UUID runId)
113
+ {
114
+ return runs.findById(runId).status;
115
+ }
116
+
117
+ private static String shortId()
118
+ {
119
+ return UUID.randomUUID().toString().substring(0, 8);
120
+ }
121
+}