gitshark

Clone repository

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

← Commits

✨ (ci): Cancel superseded runs on a new push

76d562e9530ad43f2108a6a5453bc2f54416c81a · Michael Hainz · 2026-07-23T07:39:27Z

Changes

6 files changed, +171 -4

MODIFY docs/maintainers/ci-runners.md +5 -2
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
index a165a30..a4ccd5d 100644
--- a/docs/maintainers/ci-runners.md
+++ b/docs/maintainers/ci-runners.md
@@ -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
diff --git a/docs/users/ci-runners.md b/docs/users/ci-runners.md
index 9eb7b97..c594fb9 100644
--- a/docs/users/ci-runners.md
+++ b/docs/users/ci-runners.md
@@ -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
diff --git a/src/main/java/de/workaround/ci/ActionRunService.java b/src/main/java/de/workaround/ci/ActionRunService.java
index 1fd4a36..2196e84 100644
--- a/src/main/java/de/workaround/ci/ActionRunService.java
+++ b/src/main/java/de/workaround/ci/ActionRunService.java
@@ -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
diff --git a/src/main/java/de/workaround/ci/WorkflowIngestService.java b/src/main/java/de/workaround/ci/WorkflowIngestService.java
index 4968dd9..33f7879 100644
--- a/src/main/java/de/workaround/ci/WorkflowIngestService.java
+++ b/src/main/java/de/workaround/ci/WorkflowIngestService.java
@@ -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
diff --git a/src/main/java/de/workaround/model/ActionRun.java b/src/main/java/de/workaround/model/ActionRun.java
index 093c3ac..355bae9 100644
--- a/src/main/java/de/workaround/model/ActionRun.java
+++ b/src/main/java/de/workaround/model/ActionRun.java
@@ -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
diff --git a/src/test/java/de/workaround/ci/SupersededRunsTest.java b/src/test/java/de/workaround/ci/SupersededRunsTest.java
new file mode 100644
index 0000000..b8437a6
--- /dev/null
+++ b/src/test/java/de/workaround/ci/SupersededRunsTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog