✨ (ci): Add the per-repository Actions UI
Changes
9 files changed, +398 -19
MODIFY
README.md
+8 -6
@@ -82,12 +82,14 @@
82
82
- **Federation (ForgeFed / ActivityPub)** — *opt-in, off by default.* Public repositories are
83
83
exposed as ForgeFed `Repository` actors that remote instances can follow and receive `Push`
84
84
activities from (see below)
85
-- **CI/CD runners** — *phase 1: registration only.* git-shark implements the server side of the
86
- Forgejo/Gitea `runner.v1` Connect protocol under `/api/actions`, so a stock `forgejo-runner` /
87
- `act_runner` registers and reports in unchanged. Instance admins (handles in
88
- `GITSHARK_ADMIN_HANDLES`) generate reusable registration tokens and manage runners at
89
- `/admin/runners`; secrets are stored hashed. Workflow execution (fetching and running jobs) is a
90
- follow-up phase. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
85
+- **CI/CD runners** — git-shark implements the server side of the Forgejo/Gitea `runner.v1` Connect
86
+ protocol under `/api/actions`, so a stock `forgejo-runner` / `act_runner` registers and runs jobs
87
+ unchanged. Instance admins (handles in `GITSHARK_ADMIN_HANDLES`) generate reusable registration
88
+ tokens and manage runners at `/admin/runners`; secrets are stored hashed. A push adding a workflow
89
+ to `.forgejo/workflows/` (plain `on: push`) creates a run, which a runner claims, executes, and
90
+ streams logs for — visible on the repository's **Actions** tab; a vanished runner's task is
91
+ reclaimed after a timeout. Richer triggers, secrets/variables, `needs`/`matrix`, and artifacts are
92
+ follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
91
93
[architecture](docs/maintainers/ci-runners.md)
92
94
activities from; local users can in turn follow a remote repository — or a whole remote user, whose
93
95
public repositories are then followed and shown grouped — and read their pushes (see below)
MODIFY
docs/maintainers/ci-runners.md
+6 -2
@@ -15,9 +15,10 @@
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
+| Actions UI | `web/ActionResource.java` + `templates/ActionResource/` | Read-only per-repo run list + run detail (jobs and their log rows); sidebar `Actions` tab. |
18
19
| Entities | `model/CiRunner.java`, `model/CiRunnerRegistrationToken.java` | Runner state (migration `V19`). |
19
20
| 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. |
20
-| 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. |
21
+| 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). |
21
22
| Admin UI | `ci/AdminRunnerResource.java` + `templates/AdminRunnerResource/` | Token generation, runner list, deletion. |
22
23
| Admin gate | `account/AdminAccess.java` | Config-driven instance-admin check. |
23
24
@@ -85,6 +86,10 @@
85
86
runner OFFLINE. The deadline is set at claim time from `gitshark.ci.task-timeout` (default 1h). A
86
87
late update from a runner cannot resurrect an already-terminal task. `ZombieReclaimTest` covers both
87
88
the reclaim (overdue → FAILURE, in-deadline left RUNNING) and the anti-resurrection guard.
89
+- **Actions UI:** a read-only `Actions` tab on each repository — `ActionResource` renders a run list
90
+ (workflow, run number, status, event, short commit) and a run detail page with each job and its
91
+ streamed log rows. Read-gated like the rest of the repo UI (404 for a hidden repo). Tested by
92
+ `ActionUiTest` (list shows runs + tab, detail shows jobs and logs, unknown run number → 404).
88
93
89
94
## What still needs to be implemented
90
95
@@ -95,7 +100,6 @@
95
100
it needs the single job isolated/expanded. No `needs`/`matrix` yet.
96
101
- **Trigger refinement:** only bare `on: push` is honored; branch/tag/path filters and other events
97
102
(tag push, `pull_request`) are not evaluated.
98
-- **Run UI:** per-repository run list + run detail with live per-step status and logs.
99
103
- **Real-runner integration test:** protocol round-trip against an actual `forgejo-runner` container
100
104
(the current endpoint test uses a hand-built protobuf client, not the binary).
101
105
- **Later phases:** secrets/variables delivery, label-based matching, concurrency/cancellation,
MODIFY
docs/users/ci-runners.md
+30 -11
@@ -4,21 +4,40 @@
4
4
protocol, so the standard `forgejo-runner` (or Gitea `act_runner`) connects to it directly, and
5
5
workflows use the familiar GitHub-Actions-compatible YAML format in `.forgejo/workflows/`.
6
6
7
-> **Available today:** an **instance administrator** can register runners against this instance and
8
-> see them listed. **Running workflows is not enabled yet** — pushing a workflow file does not yet
9
-> start a job. This page will grow as workflow execution, logs, and per-repository run views land.
7
+> **Available today:** an **instance administrator** registers runners against this instance, and a
8
+> push that adds a workflow to `.forgejo/workflows/` (or `.gitea/workflows/`) starts a run that a
9
+> connected runner picks up and executes, with logs and results shown on the repository's **Actions**
10
+> tab. Triggers are limited to a plain `on: push` for now; richer triggers, secrets, and `needs`/
11
+> `matrix` arrive in later phases.
10
12
11
-## What you can do now
13
+## Running a workflow
12
14
13
-- **If you are an instance admin**, you manage runners under **Account → CI runners**. See the
14
- [admin guide](../admins/ci-runners.md) for generating a registration token and connecting a runner.
15
-- **If you are not an admin**, there is nothing to configure yet. Runner management is
16
- instance-wide and admin-only in this phase; per-repository controls and workflow authoring arrive
17
- in later phases.
15
+1. An instance admin connects a runner (see the [admin guide](../admins/ci-runners.md)).
16
+2. Add a workflow file such as `.forgejo/workflows/ci.yml` with a `push` trigger:
17
+
18
+ ```yaml
19
+ name: CI
20
+ on: push
21
+ jobs:
22
+ build:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - run: echo "hello from git-shark"
26
+ ```
27
+3. Push it. Each push to a branch creates a run (one per workflow file whose `on:` includes `push`).
28
+
29
+## Viewing runs
30
+
31
+Open the **Actions** tab on the repository. It lists every run — newest first — with its workflow
32
+name, run number (`#1`, `#2`, …), status (Pending / Running / Success / Failure / Cancelled), the
33
+triggering event and the short commit. Click a run to see each job and its log output (as of when
34
+the page was loaded — reload to see newer lines).
35
+
36
+A run whose runner disappears mid-job is marked **Failure** once its time limit passes (configurable
37
+by the admin), so a run never hangs as Running forever.
18
38
19
39
## What's coming
20
40
21
-- Workflows in `.forgejo/workflows/*.yml` triggered on push, with live per-step logs and results in
22
- the repository UI.
41
+- Richer triggers (branch/tag/path filters, tag pushes, merge-request events).
23
42
- Repository-level secrets and variables, `needs`/`matrix`, and run cancellation/re-run.
24
43
- Artifacts and commit/merge-request status integration.
ADD
src/main/java/de/workaround/web/ActionResource.java
+99 -0
@@ -0,0 +1,99 @@
1
+package de.workaround.web;
2
+
3
+import java.util.List;
4
+
5
+import de.workaround.account.CurrentUser;
6
+import de.workaround.git.AccessPolicy;
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.model.ActionLog;
9
+import de.workaround.model.ActionRun;
10
+import de.workaround.model.ActionTask;
11
+import de.workaround.model.Repository;
12
+import io.quarkus.qute.CheckedTemplate;
13
+import io.quarkus.qute.TemplateInstance;
14
+import jakarta.inject.Inject;
15
+import jakarta.ws.rs.GET;
16
+import jakarta.ws.rs.NotFoundException;
17
+import jakarta.ws.rs.PathParam;
18
+import jakarta.ws.rs.Produces;
19
+import jakarta.ws.rs.core.Context;
20
+import jakarta.ws.rs.core.MediaType;
21
+import jakarta.ws.rs.core.UriInfo;
22
+
23
+/**
24
+ * Read-only Actions UI for a repository (issue #2, phase 1): a run list and a run detail page showing
25
+ * each job (task) and its streamed log rows. Mirrors {@link IssueResource}'s structure — resolve the
26
+ * repository, gate on read access (404 to hide private repos), and render a {@code @CheckedTemplate}.
27
+ */
28
+@jakarta.ws.rs.Path("/repos/{owner}/{name}/actions")
29
+@Produces(MediaType.TEXT_HTML)
30
+public class ActionResource
31
+{
32
+ @CheckedTemplate
33
+ static class Templates
34
+ {
35
+ static native TemplateInstance runs(Repository repo, RepoNav nav, List<ActionRun> runs);
36
+
37
+ static native TemplateInstance run(Repository repo, RepoNav nav, ActionRun run, List<TaskLogs> tasks);
38
+ }
39
+
40
+ /** A task paired with its log rows, for the run detail page. */
41
+ public record TaskLogs(ActionTask task, List<ActionLog> logs)
42
+ {
43
+ }
44
+
45
+ @Inject
46
+ CurrentUser currentUser;
47
+
48
+ @Inject
49
+ GitRepositoryService service;
50
+
51
+ @Inject
52
+ AccessPolicy accessPolicy;
53
+
54
+ @Inject
55
+ RepoNavService repoNav;
56
+
57
+ @Inject
58
+ ActionRun.Repo runs;
59
+
60
+ @Inject
61
+ ActionTask.Repo tasks;
62
+
63
+ @Inject
64
+ ActionLog.Repo logs;
65
+
66
+ @Context
67
+ UriInfo uriInfo;
68
+
69
+ @GET
70
+ public TemplateInstance list(@PathParam("owner") String owner, @PathParam("name") String name)
71
+ {
72
+ Repository repo = requireReadable(owner, name);
73
+ return Templates.runs(repo, repoNav.build(repo, uriInfo), runs.findByRepository(repo));
74
+ }
75
+
76
+ @GET
77
+ @jakarta.ws.rs.Path("{number:\\d+}")
78
+ public TemplateInstance detail(@PathParam("owner") String owner, @PathParam("name") String name,
79
+ @PathParam("number") int number)
80
+ {
81
+ Repository repo = requireReadable(owner, name);
82
+ ActionRun run = runs.findByRepositoryAndNumber(repo, number).orElseThrow(NotFoundException::new);
83
+ List<TaskLogs> taskLogs = tasks.findByRun(run).stream()
84
+ .map(task -> new TaskLogs(task, logs.findByTask(task)))
85
+ .toList();
86
+ return Templates.run(repo, repoNav.build(repo, uriInfo), run, taskLogs);
87
+ }
88
+
89
+ private Repository requireReadable(String owner, String name)
90
+ {
91
+ Repository repo = service.find(owner, name).orElseThrow(NotFoundException::new);
92
+ if (!accessPolicy.canRead(currentUser.get(), repo))
93
+ {
94
+ throw new NotFoundException();
95
+ }
96
+ return repo;
97
+ }
98
+
99
+}
MODIFY
src/main/resources/META-INF/resources/shark.css
+53 -0
@@ -863,6 +863,59 @@
863
863
background: var(--success);
864
864
}
865
865
866
+/* CI run / task status */
867
+
868
+.badge.status-PENDING {
869
+ color: var(--attention);
870
+ border-color: var(--attention-border);
871
+ background: var(--attention-bg);
872
+}
873
+
874
+.badge.status-RUNNING {
875
+ color: var(--accent-deep);
876
+ border-color: transparent;
877
+ background: var(--accent-soft);
878
+}
879
+
880
+.badge.status-SUCCESS {
881
+ color: #fff;
882
+ border-color: var(--success);
883
+ background: var(--success);
884
+}
885
+
886
+.badge.status-FAILURE {
887
+ color: var(--danger);
888
+ border-color: var(--danger-border);
889
+ background: var(--danger-bg);
890
+}
891
+
892
+.badge.status-CANCELLED {
893
+ color: var(--muted);
894
+ border-color: var(--border);
895
+}
896
+
897
+.run-job-head {
898
+ display: flex;
899
+ align-items: center;
900
+ gap: 10px;
901
+ margin-bottom: 10px;
902
+}
903
+
904
+.run-log {
905
+ margin: 0;
906
+ padding: 12px 14px;
907
+ overflow-x: auto;
908
+ font: 12.5px/1.5 var(--mono);
909
+ background: var(--code-bg, #0d1117);
910
+ color: var(--code-fg, #c9d1d9);
911
+ border-radius: 6px;
912
+ white-space: pre;
913
+}
914
+
915
+.run-meta {
916
+ font: 500 12px/1 var(--mono);
917
+}
918
+
866
919
.issues-head {
867
920
display: flex;
868
921
align-items: center;
ADD
src/main/resources/templates/ActionResource/run.html
+30 -0
@@ -0,0 +1,30 @@
1
+{#include layout}
2
+{#title}{run.workflowName} #{run.number} – {repo.name}{/title}
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='actions' /}
5
+ <section class="repo-main">
6
+ <p class="issue-back"><a href="/repos/{repo.ownerHandle}/{repo.name}/actions">← Actions</a></p>
7
+ <header class="issue-header">
8
+ <h2 class="issue-heading">{run.workflowName} <span class="issue-no">#{run.number}</span></h2>
9
+ </header>
10
+ <div class="issue-byline">
11
+ <span class="badge status-{run.status}">{run.status.label}</span>
12
+ <span class="run-meta muted">{run.event} · {run.ref} · {run.commitSha.substring(0, 7)}</span>
13
+ </div>
14
+ {#for entry in tasks}
15
+ <div class="panel run-job">
16
+ <div class="run-job-head">
17
+ <span class="badge status-{entry.task.status}">{entry.task.status.label}</span>
18
+ <span class="n">{entry.task.name}</span>
19
+ </div>
20
+ {#if entry.logs.isEmpty()}
21
+ <p class="muted run-job-empty">No logs.</p>
22
+ {#else}
23
+ <pre class="run-log">{#for row in entry.logs}{row.content}
24
+{/for}</pre>
25
+ {/if}
26
+ </div>
27
+ {/for}
28
+ </section>
29
+</div>
30
+{/include}
ADD
src/main/resources/templates/ActionResource/runs.html
+27 -0
@@ -0,0 +1,27 @@
1
+{#include layout}
2
+{#title}Actions – {repo.name}{/title}
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='actions' /}
5
+ <section class="repo-main">
6
+ <div class="issues-head">
7
+ <h2>Actions</h2>
8
+ </div>
9
+ {#if runs.isEmpty()}
10
+ <p class="muted">No workflow runs yet. Push a workflow to <code>.forgejo/workflows/</code> to start one.</p>
11
+ {#else}
12
+ <div class="panel">
13
+ {#for run in runs}
14
+ <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/actions/{run.number}">
15
+ <span class="fname">
16
+ <span class="badge status-{run.status}">{run.status.label}</span>
17
+ <span class="n">{run.workflowName}</span>
18
+ <span class="issue-no">#{run.number}</span>
19
+ </span>
20
+ <span class="run-meta muted">{run.event} · {run.commitSha.substring(0, 7)}</span>
21
+ </a>
22
+ {/for}
23
+ </div>
24
+ {/if}
25
+ </section>
26
+</div>
27
+{/include}
MODIFY
src/main/resources/templates/RepositoryResource/sidebar.html
+1 -0
@@ -36,6 +36,7 @@
36
36
<a class="{#if active == 'tags'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/tags"><span class="g">⬡</span> Tags <span class="ct">{nav.tagCount}</span></a>
37
37
<a class="{#if active == 'issues'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/issues"><span class="g">◇</span> Issues <span class="ct">{nav.openIssueCount}</span></a>
38
38
<a class="{#if active == 'merge-requests'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/merge-requests"><span class="g">⇄</span> Merge requests <span class="ct">{nav.openMrCount}</span></a>
39
+ <a class="{#if active == 'actions'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/actions"><span class="g">▷</span> Actions</a>
39
40
{#if nav.isOwner}
40
41
<a class="{#if active == 'settings'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/settings"><span class="g">⚙</span> Settings</a>
41
42
{/if}
ADD
src/test/java/de/workaround/web/ActionUiTest.java
+144 -0
@@ -0,0 +1,144 @@
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.ActionLog;
9
+import de.workaround.model.ActionRun;
10
+import de.workaround.model.ActionTask;
11
+import de.workaround.model.Repository;
12
+import de.workaround.model.User;
13
+import io.quarkus.test.junit.QuarkusTest;
14
+import jakarta.inject.Inject;
15
+import jakarta.transaction.Transactional;
16
+
17
+import static io.restassured.RestAssured.given;
18
+import static org.hamcrest.Matchers.containsString;
19
+
20
+/**
21
+ * The per-repository Actions UI (issue #2, phase 1): a run list and a run detail page showing the
22
+ * run's tasks and their log rows, server-rendered like the issues pages.
23
+ */
24
+@QuarkusTest
25
+class ActionUiTest
26
+{
27
+ @Inject
28
+ GitRepositoryService repositories;
29
+
30
+ @Inject
31
+ User.Repo users;
32
+
33
+ @Inject
34
+ ActionRun.Repo runs;
35
+
36
+ @Inject
37
+ ActionTask.Repo tasks;
38
+
39
+ @Inject
40
+ ActionLog.Repo logs;
41
+
42
+ @Test
43
+ void runListShowsRunsAndActionsTab()
44
+ {
45
+ Seed seed = seedRun("act-a");
46
+
47
+ given().when().get("/repos/" + seed.owner + "/act-a/actions")
48
+ .then().statusCode(200)
49
+ .body(containsString("class=\"repo-nav\""))
50
+ .body(containsString("active"))
51
+ .body(containsString("CI"))
52
+ .body(containsString("Success"))
53
+ .body(containsString("#1"));
54
+ }
55
+
56
+ @Test
57
+ void runDetailShowsTasksAndLogs()
58
+ {
59
+ Seed seed = seedRun("act-b");
60
+
61
+ given().when().get("/repos/" + seed.owner + "/act-b/actions/1")
62
+ .then().statusCode(200)
63
+ .body(containsString("build"))
64
+ .body(containsString("line 0"))
65
+ .body(containsString("line 1"));
66
+ }
67
+
68
+ @Test
69
+ void unknownRunNumberIs404()
70
+ {
71
+ Seed seed = seedRun("act-c");
72
+
73
+ given().when().get("/repos/" + seed.owner + "/act-c/actions/999")
74
+ .then().statusCode(404);
75
+ }
76
+
77
+ @Test
78
+ void privateRepoActionsHiddenFromStrangers()
79
+ {
80
+ String owner = seedPrivateRepo("act-priv");
81
+
82
+ given().when().get("/repos/" + owner + "/act-priv/actions")
83
+ .then().statusCode(404);
84
+ given().when().get("/repos/" + owner + "/act-priv/actions/1")
85
+ .then().statusCode(404);
86
+ }
87
+
88
+ @Transactional
89
+ String seedPrivateRepo(String repoName)
90
+ {
91
+ String username = repoName + "-" + UUID.randomUUID().toString().substring(0, 8);
92
+ User owner = new User();
93
+ owner.oidcSub = username;
94
+ owner.username = username;
95
+ owner.persist();
96
+ repositories.create(owner, repoName, Repository.Visibility.PRIVATE, null);
97
+ return username;
98
+ }
99
+
100
+ private record Seed(String owner)
101
+ {
102
+ }
103
+
104
+ @Transactional
105
+ Seed seedRun(String repoName)
106
+ {
107
+ String username = repoName + "-" + UUID.randomUUID().toString().substring(0, 8);
108
+ User owner = new User();
109
+ owner.oidcSub = username;
110
+ owner.username = username;
111
+ owner.persist();
112
+ Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
113
+
114
+ ActionRun run = new ActionRun();
115
+ run.repository = repo;
116
+ run.number = 1;
117
+ run.workflowName = "CI";
118
+ run.workflowFile = ".forgejo/workflows/ci.yml";
119
+ run.event = "push";
120
+ run.ref = "refs/heads/main";
121
+ run.commitSha = "abcdef1234567890abcdef1234567890abcdef12";
122
+ run.status = ActionRun.Status.SUCCESS;
123
+ run.persist();
124
+
125
+ ActionTask task = new ActionTask();
126
+ task.run = run;
127
+ task.name = "build";
128
+ task.payload = "on: push";
129
+ task.status = ActionRun.Status.SUCCESS;
130
+ task.logLength = 2;
131
+ task.persist();
132
+
133
+ for (int i = 0; i < 2; i++)
134
+ {
135
+ ActionLog log = new ActionLog();
136
+ log.task = task;
137
+ log.lineIndex = i;
138
+ log.content = "line " + i;
139
+ log.persist();
140
+ }
141
+ return new Seed(username);
142
+ }
143
+
144
+}