detached 8a6cfec

feat(ci): workflow_dispatch, schedule and workflow_run triggers (#34)

commit
8a6cfecd74244a0a5fbccc67073ce8dbfc21f848
author
brice · · 2026-10-15 10:13 UTC
committer
bnema · · 2026-10-15 10:13 UTC
parent
815fb98
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.15.0
48 files · +4384 -805
M README.md +9 -1
@@ -115,7 +115,7 @@ A merge request proposes a branch for its target (the default branch unless set)
115115
116116 ## CI
117117
118-ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a branch or a tag, and the merge of a merge request) and on `pull_request` (the merge requests, below) and shows each job as a check on the commit, the branch list and the merge request. CI is on once a runner is registered; there is no switch. Before that a push makes no run and no check.
118+ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a branch or a tag, and the merge of a merge request), on `pull_request` (the merge requests, below), `workflow_dispatch`, `schedule` and `workflow_run` (below) and shows each job as a check on the commit, the branch list and the merge request. CI is on once a runner is registered; there is no switch. Before that a push makes no run and no check.
119119
120120 Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails.
121121
@@ -125,6 +125,14 @@ An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same
125125
126126 **`pull_request`.** A merge request is a pull request for the workflows. ermite keeps `refs/pull/<n>/head` at the head of its source branch and, while that head merges cleanly into the target, `refs/pull/<n>/merge` at a test merge commit (parents: the target tip, then the head; message `Merge <head> into <base>`). Both are served to `git fetch`, never listed as branches or tags, and refused on every push (for any token, a job's included). The activities are `opened` (the request is created), `synchronize` (its source branch is pushed; each push runs for the commit it pushed, with the rights of whoever pushed it) and `closed` (closed or merged, with `merged` true or false; it uses the last test merge commit, or the head when there is none, and is never computed again, even when the source branch is gone); `reopened` is accepted in `types:` but merge requests cannot be reopened. Without `types:` a workflow gets `opened`, `synchronize` and `reopened`. The run has `github.ref` `refs/pull/<n>/merge`, `github.sha` the merge commit (the workflow file is read from it, so `actions/checkout` gets the merged tree), `github.head_ref` and `github.base_ref` set, and a payload shaped like GitHub's `pull_request` webhook with the fields ermite has. `branches` and `paths` (and their `-ignore` forms) apply to the target branch and to the files changed between the merge base and the head. A head that conflicts with the target makes no run for that update. The checks of the run are on the head commit. The run is trusted (secrets, write token) only when whoever caused the event is the owner, like a push; a job token starts no run.
127127
128+**`workflow_dispatch`.** `ermite workflow run <file> --repo <repo> [--ref <branch or tag>] [-f name=value …]` starts a run of a workflow that declares `workflow_dispatch` (the API is `RunService.DispatchWorkflow`, feature `ci.dispatch`; it needs `ci:write`). The file is its path or its name under `.github/workflows`, and it is read at the ref (the default branch when `--ref` is left out). The inputs are checked against the workflow: an undeclared input, a missing `required` input without a default, a `boolean` other than `true` or `false`, a `number` that is not a decimal number, a `choice` outside its `options`, and an `environment` that is not a valid environment name are refused; a `choice` without default takes its first option. A workflow has at most 25 inputs and their values at most 65,535 characters. In the run, `github.event.inputs` holds the values as strings and the `inputs` context as their declared types (`boolean`, `number`, else string). The actor is the caller. The run is trusted (secrets, write token) only when **both** the caller is the owner **and** the ref it runs on was moved to its current commit by the owner (see `schedule`): the owner dispatching an agent's branch gets an untrusted run, like the owner re-running an agent's run, and a ref with no record (pushed before ermite recorded pushers) is untrusted too. This holds for a tag as for a branch, and for a job token as for the owner. A token limited to branch prefixes can dispatch only on a branch under them and its run is never trusted. A job can dispatch with its repository token when its `permissions:` give `actions: write` (GitHub's exception to "events made with `GITHUB_TOKEN` start no run"), even without `contents: read`; a token that does not hold the permission is refused.
129+
130+**`schedule`.** `on.schedule[].cron` (five fields, in UTC; `timezone` is ignored) is read on the **default branch** only. Every minute the scheduler starts the workflows whose cron matches that minute, on the latest commit of the default branch; the payload is `{"schedule": "<cron>"}`. A minute is handled once: ticks missed while ermite was down or busy are not made up, and a workflow with several matching entries starts one run. The actor is **whoever last moved the default branch to its current commit** (a push or a merge), and the run is trusted only if that was the owner. ermite records who moves each branch and tag **before** it moves (in the pre-receive decision of a push, and in the merge under its lock; a record that cannot be written refuses the push), together with the commit it is for: the record counts only while the ref still points to that commit, so a late write cannot leave the owner's record on a tip an agent moved there (owner A, agent B, agent A). A tip with no record, a record for another commit (a push refused after its record was written) or an unreadable record runs untrusted, without secrets. If the owner and a token push the very same commit at the same time, the owner's record may remain: the content is the same and the owner pushed it. At most 64 repositories are looked at per second, in rotation, so a pass over more takes several seconds and never starves one. The schedules of a repository are read again when its default branch moves, and go with the repository.
131+
132+**`workflow_run`.** A workflow that lists `workflow_run: workflows: [<name>]` runs when a run of a workflow with that `name` is `requested` (created) or `completed` (`types:` picks one; `in_progress` never happens). `branches` and `branches-ignore` apply to the head branch of the triggering run (the source branch of a merge request). The workflow is read from the **default branch** and runs on its latest commit; the payload has `workflow_run` (`id`, `name`, `head_branch`, `head_sha`, `event`, `status`, `conclusion`, `run_number`, `run_attempt`, `path`, `actor`) and `workflow`. The run inherits the actor and the trust of the triggering run and never gains any: a workflow_run of an untrusted run has no secrets. A chain stops after three levels of `workflow_run`.
133+
134+The event of a run is `push`, `pull_request`, `workflow_dispatch`, `schedule` or `workflow_run`: `ermite run list` and `view` and the Actions pages show it.
135+
128136 The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm`, `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected; a `HEAD` answers the headers without building the archive) and `GET /repos/{owner}/{repo}/pulls/{number}/files` (what `dorny/paths-filter` reads on a `pull_request`: up to 3000 files, `page` and `per_page` of at most 100, 30 by default, with the `Link` header; a job token needs `pull-requests: read`, else `403 Resource not accessible by integration`). The owner in these paths and in the Git URLs is matched without regard to case, as on GitHub. Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature.
129137
130138 Actions from github.com are resolved with `git ls-remote https://github.com/<owner>/<repo>` (no credentials, 20 s, 16 MiB of output, at most four at once, 60 s for a whole call). ermite hands git only the proxy settings of its own environment (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `ALL_PROXY` and their lower-case forms), so a server that reaches the Internet through a proxy sets them for `ermite serve`; nothing else of its environment, no credential, reaches git.
M gen/ermite/storage/ci.pb.go +127 -36 generated file: collapsed
M internal/adapters/client/client_test.go +3 -3
@@ -139,16 +139,16 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets},
142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146- client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets,
146+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch,
147147 client.FeaturePasskeys,
148148 }))
149149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,
151- client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets,
151+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch,
152152 }))
153153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
154154 }
M internal/adapters/client/features.go +3 -1
@@ -40,11 +40,13 @@ const (
4040 // FeatureCISecrets is SecretService, VariableService and EnvironmentService
4141 // (`ermite secret`, `variable`, `environment`).
4242 FeatureCISecrets = "ci.secrets"
43+ // FeatureCIDispatch is RunService.DispatchWorkflow (`ermite workflow run`).
44+ FeatureCIDispatch = "ci.dispatch"
4345 )
4446
4547 // UsedFeatures lists the features the CLI uses, in the order they are reported.
4648 func UsedFeatures() []string {
47- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeaturePasskeys}
49+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeaturePasskeys}
4850 }
4951
5052 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/kvstore/ci.go +2 -1
@@ -38,6 +38,7 @@ import (
3838 // ciactive/<job key> job assigned or running
3939 // jobtoken/<hash> job token (value: job key)
4040 // concur/<repo>/<key(group)> concurrency group (ci_concurrency.go)
41+// cireftip/<repo>/<ref> who moves a branch or tag to its object (ci_tip.go)
4142 //
4243 // The job repository tokens and the action resolutions are in ci_token.go, the
4344 // annotations in ci_annotation.go.
@@ -885,7 +886,7 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by
885886 keys = append(keys, slices.Clone(key))
886887 }
887888 keys = append(keys, repoReaderKeys(ctx, snap, repo)...)
888- for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/", concurPrefix + repo.String() + "/", concurMarkPrefixAll + repo.String() + "/"} {
889+ for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/", concurPrefix + repo.String() + "/", concurMarkPrefixAll + repo.String() + "/", refTipsPrefix(repo)} {
889890 for k := range snap.Prefix(ctx, []byte(p)) {
890891 keys = append(keys, slices.Clone(k))
891892 }
A internal/adapters/kvstore/ci_tip.go +91 -0
@@ -0,0 +1,91 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ kv "bnema.dev/ermite-kv"
11+ "google.golang.org/protobuf/proto"
12+
13+ storagepb "bnema.dev/ermite/gen/ermite/storage"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// Who moves a branch or a tag to its object (CI-3, D1; domain.CIRefTip):
19+//
20+// cireftip/<repo>/<full ref name> CIRefTip
21+//
22+// Written under the guard of the repository key, deleted with the repository
23+// (ciKeys). Ref names hold '/', so the key is split at the first '/' after the
24+// repository, whose name holds none.
25+const refTipPrefix = "cireftip/"
26+
27+var _ ports.CIRefTipStore = (*CI)(nil)
28+
29+func refTipKey(repo domain.RepoName, ref string) []byte {
30+ return []byte(refTipPrefix + repo.String() + "/" + ref)
31+}
32+
33+func refTipsPrefix(repo domain.RepoName) string { return refTipPrefix + repo.String() + "/" }
34+
35+// updateAttemptsTip bounds the retries of a write that conflicts with another
36+// write of the repository.
37+const updateAttemptsTip = 8
38+
39+// PutRefTip records who moves the ref to its object. A repository that does not
40+// exist is domain.ErrNotFound.
41+func (c *CI) PutRefTip(ctx context.Context, t domain.CIRefTip) error {
42+ val, err := proto.Marshal(&storagepb.CIRefTip{
43+ Sha: t.Sha, Actor: t.Actor, Trusted: t.Trusted,
44+ PusherKind: string(t.Pusher.Kind), PusherTokenId: t.Pusher.TokenID,
45+ })
46+ if err != nil {
47+ return fmt.Errorf("encode ref tip: %w", err)
48+ }
49+ for range updateAttemptsTip {
50+ var b kv.Batch
51+ if err := c.guardRepo(ctx, &b, t.Repo); err != nil {
52+ return err
53+ }
54+ b.Put(refTipKey(t.Repo, t.Ref), val)
55+ _, err := c.db.Apply(ctx, &b)
56+ if errors.Is(err, kv.ErrConflict) {
57+ continue
58+ }
59+ if err != nil {
60+ return fmt.Errorf("store ref tip: %w", err)
61+ }
62+ return nil
63+ }
64+ return fmt.Errorf("store ref tip: %w", domain.ErrConflict)
65+}
66+
67+// GetRefTip reads the record of a ref, or domain.ErrNotFound.
68+func (c *CI) GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error) {
69+ it, ok := c.db.Get(ctx, refTipKey(repo, ref))
70+ if !ok {
71+ return domain.CIRefTip{}, domain.ErrNotFound
72+ }
73+ var pb storagepb.CIRefTip
74+ if err := proto.Unmarshal(it.Value, &pb); err != nil {
75+ return domain.CIRefTip{}, fmt.Errorf("decode ref tip: %w", err)
76+ }
77+ return domain.CIRefTip{
78+ Repo: repo, Ref: ref, Sha: pb.GetSha(), Actor: pb.GetActor(), Trusted: pb.GetTrusted(),
79+ Pusher: domain.CIPusher{Kind: domain.CIPusherKind(pb.GetPusherKind()), TokenID: pb.GetPusherTokenId()},
80+ }, nil
81+}
82+
83+// DeleteRefTip removes the record of a ref (the ref is deleted).
84+func (c *CI) DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error {
85+ var b kv.Batch
86+ b.Delete(refTipKey(repo, ref))
87+ if _, err := c.db.Apply(ctx, &b); err != nil {
88+ return fmt.Errorf("delete ref tip: %w", err)
89+ }
90+ return nil
91+}
M internal/adapters/rpc/api.go +1 -1
@@ -34,7 +34,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3434 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3535 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3636 // are.
37-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency"}
37+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch"}
3838
3939 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4040 const FeaturePasskeys = "passkeys"
M internal/adapters/rpc/ci.go +14 -2
@@ -208,8 +208,6 @@ func (s *runnerService) UploadSummary(ctx context.Context, req *connect.Request[
208208
209209 // runService implements ermiteconnect.RunServiceHandler.
210210 type runService struct {
211- // DispatchWorkflow is not implemented yet: the feature "ci.dispatch" is not
212- // listed and the RPC answers unimplemented.
213211 ermiteconnect.UnimplementedRunServiceHandler
214212 runs ports.RunService
215213 }
@@ -314,6 +312,20 @@ func (s *runService) RerunRun(ctx context.Context, req *connect.Request[pb.Rerun
314312 return connect.NewResponse(&pb.RerunRunResponse{Run: runToPB(run)}), nil
315313 }
316314
315+func (s *runService) DispatchWorkflow(ctx context.Context, req *connect.Request[pb.DispatchWorkflowRequest]) (*connect.Response[pb.DispatchWorkflowResponse], error) {
316+ name, err := repoOf(req.Msg.GetRepo())
317+ if err != nil {
318+ return nil, ciError(ctx, err)
319+ }
320+ run, err := s.runs.DispatchWorkflow(ctx, caller(ctx), name, ports.DispatchParams{
321+ WorkflowPath: req.Msg.GetWorkflowPath(), Ref: req.Msg.GetRef(), Inputs: req.Msg.GetInputs(),
322+ })
323+ if err != nil {
324+ return nil, ciError(ctx, err)
325+ }
326+ return connect.NewResponse(&pb.DispatchWorkflowResponse{Run: runToPB(run)}), nil
327+}
328+
317329 func (s *runService) ListChecks(ctx context.Context, req *connect.Request[pb.ListChecksRequest]) (*connect.Response[pb.ListChecksResponse], error) {
318330 name, err := repoOf(req.Msg.GetRepo())
319331 if err != nil {
M internal/adapters/web/actions.templ +5 -0
@@ -40,6 +40,9 @@ templ RunListRow(r RunRow) {
4040 <td class="c-name">
4141 <a href={ templ.SafeURL(r.Href) }>{ r.Workflow }</a>
4242 <span class="mute">{ r.Number }</span>
43+ if r.Event != "" {
44+ <span class="pill">{ r.Event }</span>
45+ }
4346 if r.Attempt > 1 {
4447 <span class="pill">attempt { itoa(int(r.Attempt)) }</span>
4548 }
@@ -239,6 +242,8 @@ templ runRail(p RunPage) {
239242 <dl class="kv">
240243 <dt>result</dt>
241244 <dd>{ p.State }</dd>
245+ <dt>event</dt>
246+ <dd>{ p.Event }</dd>
242247 <dt>commit</dt>
243248 <dd><a href={ templ.SafeURL(p.CHref) }>{ p.Short }</a></dd>
244249 <dt>ref</dt>
M internal/adapters/web/actions_pages.go +4 -1
@@ -119,6 +119,9 @@ type RunRow struct {
119119 Kind string // ciKind
120120 State string // ciState
121121 Workflow string
122+ // Event is the event that triggered the run (push, pull_request,
123+ // workflow_dispatch, schedule, workflow_run).
124+ Event string
122125 Title string
123126 Ref string
124127 RefHref string
@@ -228,7 +231,7 @@ func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
228231 row := RunRow{
229232 ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID),
230233 Kind: ciKind(run.Status, run.Conclusion), State: ciState(run.Status, run.Conclusion),
231- Workflow: visibleText(run.WorkflowName), Title: visibleText(run.Title),
234+ Workflow: visibleText(run.WorkflowName), Event: visibleText(run.Event), Title: visibleText(run.Title),
232235 Ref: visibleText(strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")),
233236 Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: visibleText(run.Actor),
234237 Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt,
M internal/adapters/web/actions_templ.go +760 -728
@@ -261,184 +261,203 @@ func RunListRow(r RunRow) templ.Component {
261261 if templ_7745c5c3_Err != nil {
262262 return templ_7745c5c3_Err
263263 }
264- if r.Attempt > 1 {
265- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"pill\">attempt ")
264+ if r.Event != "" {
265+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"pill\">")
266266 if templ_7745c5c3_Err != nil {
267267 return templ_7745c5c3_Err
268268 }
269269 var templ_7745c5c3_Var14 string
270- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(r.Attempt)))
270+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Event)
271271 if templ_7745c5c3_Err != nil {
272- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 44, Col: 53}
272+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 44, Col: 32}
273273 }
274274 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
275275 if templ_7745c5c3_Err != nil {
276276 return templ_7745c5c3_Err
277277 }
278- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span>")
278+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span> ")
279279 if templ_7745c5c3_Err != nil {
280280 return templ_7745c5c3_Err
281281 }
282282 }
283- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td class=\"c-sub\"><a href=\"")
284- if templ_7745c5c3_Err != nil {
285- return templ_7745c5c3_Err
286- }
287- var templ_7745c5c3_Var15 templ.SafeURL
288- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
289- if templ_7745c5c3_Err != nil {
290- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 47, Col: 52}
291- }
292- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
293- if templ_7745c5c3_Err != nil {
294- return templ_7745c5c3_Err
283+ if r.Attempt > 1 {
284+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"pill\">attempt ")
285+ if templ_7745c5c3_Err != nil {
286+ return templ_7745c5c3_Err
287+ }
288+ var templ_7745c5c3_Var15 string
289+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(r.Attempt)))
290+ if templ_7745c5c3_Err != nil {
291+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 47, Col: 53}
292+ }
293+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
294+ if templ_7745c5c3_Err != nil {
295+ return templ_7745c5c3_Err
296+ }
297+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span>")
298+ if templ_7745c5c3_Err != nil {
299+ return templ_7745c5c3_Err
300+ }
295301 }
296- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">")
302+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><td class=\"c-sub\"><a href=\"")
297303 if templ_7745c5c3_Err != nil {
298304 return templ_7745c5c3_Err
299305 }
300- var templ_7745c5c3_Var16 string
301- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
306+ var templ_7745c5c3_Var16 templ.SafeURL
307+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
302308 if templ_7745c5c3_Err != nil {
303- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 47, Col: 64}
309+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 50, Col: 52}
304310 }
305311 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
306312 if templ_7745c5c3_Err != nil {
307313 return templ_7745c5c3_Err
308314 }
309- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</a> ")
315+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")
310316 if templ_7745c5c3_Err != nil {
311317 return templ_7745c5c3_Err
312318 }
313319 var templ_7745c5c3_Var17 string
314- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
320+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
315321 if templ_7745c5c3_Err != nil {
316- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 47, Col: 80}
322+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 50, Col: 64}
317323 }
318324 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
319325 if templ_7745c5c3_Err != nil {
320326 return templ_7745c5c3_Err
321327 }
322- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</td><td class=\"c-ref\"><a href=\"")
328+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</a> ")
323329 if templ_7745c5c3_Err != nil {
324330 return templ_7745c5c3_Err
325331 }
326- var templ_7745c5c3_Var18 templ.SafeURL
327- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.RefHref))
332+ var templ_7745c5c3_Var18 string
333+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
328334 if templ_7745c5c3_Err != nil {
329- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 48, Col: 54}
335+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 50, Col: 80}
330336 }
331337 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
332338 if templ_7745c5c3_Err != nil {
333339 return templ_7745c5c3_Err
334340 }
335- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">")
341+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td class=\"c-ref\"><a href=\"")
336342 if templ_7745c5c3_Err != nil {
337343 return templ_7745c5c3_Err
338344 }
339- var templ_7745c5c3_Var19 string
340- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(r.Ref)
345+ var templ_7745c5c3_Var19 templ.SafeURL
346+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.RefHref))
341347 if templ_7745c5c3_Err != nil {
342- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 48, Col: 64}
348+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 51, Col: 54}
343349 }
344350 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
345351 if templ_7745c5c3_Err != nil {
346352 return templ_7745c5c3_Err
347353 }
348- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</a></td><td class=\"c-who dim\">")
354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
349355 if templ_7745c5c3_Err != nil {
350356 return templ_7745c5c3_Err
351357 }
352358 var templ_7745c5c3_Var20 string
353- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(r.Actor)
359+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(r.Ref)
354360 if templ_7745c5c3_Err != nil {
355- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 49, Col: 33}
361+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 51, Col: 64}
356362 }
357363 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
358364 if templ_7745c5c3_Err != nil {
359365 return templ_7745c5c3_Err
360366 }
361- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</td><td class=\"c-dur dim\">")
367+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</a></td><td class=\"c-who dim\">")
362368 if templ_7745c5c3_Err != nil {
363369 return templ_7745c5c3_Err
364370 }
365371 var templ_7745c5c3_Var21 string
366- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(r.State)
372+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(r.Actor)
367373 if templ_7745c5c3_Err != nil {
368- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 50, Col: 33}
374+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 52, Col: 33}
369375 }
370376 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
371377 if templ_7745c5c3_Err != nil {
372378 return templ_7745c5c3_Err
373379 }
380+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td><td class=\"c-dur dim\">")
381+ if templ_7745c5c3_Err != nil {
382+ return templ_7745c5c3_Err
383+ }
374384 var templ_7745c5c3_Var22 string
375- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
385+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.State)
376386 if templ_7745c5c3_Err != nil {
377- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 50, Col: 40}
387+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 53, Col: 33}
378388 }
379389 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
380390 if templ_7745c5c3_Err != nil {
381391 return templ_7745c5c3_Err
382392 }
383393 var templ_7745c5c3_Var23 string
384- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(r.Duration)
394+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
385395 if templ_7745c5c3_Err != nil {
386- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 50, Col: 54}
396+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 53, Col: 40}
387397 }
388398 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
389399 if templ_7745c5c3_Err != nil {
390400 return templ_7745c5c3_Err
391401 }
392- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td class=\"c-when dim\">")
402+ var templ_7745c5c3_Var24 string
403+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(r.Duration)
404+ if templ_7745c5c3_Err != nil {
405+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 53, Col: 54}
406+ }
407+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
408+ if templ_7745c5c3_Err != nil {
409+ return templ_7745c5c3_Err
410+ }
411+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</td><td class=\"c-when dim\">")
393412 if templ_7745c5c3_Err != nil {
394413 return templ_7745c5c3_Err
395414 }
396415 if r.WhenAt != "" {
397- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<time datetime=\"")
416+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<time datetime=\"")
398417 if templ_7745c5c3_Err != nil {
399418 return templ_7745c5c3_Err
400419 }
401- var templ_7745c5c3_Var24 string
402- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
420+ var templ_7745c5c3_Var25 string
421+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
403422 if templ_7745c5c3_Err != nil {
404- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 53, Col: 29}
423+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 56, Col: 29}
405424 }
406- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
425+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
407426 if templ_7745c5c3_Err != nil {
408427 return templ_7745c5c3_Err
409428 }
410- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" title=\"")
429+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" title=\"")
411430 if templ_7745c5c3_Err != nil {
412431 return templ_7745c5c3_Err
413432 }
414- var templ_7745c5c3_Var25 string
415- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
433+ var templ_7745c5c3_Var26 string
434+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
416435 if templ_7745c5c3_Err != nil {
417- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 53, Col: 48}
436+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 56, Col: 48}
418437 }
419- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
438+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
420439 if templ_7745c5c3_Err != nil {
421440 return templ_7745c5c3_Err
422441 }
423- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">")
442+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">")
424443 if templ_7745c5c3_Err != nil {
425444 return templ_7745c5c3_Err
426445 }
427- var templ_7745c5c3_Var26 string
428- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
446+ var templ_7745c5c3_Var27 string
447+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
429448 if templ_7745c5c3_Err != nil {
430- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 53, Col: 59}
449+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 56, Col: 59}
431450 }
432- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
451+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
433452 if templ_7745c5c3_Err != nil {
434453 return templ_7745c5c3_Err
435454 }
436- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</time>")
455+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</time>")
437456 if templ_7745c5c3_Err != nil {
438457 return templ_7745c5c3_Err
439458 }
440459 }
441- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</td></tr>")
460+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</td></tr>")
442461 if templ_7745c5c3_Err != nil {
443462 return templ_7745c5c3_Err
444463 }
@@ -464,40 +483,40 @@ func RecentActionsBlock(a RecentActions) templ.Component {
464483 }()
465484 }
466485 ctx = templ.InitializeContext(ctx)
467- templ_7745c5c3_Var27 := templ.GetChildren(ctx)
468- if templ_7745c5c3_Var27 == nil {
469- templ_7745c5c3_Var27 = templ.NopComponent
486+ templ_7745c5c3_Var28 := templ.GetChildren(ctx)
487+ if templ_7745c5c3_Var28 == nil {
488+ templ_7745c5c3_Var28 = templ.NopComponent
470489 }
471490 ctx = templ.ClearChildren(ctx)
472- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<section id=\"recent-actions\" aria-labelledby=\"r-actions\"")
491+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<section id=\"recent-actions\" aria-labelledby=\"r-actions\"")
473492 if templ_7745c5c3_Err != nil {
474493 return templ_7745c5c3_Err
475494 }
476495 if len(a.Runs) == 0 {
477- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " hidden")
496+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " hidden")
478497 if templ_7745c5c3_Err != nil {
479498 return templ_7745c5c3_Err
480499 }
481500 }
482- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "><h2 id=\"r-actions\">Recent actions <a href=\"")
501+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "><h2 id=\"r-actions\">Recent actions <a href=\"")
483502 if templ_7745c5c3_Err != nil {
484503 return templ_7745c5c3_Err
485504 }
486- var templ_7745c5c3_Var28 templ.SafeURL
487- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.AllHref))
505+ var templ_7745c5c3_Var29 templ.SafeURL
506+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.AllHref))
488507 if templ_7745c5c3_Err != nil {
489- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 63, Col: 70}
508+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 66, Col: 70}
490509 }
491- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
510+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
492511 if templ_7745c5c3_Err != nil {
493512 return templ_7745c5c3_Err
494513 }
495- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">View all</a></h2><ul class=\"rruns\">")
514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\">View all</a></h2><ul class=\"rruns\">")
496515 if templ_7745c5c3_Err != nil {
497516 return templ_7745c5c3_Err
498517 }
499518 for _, r := range a.Runs {
500- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<li>")
519+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<li>")
501520 if templ_7745c5c3_Err != nil {
502521 return templ_7745c5c3_Err
503522 }
@@ -505,139 +524,139 @@ func RecentActionsBlock(a RecentActions) templ.Component {
505524 if templ_7745c5c3_Err != nil {
506525 return templ_7745c5c3_Err
507526 }
508- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a class=\"rr-name\" href=\"")
527+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<a class=\"rr-name\" href=\"")
509528 if templ_7745c5c3_Err != nil {
510529 return templ_7745c5c3_Err
511530 }
512- var templ_7745c5c3_Var29 templ.SafeURL
513- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
531+ var templ_7745c5c3_Var30 templ.SafeURL
532+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
514533 if templ_7745c5c3_Err != nil {
515- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 68, Col: 52}
534+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 71, Col: 52}
516535 }
517- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
536+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
518537 if templ_7745c5c3_Err != nil {
519538 return templ_7745c5c3_Err
520539 }
521- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" title=\"")
540+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" title=\"")
522541 if templ_7745c5c3_Err != nil {
523542 return templ_7745c5c3_Err
524543 }
525- var templ_7745c5c3_Var30 string
526- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Workflow + " " + r.Number + " · " + r.State)
544+ var templ_7745c5c3_Var31 string
545+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Workflow + " " + r.Number + " · " + r.State)
527546 if templ_7745c5c3_Err != nil {
528- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 68, Col: 109}
547+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 71, Col: 109}
529548 }
530- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
549+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
531550 if templ_7745c5c3_Err != nil {
532551 return templ_7745c5c3_Err
533552 }
534- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">")
553+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">")
535554 if templ_7745c5c3_Err != nil {
536555 return templ_7745c5c3_Err
537556 }
538- var templ_7745c5c3_Var31 string
539- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Workflow)
557+ var templ_7745c5c3_Var32 string
558+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Workflow)
540559 if templ_7745c5c3_Err != nil {
541- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 68, Col: 124}
560+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 71, Col: 124}
542561 }
543- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
562+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
544563 if templ_7745c5c3_Err != nil {
545564 return templ_7745c5c3_Err
546565 }
547- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</a> <span class=\"mute\">")
566+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</a> <span class=\"mute\">")
548567 if templ_7745c5c3_Err != nil {
549568 return templ_7745c5c3_Err
550569 }
551- var templ_7745c5c3_Var32 string
552- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Number)
570+ var templ_7745c5c3_Var33 string
571+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Number)
553572 if templ_7745c5c3_Err != nil {
554- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 69, Col: 34}
573+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 72, Col: 34}
555574 }
556- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
575+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
557576 if templ_7745c5c3_Err != nil {
558577 return templ_7745c5c3_Err
559578 }
560- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span> <a class=\"rr-ref\" href=\"")
579+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span> <a class=\"rr-ref\" href=\"")
561580 if templ_7745c5c3_Err != nil {
562581 return templ_7745c5c3_Err
563582 }
564- var templ_7745c5c3_Var33 templ.SafeURL
565- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.RefHref))
583+ var templ_7745c5c3_Var34 templ.SafeURL
584+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.RefHref))
566585 if templ_7745c5c3_Err != nil {
567- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 70, Col: 54}
586+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 73, Col: 54}
568587 }
569- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
588+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
570589 if templ_7745c5c3_Err != nil {
571590 return templ_7745c5c3_Err
572591 }
573- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">")
592+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">")
574593 if templ_7745c5c3_Err != nil {
575594 return templ_7745c5c3_Err
576595 }
577- var templ_7745c5c3_Var34 string
578- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(r.Ref)
596+ var templ_7745c5c3_Var35 string
597+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.Ref)
579598 if templ_7745c5c3_Err != nil {
580- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 70, Col: 64}
599+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 73, Col: 64}
581600 }
582- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
601+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
583602 if templ_7745c5c3_Err != nil {
584603 return templ_7745c5c3_Err
585604 }
586- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</a> ")
605+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</a> ")
587606 if templ_7745c5c3_Err != nil {
588607 return templ_7745c5c3_Err
589608 }
590609 if r.WhenAt != "" {
591- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<time class=\"dim\" datetime=\"")
610+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<time class=\"dim\" datetime=\"")
592611 if templ_7745c5c3_Err != nil {
593612 return templ_7745c5c3_Err
594613 }
595- var templ_7745c5c3_Var35 string
596- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
614+ var templ_7745c5c3_Var36 string
615+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
597616 if templ_7745c5c3_Err != nil {
598- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 72, Col: 43}
617+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 75, Col: 43}
599618 }
600- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
619+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
601620 if templ_7745c5c3_Err != nil {
602621 return templ_7745c5c3_Err
603622 }
604- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" title=\"")
623+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" title=\"")
605624 if templ_7745c5c3_Err != nil {
606625 return templ_7745c5c3_Err
607626 }
608- var templ_7745c5c3_Var36 string
609- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
627+ var templ_7745c5c3_Var37 string
628+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
610629 if templ_7745c5c3_Err != nil {
611- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 72, Col: 62}
630+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 75, Col: 62}
612631 }
613- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
632+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
614633 if templ_7745c5c3_Err != nil {
615634 return templ_7745c5c3_Err
616635 }
617- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")
636+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\">")
618637 if templ_7745c5c3_Err != nil {
619638 return templ_7745c5c3_Err
620639 }
621- var templ_7745c5c3_Var37 string
622- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
640+ var templ_7745c5c3_Var38 string
641+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
623642 if templ_7745c5c3_Err != nil {
624- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 72, Col: 73}
643+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 75, Col: 73}
625644 }
626- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
645+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
627646 if templ_7745c5c3_Err != nil {
628647 return templ_7745c5c3_Err
629648 }
630- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</time>")
649+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</time>")
631650 if templ_7745c5c3_Err != nil {
632651 return templ_7745c5c3_Err
633652 }
634653 }
635- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</li>")
654+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</li>")
636655 if templ_7745c5c3_Err != nil {
637656 return templ_7745c5c3_Err
638657 }
639658 }
640- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</ul></section>")
659+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</ul></section>")
641660 if templ_7745c5c3_Err != nil {
642661 return templ_7745c5c3_Err
643662 }
@@ -661,12 +680,12 @@ func RunDetail(l Layout, p RunPage) templ.Component {
661680 }()
662681 }
663682 ctx = templ.InitializeContext(ctx)
664- templ_7745c5c3_Var38 := templ.GetChildren(ctx)
665- if templ_7745c5c3_Var38 == nil {
666- templ_7745c5c3_Var38 = templ.NopComponent
683+ templ_7745c5c3_Var39 := templ.GetChildren(ctx)
684+ if templ_7745c5c3_Var39 == nil {
685+ templ_7745c5c3_Var39 = templ.NopComponent
667686 }
668687 ctx = templ.ClearChildren(ctx)
669- templ_7745c5c3_Var39 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
688+ templ_7745c5c3_Var40 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
670689 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
671690 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
672691 if !templ_7745c5c3_IsBuffer {
@@ -682,7 +701,7 @@ func RunDetail(l Layout, p RunPage) templ.Component {
682701 if templ_7745c5c3_Err != nil {
683702 return templ_7745c5c3_Err
684703 }
685- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " <div id=\"run-body\">")
704+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " <div id=\"run-body\">")
686705 if templ_7745c5c3_Err != nil {
687706 return templ_7745c5c3_Err
688707 }
@@ -690,7 +709,7 @@ func RunDetail(l Layout, p RunPage) templ.Component {
690709 if templ_7745c5c3_Err != nil {
691710 return templ_7745c5c3_Err
692711 }
693- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div>")
712+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div>")
694713 if templ_7745c5c3_Err != nil {
695714 return templ_7745c5c3_Err
696715 }
@@ -700,7 +719,7 @@ func RunDetail(l Layout, p RunPage) templ.Component {
700719 }
701720 return nil
702721 })
703- templ_7745c5c3_Err = Shell(l, runRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var39), templ_7745c5c3_Buffer)
722+ templ_7745c5c3_Err = Shell(l, runRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var40), templ_7745c5c3_Buffer)
704723 if templ_7745c5c3_Err != nil {
705724 return templ_7745c5c3_Err
706725 }
@@ -725,12 +744,12 @@ func runBodyFragment(p RunPage) templ.Component {
725744 }()
726745 }
727746 ctx = templ.InitializeContext(ctx)
728- templ_7745c5c3_Var40 := templ.GetChildren(ctx)
729- if templ_7745c5c3_Var40 == nil {
730- templ_7745c5c3_Var40 = templ.NopComponent
747+ templ_7745c5c3_Var41 := templ.GetChildren(ctx)
748+ if templ_7745c5c3_Var41 == nil {
749+ templ_7745c5c3_Var41 = templ.NopComponent
731750 }
732751 ctx = templ.ClearChildren(ctx)
733- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div id=\"run-body\">")
752+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<div id=\"run-body\">")
734753 if templ_7745c5c3_Err != nil {
735754 return templ_7745c5c3_Err
736755 }
@@ -738,7 +757,7 @@ func runBodyFragment(p RunPage) templ.Component {
738757 if templ_7745c5c3_Err != nil {
739758 return templ_7745c5c3_Err
740759 }
741- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div>")
760+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div>")
742761 if templ_7745c5c3_Err != nil {
743762 return templ_7745c5c3_Err
744763 }
@@ -763,12 +782,12 @@ func RunBody(p RunPage) templ.Component {
763782 }()
764783 }
765784 ctx = templ.InitializeContext(ctx)
766- templ_7745c5c3_Var41 := templ.GetChildren(ctx)
767- if templ_7745c5c3_Var41 == nil {
768- templ_7745c5c3_Var41 = templ.NopComponent
785+ templ_7745c5c3_Var42 := templ.GetChildren(ctx)
786+ if templ_7745c5c3_Var42 == nil {
787+ templ_7745c5c3_Var42 = templ.NopComponent
769788 }
770789 ctx = templ.ClearChildren(ctx)
771- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<h2 class=\"run-h\">")
790+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<h2 class=\"run-h\">")
772791 if templ_7745c5c3_Err != nil {
773792 return templ_7745c5c3_Err
774793 }
@@ -776,70 +795,70 @@ func RunBody(p RunPage) templ.Component {
776795 if templ_7745c5c3_Err != nil {
777796 return templ_7745c5c3_Err
778797 }
779- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span>")
798+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span>")
780799 if templ_7745c5c3_Err != nil {
781800 return templ_7745c5c3_Err
782801 }
783- var templ_7745c5c3_Var42 string
784- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(p.Workflow)
802+ var templ_7745c5c3_Var43 string
803+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(p.Workflow)
785804 if templ_7745c5c3_Err != nil {
786- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 99, Col: 64}
805+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 102, Col: 64}
787806 }
788- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
807+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
789808 if templ_7745c5c3_Err != nil {
790809 return templ_7745c5c3_Err
791810 }
792- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span> <span class=\"mute\">")
811+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span> <span class=\"mute\">")
793812 if templ_7745c5c3_Err != nil {
794813 return templ_7745c5c3_Err
795814 }
796- var templ_7745c5c3_Var43 string
797- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(p.Number)
815+ var templ_7745c5c3_Var44 string
816+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(p.Number)
798817 if templ_7745c5c3_Err != nil {
799- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 99, Col: 103}
818+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 102, Col: 103}
800819 }
801- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
820+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
802821 if templ_7745c5c3_Err != nil {
803822 return templ_7745c5c3_Err
804823 }
805- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span></h2>")
824+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span></h2>")
806825 if templ_7745c5c3_Err != nil {
807826 return templ_7745c5c3_Err
808827 }
809828 if p.Error != "" {
810- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<pre class=\"run-err\">")
829+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<pre class=\"run-err\">")
811830 if templ_7745c5c3_Err != nil {
812831 return templ_7745c5c3_Err
813832 }
814- var templ_7745c5c3_Var44 string
815- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
833+ var templ_7745c5c3_Var45 string
834+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
816835 if templ_7745c5c3_Err != nil {
817- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 101, Col: 32}
836+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 104, Col: 32}
818837 }
819- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
838+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
820839 if templ_7745c5c3_Err != nil {
821840 return templ_7745c5c3_Err
822841 }
823- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</pre>")
842+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</pre>")
824843 if templ_7745c5c3_Err != nil {
825844 return templ_7745c5c3_Err
826845 }
827846 }
828847 if len(p.Annotations) > 0 {
829- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<section class=\"annots\" aria-labelledby=\"run-annots\"><h3 id=\"run-annots\">Annotations <span class=\"count\">")
848+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<section class=\"annots\" aria-labelledby=\"run-annots\"><h3 id=\"run-annots\">Annotations <span class=\"count\">")
830849 if templ_7745c5c3_Err != nil {
831850 return templ_7745c5c3_Err
832851 }
833- var templ_7745c5c3_Var45 string
834- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Annotations)))
852+ var templ_7745c5c3_Var46 string
853+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Annotations)))
835854 if templ_7745c5c3_Err != nil {
836- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 105, Col: 81}
855+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 108, Col: 81}
837856 }
838- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
857+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
839858 if templ_7745c5c3_Err != nil {
840859 return templ_7745c5c3_Err
841860 }
842- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span></h3>")
861+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</span></h3>")
843862 if templ_7745c5c3_Err != nil {
844863 return templ_7745c5c3_Err
845864 }
@@ -847,43 +866,43 @@ func RunBody(p RunPage) templ.Component {
847866 if templ_7745c5c3_Err != nil {
848867 return templ_7745c5c3_Err
849868 }
850- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</section>")
869+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</section>")
851870 if templ_7745c5c3_Err != nil {
852871 return templ_7745c5c3_Err
853872 }
854873 }
855- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<ul class=\"jobs\">")
874+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<ul class=\"jobs\">")
856875 if templ_7745c5c3_Err != nil {
857876 return templ_7745c5c3_Err
858877 }
859878 for _, j := range p.Jobs {
860- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<li id=\"")
879+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<li id=\"")
861880 if templ_7745c5c3_Err != nil {
862881 return templ_7745c5c3_Err
863882 }
864- var templ_7745c5c3_Var46 string
865- templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue("job-" + j.ID)
883+ var templ_7745c5c3_Var47 string
884+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue("job-" + j.ID)
866885 if templ_7745c5c3_Err != nil {
867- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 111, Col: 25}
886+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 114, Col: 25}
868887 }
869- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
888+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
870889 if templ_7745c5c3_Err != nil {
871890 return templ_7745c5c3_Err
872891 }
873- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\"><a class=\"job-h\" href=\"")
892+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"><a class=\"job-h\" href=\"")
874893 if templ_7745c5c3_Err != nil {
875894 return templ_7745c5c3_Err
876895 }
877- var templ_7745c5c3_Var47 templ.SafeURL
878- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(j.Href))
896+ var templ_7745c5c3_Var48 templ.SafeURL
897+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(j.Href))
879898 if templ_7745c5c3_Err != nil {
880- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 112, Col: 49}
899+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 115, Col: 49}
881900 }
882- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
901+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
883902 if templ_7745c5c3_Err != nil {
884903 return templ_7745c5c3_Err
885904 }
886- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\">")
905+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\">")
887906 if templ_7745c5c3_Err != nil {
888907 return templ_7745c5c3_Err
889908 }
@@ -891,118 +910,118 @@ func RunBody(p RunPage) templ.Component {
891910 if templ_7745c5c3_Err != nil {
892911 return templ_7745c5c3_Err
893912 }
894- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<span class=\"j-name\">")
913+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span class=\"j-name\">")
895914 if templ_7745c5c3_Err != nil {
896915 return templ_7745c5c3_Err
897916 }
898- var templ_7745c5c3_Var48 string
899- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(j.Name)
917+ var templ_7745c5c3_Var49 string
918+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(j.Name)
900919 if templ_7745c5c3_Err != nil {
901- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 114, Col: 34}
920+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 117, Col: 34}
902921 }
903- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
922+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
904923 if templ_7745c5c3_Err != nil {
905924 return templ_7745c5c3_Err
906925 }
907- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</span> <span class=\"dim\">")
926+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span> <span class=\"dim\">")
908927 if templ_7745c5c3_Err != nil {
909928 return templ_7745c5c3_Err
910929 }
911- var templ_7745c5c3_Var49 string
912- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(j.State)
930+ var templ_7745c5c3_Var50 string
931+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(j.State)
913932 if templ_7745c5c3_Err != nil {
914- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 115, Col: 32}
933+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 118, Col: 32}
915934 }
916- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
935+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
917936 if templ_7745c5c3_Err != nil {
918937 return templ_7745c5c3_Err
919938 }
920- var templ_7745c5c3_Var50 string
921- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
939+ var templ_7745c5c3_Var51 string
940+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
922941 if templ_7745c5c3_Err != nil {
923- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 115, Col: 39}
942+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 118, Col: 39}
924943 }
925- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
944+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
926945 if templ_7745c5c3_Err != nil {
927946 return templ_7745c5c3_Err
928947 }
929- var templ_7745c5c3_Var51 string
930- templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(j.Duration)
948+ var templ_7745c5c3_Var52 string
949+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(j.Duration)
931950 if templ_7745c5c3_Err != nil {
932- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 115, Col: 53}
951+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 118, Col: 53}
933952 }
934- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
953+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
935954 if templ_7745c5c3_Err != nil {
936955 return templ_7745c5c3_Err
937956 }
938- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</span></a> ")
957+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span></a> ")
939958 if templ_7745c5c3_Err != nil {
940959 return templ_7745c5c3_Err
941960 }
942961 if j.Runner != "" {
943- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<span class=\"mute\">on ")
962+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<span class=\"mute\">on ")
944963 if templ_7745c5c3_Err != nil {
945964 return templ_7745c5c3_Err
946965 }
947- var templ_7745c5c3_Var52 string
948- templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(runnerText(j))
966+ var templ_7745c5c3_Var53 string
967+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(runnerText(j))
949968 if templ_7745c5c3_Err != nil {
950- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 118, Col: 42}
969+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 121, Col: 42}
951970 }
952- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
971+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
953972 if templ_7745c5c3_Err != nil {
954973 return templ_7745c5c3_Err
955974 }
956- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span> ")
975+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> ")
957976 if templ_7745c5c3_Err != nil {
958977 return templ_7745c5c3_Err
959978 }
960979 }
961980 if len(j.Needs) > 0 {
962- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<span class=\"mute\">needs ")
981+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<span class=\"mute\">needs ")
963982 if templ_7745c5c3_Err != nil {
964983 return templ_7745c5c3_Err
965984 }
966- var templ_7745c5c3_Var53 string
967- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(joinWords(j.Needs))
985+ var templ_7745c5c3_Var54 string
986+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(joinWords(j.Needs))
968987 if templ_7745c5c3_Err != nil {
969- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 121, Col: 50}
988+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 124, Col: 50}
970989 }
971- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
990+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
972991 if templ_7745c5c3_Err != nil {
973992 return templ_7745c5c3_Err
974993 }
975- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> ")
994+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span> ")
976995 if templ_7745c5c3_Err != nil {
977996 return templ_7745c5c3_Err
978997 }
979998 }
980999 if j.Error != "" {
981- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<p class=\"job-err\">")
1000+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<p class=\"job-err\">")
9821001 if templ_7745c5c3_Err != nil {
9831002 return templ_7745c5c3_Err
9841003 }
985- var templ_7745c5c3_Var54 string
986- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(j.Error)
1004+ var templ_7745c5c3_Var55 string
1005+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(j.Error)
9871006 if templ_7745c5c3_Err != nil {
988- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 124, Col: 33}
1007+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 127, Col: 33}
9891008 }
990- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
1009+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
9911010 if templ_7745c5c3_Err != nil {
9921011 return templ_7745c5c3_Err
9931012 }
994- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</p>")
1013+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</p>")
9951014 if templ_7745c5c3_Err != nil {
9961015 return templ_7745c5c3_Err
9971016 }
9981017 }
9991018 if len(j.Steps) > 0 {
1000- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<ol class=\"steps\">")
1019+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<ol class=\"steps\">")
10011020 if templ_7745c5c3_Err != nil {
10021021 return templ_7745c5c3_Err
10031022 }
10041023 for _, s := range j.Steps {
1005- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<li>")
1024+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<li>")
10061025 if templ_7745c5c3_Err != nil {
10071026 return templ_7745c5c3_Err
10081027 }
@@ -1010,48 +1029,48 @@ func RunBody(p RunPage) templ.Component {
10101029 if templ_7745c5c3_Err != nil {
10111030 return templ_7745c5c3_Err
10121031 }
1013- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span>")
1032+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span>")
10141033 if templ_7745c5c3_Err != nil {
10151034 return templ_7745c5c3_Err
10161035 }
1017- var templ_7745c5c3_Var55 string
1018- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
1036+ var templ_7745c5c3_Var56 string
1037+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
10191038 if templ_7745c5c3_Err != nil {
1020- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 129, Col: 53}
1039+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 132, Col: 53}
10211040 }
1022- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1041+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
10231042 if templ_7745c5c3_Err != nil {
10241043 return templ_7745c5c3_Err
10251044 }
1026- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span> <span class=\"dim\">")
1045+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span> <span class=\"dim\">")
10271046 if templ_7745c5c3_Err != nil {
10281047 return templ_7745c5c3_Err
10291048 }
1030- var templ_7745c5c3_Var56 string
1031- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(s.Duration)
1049+ var templ_7745c5c3_Var57 string
1050+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(s.Duration)
10321051 if templ_7745c5c3_Err != nil {
1033- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 129, Col: 93}
1052+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 132, Col: 93}
10341053 }
1035- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1054+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
10361055 if templ_7745c5c3_Err != nil {
10371056 return templ_7745c5c3_Err
10381057 }
1039- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span></li>")
1058+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span></li>")
10401059 if templ_7745c5c3_Err != nil {
10411060 return templ_7745c5c3_Err
10421061 }
10431062 }
1044- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</ol>")
1063+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</ol>")
10451064 if templ_7745c5c3_Err != nil {
10461065 return templ_7745c5c3_Err
10471066 }
10481067 }
1049- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</li>")
1068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</li>")
10501069 if templ_7745c5c3_Err != nil {
10511070 return templ_7745c5c3_Err
10521071 }
10531072 }
1054- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</ul>")
1073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</ul>")
10551074 if templ_7745c5c3_Err != nil {
10561075 return templ_7745c5c3_Err
10571076 }
@@ -1079,327 +1098,327 @@ func AnnotationList(anns []AnnotationView, more bool, grouped bool) templ.Compon
10791098 }()
10801099 }
10811100 ctx = templ.InitializeContext(ctx)
1082- templ_7745c5c3_Var57 := templ.GetChildren(ctx)
1083- if templ_7745c5c3_Var57 == nil {
1084- templ_7745c5c3_Var57 = templ.NopComponent
1101+ templ_7745c5c3_Var58 := templ.GetChildren(ctx)
1102+ if templ_7745c5c3_Var58 == nil {
1103+ templ_7745c5c3_Var58 = templ.NopComponent
10851104 }
10861105 ctx = templ.ClearChildren(ctx)
1087- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<ul class=\"annot-list\">")
1106+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<ul class=\"annot-list\">")
10881107 if templ_7745c5c3_Err != nil {
10891108 return templ_7745c5c3_Err
10901109 }
10911110 for _, a := range anns {
1092- var templ_7745c5c3_Var58 = []any{"annot", "annot-" + a.Kind}
1093- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var58...)
1111+ var templ_7745c5c3_Var59 = []any{"annot", "annot-" + a.Kind}
1112+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var59...)
10941113 if templ_7745c5c3_Err != nil {
10951114 return templ_7745c5c3_Err
10961115 }
1097- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<li class=\"")
1116+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<li class=\"")
10981117 if templ_7745c5c3_Err != nil {
10991118 return templ_7745c5c3_Err
11001119 }
1101- var templ_7745c5c3_Var59 string
1102- templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var58).String())
1120+ var templ_7745c5c3_Var60 string
1121+ templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var59).String())
11031122 if templ_7745c5c3_Err != nil {
11041123 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 1, Col: 0}
11051124 }
1106- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
1125+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
11071126 if templ_7745c5c3_Err != nil {
11081127 return templ_7745c5c3_Err
11091128 }
1110- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\">")
1129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\">")
11111130 if templ_7745c5c3_Err != nil {
11121131 return templ_7745c5c3_Err
11131132 }
11141133 if grouped && a.StepHead {
1115- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<p class=\"annot-step\">")
1134+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<p class=\"annot-step\">")
11161135 if templ_7745c5c3_Err != nil {
11171136 return templ_7745c5c3_Err
11181137 }
11191138 if a.StepHref != "" {
1120- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<a href=\"")
1139+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<a href=\"")
11211140 if templ_7745c5c3_Err != nil {
11221141 return templ_7745c5c3_Err
11231142 }
1124- var templ_7745c5c3_Var60 templ.SafeURL
1125- templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.StepHref))
1143+ var templ_7745c5c3_Var61 templ.SafeURL
1144+ templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.StepHref))
11261145 if templ_7745c5c3_Err != nil {
1127- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 149, Col: 42}
1146+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 152, Col: 42}
11281147 }
1129- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
1148+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
11301149 if templ_7745c5c3_Err != nil {
11311150 return templ_7745c5c3_Err
11321151 }
1133- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\">")
1152+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\">")
11341153 if templ_7745c5c3_Err != nil {
11351154 return templ_7745c5c3_Err
11361155 }
1137- var templ_7745c5c3_Var61 string
1138- templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(a.Step)
1156+ var templ_7745c5c3_Var62 string
1157+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(a.Step)
11391158 if templ_7745c5c3_Err != nil {
1140- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 149, Col: 53}
1159+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 152, Col: 53}
11411160 }
1142- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
1161+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
11431162 if templ_7745c5c3_Err != nil {
11441163 return templ_7745c5c3_Err
11451164 }
1146- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</a>")
1165+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</a>")
11471166 if templ_7745c5c3_Err != nil {
11481167 return templ_7745c5c3_Err
11491168 }
11501169 } else {
1151- var templ_7745c5c3_Var62 string
1152- templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(a.Step)
1170+ var templ_7745c5c3_Var63 string
1171+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(a.Step)
11531172 if templ_7745c5c3_Err != nil {
1154- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 151, Col: 15}
1173+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 154, Col: 15}
11551174 }
1156- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1175+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
11571176 if templ_7745c5c3_Err != nil {
11581177 return templ_7745c5c3_Err
11591178 }
11601179 }
1161- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</p>")
1180+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</p>")
11621181 if templ_7745c5c3_Err != nil {
11631182 return templ_7745c5c3_Err
11641183 }
11651184 }
1166- var templ_7745c5c3_Var63 = []any{"an", "an-" + a.Kind}
1167- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var63...)
1185+ var templ_7745c5c3_Var64 = []any{"an", "an-" + a.Kind}
1186+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var64...)
11681187 if templ_7745c5c3_Err != nil {
11691188 return templ_7745c5c3_Err
11701189 }
1171- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<span class=\"")
1190+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<span class=\"")
11721191 if templ_7745c5c3_Err != nil {
11731192 return templ_7745c5c3_Err
11741193 }
1175- var templ_7745c5c3_Var64 string
1176- templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var63).String())
1194+ var templ_7745c5c3_Var65 string
1195+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var64).String())
11771196 if templ_7745c5c3_Err != nil {
11781197 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 1, Col: 0}
11791198 }
1180- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
1199+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
11811200 if templ_7745c5c3_Err != nil {
11821201 return templ_7745c5c3_Err
11831202 }
1184- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "\" role=\"img\" aria-label=\"")
1203+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" role=\"img\" aria-label=\"")
11851204 if templ_7745c5c3_Err != nil {
11861205 return templ_7745c5c3_Err
11871206 }
1188- var templ_7745c5c3_Var65 string
1189- templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Level)
1207+ var templ_7745c5c3_Var66 string
1208+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Level)
11901209 if templ_7745c5c3_Err != nil {
1191- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 155, Col: 72}
1210+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 158, Col: 72}
11921211 }
1193- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
1212+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
11941213 if templ_7745c5c3_Err != nil {
11951214 return templ_7745c5c3_Err
11961215 }
1197- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\" title=\"")
1216+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" title=\"")
11981217 if templ_7745c5c3_Err != nil {
11991218 return templ_7745c5c3_Err
12001219 }
1201- var templ_7745c5c3_Var66 string
1202- templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Level)
1220+ var templ_7745c5c3_Var67 string
1221+ templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Level)
12031222 if templ_7745c5c3_Err != nil {
1204- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 155, Col: 90}
1223+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 158, Col: 90}
12051224 }
1206- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
1225+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
12071226 if templ_7745c5c3_Err != nil {
12081227 return templ_7745c5c3_Err
12091228 }
1210- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\"></span><div class=\"annot-body\"><p class=\"annot-head\"><span class=\"annot-level\">")
1229+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\"></span><div class=\"annot-body\"><p class=\"annot-head\"><span class=\"annot-level\">")
12111230 if templ_7745c5c3_Err != nil {
12121231 return templ_7745c5c3_Err
12131232 }
1214- var templ_7745c5c3_Var67 string
1215- templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(a.Level)
1233+ var templ_7745c5c3_Var68 string
1234+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(a.Level)
12161235 if templ_7745c5c3_Err != nil {
1217- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 158, Col: 41}
1236+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 41}
12181237 }
1219- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
1238+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
12201239 if templ_7745c5c3_Err != nil {
12211240 return templ_7745c5c3_Err
12221241 }
1223- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</span> ")
1242+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</span> ")
12241243 if templ_7745c5c3_Err != nil {
12251244 return templ_7745c5c3_Err
12261245 }
12271246 if !grouped {
12281247 if a.JobHref != "" {
1229- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<a href=\"")
1248+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<a href=\"")
12301249 if templ_7745c5c3_Err != nil {
12311250 return templ_7745c5c3_Err
12321251 }
1233- var templ_7745c5c3_Var68 templ.SafeURL
1234- templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.JobHref))
1252+ var templ_7745c5c3_Var69 templ.SafeURL
1253+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.JobHref))
12351254 if templ_7745c5c3_Err != nil {
1236- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 42}
1255+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 164, Col: 42}
12371256 }
1238- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1257+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
12391258 if templ_7745c5c3_Err != nil {
12401259 return templ_7745c5c3_Err
12411260 }
1242- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\">")
1261+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\">")
12431262 if templ_7745c5c3_Err != nil {
12441263 return templ_7745c5c3_Err
12451264 }
1246- var templ_7745c5c3_Var69 string
1247- templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(a.Job)
1265+ var templ_7745c5c3_Var70 string
1266+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(a.Job)
12481267 if templ_7745c5c3_Err != nil {
1249- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 52}
1268+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 164, Col: 52}
12501269 }
1251- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
1270+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
12521271 if templ_7745c5c3_Err != nil {
12531272 return templ_7745c5c3_Err
12541273 }
1255- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</a>")
1274+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</a>")
12561275 if templ_7745c5c3_Err != nil {
12571276 return templ_7745c5c3_Err
12581277 }
12591278 } else {
1260- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<span>")
1279+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<span>")
12611280 if templ_7745c5c3_Err != nil {
12621281 return templ_7745c5c3_Err
12631282 }
1264- var templ_7745c5c3_Var70 string
1265- templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(a.Job)
1283+ var templ_7745c5c3_Var71 string
1284+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(a.Job)
12661285 if templ_7745c5c3_Err != nil {
1267- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 163, Col: 21}
1286+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 166, Col: 21}
12681287 }
1269- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
1288+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
12701289 if templ_7745c5c3_Err != nil {
12711290 return templ_7745c5c3_Err
12721291 }
1273- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</span>")
1292+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</span>")
12741293 if templ_7745c5c3_Err != nil {
12751294 return templ_7745c5c3_Err
12761295 }
12771296 }
1278- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, " <span class=\"mute\">")
1297+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, " <span class=\"mute\">")
12791298 if templ_7745c5c3_Err != nil {
12801299 return templ_7745c5c3_Err
12811300 }
1282- var templ_7745c5c3_Var71 string
1283- templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(a.Step)
1301+ var templ_7745c5c3_Var72 string
1302+ templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(a.Step)
12841303 if templ_7745c5c3_Err != nil {
1285- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 165, Col: 34}
1304+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 168, Col: 34}
12861305 }
1287- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
1306+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
12881307 if templ_7745c5c3_Err != nil {
12891308 return templ_7745c5c3_Err
12901309 }
1291- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</span> ")
1310+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</span> ")
12921311 if templ_7745c5c3_Err != nil {
12931312 return templ_7745c5c3_Err
12941313 }
12951314 }
12961315 if a.Title != "" {
1297- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<strong>")
1316+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<strong>")
12981317 if templ_7745c5c3_Err != nil {
12991318 return templ_7745c5c3_Err
13001319 }
1301- var templ_7745c5c3_Var72 string
1302- templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(a.Title)
1320+ var templ_7745c5c3_Var73 string
1321+ templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(a.Title)
13031322 if templ_7745c5c3_Err != nil {
1304- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 168, Col: 24}
1323+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 171, Col: 24}
13051324 }
1306- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
1325+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
13071326 if templ_7745c5c3_Err != nil {
13081327 return templ_7745c5c3_Err
13091328 }
1310- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</strong>")
1329+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</strong>")
13111330 if templ_7745c5c3_Err != nil {
13121331 return templ_7745c5c3_Err
13131332 }
13141333 }
1315- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</p><p class=\"annot-msg\">")
1334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</p><p class=\"annot-msg\">")
13161335 if templ_7745c5c3_Err != nil {
13171336 return templ_7745c5c3_Err
13181337 }
1319- var templ_7745c5c3_Var73 string
1320- templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(a.Message)
1338+ var templ_7745c5c3_Var74 string
1339+ templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(a.Message)
13211340 if templ_7745c5c3_Err != nil {
1322- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 171, Col: 37}
1341+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 174, Col: 37}
13231342 }
1324- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
1343+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
13251344 if templ_7745c5c3_Err != nil {
13261345 return templ_7745c5c3_Err
13271346 }
1328- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</p>")
1347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</p>")
13291348 if templ_7745c5c3_Err != nil {
13301349 return templ_7745c5c3_Err
13311350 }
13321351 if a.Where != "" {
1333- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<p class=\"annot-where\">")
1352+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<p class=\"annot-where\">")
13341353 if templ_7745c5c3_Err != nil {
13351354 return templ_7745c5c3_Err
13361355 }
13371356 if a.WhereHref != "" {
1338- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<a href=\"")
1357+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<a href=\"")
13391358 if templ_7745c5c3_Err != nil {
13401359 return templ_7745c5c3_Err
13411360 }
1342- var templ_7745c5c3_Var74 templ.SafeURL
1343- templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.WhereHref))
1361+ var templ_7745c5c3_Var75 templ.SafeURL
1362+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.WhereHref))
13441363 if templ_7745c5c3_Err != nil {
1345- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 175, Col: 44}
1364+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 178, Col: 44}
13461365 }
1347- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
1366+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
13481367 if templ_7745c5c3_Err != nil {
13491368 return templ_7745c5c3_Err
13501369 }
1351- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\">")
1370+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\">")
13521371 if templ_7745c5c3_Err != nil {
13531372 return templ_7745c5c3_Err
13541373 }
1355- var templ_7745c5c3_Var75 string
1356- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(a.Where)
1374+ var templ_7745c5c3_Var76 string
1375+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(a.Where)
13571376 if templ_7745c5c3_Err != nil {
1358- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 175, Col: 56}
1377+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 178, Col: 56}
13591378 }
1360- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
1379+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
13611380 if templ_7745c5c3_Err != nil {
13621381 return templ_7745c5c3_Err
13631382 }
1364- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</a>")
1383+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</a>")
13651384 if templ_7745c5c3_Err != nil {
13661385 return templ_7745c5c3_Err
13671386 }
13681387 } else {
1369- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "<span>")
1388+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "<span>")
13701389 if templ_7745c5c3_Err != nil {
13711390 return templ_7745c5c3_Err
13721391 }
1373- var templ_7745c5c3_Var76 string
1374- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(a.Where)
1392+ var templ_7745c5c3_Var77 string
1393+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(a.Where)
13751394 if templ_7745c5c3_Err != nil {
1376- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 177, Col: 23}
1395+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 180, Col: 23}
13771396 }
1378- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1397+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77))
13791398 if templ_7745c5c3_Err != nil {
13801399 return templ_7745c5c3_Err
13811400 }
1382- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</span>")
1401+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</span>")
13831402 if templ_7745c5c3_Err != nil {
13841403 return templ_7745c5c3_Err
13851404 }
13861405 }
1387- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</p>")
1406+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</p>")
13881407 if templ_7745c5c3_Err != nil {
13891408 return templ_7745c5c3_Err
13901409 }
13911410 }
1392- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</div></li>")
1411+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</div></li>")
13931412 if templ_7745c5c3_Err != nil {
13941413 return templ_7745c5c3_Err
13951414 }
13961415 }
1397- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</ul>")
1416+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</ul>")
13981417 if templ_7745c5c3_Err != nil {
13991418 return templ_7745c5c3_Err
14001419 }
14011420 if more {
1402- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<p class=\"mute\">more annotations exist than are shown.</p>")
1421+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<p class=\"mute\">more annotations exist than are shown.</p>")
14031422 if templ_7745c5c3_Err != nil {
14041423 return templ_7745c5c3_Err
14051424 }
@@ -1427,30 +1446,30 @@ func JobAnnotations(anns []AnnotationView, more bool) templ.Component {
14271446 }()
14281447 }
14291448 ctx = templ.InitializeContext(ctx)
1430- templ_7745c5c3_Var77 := templ.GetChildren(ctx)
1431- if templ_7745c5c3_Var77 == nil {
1432- templ_7745c5c3_Var77 = templ.NopComponent
1449+ templ_7745c5c3_Var78 := templ.GetChildren(ctx)
1450+ if templ_7745c5c3_Var78 == nil {
1451+ templ_7745c5c3_Var78 = templ.NopComponent
14331452 }
14341453 ctx = templ.ClearChildren(ctx)
1435- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "<section id=\"job-annotations\" class=\"annots\" aria-label=\"Annotations\">")
1454+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "<section id=\"job-annotations\" class=\"annots\" aria-label=\"Annotations\">")
14361455 if templ_7745c5c3_Err != nil {
14371456 return templ_7745c5c3_Err
14381457 }
14391458 if len(anns) > 0 {
1440- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<h3>Annotations <span class=\"count\">")
1459+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "<h3>Annotations <span class=\"count\">")
14411460 if templ_7745c5c3_Err != nil {
14421461 return templ_7745c5c3_Err
14431462 }
1444- var templ_7745c5c3_Var78 string
1445- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(anns)))
1463+ var templ_7745c5c3_Var79 string
1464+ templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(anns)))
14461465 if templ_7745c5c3_Err != nil {
1447- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 196, Col: 56}
1466+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 199, Col: 56}
14481467 }
1449- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
1468+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))
14501469 if templ_7745c5c3_Err != nil {
14511470 return templ_7745c5c3_Err
14521471 }
1453- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</span></h3>")
1472+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</span></h3>")
14541473 if templ_7745c5c3_Err != nil {
14551474 return templ_7745c5c3_Err
14561475 }
@@ -1459,7 +1478,7 @@ func JobAnnotations(anns []AnnotationView, more bool) templ.Component {
14591478 return templ_7745c5c3_Err
14601479 }
14611480 }
1462- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</section>")
1481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</section>")
14631482 if templ_7745c5c3_Err != nil {
14641483 return templ_7745c5c3_Err
14651484 }
@@ -1486,125 +1505,125 @@ func RunSummaries(sums []JobSummaries, more bool) templ.Component {
14861505 }()
14871506 }
14881507 ctx = templ.InitializeContext(ctx)
1489- templ_7745c5c3_Var79 := templ.GetChildren(ctx)
1490- if templ_7745c5c3_Var79 == nil {
1491- templ_7745c5c3_Var79 = templ.NopComponent
1508+ templ_7745c5c3_Var80 := templ.GetChildren(ctx)
1509+ if templ_7745c5c3_Var80 == nil {
1510+ templ_7745c5c3_Var80 = templ.NopComponent
14921511 }
14931512 ctx = templ.ClearChildren(ctx)
14941513 for _, j := range sums {
1495- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "<section class=\"summaries\" aria-label=\"")
1514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<section class=\"summaries\" aria-label=\"")
14961515 if templ_7745c5c3_Err != nil {
14971516 return templ_7745c5c3_Err
14981517 }
1499- var templ_7745c5c3_Var80 string
1500- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue("Summary of " + j.Job)
1518+ var templ_7745c5c3_Var81 string
1519+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.ResolveAttributeValue("Summary of " + j.Job)
15011520 if templ_7745c5c3_Err != nil {
1502- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 207, Col: 63}
1521+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 210, Col: 63}
15031522 }
1504- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1523+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var81)
15051524 if templ_7745c5c3_Err != nil {
15061525 return templ_7745c5c3_Err
15071526 }
1508- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\"><h3>")
1527+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\"><h3>")
15091528 if templ_7745c5c3_Err != nil {
15101529 return templ_7745c5c3_Err
15111530 }
15121531 if j.JobHref != "" {
1513- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<a href=\"")
1532+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<a href=\"")
15141533 if templ_7745c5c3_Err != nil {
15151534 return templ_7745c5c3_Err
15161535 }
1517- var templ_7745c5c3_Var81 templ.SafeURL
1518- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(j.JobHref))
1536+ var templ_7745c5c3_Var82 templ.SafeURL
1537+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(j.JobHref))
15191538 if templ_7745c5c3_Err != nil {
1520- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 210, Col: 39}
1539+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 213, Col: 39}
15211540 }
1522- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1541+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82))
15231542 if templ_7745c5c3_Err != nil {
15241543 return templ_7745c5c3_Err
15251544 }
1526- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\">")
1545+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\">")
15271546 if templ_7745c5c3_Err != nil {
15281547 return templ_7745c5c3_Err
15291548 }
1530- var templ_7745c5c3_Var82 string
1531- templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(j.Job)
1549+ var templ_7745c5c3_Var83 string
1550+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(j.Job)
15321551 if templ_7745c5c3_Err != nil {
1533- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 210, Col: 49}
1552+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 213, Col: 49}
15341553 }
1535- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82))
1554+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
15361555 if templ_7745c5c3_Err != nil {
15371556 return templ_7745c5c3_Err
15381557 }
1539- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</a> ")
1558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "</a> ")
15401559 if templ_7745c5c3_Err != nil {
15411560 return templ_7745c5c3_Err
15421561 }
15431562 } else {
1544- var templ_7745c5c3_Var83 string
1545- templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(j.Job)
1563+ var templ_7745c5c3_Var84 string
1564+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(j.Job)
15461565 if templ_7745c5c3_Err != nil {
1547- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 212, Col: 12}
1566+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 215, Col: 12}
15481567 }
1549- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
1568+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
15501569 if templ_7745c5c3_Err != nil {
15511570 return templ_7745c5c3_Err
15521571 }
1553- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, " ")
1572+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, " ")
15541573 if templ_7745c5c3_Err != nil {
15551574 return templ_7745c5c3_Err
15561575 }
15571576 }
1558- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<span class=\"mute\">summary</span></h3>")
1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<span class=\"mute\">summary</span></h3>")
15591578 if templ_7745c5c3_Err != nil {
15601579 return templ_7745c5c3_Err
15611580 }
15621581 for _, s := range j.Steps {
1563- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<article class=\"prose summary\" aria-label=\"")
1582+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<article class=\"prose summary\" aria-label=\"")
15641583 if templ_7745c5c3_Err != nil {
15651584 return templ_7745c5c3_Err
15661585 }
1567- var templ_7745c5c3_Var84 string
1568- templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Step)
1586+ var templ_7745c5c3_Var85 string
1587+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Step)
15691588 if templ_7745c5c3_Err != nil {
1570- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 217, Col: 54}
1589+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 220, Col: 54}
15711590 }
1572- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)
1591+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var85)
15731592 if templ_7745c5c3_Err != nil {
15741593 return templ_7745c5c3_Err
15751594 }
1576- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "\" hx-ignore><h4 class=\"summary-step\">")
1595+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\" hx-ignore><h4 class=\"summary-step\">")
15771596 if templ_7745c5c3_Err != nil {
15781597 return templ_7745c5c3_Err
15791598 }
1580- var templ_7745c5c3_Var85 string
1581- templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(s.Step)
1599+ var templ_7745c5c3_Var86 string
1600+ templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(s.Step)
15821601 if templ_7745c5c3_Err != nil {
1583- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 218, Col: 38}
1602+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 221, Col: 38}
15841603 }
1585- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
1604+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
15861605 if templ_7745c5c3_Err != nil {
15871606 return templ_7745c5c3_Err
15881607 }
1589- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "</h4>")
1608+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</h4>")
15901609 if templ_7745c5c3_Err != nil {
15911610 return templ_7745c5c3_Err
15921611 }
15931612 if s.Note != "" {
1594- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "<p class=\"dim\">")
1613+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "<p class=\"dim\">")
15951614 if templ_7745c5c3_Err != nil {
15961615 return templ_7745c5c3_Err
15971616 }
1598- var templ_7745c5c3_Var86 string
1599- templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(s.Note)
1617+ var templ_7745c5c3_Var87 string
1618+ templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(s.Note)
16001619 if templ_7745c5c3_Err != nil {
1601- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 220, Col: 29}
1620+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 223, Col: 29}
16021621 }
1603- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
1622+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
16041623 if templ_7745c5c3_Err != nil {
16051624 return templ_7745c5c3_Err
16061625 }
1607- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</p>")
1626+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</p>")
16081627 if templ_7745c5c3_Err != nil {
16091628 return templ_7745c5c3_Err
16101629 }
@@ -1614,37 +1633,37 @@ func RunSummaries(sums []JobSummaries, more bool) templ.Component {
16141633 return templ_7745c5c3_Err
16151634 }
16161635 }
1617- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</article>")
1636+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</article>")
16181637 if templ_7745c5c3_Err != nil {
16191638 return templ_7745c5c3_Err
16201639 }
16211640 }
16221641 if j.More {
1623- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<p class=\"mute\">the job has more summaries than the first 20, which are shown.</p>")
1642+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<p class=\"mute\">the job has more summaries than the first 20, which are shown.</p>")
16241643 if templ_7745c5c3_Err != nil {
16251644 return templ_7745c5c3_Err
16261645 }
16271646 }
1628- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</section>")
1647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</section>")
16291648 if templ_7745c5c3_Err != nil {
16301649 return templ_7745c5c3_Err
16311650 }
16321651 }
16331652 if more {
1634- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<p class=\"mute\">more summaries are not shown: the summaries of the page are over ")
1653+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<p class=\"mute\">more summaries are not shown: the summaries of the page are over ")
16351654 if templ_7745c5c3_Err != nil {
16361655 return templ_7745c5c3_Err
16371656 }
1638- var templ_7745c5c3_Var87 string
1639- templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(humanSize(maxSummaryPageBytes))
1657+ var templ_7745c5c3_Var88 string
1658+ templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(humanSize(maxSummaryPageBytes))
16401659 if templ_7745c5c3_Err != nil {
1641- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 232, Col: 115}
1660+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 235, Col: 115}
16421661 }
1643- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
1662+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))
16441663 if templ_7745c5c3_Err != nil {
16451664 return templ_7745c5c3_Err
16461665 }
1647- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, ".</p>")
1666+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, ".</p>")
16481667 if templ_7745c5c3_Err != nil {
16491668 return templ_7745c5c3_Err
16501669 }
@@ -1669,256 +1688,269 @@ func runRail(p RunPage) templ.Component {
16691688 }()
16701689 }
16711690 ctx = templ.InitializeContext(ctx)
1672- templ_7745c5c3_Var88 := templ.GetChildren(ctx)
1673- if templ_7745c5c3_Var88 == nil {
1674- templ_7745c5c3_Var88 = templ.NopComponent
1691+ templ_7745c5c3_Var89 := templ.GetChildren(ctx)
1692+ if templ_7745c5c3_Var89 == nil {
1693+ templ_7745c5c3_Var89 = templ.NopComponent
16751694 }
16761695 ctx = templ.ClearChildren(ctx)
1677- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<section aria-labelledby=\"r-run\"><h2 id=\"r-run\">Run</h2><dl class=\"kv\"><dt>result</dt><dd>")
1696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "<section aria-labelledby=\"r-run\"><h2 id=\"r-run\">Run</h2><dl class=\"kv\"><dt>result</dt><dd>")
16781697 if templ_7745c5c3_Err != nil {
16791698 return templ_7745c5c3_Err
16801699 }
1681- var templ_7745c5c3_Var89 string
1682- templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(p.State)
1700+ var templ_7745c5c3_Var90 string
1701+ templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(p.State)
16831702 if templ_7745c5c3_Err != nil {
1684- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 241, Col: 16}
1685- }
1686- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
1687- if templ_7745c5c3_Err != nil {
1688- return templ_7745c5c3_Err
1689- }
1690- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</dd><dt>commit</dt><dd><a href=\"")
1691- if templ_7745c5c3_Err != nil {
1692- return templ_7745c5c3_Err
1693- }
1694- var templ_7745c5c3_Var90 templ.SafeURL
1695- templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CHref))
1696- if templ_7745c5c3_Err != nil {
1697- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 243, Col: 39}
1703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 244, Col: 16}
16981704 }
16991705 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90))
17001706 if templ_7745c5c3_Err != nil {
17011707 return templ_7745c5c3_Err
17021708 }
1703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\">")
1709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</dd><dt>event</dt><dd>")
17041710 if templ_7745c5c3_Err != nil {
17051711 return templ_7745c5c3_Err
17061712 }
17071713 var templ_7745c5c3_Var91 string
1708- templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(p.Short)
1714+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(p.Event)
17091715 if templ_7745c5c3_Err != nil {
1710- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 243, Col: 51}
1716+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 246, Col: 16}
17111717 }
17121718 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
17131719 if templ_7745c5c3_Err != nil {
17141720 return templ_7745c5c3_Err
17151721 }
1716- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</a></dd><dt>ref</dt><dd><a href=\"")
1722+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "</dd><dt>commit</dt><dd><a href=\"")
17171723 if templ_7745c5c3_Err != nil {
17181724 return templ_7745c5c3_Err
17191725 }
17201726 var templ_7745c5c3_Var92 templ.SafeURL
1721- templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RefHref))
1727+ templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CHref))
17221728 if templ_7745c5c3_Err != nil {
1723- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 245, Col: 41}
1729+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 248, Col: 39}
17241730 }
17251731 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
17261732 if templ_7745c5c3_Err != nil {
17271733 return templ_7745c5c3_Err
17281734 }
1729- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\">")
1735+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\">")
17301736 if templ_7745c5c3_Err != nil {
17311737 return templ_7745c5c3_Err
17321738 }
17331739 var templ_7745c5c3_Var93 string
1734- templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(p.Ref)
1740+ templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(p.Short)
17351741 if templ_7745c5c3_Err != nil {
1736- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 245, Col: 51}
1742+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 248, Col: 51}
17371743 }
17381744 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
17391745 if templ_7745c5c3_Err != nil {
17401746 return templ_7745c5c3_Err
17411747 }
1742- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "</a></dd><dt>actor</dt><dd>")
1748+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "</a></dd><dt>ref</dt><dd><a href=\"")
17431749 if templ_7745c5c3_Err != nil {
17441750 return templ_7745c5c3_Err
17451751 }
1746- var templ_7745c5c3_Var94 string
1747- templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(p.Actor)
1752+ var templ_7745c5c3_Var94 templ.SafeURL
1753+ templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RefHref))
17481754 if templ_7745c5c3_Err != nil {
1749- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 247, Col: 16}
1755+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 250, Col: 41}
17501756 }
17511757 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94))
17521758 if templ_7745c5c3_Err != nil {
17531759 return templ_7745c5c3_Err
17541760 }
1755- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "</dd><dt>attempt</dt><dd>")
1761+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\">")
17561762 if templ_7745c5c3_Err != nil {
17571763 return templ_7745c5c3_Err
17581764 }
17591765 var templ_7745c5c3_Var95 string
1760- templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(p.Attempt)))
1766+ templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(p.Ref)
17611767 if templ_7745c5c3_Err != nil {
1762- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 249, Col: 29}
1768+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 250, Col: 51}
17631769 }
17641770 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
17651771 if templ_7745c5c3_Err != nil {
17661772 return templ_7745c5c3_Err
17671773 }
1768- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, " of ")
1774+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</a></dd><dt>actor</dt><dd>")
17691775 if templ_7745c5c3_Err != nil {
17701776 return templ_7745c5c3_Err
17711777 }
17721778 var templ_7745c5c3_Var96 string
1773- templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(p.Latest)))
1779+ templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(p.Actor)
17741780 if templ_7745c5c3_Err != nil {
1775- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 249, Col: 56}
1781+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 252, Col: 16}
17761782 }
17771783 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
17781784 if templ_7745c5c3_Err != nil {
17791785 return templ_7745c5c3_Err
17801786 }
1781- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</dd></dl>")
1787+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</dd><dt>attempt</dt><dd>")
1788+ if templ_7745c5c3_Err != nil {
1789+ return templ_7745c5c3_Err
1790+ }
1791+ var templ_7745c5c3_Var97 string
1792+ templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(p.Attempt)))
1793+ if templ_7745c5c3_Err != nil {
1794+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 254, Col: 29}
1795+ }
1796+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
1797+ if templ_7745c5c3_Err != nil {
1798+ return templ_7745c5c3_Err
1799+ }
1800+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, " of ")
1801+ if templ_7745c5c3_Err != nil {
1802+ return templ_7745c5c3_Err
1803+ }
1804+ var templ_7745c5c3_Var98 string
1805+ templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(p.Latest)))
1806+ if templ_7745c5c3_Err != nil {
1807+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 254, Col: 56}
1808+ }
1809+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
1810+ if templ_7745c5c3_Err != nil {
1811+ return templ_7745c5c3_Err
1812+ }
1813+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</dd></dl>")
17821814 if templ_7745c5c3_Err != nil {
17831815 return templ_7745c5c3_Err
17841816 }
17851817 if len(p.Attempts) > 0 {
1786- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<ul class=\"attempts\">")
1818+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<ul class=\"attempts\">")
17871819 if templ_7745c5c3_Err != nil {
17881820 return templ_7745c5c3_Err
17891821 }
17901822 for _, a := range p.Attempts {
1791- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<li>")
1823+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<li>")
17921824 if templ_7745c5c3_Err != nil {
17931825 return templ_7745c5c3_Err
17941826 }
17951827 if a.Current {
1796- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<span aria-current=\"page\">")
1828+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<span aria-current=\"page\">")
17971829 if templ_7745c5c3_Err != nil {
17981830 return templ_7745c5c3_Err
17991831 }
1800- var templ_7745c5c3_Var97 string
1801- templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(a.Label)
1832+ var templ_7745c5c3_Var99 string
1833+ templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(a.Label)
18021834 if templ_7745c5c3_Err != nil {
1803- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 256, Col: 42}
1835+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 261, Col: 42}
18041836 }
1805- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
1837+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
18061838 if templ_7745c5c3_Err != nil {
18071839 return templ_7745c5c3_Err
18081840 }
1809- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</span>")
1841+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</span>")
18101842 if templ_7745c5c3_Err != nil {
18111843 return templ_7745c5c3_Err
18121844 }
18131845 } else {
1814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<a href=\"")
1846+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "<a href=\"")
18151847 if templ_7745c5c3_Err != nil {
18161848 return templ_7745c5c3_Err
18171849 }
1818- var templ_7745c5c3_Var98 templ.SafeURL
1819- templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.Href))
1850+ var templ_7745c5c3_Var100 templ.SafeURL
1851+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.Href))
18201852 if templ_7745c5c3_Err != nil {
1821- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 258, Col: 38}
1853+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 263, Col: 38}
18221854 }
1823- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
1855+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
18241856 if templ_7745c5c3_Err != nil {
18251857 return templ_7745c5c3_Err
18261858 }
1827- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "\">")
1859+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\">")
18281860 if templ_7745c5c3_Err != nil {
18291861 return templ_7745c5c3_Err
18301862 }
1831- var templ_7745c5c3_Var99 string
1832- templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(a.Label)
1863+ var templ_7745c5c3_Var101 string
1864+ templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(a.Label)
18331865 if templ_7745c5c3_Err != nil {
1834- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 258, Col: 50}
1866+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 263, Col: 50}
18351867 }
1836- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
1868+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
18371869 if templ_7745c5c3_Err != nil {
18381870 return templ_7745c5c3_Err
18391871 }
1840- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</a>")
1872+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</a>")
18411873 if templ_7745c5c3_Err != nil {
18421874 return templ_7745c5c3_Err
18431875 }
18441876 }
1845- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</li>")
1877+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</li>")
18461878 if templ_7745c5c3_Err != nil {
18471879 return templ_7745c5c3_Err
18481880 }
18491881 }
1850- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</ul>")
1882+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "</ul>")
18511883 if templ_7745c5c3_Err != nil {
18521884 return templ_7745c5c3_Err
18531885 }
18541886 }
1855- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</section>")
1887+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</section>")
18561888 if templ_7745c5c3_Err != nil {
18571889 return templ_7745c5c3_Err
18581890 }
18591891 if p.CancelAction != "" || p.RerunAction != "" {
1860- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "<section aria-labelledby=\"r-act\" class=\"decide\"><h2 id=\"r-act\">Actions</h2><div class=\"d-btns\">")
1892+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "<section aria-labelledby=\"r-act\" class=\"decide\"><h2 id=\"r-act\">Actions</h2><div class=\"d-btns\">")
18611893 if templ_7745c5c3_Err != nil {
18621894 return templ_7745c5c3_Err
18631895 }
18641896 if p.CancelAction != "" {
1865- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<form method=\"post\" action=\"")
1897+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<form method=\"post\" action=\"")
18661898 if templ_7745c5c3_Err != nil {
18671899 return templ_7745c5c3_Err
18681900 }
1869- var templ_7745c5c3_Var100 templ.SafeURL
1870- templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CancelAction))
1901+ var templ_7745c5c3_Var102 templ.SafeURL
1902+ templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CancelAction))
18711903 if templ_7745c5c3_Err != nil {
1872- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 270, Col: 63}
1904+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 275, Col: 63}
18731905 }
1874- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
1906+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
18751907 if templ_7745c5c3_Err != nil {
18761908 return templ_7745c5c3_Err
18771909 }
1878- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "\"><button type=\"submit\" class=\"btn\">Cancel run</button></form>")
1910+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "\"><button type=\"submit\" class=\"btn\">Cancel run</button></form>")
18791911 if templ_7745c5c3_Err != nil {
18801912 return templ_7745c5c3_Err
18811913 }
18821914 }
18831915 if p.RerunAction != "" {
1884- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "<form method=\"post\" action=\"")
1916+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<form method=\"post\" action=\"")
18851917 if templ_7745c5c3_Err != nil {
18861918 return templ_7745c5c3_Err
18871919 }
1888- var templ_7745c5c3_Var101 templ.SafeURL
1889- templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RerunAction))
1920+ var templ_7745c5c3_Var103 templ.SafeURL
1921+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RerunAction))
18901922 if templ_7745c5c3_Err != nil {
1891- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 273, Col: 62}
1923+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 278, Col: 62}
18921924 }
1893- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
1925+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
18941926 if templ_7745c5c3_Err != nil {
18951927 return templ_7745c5c3_Err
18961928 }
1897- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "\"><button type=\"submit\" class=\"btn primary\">Re-run all jobs</button></form>")
1929+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"><button type=\"submit\" class=\"btn primary\">Re-run all jobs</button></form>")
18981930 if templ_7745c5c3_Err != nil {
18991931 return templ_7745c5c3_Err
19001932 }
19011933 }
19021934 if p.CanRerunFailed {
1903- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<form method=\"post\" action=\"")
1935+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "<form method=\"post\" action=\"")
19041936 if templ_7745c5c3_Err != nil {
19051937 return templ_7745c5c3_Err
19061938 }
1907- var templ_7745c5c3_Var102 templ.SafeURL
1908- templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RerunFailedAction))
1939+ var templ_7745c5c3_Var104 templ.SafeURL
1940+ templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RerunFailedAction))
19091941 if templ_7745c5c3_Err != nil {
1910- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 276, Col: 68}
1942+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 281, Col: 68}
19111943 }
1912- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
1944+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
19131945 if templ_7745c5c3_Err != nil {
19141946 return templ_7745c5c3_Err
19151947 }
1916- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "\"><button type=\"submit\" class=\"btn\">Re-run failed jobs</button></form>")
1948+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "\"><button type=\"submit\" class=\"btn\">Re-run failed jobs</button></form>")
19171949 if templ_7745c5c3_Err != nil {
19181950 return templ_7745c5c3_Err
19191951 }
19201952 }
1921- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "</div></section>")
1953+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</div></section>")
19221954 if templ_7745c5c3_Err != nil {
19231955 return templ_7745c5c3_Err
19241956 }
@@ -1945,25 +1977,25 @@ func JobHead(j JobRow, total int64) templ.Component {
19451977 }()
19461978 }
19471979 ctx = templ.InitializeContext(ctx)
1948- templ_7745c5c3_Var103 := templ.GetChildren(ctx)
1949- if templ_7745c5c3_Var103 == nil {
1950- templ_7745c5c3_Var103 = templ.NopComponent
1980+ templ_7745c5c3_Var105 := templ.GetChildren(ctx)
1981+ if templ_7745c5c3_Var105 == nil {
1982+ templ_7745c5c3_Var105 = templ.NopComponent
19511983 }
19521984 ctx = templ.ClearChildren(ctx)
1953- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "<div id=\"job-head\" data-total=\"")
1985+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<div id=\"job-head\" data-total=\"")
19541986 if templ_7745c5c3_Err != nil {
19551987 return templ_7745c5c3_Err
19561988 }
1957- var templ_7745c5c3_Var104 string
1958- templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(total))
1989+ var templ_7745c5c3_Var106 string
1990+ templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(total))
19591991 if templ_7745c5c3_Err != nil {
1960- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 286, Col: 46}
1992+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 291, Col: 46}
19611993 }
1962- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var104)
1994+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var106)
19631995 if templ_7745c5c3_Err != nil {
19641996 return templ_7745c5c3_Err
19651997 }
1966- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "\"><h2 class=\"run-h\">")
1998+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "\"><h2 class=\"run-h\">")
19671999 if templ_7745c5c3_Err != nil {
19682000 return templ_7745c5c3_Err
19692001 }
@@ -1971,74 +2003,74 @@ func JobHead(j JobRow, total int64) templ.Component {
19712003 if templ_7745c5c3_Err != nil {
19722004 return templ_7745c5c3_Err
19732005 }
1974- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<span>")
2006+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "<span>")
19752007 if templ_7745c5c3_Err != nil {
19762008 return templ_7745c5c3_Err
19772009 }
1978- var templ_7745c5c3_Var105 string
1979- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(j.Name)
2010+ var templ_7745c5c3_Var107 string
2011+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(j.Name)
19802012 if templ_7745c5c3_Err != nil {
1981- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 287, Col: 61}
2013+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 292, Col: 61}
19822014 }
1983- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
2015+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
19842016 if templ_7745c5c3_Err != nil {
19852017 return templ_7745c5c3_Err
19862018 }
1987- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "</span> <span class=\"dim\">")
2019+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</span> <span class=\"dim\">")
19882020 if templ_7745c5c3_Err != nil {
19892021 return templ_7745c5c3_Err
19902022 }
1991- var templ_7745c5c3_Var106 string
1992- templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(j.State)
2023+ var templ_7745c5c3_Var108 string
2024+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(j.State)
19932025 if templ_7745c5c3_Err != nil {
1994- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 287, Col: 98}
2026+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 292, Col: 98}
19952027 }
1996- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
2028+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
19972029 if templ_7745c5c3_Err != nil {
19982030 return templ_7745c5c3_Err
19992031 }
2000- var templ_7745c5c3_Var107 string
2001- templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
2032+ var templ_7745c5c3_Var109 string
2033+ templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
20022034 if templ_7745c5c3_Err != nil {
2003- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 287, Col: 105}
2035+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 292, Col: 105}
20042036 }
2005- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
2037+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
20062038 if templ_7745c5c3_Err != nil {
20072039 return templ_7745c5c3_Err
20082040 }
2009- var templ_7745c5c3_Var108 string
2010- templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(j.Duration)
2041+ var templ_7745c5c3_Var110 string
2042+ templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinStringErrs(j.Duration)
20112043 if templ_7745c5c3_Err != nil {
2012- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 287, Col: 119}
2044+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 292, Col: 119}
20132045 }
2014- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
2046+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var110))
20152047 if templ_7745c5c3_Err != nil {
20162048 return templ_7745c5c3_Err
20172049 }
2018- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "</span></h2>")
2050+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</span></h2>")
20192051 if templ_7745c5c3_Err != nil {
20202052 return templ_7745c5c3_Err
20212053 }
20222054 if j.Error != "" {
2023- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "<p class=\"job-err\">")
2055+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<p class=\"job-err\">")
20242056 if templ_7745c5c3_Err != nil {
20252057 return templ_7745c5c3_Err
20262058 }
2027- var templ_7745c5c3_Var109 string
2028- templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(j.Error)
2059+ var templ_7745c5c3_Var111 string
2060+ templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(j.Error)
20292061 if templ_7745c5c3_Err != nil {
2030- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 289, Col: 31}
2062+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 294, Col: 31}
20312063 }
2032- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
2064+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
20332065 if templ_7745c5c3_Err != nil {
20342066 return templ_7745c5c3_Err
20352067 }
2036- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</p>")
2068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "</p>")
20372069 if templ_7745c5c3_Err != nil {
20382070 return templ_7745c5c3_Err
20392071 }
20402072 }
2041- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</div>")
2073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</div>")
20422074 if templ_7745c5c3_Err != nil {
20432075 return templ_7745c5c3_Err
20442076 }
@@ -2063,17 +2095,17 @@ func JobSteps(steps []StepView) templ.Component {
20632095 }()
20642096 }
20652097 ctx = templ.InitializeContext(ctx)
2066- templ_7745c5c3_Var110 := templ.GetChildren(ctx)
2067- if templ_7745c5c3_Var110 == nil {
2068- templ_7745c5c3_Var110 = templ.NopComponent
2098+ templ_7745c5c3_Var112 := templ.GetChildren(ctx)
2099+ if templ_7745c5c3_Var112 == nil {
2100+ templ_7745c5c3_Var112 = templ.NopComponent
20692101 }
20702102 ctx = templ.ClearChildren(ctx)
2071- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<ol class=\"steps\" id=\"job-steps\">")
2103+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "<ol class=\"steps\" id=\"job-steps\">")
20722104 if templ_7745c5c3_Err != nil {
20732105 return templ_7745c5c3_Err
20742106 }
20752107 for _, s := range steps {
2076- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<li>")
2108+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "<li>")
20772109 if templ_7745c5c3_Err != nil {
20782110 return templ_7745c5c3_Err
20792111 }
@@ -2082,74 +2114,74 @@ func JobSteps(steps []StepView) templ.Component {
20822114 return templ_7745c5c3_Err
20832115 }
20842116 if s.LogEnd > s.LogStart {
2085- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<a href=\"")
2117+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<a href=\"")
20862118 if templ_7745c5c3_Err != nil {
20872119 return templ_7745c5c3_Err
20882120 }
2089- var templ_7745c5c3_Var111 templ.SafeURL
2090- templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#L" + itoa64(s.LogStart)))
2121+ var templ_7745c5c3_Var113 templ.SafeURL
2122+ templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#L" + itoa64(s.LogStart)))
20912123 if templ_7745c5c3_Err != nil {
2092- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 301, Col: 55}
2124+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 306, Col: 55}
20932125 }
2094- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
2126+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
20952127 if templ_7745c5c3_Err != nil {
20962128 return templ_7745c5c3_Err
20972129 }
2098- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "\">")
2130+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "\">")
20992131 if templ_7745c5c3_Err != nil {
21002132 return templ_7745c5c3_Err
21012133 }
2102- var templ_7745c5c3_Var112 string
2103- templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
2134+ var templ_7745c5c3_Var114 string
2135+ templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
21042136 if templ_7745c5c3_Err != nil {
2105- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 301, Col: 66}
2137+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 306, Col: 66}
21062138 }
2107- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
2139+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))
21082140 if templ_7745c5c3_Err != nil {
21092141 return templ_7745c5c3_Err
21102142 }
2111- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</a> ")
2143+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "</a> ")
21122144 if templ_7745c5c3_Err != nil {
21132145 return templ_7745c5c3_Err
21142146 }
21152147 } else {
2116- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<span>")
2148+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<span>")
21172149 if templ_7745c5c3_Err != nil {
21182150 return templ_7745c5c3_Err
21192151 }
2120- var templ_7745c5c3_Var113 string
2121- templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
2152+ var templ_7745c5c3_Var115 string
2153+ templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
21222154 if templ_7745c5c3_Err != nil {
2123- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 303, Col: 19}
2155+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 308, Col: 19}
21242156 }
2125- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
2157+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
21262158 if templ_7745c5c3_Err != nil {
21272159 return templ_7745c5c3_Err
21282160 }
2129- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</span> ")
2161+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</span> ")
21302162 if templ_7745c5c3_Err != nil {
21312163 return templ_7745c5c3_Err
21322164 }
21332165 }
2134- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<span class=\"dim\">")
2166+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<span class=\"dim\">")
21352167 if templ_7745c5c3_Err != nil {
21362168 return templ_7745c5c3_Err
21372169 }
2138- var templ_7745c5c3_Var114 string
2139- templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(s.Duration)
2170+ var templ_7745c5c3_Var116 string
2171+ templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(s.Duration)
21402172 if templ_7745c5c3_Err != nil {
2141- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 305, Col: 34}
2173+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 310, Col: 34}
21422174 }
2143- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))
2175+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
21442176 if templ_7745c5c3_Err != nil {
21452177 return templ_7745c5c3_Err
21462178 }
2147- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</span></li>")
2179+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "</span></li>")
21482180 if templ_7745c5c3_Err != nil {
21492181 return templ_7745c5c3_Err
21502182 }
21512183 }
2152- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</ol>")
2184+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "</ol>")
21532185 if templ_7745c5c3_Err != nil {
21542186 return templ_7745c5c3_Err
21552187 }
@@ -2173,12 +2205,12 @@ func Job(l Layout, p JobPage) templ.Component {
21732205 }()
21742206 }
21752207 ctx = templ.InitializeContext(ctx)
2176- templ_7745c5c3_Var115 := templ.GetChildren(ctx)
2177- if templ_7745c5c3_Var115 == nil {
2178- templ_7745c5c3_Var115 = templ.NopComponent
2208+ templ_7745c5c3_Var117 := templ.GetChildren(ctx)
2209+ if templ_7745c5c3_Var117 == nil {
2210+ templ_7745c5c3_Var117 = templ.NopComponent
21792211 }
21802212 ctx = templ.ClearChildren(ctx)
2181- templ_7745c5c3_Var116 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
2213+ templ_7745c5c3_Var118 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
21822214 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
21832215 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
21842216 if !templ_7745c5c3_IsBuffer {
@@ -2194,7 +2226,7 @@ func Job(l Layout, p JobPage) templ.Component {
21942226 if templ_7745c5c3_Err != nil {
21952227 return templ_7745c5c3_Err
21962228 }
2197- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, " ")
2229+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, " ")
21982230 if templ_7745c5c3_Err != nil {
21992231 return templ_7745c5c3_Err
22002232 }
@@ -2202,7 +2234,7 @@ func Job(l Layout, p JobPage) templ.Component {
22022234 if templ_7745c5c3_Err != nil {
22032235 return templ_7745c5c3_Err
22042236 }
2205- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, " ")
2237+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, " ")
22062238 if templ_7745c5c3_Err != nil {
22072239 return templ_7745c5c3_Err
22082240 }
@@ -2210,111 +2242,111 @@ func Job(l Layout, p JobPage) templ.Component {
22102242 if templ_7745c5c3_Err != nil {
22112243 return templ_7745c5c3_Err
22122244 }
2213- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, " <div id=\"job-log\" class=\"joblog\" data-total=\"")
2245+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, " <div id=\"job-log\" class=\"joblog\" data-total=\"")
22142246 if templ_7745c5c3_Err != nil {
22152247 return templ_7745c5c3_Err
22162248 }
2217- var templ_7745c5c3_Var117 string
2218- templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.Total))
2249+ var templ_7745c5c3_Var119 string
2250+ templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.Total))
22192251 if templ_7745c5c3_Err != nil {
2220- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 316, Col: 63}
2252+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 321, Col: 63}
22212253 }
2222- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var117)
2254+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var119)
22232255 if templ_7745c5c3_Err != nil {
22242256 return templ_7745c5c3_Err
22252257 }
2226- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\"")
2258+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "\"")
22272259 if templ_7745c5c3_Err != nil {
22282260 return templ_7745c5c3_Err
22292261 }
22302262 if p.Follow {
2231- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, " data-follow")
2263+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, " data-follow")
22322264 if templ_7745c5c3_Err != nil {
22332265 return templ_7745c5c3_Err
22342266 }
22352267 }
2236- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, " data-stream=\"")
2268+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, " data-stream=\"")
22372269 if templ_7745c5c3_Err != nil {
22382270 return templ_7745c5c3_Err
22392271 }
2240- var templ_7745c5c3_Var118 string
2241- templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.StreamHref)
2272+ var templ_7745c5c3_Var120 string
2273+ templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.StreamHref)
22422274 if templ_7745c5c3_Err != nil {
2243- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 316, Col: 118}
2275+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 321, Col: 118}
22442276 }
2245- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var118)
2277+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var120)
22462278 if templ_7745c5c3_Err != nil {
22472279 return templ_7745c5c3_Err
22482280 }
2249- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "\" data-next=\"")
2281+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "\" data-next=\"")
22502282 if templ_7745c5c3_Err != nil {
22512283 return templ_7745c5c3_Err
22522284 }
2253- var templ_7745c5c3_Var119 string
2254- templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.From + int64(len(p.Lines))))
2285+ var templ_7745c5c3_Var121 string
2286+ templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.From + int64(len(p.Lines))))
22552287 if templ_7745c5c3_Err != nil {
2256- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 316, Col: 169}
2288+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 321, Col: 169}
22572289 }
2258- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var119)
2290+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var121)
22592291 if templ_7745c5c3_Err != nil {
22602292 return templ_7745c5c3_Err
22612293 }
2262- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "\">")
2294+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "\">")
22632295 if templ_7745c5c3_Err != nil {
22642296 return templ_7745c5c3_Err
22652297 }
22662298 if p.Note != "" {
2267- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "<p class=\"dim\">")
2299+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "<p class=\"dim\">")
22682300 if templ_7745c5c3_Err != nil {
22692301 return templ_7745c5c3_Err
22702302 }
2271- var templ_7745c5c3_Var120 string
2272- templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
2303+ var templ_7745c5c3_Var122 string
2304+ templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
22732305 if templ_7745c5c3_Err != nil {
2274- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 318, Col: 27}
2306+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 323, Col: 27}
22752307 }
2276- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
2308+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122))
22772309 if templ_7745c5c3_Err != nil {
22782310 return templ_7745c5c3_Err
22792311 }
2280- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "</p>")
2312+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "</p>")
22812313 if templ_7745c5c3_Err != nil {
22822314 return templ_7745c5c3_Err
22832315 }
22842316 }
22852317 if p.Prev != "" {
2286- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "<p class=\"more\"><a href=\"")
2318+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "<p class=\"more\"><a href=\"")
22872319 if templ_7745c5c3_Err != nil {
22882320 return templ_7745c5c3_Err
22892321 }
2290- var templ_7745c5c3_Var121 templ.SafeURL
2291- templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Prev))
2322+ var templ_7745c5c3_Var123 templ.SafeURL
2323+ templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Prev))
22922324 if templ_7745c5c3_Err != nil {
2293- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 321, Col: 51}
2325+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 326, Col: 51}
22942326 }
2295- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
2327+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
22962328 if templ_7745c5c3_Err != nil {
22972329 return templ_7745c5c3_Err
22982330 }
2299- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "\" rel=\"prev\">earlier lines</a></p>")
2331+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "\" rel=\"prev\">earlier lines</a></p>")
23002332 if templ_7745c5c3_Err != nil {
23012333 return templ_7745c5c3_Err
23022334 }
23032335 }
2304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "<ol class=\"log\" start=\"")
2336+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "<ol class=\"log\" start=\"")
23052337 if templ_7745c5c3_Err != nil {
23062338 return templ_7745c5c3_Err
23072339 }
2308- var templ_7745c5c3_Var122 string
2309- templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.From + 1))
2340+ var templ_7745c5c3_Var124 string
2341+ templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.From + 1))
23102342 if templ_7745c5c3_Err != nil {
2311- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 323, Col: 45}
2343+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 328, Col: 45}
23122344 }
2313- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var122)
2345+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var124)
23142346 if templ_7745c5c3_Err != nil {
23152347 return templ_7745c5c3_Err
23162348 }
2317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "\">")
2349+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "\">")
23182350 if templ_7745c5c3_Err != nil {
23192351 return templ_7745c5c3_Err
23202352 }
@@ -2324,36 +2356,36 @@ func Job(l Layout, p JobPage) templ.Component {
23242356 return templ_7745c5c3_Err
23252357 }
23262358 }
2327- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "</ol>")
2359+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</ol>")
23282360 if templ_7745c5c3_Err != nil {
23292361 return templ_7745c5c3_Err
23302362 }
23312363 if p.Next != "" {
2332- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "<p class=\"more\"><a href=\"")
2364+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "<p class=\"more\"><a href=\"")
23332365 if templ_7745c5c3_Err != nil {
23342366 return templ_7745c5c3_Err
23352367 }
2336- var templ_7745c5c3_Var123 templ.SafeURL
2337- templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Next))
2368+ var templ_7745c5c3_Var125 templ.SafeURL
2369+ templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Next))
23382370 if templ_7745c5c3_Err != nil {
2339- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 329, Col: 51}
2371+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 334, Col: 51}
23402372 }
2341- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
2373+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
23422374 if templ_7745c5c3_Err != nil {
23432375 return templ_7745c5c3_Err
23442376 }
2345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "\" rel=\"next\">more lines</a></p>")
2377+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "\" rel=\"next\">more lines</a></p>")
23462378 if templ_7745c5c3_Err != nil {
23472379 return templ_7745c5c3_Err
23482380 }
23492381 }
2350- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</div>")
2382+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "</div>")
23512383 if templ_7745c5c3_Err != nil {
23522384 return templ_7745c5c3_Err
23532385 }
23542386 return nil
23552387 })
2356- templ_7745c5c3_Err = Shell(l, jobRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var116), templ_7745c5c3_Buffer)
2388+ templ_7745c5c3_Err = Shell(l, jobRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var118), templ_7745c5c3_Buffer)
23572389 if templ_7745c5c3_Err != nil {
23582390 return templ_7745c5c3_Err
23592391 }
@@ -2379,92 +2411,92 @@ func LogLine(ln LogLineView) templ.Component {
23792411 }()
23802412 }
23812413 ctx = templ.InitializeContext(ctx)
2382- templ_7745c5c3_Var124 := templ.GetChildren(ctx)
2383- if templ_7745c5c3_Var124 == nil {
2384- templ_7745c5c3_Var124 = templ.NopComponent
2414+ templ_7745c5c3_Var126 := templ.GetChildren(ctx)
2415+ if templ_7745c5c3_Var126 == nil {
2416+ templ_7745c5c3_Var126 = templ.NopComponent
23852417 }
23862418 ctx = templ.ClearChildren(ctx)
2387- var templ_7745c5c3_Var125 = []any{logClass(ln)}
2388- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var125...)
2419+ var templ_7745c5c3_Var127 = []any{logClass(ln)}
2420+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var127...)
23892421 if templ_7745c5c3_Err != nil {
23902422 return templ_7745c5c3_Err
23912423 }
2392- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "<li id=\"")
2424+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "<li id=\"")
23932425 if templ_7745c5c3_Err != nil {
23942426 return templ_7745c5c3_Err
23952427 }
2396- var templ_7745c5c3_Var126 string
2397- templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.ResolveAttributeValue("L" + itoa64(ln.N))
2428+ var templ_7745c5c3_Var128 string
2429+ templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.ResolveAttributeValue("L" + itoa64(ln.N))
23982430 if templ_7745c5c3_Err != nil {
2399- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 338, Col: 28}
2431+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 343, Col: 28}
24002432 }
2401- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var126)
2433+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var128)
24022434 if templ_7745c5c3_Err != nil {
24032435 return templ_7745c5c3_Err
24042436 }
2405- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "\" class=\"")
2437+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "\" class=\"")
24062438 if templ_7745c5c3_Err != nil {
24072439 return templ_7745c5c3_Err
24082440 }
2409- var templ_7745c5c3_Var127 string
2410- templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var125).String())
2441+ var templ_7745c5c3_Var129 string
2442+ templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var127).String())
24112443 if templ_7745c5c3_Err != nil {
24122444 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 1, Col: 0}
24132445 }
2414- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var127)
2446+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129)
24152447 if templ_7745c5c3_Err != nil {
24162448 return templ_7745c5c3_Err
24172449 }
2418- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "\" data-step=\"")
2450+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "\" data-step=\"")
24192451 if templ_7745c5c3_Err != nil {
24202452 return templ_7745c5c3_Err
24212453 }
2422- var templ_7745c5c3_Var128 string
2423- templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(int(ln.Step)))
2454+ var templ_7745c5c3_Var130 string
2455+ templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(int(ln.Step)))
24242456 if templ_7745c5c3_Err != nil {
2425- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 338, Col: 84}
2457+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 343, Col: 84}
24262458 }
2427- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var128)
2459+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var130)
24282460 if templ_7745c5c3_Err != nil {
24292461 return templ_7745c5c3_Err
24302462 }
2431- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "\"")
2463+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "\"")
24322464 if templ_7745c5c3_Err != nil {
24332465 return templ_7745c5c3_Err
24342466 }
24352467 if ln.Continued {
2436- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, " data-cont")
2468+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, " data-cont")
24372469 if templ_7745c5c3_Err != nil {
24382470 return templ_7745c5c3_Err
24392471 }
24402472 }
2441- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "><time>")
2473+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "><time>")
24422474 if templ_7745c5c3_Err != nil {
24432475 return templ_7745c5c3_Err
24442476 }
2445- var templ_7745c5c3_Var129 string
2446- templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Time)
2477+ var templ_7745c5c3_Var131 string
2478+ templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Time)
24472479 if templ_7745c5c3_Err != nil {
2448- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 338, Col: 130}
2480+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 343, Col: 130}
24492481 }
2450- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var129))
2482+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
24512483 if templ_7745c5c3_Err != nil {
24522484 return templ_7745c5c3_Err
24532485 }
2454- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "</time><span class=\"t\">")
2486+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "</time><span class=\"t\">")
24552487 if templ_7745c5c3_Err != nil {
24562488 return templ_7745c5c3_Err
24572489 }
2458- var templ_7745c5c3_Var130 string
2459- templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Text)
2490+ var templ_7745c5c3_Var132 string
2491+ templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Text)
24602492 if templ_7745c5c3_Err != nil {
2461- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 338, Col: 164}
2493+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 343, Col: 164}
24622494 }
2463- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var130))
2495+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
24642496 if templ_7745c5c3_Err != nil {
24652497 return templ_7745c5c3_Err
24662498 }
2467- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "</span></li>")
2499+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "</span></li>")
24682500 if templ_7745c5c3_Err != nil {
24692501 return templ_7745c5c3_Err
24702502 }
@@ -2488,12 +2520,12 @@ func jobRail(p JobPage) templ.Component {
24882520 }()
24892521 }
24902522 ctx = templ.InitializeContext(ctx)
2491- templ_7745c5c3_Var131 := templ.GetChildren(ctx)
2492- if templ_7745c5c3_Var131 == nil {
2493- templ_7745c5c3_Var131 = templ.NopComponent
2523+ templ_7745c5c3_Var133 := templ.GetChildren(ctx)
2524+ if templ_7745c5c3_Var133 == nil {
2525+ templ_7745c5c3_Var133 = templ.NopComponent
24942526 }
24952527 ctx = templ.ClearChildren(ctx)
2496- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "<section aria-labelledby=\"r-job\"><h2 id=\"r-job\">Steps</h2>")
2528+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "<section aria-labelledby=\"r-job\"><h2 id=\"r-job\">Steps</h2>")
24972529 if templ_7745c5c3_Err != nil {
24982530 return templ_7745c5c3_Err
24992531 }
@@ -2501,101 +2533,101 @@ func jobRail(p JobPage) templ.Component {
25012533 if templ_7745c5c3_Err != nil {
25022534 return templ_7745c5c3_Err
25032535 }
2504- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "</section><section aria-labelledby=\"r-jobf\"><h2 id=\"r-jobf\">Job</h2><dl class=\"kv\"><dt>run</dt><dd><a href=\"")
2536+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "</section><section aria-labelledby=\"r-jobf\"><h2 id=\"r-jobf\">Job</h2><dl class=\"kv\"><dt>run</dt><dd><a href=\"")
25052537 if templ_7745c5c3_Err != nil {
25062538 return templ_7745c5c3_Err
25072539 }
2508- var templ_7745c5c3_Var132 templ.SafeURL
2509- templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RunHref))
2540+ var templ_7745c5c3_Var134 templ.SafeURL
2541+ templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RunHref))
25102542 if templ_7745c5c3_Err != nil {
2511- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 350, Col: 41}
2543+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 355, Col: 41}
25122544 }
2513- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
2545+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
25142546 if templ_7745c5c3_Err != nil {
25152547 return templ_7745c5c3_Err
25162548 }
2517- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "\">")
2549+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "\">")
25182550 if templ_7745c5c3_Err != nil {
25192551 return templ_7745c5c3_Err
25202552 }
2521- var templ_7745c5c3_Var133 string
2522- templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(p.RunName)
2553+ var templ_7745c5c3_Var135 string
2554+ templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(p.RunName)
25232555 if templ_7745c5c3_Err != nil {
2524- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 350, Col: 55}
2556+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 355, Col: 55}
25252557 }
2526- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
2558+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
25272559 if templ_7745c5c3_Err != nil {
25282560 return templ_7745c5c3_Err
25292561 }
2530- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "</a></dd>")
2562+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "</a></dd>")
25312563 if templ_7745c5c3_Err != nil {
25322564 return templ_7745c5c3_Err
25332565 }
25342566 if p.Runner != "" {
2535- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "<dt>runner</dt><dd>")
2567+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "<dt>runner</dt><dd>")
25362568 if templ_7745c5c3_Err != nil {
25372569 return templ_7745c5c3_Err
25382570 }
2539- var templ_7745c5c3_Var134 string
2540- templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(p.Runner)
2571+ var templ_7745c5c3_Var136 string
2572+ templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(p.Runner)
25412573 if templ_7745c5c3_Err != nil {
2542- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 353, Col: 18}
2574+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 358, Col: 18}
25432575 }
2544- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
2576+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
25452577 if templ_7745c5c3_Err != nil {
25462578 return templ_7745c5c3_Err
25472579 }
2548- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "</dd>")
2580+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "</dd>")
25492581 if templ_7745c5c3_Err != nil {
25502582 return templ_7745c5c3_Err
25512583 }
25522584 }
25532585 if p.Isolation != "" {
2554- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "<dt>isolation</dt><dd>")
2586+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "<dt>isolation</dt><dd>")
25552587 if templ_7745c5c3_Err != nil {
25562588 return templ_7745c5c3_Err
25572589 }
2558- var templ_7745c5c3_Var135 string
2559- templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(p.Isolation)
2590+ var templ_7745c5c3_Var137 string
2591+ templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(p.Isolation)
25602592 if templ_7745c5c3_Err != nil {
2561- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 357, Col: 21}
2593+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 362, Col: 21}
25622594 }
2563- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
2595+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137))
25642596 if templ_7745c5c3_Err != nil {
25652597 return templ_7745c5c3_Err
25662598 }
2567- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "</dd>")
2599+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "</dd>")
25682600 if templ_7745c5c3_Err != nil {
25692601 return templ_7745c5c3_Err
25702602 }
25712603 }
2572- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "<dt>lines</dt><dd>")
2604+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "<dt>lines</dt><dd>")
25732605 if templ_7745c5c3_Err != nil {
25742606 return templ_7745c5c3_Err
25752607 }
2576- var templ_7745c5c3_Var136 string
2577- templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(itoa64(p.Total))
2608+ var templ_7745c5c3_Var138 string
2609+ templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(itoa64(p.Total))
25782610 if templ_7745c5c3_Err != nil {
2579- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 360, Col: 24}
2611+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 365, Col: 24}
25802612 }
2581- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
2613+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
25822614 if templ_7745c5c3_Err != nil {
25832615 return templ_7745c5c3_Err
25842616 }
2585- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "</dd></dl><p><a href=\"")
2617+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "</dd></dl><p><a href=\"")
25862618 if templ_7745c5c3_Err != nil {
25872619 return templ_7745c5c3_Err
25882620 }
2589- var templ_7745c5c3_Var137 templ.SafeURL
2590- templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RawHref))
2621+ var templ_7745c5c3_Var139 templ.SafeURL
2622+ templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RawHref))
25912623 if templ_7745c5c3_Err != nil {
2592- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 362, Col: 39}
2624+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 367, Col: 39}
25932625 }
2594- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137))
2626+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
25952627 if templ_7745c5c3_Err != nil {
25962628 return templ_7745c5c3_Err
25972629 }
2598- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "\">raw log</a></p></section>")
2630+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "\">raw log</a></p></section>")
25992631 if templ_7745c5c3_Err != nil {
26002632 return templ_7745c5c3_Err
26012633 }
@@ -2621,39 +2653,39 @@ func CommitChecks(c ChecksSummary) templ.Component {
26212653 }()
26222654 }
26232655 ctx = templ.InitializeContext(ctx)
2624- templ_7745c5c3_Var138 := templ.GetChildren(ctx)
2625- if templ_7745c5c3_Var138 == nil {
2626- templ_7745c5c3_Var138 = templ.NopComponent
2656+ templ_7745c5c3_Var140 := templ.GetChildren(ctx)
2657+ if templ_7745c5c3_Var140 == nil {
2658+ templ_7745c5c3_Var140 = templ.NopComponent
26272659 }
26282660 ctx = templ.ClearChildren(ctx)
26292661 if c.Total > 0 {
2630- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "<a class=\"ck-sum\" href=\"")
2662+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "<a class=\"ck-sum\" href=\"")
26312663 if templ_7745c5c3_Err != nil {
26322664 return templ_7745c5c3_Err
26332665 }
2634- var templ_7745c5c3_Var139 templ.SafeURL
2635- templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
2666+ var templ_7745c5c3_Var141 templ.SafeURL
2667+ templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
26362668 if templ_7745c5c3_Err != nil {
2637- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 370, Col: 48}
2669+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 375, Col: 48}
26382670 }
2639- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
2671+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))
26402672 if templ_7745c5c3_Err != nil {
26412673 return templ_7745c5c3_Err
26422674 }
2643- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "\" title=\"")
2675+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "\" title=\"")
26442676 if templ_7745c5c3_Err != nil {
26452677 return templ_7745c5c3_Err
26462678 }
2647- var templ_7745c5c3_Var140 string
2648- templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Title)
2679+ var templ_7745c5c3_Var142 string
2680+ templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Title)
26492681 if templ_7745c5c3_Err != nil {
2650- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 370, Col: 66}
2682+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 375, Col: 66}
26512683 }
2652- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var140)
2684+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var142)
26532685 if templ_7745c5c3_Err != nil {
26542686 return templ_7745c5c3_Err
26552687 }
2656- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "\">")
2688+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "\">")
26572689 if templ_7745c5c3_Err != nil {
26582690 return templ_7745c5c3_Err
26592691 }
@@ -2661,20 +2693,20 @@ func CommitChecks(c ChecksSummary) templ.Component {
26612693 if templ_7745c5c3_Err != nil {
26622694 return templ_7745c5c3_Err
26632695 }
2664- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "<span class=\"mute\">")
2696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "<span class=\"mute\">")
26652697 if templ_7745c5c3_Err != nil {
26662698 return templ_7745c5c3_Err
26672699 }
2668- var templ_7745c5c3_Var141 string
2669- templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
2700+ var templ_7745c5c3_Var143 string
2701+ templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
26702702 if templ_7745c5c3_Err != nil {
2671- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 372, Col: 31}
2703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 377, Col: 31}
26722704 }
2673- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))
2705+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var143))
26742706 if templ_7745c5c3_Err != nil {
26752707 return templ_7745c5c3_Err
26762708 }
2677- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "</span></a>")
2709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "</span></a>")
26782710 if templ_7745c5c3_Err != nil {
26792711 return templ_7745c5c3_Err
26802712 }
@@ -2700,40 +2732,40 @@ func MRChecks(c ChecksSection) templ.Component {
27002732 }()
27012733 }
27022734 ctx = templ.InitializeContext(ctx)
2703- templ_7745c5c3_Var142 := templ.GetChildren(ctx)
2704- if templ_7745c5c3_Var142 == nil {
2705- templ_7745c5c3_Var142 = templ.NopComponent
2735+ templ_7745c5c3_Var144 := templ.GetChildren(ctx)
2736+ if templ_7745c5c3_Var144 == nil {
2737+ templ_7745c5c3_Var144 = templ.NopComponent
27062738 }
27072739 ctx = templ.ClearChildren(ctx)
2708- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">")
2740+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">")
27092741 if templ_7745c5c3_Err != nil {
27102742 return templ_7745c5c3_Err
27112743 }
2712- var templ_7745c5c3_Var143 string
2713- templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(c.Rows)))
2744+ var templ_7745c5c3_Var145 string
2745+ templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(c.Rows)))
27142746 if templ_7745c5c3_Err != nil {
2715- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 380, Col: 66}
2747+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 385, Col: 66}
27162748 }
2717- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var143))
2749+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145))
27182750 if templ_7745c5c3_Err != nil {
27192751 return templ_7745c5c3_Err
27202752 }
2721- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "</span></h2>")
2753+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "</span></h2>")
27222754 if templ_7745c5c3_Err != nil {
27232755 return templ_7745c5c3_Err
27242756 }
27252757 if len(c.Rows) == 0 {
2726- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "<p class=\"dim\">no checks</p>")
2758+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "<p class=\"dim\">no checks</p>")
27272759 if templ_7745c5c3_Err != nil {
27282760 return templ_7745c5c3_Err
27292761 }
27302762 } else {
2731- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "<ul class=\"checks\">")
2763+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "<ul class=\"checks\">")
27322764 if templ_7745c5c3_Err != nil {
27332765 return templ_7745c5c3_Err
27342766 }
27352767 for _, r := range c.Rows {
2736- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "<li>")
2768+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "<li>")
27372769 if templ_7745c5c3_Err != nil {
27382770 return templ_7745c5c3_Err
27392771 }
@@ -2742,108 +2774,108 @@ func MRChecks(c ChecksSection) templ.Component {
27422774 return templ_7745c5c3_Err
27432775 }
27442776 if r.Href != "" {
2745- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "<a href=\"")
2777+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "<a href=\"")
27462778 if templ_7745c5c3_Err != nil {
27472779 return templ_7745c5c3_Err
27482780 }
2749- var templ_7745c5c3_Var144 templ.SafeURL
2750- templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
2781+ var templ_7745c5c3_Var146 templ.SafeURL
2782+ templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
27512783 if templ_7745c5c3_Err != nil {
2752- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 389, Col: 38}
2784+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 394, Col: 38}
27532785 }
2754- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var144))
2786+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146))
27552787 if templ_7745c5c3_Err != nil {
27562788 return templ_7745c5c3_Err
27572789 }
2758- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "\">")
2790+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "\">")
27592791 if templ_7745c5c3_Err != nil {
27602792 return templ_7745c5c3_Err
27612793 }
2762- var templ_7745c5c3_Var145 string
2763- templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
2794+ var templ_7745c5c3_Var147 string
2795+ templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
27642796 if templ_7745c5c3_Err != nil {
2765- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 389, Col: 49}
2797+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 394, Col: 49}
27662798 }
2767- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145))
2799+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147))
27682800 if templ_7745c5c3_Err != nil {
27692801 return templ_7745c5c3_Err
27702802 }
2771- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "</a> ")
2803+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "</a> ")
27722804 if templ_7745c5c3_Err != nil {
27732805 return templ_7745c5c3_Err
27742806 }
27752807 } else {
2776- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "<span>")
2808+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "<span>")
27772809 if templ_7745c5c3_Err != nil {
27782810 return templ_7745c5c3_Err
27792811 }
2780- var templ_7745c5c3_Var146 string
2781- templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
2812+ var templ_7745c5c3_Var148 string
2813+ templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
27822814 if templ_7745c5c3_Err != nil {
2783- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 391, Col: 21}
2815+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 396, Col: 21}
27842816 }
2785- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146))
2817+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var148))
27862818 if templ_7745c5c3_Err != nil {
27872819 return templ_7745c5c3_Err
27882820 }
2789- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "</span> ")
2821+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</span> ")
27902822 if templ_7745c5c3_Err != nil {
27912823 return templ_7745c5c3_Err
27922824 }
27932825 }
2794- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "<span class=\"dim\">")
2826+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "<span class=\"dim\">")
27952827 if templ_7745c5c3_Err != nil {
27962828 return templ_7745c5c3_Err
27972829 }
2798- var templ_7745c5c3_Var147 string
2799- templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(r.State)
2830+ var templ_7745c5c3_Var149 string
2831+ templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.JoinStringErrs(r.State)
28002832 if templ_7745c5c3_Err != nil {
2801- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 393, Col: 33}
2833+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 398, Col: 33}
28022834 }
2803- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147))
2835+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var149))
28042836 if templ_7745c5c3_Err != nil {
28052837 return templ_7745c5c3_Err
28062838 }
2807- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "</span> ")
2839+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "</span> ")
28082840 if templ_7745c5c3_Err != nil {
28092841 return templ_7745c5c3_Err
28102842 }
28112843 if r.Waiting {
2812- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "<span class=\"mute\">waiting for a runner</span>")
2844+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "<span class=\"mute\">waiting for a runner</span>")
28132845 if templ_7745c5c3_Err != nil {
28142846 return templ_7745c5c3_Err
28152847 }
28162848 }
2817- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "</li>")
2849+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "</li>")
28182850 if templ_7745c5c3_Err != nil {
28192851 return templ_7745c5c3_Err
28202852 }
28212853 }
2822- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "</ul>")
2854+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "</ul>")
28232855 if templ_7745c5c3_Err != nil {
28242856 return templ_7745c5c3_Err
28252857 }
28262858 }
28272859 if c.Note != "" {
2828- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "<p class=\"mute\">")
2860+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "<p class=\"mute\">")
28292861 if templ_7745c5c3_Err != nil {
28302862 return templ_7745c5c3_Err
28312863 }
2832- var templ_7745c5c3_Var148 string
2833- templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.JoinStringErrs(c.Note)
2864+ var templ_7745c5c3_Var150 string
2865+ templ_7745c5c3_Var150, templ_7745c5c3_Err = templ.JoinStringErrs(c.Note)
28342866 if templ_7745c5c3_Err != nil {
2835- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 402, Col: 27}
2867+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 407, Col: 27}
28362868 }
2837- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var148))
2869+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var150))
28382870 if templ_7745c5c3_Err != nil {
28392871 return templ_7745c5c3_Err
28402872 }
2841- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "</p>")
2873+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "</p>")
28422874 if templ_7745c5c3_Err != nil {
28432875 return templ_7745c5c3_Err
28442876 }
28452877 }
2846- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "</section>")
2878+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "</section>")
28472879 if templ_7745c5c3_Err != nil {
28482880 return templ_7745c5c3_Err
28492881 }
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.secrets"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.secrets"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
A internal/app/ci_dispatch_test.go +239 -0
@@ -0,0 +1,239 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "encoding/json"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// CI-3 D5, AC5: workflow_dispatch.
18+
19+const dispatchWorkflow = `name: deploy
20+on:
21+ workflow_dispatch:
22+ inputs:
23+ version:
24+ type: string
25+ required: true
26+ dry:
27+ type: boolean
28+ default: false
29+ count:
30+ type: number
31+ default: 3
32+ level:
33+ type: choice
34+ options: [low, high]
35+ target:
36+ type: environment
37+ required: false
38+run-name: deploy ${{ inputs.version }} dry=${{ inputs.dry }}
39+jobs:
40+ j:
41+ runs-on: ubuntu-latest
42+ steps:
43+ - run: echo hi
44+`
45+
46+const pushOnlyWorkflow = "name: ci\non: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
47+
48+const jobTokenDispatcher = `name: dispatcher
49+on: push
50+permissions:
51+ actions: write
52+ contents: read
53+jobs:
54+ with:
55+ runs-on: ubuntu-latest
56+ steps:
57+ - run: echo hi
58+ without:
59+ runs-on: ubuntu-latest
60+ permissions:
61+ contents: read
62+ steps:
63+ - run: echo hi
64+ actionsonly:
65+ runs-on: ubuntu-latest
66+ permissions:
67+ actions: write
68+ steps:
69+ - run: echo hi
70+ readonly:
71+ runs-on: ubuntu-latest
72+ permissions: {}
73+ steps:
74+ - run: echo hi
75+`
76+
77+func (c *ciHarness) dispatch(api *clients, path, ref string, inputs map[string]string) (*pb.Run, error) {
78+ c.t.Helper()
79+ res, err := api.run.DispatchWorkflow(c.ctx, connect.NewRequest(&pb.DispatchWorkflowRequest{
80+ Repo: c.repo, WorkflowPath: path, Ref: ref, Inputs: inputs,
81+ }))
82+ if err != nil {
83+ return nil, err
84+ }
85+ return res.Msg.GetRun(), nil
86+}
87+
88+func TestDispatchInputsAreValidatedAndTyped(t *testing.T) {
89+ c := newCIHarness(t, nil)
90+ runner := c.registerRunner("alpha", "ubuntu-latest")
91+ c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow, ".github/workflows/ci.yml": pushOnlyWorkflow}, "")
92+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "push run")
93+
94+ _, err := c.dispatch(c.root, "deploy.yml", "", nil)
95+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "a required input is missing")
96+ assert.Contains(t, err.Error(), "Required input 'version'")
97+ _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1", "nope": "x"})
98+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "an undeclared input")
99+ _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1", "dry": "maybe"})
100+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "a boolean")
101+ _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1", "count": "0x10"})
102+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "a number")
103+ _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1", "level": "medium"})
104+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "a choice outside its options")
105+ _, err = c.dispatch(c.root, "ci.yml", "", nil)
106+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "no workflow_dispatch trigger")
107+ _, err = c.dispatch(c.root, "missing.yml", "", nil)
108+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
109+ _, err = c.dispatch(c.root, "deploy.yml", "nobranch", map[string]string{"version": "1"})
110+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err), "a ref that does not exist")
111+ assert.Len(t, c.runs(), 1, "no refused dispatch made a run")
112+
113+ run, err := c.dispatch(c.root, ".github/workflows/deploy.yml", "main", map[string]string{"version": "1.2", "dry": "true"})
114+ require.NoError(t, err)
115+ assert.Equal(t, "workflow_dispatch", run.GetEvent())
116+ assert.Equal(t, "refs/heads/main", run.GetRef())
117+ assert.True(t, run.GetTrusted())
118+ assert.Equal(t, "root", run.GetActor(), "the caller, as for a push")
119+ assert.Equal(t, "deploy 1.2 dry=true", run.GetName(), "run-name sees the typed inputs")
120+ assert.Equal(t, pb.CiStatus_CI_STATUS_QUEUED, run.GetStatus())
121+
122+ job := runner.await()
123+ gh := c.ghContext(job)
124+ assert.Equal(t, "workflow_dispatch", gh["event_name"])
125+ assert.Equal(t, "refs/heads/main", gh["ref"])
126+ ev := gh["event"].(map[string]any)
127+ assert.Equal(t, map[string]any{"version": "1.2", "dry": "true", "count": "3", "level": "low"}, ev["inputs"], "github.event.inputs are strings, defaults applied")
128+ assert.Equal(t, ".github/workflows/deploy.yml", ev["workflow"])
129+ assert.Equal(t, "refs/heads/main", ev["ref"])
130+
131+ var ctxs map[string]json.RawMessage
132+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
133+ var inputs map[string]any
134+ require.NoError(t, json.Unmarshal(ctxs["inputs"], &inputs))
135+ assert.Equal(t, map[string]any{"version": "1.2", "dry": true, "count": float64(3), "level": "low", "target": ""}, inputs, "the inputs context is typed")
136+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
137+}
138+
139+func TestDispatchReadsTheWorkflowAtTheRef(t *testing.T) {
140+ c := newCIHarness(t, nil)
141+ runner := c.registerRunner("alpha", "ubuntu-latest")
142+ c.push("main", map[string]string{".github/workflows/ci.yml": pushOnlyWorkflow}, "")
143+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "push run")
144+ c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
145+ c.push("feat", map[string]string{".github/workflows/ci.yml": "name: ci\non: [push, workflow_dispatch]\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
146+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "push run")
147+
148+ _, err := c.dispatch(c.root, "ci.yml", "", nil)
149+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "main has no workflow_dispatch")
150+ run, err := c.dispatch(c.root, "ci.yml", "feat", nil)
151+ require.NoError(t, err)
152+ assert.Equal(t, "refs/heads/feat", run.GetRef())
153+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
154+}
155+
156+func TestDispatchAccess(t *testing.T) {
157+ c := newCIHarness(t, nil)
158+ runner := c.registerRunner("alpha", "ubuntu-latest")
159+ c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
160+ in := map[string]string{"version": "1"}
161+
162+ readOnly := c.api(c.token(scopes(domain.ScopeRepoRead), "proj"))
163+ _, err := c.dispatch(readOnly, "deploy.yml", "", in)
164+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "ci:write is needed")
165+
166+ // A token with ci:write but limited to branch prefixes: its run is not trusted,
167+ // and it may only dispatch on a branch under its prefixes.
168+ secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeCIWrite), []string{"agent/claude/"}, "proj")
169+ limited := c.api(secret)
170+ _, err = c.dispatch(limited, "deploy.yml", "main", in)
171+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "main is outside the prefixes")
172+ c.mustGit(c.wt, "checkout", "-q", "-b", "agent/claude/x")
173+ c.push("agent/claude/x", nil, "")
174+ run, err := c.dispatch(limited, "deploy.yml", "agent/claude/x", in)
175+ require.NoError(t, err)
176+ assert.False(t, run.GetTrusted(), "a branch-limited caller gets an untrusted run")
177+ job := runner.await()
178+ assert.False(t, job.GetTrusted())
179+ assert.Empty(t, job.GetSecrets())
180+}
181+
182+func TestDispatchWithAJobToken(t *testing.T) {
183+ c := newCIHarness(t, nil)
184+ runner := c.registerRunner("alpha", "ubuntu-latest")
185+ c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow, ".github/workflows/d.yml": jobTokenDispatcher}, "")
186+ jobs := runner.jobsByKey(4)
187+ with, without := jobs["with"], jobs["without"]
188+ require.NotNil(t, with)
189+ require.NotNil(t, without)
190+ onlyActions, none := jobs["actionsonly"], jobs["readonly"]
191+
192+ in := map[string]string{"version": "9"}
193+ _, err := c.dispatch(c.api(without.GetGithubToken()), "deploy.yml", "", in)
194+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "no actions: write")
195+ run, err := c.dispatch(c.api(with.GetGithubToken()), "deploy.yml", "", in)
196+ require.NoError(t, err, "actions: write lets a job token dispatch (GitHub's exception)")
197+ assert.Equal(t, "workflow_dispatch", run.GetEvent())
198+ assert.Equal(t, "github-actions[bot]", run.GetActor())
199+ assert.True(t, run.GetTrusted(), "the dispatching run was trusted")
200+
201+ // The repository is private and this token has no contents permission at all:
202+ // actions: write alone is enough, and a token without it is told nothing.
203+ _, err = c.dispatch(c.api(none.GetGithubToken()), "deploy.yml", "", in)
204+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err), "no permission at all on a private repository: concealed")
205+ run, err = c.dispatch(c.api(onlyActions.GetGithubToken()), "deploy.yml", "", in)
206+ require.NoError(t, err, "actions: write without contents: read")
207+ assert.Equal(t, "workflow_dispatch", run.GetEvent())
208+
209+ for _, j := range []*pb.Job{with, without, onlyActions, none} {
210+ runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
211+ }
212+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "dispatched")
213+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "dispatched")
214+}
215+
216+func TestWorkflowRunCommand(t *testing.T) {
217+ c := newCIHarness(t, nil)
218+ h := &cliHarness{harness: c.harness, url: c.srv.URL}
219+ runner := c.registerRunner("alpha", "ubuntu-latest")
220+ c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
221+
222+ bad := h.cli(t, adminToken, "workflow", "run", "deploy.yml", "--repo", "proj", "-f", "oops")
223+ require.Error(t, bad.err)
224+ missing := h.cli(t, adminToken, "workflow", "run", "deploy.yml", "--repo", "proj")
225+ require.Error(t, missing.err)
226+ assert.Contains(t, missing.err.Error(), "Required input 'version'")
227+
228+ ok := h.cli(t, adminToken, "workflow", "run", "deploy.yml", "--repo", "proj", "--ref", "main", "-f", "version=a=b", "-f", "dry=true")
229+ require.NoError(t, ok.err, ok.stderr)
230+ assert.Contains(t, ok.stderr, "started on main")
231+ job := runner.await()
232+ ev := c.ghContext(job)["event"].(map[string]any)
233+ assert.Equal(t, "a=b", ev["inputs"].(map[string]any)["version"])
234+
235+ list := h.cli(t, adminToken, "run", "list", "proj")
236+ require.NoError(t, list.err)
237+ assert.Contains(t, list.stdout, "EVENT")
238+ assert.Contains(t, list.stdout, "workflow_dispatch")
239+}
A internal/app/ci_schedule_test.go +257 -0
@@ -0,0 +1,257 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+// CI-3 D5, AC5: schedule, with an injected clock.
21+
22+// testClock is a clock the test moves.
23+type testClock struct {
24+ mu sync.Mutex
25+ t time.Time
26+}
27+
28+func (c *testClock) Now() time.Time {
29+ c.mu.Lock()
30+ defer c.mu.Unlock()
31+ return c.t
32+}
33+
34+func (c *testClock) set(s string) {
35+ c.mu.Lock()
36+ defer c.mu.Unlock()
37+ t, err := time.Parse("2006-01-02 15:04:05", s)
38+ if err != nil {
39+ panic(err)
40+ }
41+ c.t = t
42+}
43+
44+func scheduleWorkflow(crons ...string) map[string]string {
45+ on := "on:\n schedule:\n"
46+ for _, c := range crons {
47+ on += " - cron: '" + c + "'\n"
48+ }
49+ return map[string]string{".github/workflows/nightly.yml": "name: nightly\n" + on + "jobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}
50+}
51+
52+func newScheduleHarness(t *testing.T, start string) (*ciHarness, *testClock, *fakeRunner) {
53+ t.Helper()
54+ clk := &testClock{}
55+ clk.set(start)
56+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
57+ cfg.Clock = clk
58+ cfg.ScheduleTick = time.Hour // the tests call ScheduleNow
59+ })
60+ return c, clk, c.registerRunner("alpha", "ubuntu-latest")
61+}
62+
63+func createRepoParams(name domain.RepoName) ports.CreateRepoParams {
64+ return ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate}
65+}
66+
67+func (c *ciHarness) scheduled() []*pb.Run { return scheduleRuns(c.runsOf("nightly")) }
68+
69+func scheduleRuns(runs []*pb.Run) []*pb.Run {
70+ var out []*pb.Run
71+ for _, r := range runs {
72+ if r.GetEvent() == "schedule" {
73+ out = append(out, r)
74+ }
75+ }
76+ return out
77+}
78+
79+func TestScheduleFiresOncePerMatchingMinuteWithoutCatchUp(t *testing.T) {
80+ c, clk, runner := newScheduleHarness(t, "2026-03-04 10:00:30")
81+ c.push("main", scheduleWorkflow("*/5 * * * *"), "")
82+ ci := c.forge.CI
83+ tick := func(at string) {
84+ clk.set(at)
85+ ci.ScheduleNow(c.ctx)
86+ }
87+
88+ tick("2026-03-04 10:00:40")
89+ assert.Empty(t, c.scheduled(), "the minute ermite started in belongs to the process before it")
90+
91+ tick("2026-03-04 10:05:10")
92+ require.Len(t, c.scheduled(), 1)
93+ run := c.scheduled()[0]
94+ assert.Equal(t, "schedule", run.GetEvent())
95+ assert.Equal(t, "refs/heads/main", run.GetRef())
96+ assert.Equal(t, c.ref("refs/heads/main"), run.GetSha(), "the latest commit of the default branch")
97+ assert.Equal(t, "root", run.GetActor(), "whoever pushed the tip (the root token here)")
98+ assert.True(t, run.GetTrusted())
99+
100+ tick("2026-03-04 10:05:50")
101+ tick("2026-03-04 10:05:59")
102+ assert.Len(t, c.scheduled(), 1, "a minute fires once, however many ticks it holds")
103+ tick("2026-03-04 10:06:00")
104+ tick("2026-03-04 10:09:00")
105+ assert.Len(t, c.scheduled(), 1, "minutes that do not match fire nothing")
106+
107+ // ermite was busy or down for the minutes 10:10 and 10:15: they are not made up.
108+ tick("2026-03-04 10:20:00")
109+ assert.Len(t, c.scheduled(), 2, "only the minute the clock reached fires, not the ones skipped")
110+ assert.Equal(t, time.Date(2026, 3, 4, 10, 20, 0, 0, time.UTC), ci.SchedulePassed())
111+
112+ job := runner.await()
113+ gh := c.ghContext(job)
114+ assert.Equal(t, "schedule", gh["event_name"])
115+ assert.Equal(t, "refs/heads/main", gh["ref"])
116+ assert.Equal(t, "*/5 * * * *", gh["event"].(map[string]any)["schedule"], "the payload is the cron entry")
117+ assert.True(t, job.GetTrusted())
118+}
119+
120+// Several cron entries of one workflow that match the same minute start one
121+// run, with the first matching entry as payload; an entry that matches another
122+// minute starts its own.
123+func TestScheduleSeveralMatchingCronsOfOneWorkflowStartOneRun(t *testing.T) {
124+ c, clk, runner := newScheduleHarness(t, "2026-03-04 10:00:30")
125+ c.push("main", scheduleWorkflow("*/2 * * * *", "* * * * *", "7 10 * * *"), "")
126+ ci := c.forge.CI
127+
128+ clk.set("2026-03-04 10:02:00") // the first and the second entries match
129+ ci.ScheduleNow(c.ctx)
130+ require.Len(t, c.scheduled(), 1, "one run for the workflow, not one per entry")
131+ job := runner.await()
132+ assert.Equal(t, "*/2 * * * *", c.ghContext(job)["event"].(map[string]any)["schedule"], "the first entry that matches")
133+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
134+
135+ clk.set("2026-03-04 10:03:00") // only the second
136+ ci.ScheduleNow(c.ctx)
137+ require.Len(t, c.scheduled(), 2)
138+ job = runner.await()
139+ assert.Equal(t, "* * * * *", c.ghContext(job)["event"].(map[string]any)["schedule"])
140+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
141+}
142+
143+func TestScheduleReadsAgainAfterAPushToTheDefaultBranch(t *testing.T) {
144+ c, clk, _ := newScheduleHarness(t, "2026-03-04 10:00:30")
145+ c.push("main", scheduleWorkflow("*/5 * * * *"), "")
146+ ci := c.forge.CI
147+ tick := func(at string) {
148+ clk.set(at)
149+ ci.ScheduleNow(c.ctx)
150+ }
151+ tick("2026-03-04 10:05:00")
152+ require.Len(t, c.scheduled(), 1)
153+ assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")), "the schedules are cached")
154+
155+ // 10:07 matches no */5 entry; the new file fires every minute.
156+ c.push("main", scheduleWorkflow("* * * * *"), "")
157+ tick("2026-03-04 10:06:00")
158+ require.Len(t, c.scheduled(), 2, "the pushed workflow is the one scheduled")
159+ assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
160+
161+ // A push to another branch changes nothing; a push that removes the schedule
162+ // ends it.
163+ c.mustGit(c.wt, "checkout", "-q", "-b", "side")
164+ c.push("side", scheduleWorkflow("0 0 1 1 *"), "")
165+ tick("2026-03-04 10:07:00")
166+ require.Len(t, c.scheduled(), 3, "the default branch's workflow still fires every minute")
167+
168+ c.mustGit(c.wt, "checkout", "-q", "main")
169+ c.push("main", map[string]string{".github/workflows/nightly.yml": "name: nightly\non: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
170+ tick("2026-03-04 10:08:00")
171+ c.waitFor("the push run of the new file", func() bool { return len(c.runs()) == 4 })
172+ assert.Len(t, scheduleRuns(c.runs()), 3, "no schedule left on the default branch")
173+ assert.Equal(t, 0, ci.CachedSchedules(c.name("proj")))
174+}
175+
176+func TestScheduleIsRemovedWithTheRepository(t *testing.T) {
177+ c, clk, _ := newScheduleHarness(t, "2026-03-04 10:00:30")
178+ c.push("main", scheduleWorkflow("* * * * *"), "")
179+ ci := c.forge.CI
180+ clk.set("2026-03-04 10:01:00")
181+ ci.ScheduleNow(c.ctx)
182+ require.Len(t, c.scheduled(), 1)
183+ require.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
184+
185+ _, err := c.root.repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"}))
186+ require.NoError(t, err)
187+ assert.Equal(t, -1, ci.CachedSchedules(c.name("proj")), "the cached schedules go with the repository")
188+
189+ for _, at := range []string{"2026-03-04 10:02:00", "2026-03-04 10:03:00"} {
190+ clk.set(at)
191+ ci.ScheduleNow(c.ctx)
192+ }
193+ assert.Equal(t, -1, ci.CachedSchedules(c.name("proj")), "never scheduled again")
194+
195+ // The same name created again starts empty: no schedule until it has a workflow.
196+ _, err = c.forge.Repos.Create(c.ctx, domain.Root(), createRepoParams(c.name("proj")))
197+ require.NoError(t, err)
198+ clk.set("2026-03-04 10:04:00")
199+ ci.ScheduleNow(c.ctx)
200+ assert.Empty(t, c.scheduled())
201+}
202+
203+// More repositories than one tick can handle, and a new minute at every tick:
204+// each pass is dropped after its first repository, and the cursor makes the next
205+// one start after it, so that none is starved.
206+func TestScheduleDoesNotStarveRepositoriesBeyondTheBudget(t *testing.T) {
207+ clk := &testClock{}
208+ clk.set("2026-03-04 10:00:30")
209+ visited := map[domain.RepoName]bool{}
210+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
211+ cfg.Clock = clk
212+ cfg.ScheduleTick = time.Hour
213+ cfg.ScheduleBudget = 1
214+ cfg.ScheduleVisit = func(r domain.RepoName) { visited[r] = true }
215+ })
216+ c.registerRunner("alpha", "ubuntu-latest")
217+ names := []domain.RepoName{c.name("proj")}
218+ for _, n := range []string{"aa", "zz", "mm", "bb"} {
219+ names = append(names, c.name(n))
220+ _, err := c.forge.Repos.Create(c.ctx, domain.Root(), createRepoParams(c.name(n)))
221+ require.NoError(t, err)
222+ }
223+ ci := c.forge.CI
224+ seen := func() int { return len(visited) }
225+ minutes := []string{"10:01:00", "10:02:00", "10:03:00", "10:04:00", "10:05:00"}
226+ for i, m := range minutes {
227+ clk.set("2026-03-04 " + m)
228+ ci.ScheduleNow(c.ctx) // one repository, then the next minute drops the pass
229+ assert.Equal(t, i+1, seen(), "each minute reaches a repository no earlier minute reached")
230+ }
231+ assert.Equal(t, len(names), seen(), "every repository was processed")
232+}
233+
234+func TestScheduleBoundsTheWorkPerTick(t *testing.T) {
235+ clk := &testClock{}
236+ clk.set("2026-03-04 10:00:30")
237+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
238+ cfg.Clock = clk
239+ cfg.ScheduleTick = time.Hour
240+ cfg.ScheduleBudget = 1
241+ })
242+ c.registerRunner("alpha", "ubuntu-latest")
243+ c.push("main", scheduleWorkflow("* * * * *"), "")
244+ for _, name := range []string{"two", "three"} {
245+ _, err := c.forge.Repos.Create(c.ctx, domain.Root(), createRepoParams(c.name(name)))
246+ require.NoError(t, err)
247+ }
248+ ci := c.forge.CI
249+ clk.set("2026-03-04 10:01:00")
250+ ci.ScheduleNow(c.ctx)
251+ assert.True(t, ci.SchedulePassed().IsZero(), "one repository per tick: the pass is not over")
252+ ci.ScheduleNow(c.ctx)
253+ assert.True(t, ci.SchedulePassed().IsZero())
254+ ci.ScheduleNow(c.ctx)
255+ assert.Equal(t, time.Date(2026, 3, 4, 10, 1, 0, 0, time.UTC), ci.SchedulePassed(), "three ticks go through three repositories")
256+ assert.Len(t, c.scheduled(), 1)
257+}
A internal/app/ci_schedule_trust_test.go +366 -0
@@ -0,0 +1,366 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/config"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/usecase"
19+)
20+
21+// CI-3 D1, S1: a scheduled run is run as whoever moved the default branch to
22+// its tip, with their trust.
23+
24+const scheduleSecretWorkflow = `name: nightly
25+on:
26+ schedule:
27+ - cron: '* * * * *'
28+jobs:
29+ j:
30+ runs-on: ubuntu-latest
31+ steps:
32+ - run: echo ${{ secrets.NIGHTLY_TOKEN }}
33+`
34+
35+func newScheduleSecretsHarness(t *testing.T) (*ciHarness, *testClock, *fakeRunner) {
36+ t.Helper()
37+ clk := &testClock{}
38+ clk.set("2026-03-04 10:00:30")
39+ h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {
40+ o.ci = func(cfg *usecase.CIConfig) {
41+ cfg.Clock = clk
42+ cfg.ScheduleTick = time.Hour
43+ }
44+ })
45+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
46+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
47+ require.NoError(t, err)
48+ c.wt = h.workTree("main")
49+ c.setSecret(repoScope("proj"), "NIGHTLY_TOKEN", "s3cret")
50+ return c, clk, c.registerRunner("alpha", "ubuntu-latest")
51+}
52+
53+// fireAt moves the clock to the minute, ticks the scheduler and returns the job
54+// of the scheduled run it started.
55+func (c *ciHarness) fireAt(clk *testClock, runner *fakeRunner, at string) (*pb.Run, *pb.Job) {
56+ c.t.Helper()
57+ before := len(scheduleRuns(c.runs()))
58+ clk.set(at)
59+ c.forge.CI.ScheduleNow(c.ctx)
60+ runs := scheduleRuns(c.runs())
61+ require.Len(c.t, runs, before+1, "one scheduled run")
62+ job := runner.await()
63+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
64+ return runs[0], job // newest first
65+}
66+
67+func (c *ciHarness) repoToken(label string) string {
68+ return c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), "proj")
69+}
70+
71+func TestScheduleOnABranchAnAgentMadeTheDefaultIsNotTrusted(t *testing.T) {
72+ c, clk, runner := newScheduleSecretsHarness(t)
73+ // The repository is empty: the first branch pushed becomes the default
74+ // branch, whoever pushes it.
75+ secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
76+ c.mustGit(c.wt, "checkout", "-q", "-b", "agent/x")
77+ c.writeFile(c.wt, ".github/workflows/nightly.yml", []byte(scheduleSecretWorkflow))
78+ c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/x")
79+ def, _, err := c.forge.git.DefaultBranch(c.ctx, c.name("proj"))
80+ require.NoError(t, err)
81+ require.Equal(t, "agent/x", def, "the agent's branch is the default branch")
82+
83+ run, job := c.fireAt(clk, runner, "2026-03-04 10:01:00")
84+ assert.False(t, run.GetTrusted(), "the owner's secrets do not run for an agent's workflow")
85+ assert.Equal(t, "claude", run.GetActor(), "the actor is who pushed the tip")
86+ assert.False(t, job.GetTrusted())
87+ assert.Empty(t, job.GetSecrets())
88+}
89+
90+func TestScheduleActorFollowsTheLastPusherOfTheDefaultBranch(t *testing.T) {
91+ c, clk, runner := newScheduleSecretsHarness(t)
92+
93+ // The owner pushes main: trusted, with the secrets.
94+ c.push("main", map[string]string{".github/workflows/nightly.yml": scheduleSecretWorkflow}, "")
95+ run, job := c.fireAt(clk, runner, "2026-03-04 10:01:00")
96+ assert.True(t, run.GetTrusted())
97+ assert.Equal(t, "root", run.GetActor())
98+ assert.Equal(t, map[string]string{"NIGHTLY_TOKEN": "s3cret"}, job.GetSecrets())
99+
100+ // A token scoped to the repository (not the owner) pushes main: untrusted.
101+ tok := c.repoToken("scoped")
102+ c.writeFile(c.wt, "x.txt", []byte("x"))
103+ c.mustGit(c.wt, "push", "-q", c.urlAuth(tok, "/proj.git"), "main")
104+ run, job = c.fireAt(clk, runner, "2026-03-04 10:02:00")
105+ assert.False(t, run.GetTrusted(), "a repository-scoped token is not the owner")
106+ assert.Empty(t, job.GetSecrets())
107+
108+ // The owner pushes again: trusted again.
109+ c.push("main", map[string]string{"y.txt": "y"}, "")
110+ run, _ = c.fireAt(clk, runner, "2026-03-04 10:03:00")
111+ assert.True(t, run.GetTrusted())
112+
113+ // The record is for one commit: a tip it does not name is not trusted.
114+ require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
115+ Repo: c.name("proj"), Ref: "refs/heads/main", Sha: "1111111111111111111111111111111111111111", Actor: "root", Trusted: true,
116+ }))
117+ run, job = c.fireAt(clk, runner, "2026-03-04 10:04:00")
118+ assert.False(t, run.GetTrusted(), "the record names another commit")
119+ assert.Empty(t, job.GetSecrets())
120+}
121+
122+func TestScheduleAfterAMergeIsTheMergersToo(t *testing.T) {
123+ c, clk, runner := newScheduleSecretsHarness(t)
124+ c.push("main", map[string]string{".github/workflows/nightly.yml": scheduleSecretWorkflow}, "")
125+ tok := c.repoToken("scoped")
126+ c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
127+ c.writeFile(c.wt, "f.txt", []byte("f"))
128+ c.mustGit(c.wt, "push", "-q", c.urlAuth(tok, "/proj.git"), "feat")
129+ n := c.openMR(c.root, "feat")
130+ c.waitFor("the head ref", func() bool { return c.ref(domain.PullHeadRef(n)) != "" })
131+ head := c.ref(domain.PullHeadRef(n))
132+
133+ // The owner merges: main is theirs from now on.
134+ _, err := c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: head}))
135+ require.NoError(t, err)
136+ run, job := c.fireAt(clk, runner, "2026-03-04 10:01:00")
137+ assert.True(t, run.GetTrusted(), "merged by the owner")
138+ assert.Equal(t, map[string]string{"NIGHTLY_TOKEN": "s3cret"}, job.GetSecrets())
139+}
140+
141+func TestRefTipRecordsGoWithTheRepository(t *testing.T) {
142+ c, _, _ := newScheduleSecretsHarness(t)
143+ c.push("main", map[string]string{"a.txt": "a"}, "")
144+ tips := c.forge.store.CI()
145+ got, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
146+ require.NoError(t, err)
147+ assert.True(t, got.Trusted)
148+ assert.Equal(t, c.ref("refs/heads/main"), got.Sha)
149+
150+ _, err = c.root.repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"}))
151+ require.NoError(t, err)
152+ _, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
153+ assert.ErrorIs(t, err, domain.ErrNotFound, "deleted with the repository")
154+}
155+
156+// GO-004: the record is written before the ref moves, so the sequence owner A,
157+// someone else B, someone else A (the same commit as the owner's) never leaves
158+// the owner's record on the tip, however late the post-receive of the last push
159+// is. A push that is refused after its record was written leaves a record that
160+// matches no tip.
161+func TestScheduleABAOfThePusherIsNeverTrusted(t *testing.T) {
162+ c, clk, runner := newScheduleSecretsHarness(t)
163+ tips := c.forge.store.CI()
164+ c.push("main", map[string]string{".github/workflows/nightly.yml": scheduleSecretWorkflow}, "")
165+ commitA := c.ref("refs/heads/main")
166+ run, _ := c.fireAt(clk, runner, "2026-03-04 10:01:00")
167+ require.True(t, run.GetTrusted(), "the owner's A is trusted")
168+
169+ // B and A again, by a repository-scoped token.
170+ tok := c.repoToken("scoped")
171+ c.writeFile(c.wt, "b.txt", []byte("b"))
172+ c.mustGit(c.wt, "push", "-q", c.urlAuth(tok, "/proj.git"), "main")
173+ rec, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
174+ require.NoError(t, err)
175+ assert.False(t, rec.Trusted, "B was recorded as the token's before the ref moved")
176+ c.mustGit(c.wt, "push", "-q", "-f", c.urlAuth(tok, "/proj.git"), commitA+":refs/heads/main")
177+ require.Equal(t, commitA, c.ref("refs/heads/main"), "main is back at the owner's commit")
178+
179+ // The record of that push is the token's, not the owner's: A is untrusted.
180+ rec, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
181+ require.NoError(t, err)
182+ assert.Equal(t, commitA, rec.Sha)
183+ assert.False(t, rec.Trusted)
184+ run, job := c.fireAt(clk, runner, "2026-03-04 10:02:00")
185+ assert.False(t, run.GetTrusted(), "owner A, token B, token A: not the owner's")
186+ assert.Empty(t, job.GetSecrets())
187+
188+ // A push that moves nothing (the owner pushes A where A already is) is not a
189+ // push for git: it endorses nothing.
190+ c.mustGit(c.wt, "push", "-q", "-f", c.urlAuth(adminToken, "/proj.git"), commitA+":refs/heads/main")
191+ run, _ = c.fireAt(clk, runner, "2026-03-04 10:03:00")
192+ assert.False(t, run.GetTrusted(), "still the token's")
193+
194+ // The owner moves the tip: trusted again.
195+ c.push("main", map[string]string{"owner.txt": "o"}, "")
196+ run, _ = c.fireAt(clk, runner, "2026-03-04 10:04:00")
197+ assert.True(t, run.GetTrusted())
198+ tip := c.ref("refs/heads/main")
199+
200+ // A push refused after the decision recorded it (here the check alone runs,
201+ // then git never moves the ref): the record names a commit the tip does not
202+ // have, and the schedule fails closed.
203+ scoped, err := c.forge.Auth.Authenticate(c.ctx, tok)
204+ require.NoError(t, err)
205+ never := strings.Repeat("2", 40)
206+ require.NoError(t, c.forge.Pushes.Check(c.ctx, scoped, c.name("proj"),
207+ []domain.RefUpdate{{Ref: "refs/heads/main", Old: tip, New: never}}, ports.PushObjects{}))
208+ left, err := c.forge.store.CI().GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
209+ require.NoError(t, err)
210+ assert.Equal(t, never, left.Sha, "the refused push left its record behind")
211+ run, _ = c.fireAt(clk, runner, "2026-03-04 10:05:00")
212+ assert.False(t, run.GetTrusted(), "a record for a commit that never became the tip: fail closed")
213+}
214+
215+// GO-006: a merge that deletes its source branch drops the record of that
216+// branch, and a merge records the target before it moves.
217+func TestMergeRecordsTheTargetAndDropsTheSourceRecord(t *testing.T) {
218+ c, _, _ := newScheduleSecretsHarness(t)
219+ tips := c.forge.store.CI()
220+ c.push("main", map[string]string{"a.txt": "a"}, "")
221+ c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
222+ c.push("feat", map[string]string{"f.txt": "f"}, "")
223+ _, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat")
224+ require.NoError(t, err, "the pushed branch has a record")
225+ n := c.openMR(c.root, "feat")
226+ c.waitFor("the head ref", func() bool { return c.ref(domain.PullHeadRef(n)) != "" })
227+
228+ _, err = c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
229+ Repo: "proj", Number: n, HeadId: c.ref(domain.PullHeadRef(n)), DeleteSourceBranch: true,
230+ }))
231+ require.NoError(t, err)
232+
233+ _, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat")
234+ assert.ErrorIs(t, err, domain.ErrNotFound, "the deleted source branch has no record")
235+ main, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
236+ require.NoError(t, err)
237+ assert.Equal(t, c.ref("refs/heads/main"), main.Sha, "the merge recorded the new tip")
238+ assert.True(t, main.Trusted)
239+
240+ // A pushed branch that is deleted by a push loses its record too.
241+ c.mustGit(c.wt, "checkout", "-q", "-b", "gone")
242+ c.push("gone", map[string]string{"g.txt": "g"}, "")
243+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":refs/heads/gone")
244+ _, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/gone")
245+ assert.ErrorIs(t, err, domain.ErrNotFound)
246+}
247+
248+// GO-005: a dispatched run is trusted only when the caller is and the ref was
249+// moved to its commit by a trusted principal, for every caller.
250+func TestDispatchTrustFollowsTheRefsPusher(t *testing.T) {
251+ c := newCIHarness(t, nil)
252+ runner := c.registerRunner("alpha", "ubuntu-latest")
253+ c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow, ".github/workflows/d.yml": jobTokenDispatcher}, "")
254+ jobs := runner.jobsByKey(4)
255+ in := map[string]string{"version": "1"}
256+
257+ // An agent's branch.
258+ secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
259+ c.mustGit(c.wt, "checkout", "-q", "-b", "agent/x")
260+ c.writeFile(c.wt, "x.txt", []byte("x"))
261+ c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/x")
262+ c.waitFor("the agent's push run", func() bool { return len(c.runsOf("dispatcher")) >= 2 })
263+
264+ run, err := c.dispatch(c.root, "deploy.yml", "main", in)
265+ require.NoError(t, err)
266+ assert.True(t, run.GetTrusted(), "the owner dispatching what the owner pushed")
267+
268+ run, err = c.dispatch(c.root, "deploy.yml", "agent/x", in)
269+ require.NoError(t, err)
270+ assert.False(t, run.GetTrusted(), "the owner dispatching an agent's branch")
271+
272+ run, err = c.dispatch(c.api(jobs["with"].GetGithubToken()), "deploy.yml", "agent/x", in)
273+ require.NoError(t, err)
274+ assert.False(t, run.GetTrusted(), "a trusted job token dispatching an agent's branch")
275+ run, err = c.dispatch(c.api(jobs["with"].GetGithubToken()), "deploy.yml", "main", in)
276+ require.NoError(t, err)
277+ assert.True(t, run.GetTrusted(), "a trusted job token dispatching the owner's branch")
278+
279+ // What the jobs of those runs received: the untrusted ones got no secrets.
280+ got := map[int64]*pb.Job{}
281+ for range 4 {
282+ j := runner.await()
283+ got[j.GetRunId()] = j
284+ }
285+ for _, r := range c.runs() {
286+ if r.GetEvent() != "workflow_dispatch" {
287+ continue
288+ }
289+ if j := got[r.GetId()]; j != nil {
290+ assert.Equal(t, r.GetTrusted(), j.GetTrusted(), "the job follows its run")
291+ if !r.GetTrusted() {
292+ assert.Empty(t, j.GetSecrets())
293+ }
294+ }
295+ }
296+}
297+
298+// GO-007: the trust of a dispatch is judged on the object and the commit it
299+// resolved, not on the tag as it is read afterwards. The tag is a lightweight
300+// one at commit A when the dispatch resolves it; before the trust is judged it
301+// is retargeted by the owner to an annotated tag on commit B, whose record is
302+// trusted: A runs, and must not borrow that record.
303+func TestDispatchOnATagRetargetedMeanwhileIsNotTrusted(t *testing.T) {
304+ var retarget func()
305+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
306+ cfg.DispatchResolved = func() {
307+ if retarget != nil {
308+ retarget()
309+ }
310+ }
311+ })
312+ c.registerRunner("alpha", "ubuntu-latest")
313+ c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
314+ c.mustGit(c.wt, "tag", "v1")
315+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
316+ commitA := c.ref("refs/heads/main")
317+ in := map[string]string{"version": "1"}
318+
319+ run, err := c.dispatch(c.root, "deploy.yml", "v1", in)
320+ require.NoError(t, err)
321+ require.True(t, run.GetTrusted(), "without a retarget the owner's own tag is trusted")
322+
323+ retarget = func() {
324+ retarget = nil // once
325+ c.writeFile(c.wt, "b.txt", []byte("b"))
326+ c.mustGit(c.wt, "tag", "-a", "-f", "-m", "annotated", "v1")
327+ c.mustGit(c.wt, "push", "-q", "-f", c.urlAuth(adminToken, "/proj.git"), "v1")
328+ }
329+ run, err = c.dispatch(c.root, "deploy.yml", "v1", in)
330+ require.NoError(t, err)
331+ assert.Equal(t, commitA, run.GetSha(), "the dispatch runs the commit it resolved")
332+ assert.False(t, run.GetTrusted(), "the tag moved to another object meanwhile: the record of the new one is not A's")
333+
334+ // The tag really moved: dispatching it now is trusted again, on the new commit.
335+ run, err = c.dispatch(c.root, "deploy.yml", "v1", in)
336+ require.NoError(t, err)
337+ assert.NotEqual(t, commitA, run.GetSha())
338+ assert.True(t, run.GetTrusted())
339+}
340+
341+// A tag is judged by its own record, like a branch.
342+func TestDispatchOnATagFollowsItsPusher(t *testing.T) {
343+ c := newCIHarness(t, nil)
344+ c.registerRunner("alpha", "ubuntu-latest")
345+ c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
346+ c.mustGit(c.wt, "tag", "v1")
347+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
348+ c.mustGit(c.wt, "tag", "-a", "-m", "annotated", "v2")
349+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v2")
350+ in := map[string]string{"version": "1"}
351+
352+ run, err := c.dispatch(c.root, "deploy.yml", "v1", in)
353+ require.NoError(t, err)
354+ assert.True(t, run.GetTrusted(), "a lightweight tag the owner pushed")
355+ run, err = c.dispatch(c.root, "deploy.yml", "v2", in)
356+ require.NoError(t, err)
357+ assert.True(t, run.GetTrusted(), "an annotated tag the owner pushed (the record names the tag object)")
358+
359+ tok := c.repoToken("scoped")
360+ c.writeFile(c.wt, "t.txt", []byte("t"))
361+ c.mustGit(c.wt, "tag", "v3")
362+ c.mustGit(c.wt, "push", "-q", c.urlAuth(tok, "/proj.git"), "v3")
363+ run, err = c.dispatch(c.root, "deploy.yml", "v3", in)
364+ require.NoError(t, err)
365+ assert.False(t, run.GetTrusted(), "a tag a scoped token pushed")
366+}
A internal/app/ci_workflowrun_test.go +168 -0
@@ -0,0 +1,168 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "fmt"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/usecase"
15+)
16+
17+// CI-3 D5, AC5: workflow_run.
18+
19+const triggeringWorkflow = "name: ci\non: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
20+
21+func followWorkflow(name, on string) map[string]string {
22+ return map[string]string{".github/workflows/" + name + ".yml": fmt.Sprintf(
23+ "name: %s\non:\n workflow_run:\n%s\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n", name, on)}
24+}
25+
26+func merge(maps ...map[string]string) map[string]string {
27+ out := map[string]string{}
28+ for _, m := range maps {
29+ for k, v := range m {
30+ out[k] = v
31+ }
32+ }
33+ return out
34+}
35+
36+func (c *ciHarness) runsOf(workflowName string) []*pb.Run {
37+ var out []*pb.Run
38+ for _, r := range c.runs() {
39+ if r.GetWorkflowName() == workflowName {
40+ out = append(out, r)
41+ }
42+ }
43+ return out
44+}
45+
46+func TestWorkflowRunRequestedCompletedFiltersAndPayload(t *testing.T) {
47+ c := newCIHarness(t, nil)
48+ runner := c.registerRunner("alpha", "ubuntu-latest")
49+ c.push("main", merge(
50+ map[string]string{".github/workflows/ci.yml": triggeringWorkflow},
51+ followWorkflow("after", " workflows: [ci]\n types: [completed]\n branches: [main]"),
52+ followWorkflow("started", " workflows: [ci]\n types: [requested]"),
53+ followWorkflow("release-only", " workflows: [ci]\n branches: [release]"),
54+ followWorkflow("other-name", " workflows: [nobody]"),
55+ ), "")
56+ c.waitFor("ci and the run it requested", func() bool { return len(c.runs()) == 2 })
57+ assert.Len(t, c.runsOf("started"), 1, "requested fires when the run is created")
58+ assert.Empty(t, c.runsOf("after"), "completed has not happened yet")
59+
60+ // Both jobs are given to the runner: finish them.
61+ for range 2 {
62+ j := runner.await()
63+ runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "ok")
64+ }
65+ c.waitFor("the completed run", func() bool { return len(c.runsOf("after")) == 1 })
66+ assert.Empty(t, c.runsOf("release-only"), "the branches filter applies to the head branch of the triggering run")
67+ assert.Empty(t, c.runsOf("other-name"))
68+ assert.Len(t, c.runsOf("started"), 1, "requested does not fire on completion")
69+
70+ after := c.runsOf("after")[0]
71+ assert.Equal(t, "workflow_run", after.GetEvent())
72+ assert.Equal(t, "refs/heads/main", after.GetRef(), "it runs on the default branch")
73+ assert.True(t, after.GetTrusted())
74+ main := c.ref("refs/heads/main")
75+ assert.Equal(t, main, after.GetSha())
76+
77+ job := runner.await()
78+ gh := c.ghContext(job)
79+ assert.Equal(t, "workflow_run", gh["event_name"])
80+ ev := gh["event"].(map[string]any)
81+ assert.Equal(t, "completed", ev["action"])
82+ wr := ev["workflow_run"].(map[string]any)
83+ trig := c.runsOf("ci")[0]
84+ assert.EqualValues(t, trig.GetId(), wr["id"])
85+ assert.Equal(t, "ci", wr["name"])
86+ assert.Equal(t, "main", wr["head_branch"])
87+ assert.Equal(t, main, wr["head_sha"])
88+ assert.Equal(t, "push", wr["event"])
89+ assert.Equal(t, "completed", wr["status"])
90+ assert.Equal(t, "failure", wr["conclusion"], "the conclusion of the triggering run")
91+ assert.EqualValues(t, trig.GetNumber(), wr["run_number"])
92+ assert.EqualValues(t, 1, wr["run_attempt"])
93+ assert.Equal(t, ".github/workflows/ci.yml", wr["path"])
94+ assert.NotEmpty(t, wr["actor"].(map[string]any)["login"])
95+ wf := ev["workflow"].(map[string]any)
96+ assert.Equal(t, "ci", wf["name"])
97+ assert.Equal(t, ".github/workflows/ci.yml", wf["path"])
98+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
99+}
100+
101+func TestWorkflowRunInheritsTrustAndNeverRaisesIt(t *testing.T) {
102+ c := newCIHarness(t, nil)
103+ runner := c.registerRunner("alpha", "ubuntu-latest")
104+ c.push("main", merge(
105+ map[string]string{".github/workflows/ci.yml": triggeringWorkflow},
106+ followWorkflow("after", " workflows: [ci]\n types: [completed]"),
107+ ), "")
108+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
109+ c.waitFor("the owner's workflow_run", func() bool { return len(c.runsOf("after")) == 1 })
110+ assert.True(t, c.runsOf("after")[0].GetTrusted())
111+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
112+
113+ // An agent pushes a branch: its push run is untrusted, and so is the
114+ // workflow_run it triggers, though that workflow is the owner's, on main.
115+ secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/claude/"}, "proj")
116+ c.mustGit(c.wt, "checkout", "-q", "-b", "agent/claude/x")
117+ c.writeFile(c.wt, "a.txt", []byte("a"))
118+ c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/claude/x")
119+ c.waitFor("the agent's push run", func() bool { return len(c.runsOf("ci")) == 2 })
120+ agentRun := c.runsOf("ci")[0]
121+ assert.False(t, agentRun.GetTrusted())
122+ job := runner.await()
123+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
124+ c.waitFor("the agent's workflow_run", func() bool { return len(c.runsOf("after")) == 2 })
125+ follow := c.runsOf("after")[0]
126+ assert.False(t, follow.GetTrusted(), "never raised")
127+ assert.Equal(t, agentRun.GetActor(), follow.GetActor(), "the actor of the triggering run")
128+ next := runner.await()
129+ assert.False(t, next.GetTrusted())
130+ assert.Empty(t, next.GetSecrets())
131+ assert.Equal(t, "agent/claude/x", c.ghContext(next)["event"].(map[string]any)["workflow_run"].(map[string]any)["head_branch"])
132+ runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
133+}
134+
135+func TestWorkflowRunChainStopsAtDepthThree(t *testing.T) {
136+ // handled tells when the workflows waiting for an event have been looked at:
137+ // a barrier instead of a wait for something that must not happen.
138+ handled := make(chan [2]any, 64)
139+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
140+ cfg.RunEventHandled = func(id int64, action string) { handled <- [2]any{id, action} }
141+ })
142+ runner := c.registerRunner("alpha", "ubuntu-latest")
143+ files := map[string]string{".github/workflows/a.yml": "name: a\non: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}
144+ prev := "a"
145+ for _, next := range []string{"b", "c", "d", "e"} {
146+ files = merge(files, followWorkflow(next, " workflows: ["+prev+"]\n types: [completed]"))
147+ prev = next
148+ }
149+ c.push("main", files, "")
150+ for _, name := range []string{"a", "b", "c", "d"} {
151+ c.waitFor("the run of "+name, func() bool { return len(c.runsOf(name)) == 1 })
152+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
153+ }
154+ c.waitFor("d completes", func() bool { return c.runsOf("d")[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
155+ // The next level would be e: three workflow_run levels (b, c, d) are the most.
156+ // Wait until the "completed" event of d was handled, then look: e did not start.
157+ dID := c.runsOf("d")[0].GetId()
158+ for done := false; !done; {
159+ select {
160+ case ev := <-handled:
161+ done = ev == [2]any{dID, "completed"}
162+ case <-time.After(15 * time.Second):
163+ t.Fatal("the completed event of d was never handled")
164+ }
165+ }
166+ assert.Empty(t, c.runsOf("e"), "a chain of more than three levels stops")
167+ assert.Len(t, c.runs(), 4)
168+}
M internal/app/cli_e2e_test.go +1 -1
@@ -326,7 +326,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
326326 ver := decode[versionOut](t, res.stdout)
327327 assert.Equal(t, c.url, ver.Server.URL)
328328 assert.NotEmpty(t, ver.Server.Version)
329- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.secrets"}, ver.Server.Features)
329+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.secrets"}, ver.Server.Features)
330330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
331331 stdout, stderr, code = c.bin(t, secret, "version")
332332 require.Equal(t, 0, code, stderr)
M internal/app/cli_run.go +3 -2
@@ -253,12 +253,12 @@ func newRunListCmd(env *cliEnv) *cobra.Command {
253253 rows := make([][]string, 0, len(runs))
254254 for _, r := range runs {
255255 rows = append(rows, []string{
256- strconv.FormatInt(r.GetId(), 10), runResult(r.GetStatus(), r.GetConclusion()), r.GetWorkflowName(),
256+ strconv.FormatInt(r.GetId(), 10), runResult(r.GetStatus(), r.GetConclusion()), r.GetWorkflowName(), r.GetEvent(),
257257 refText(r.GetRef()), shortSha(r.GetSha()),
258258 r.GetActor(), shortTime(r.GetCreatedAt(), "-"), r.GetTitle(),
259259 })
260260 }
261- return table(cmd.OutOrStdout(), []string{"ID", "RESULT", "WORKFLOW", "REF", "COMMIT", "ACTOR", "CREATED", "TITLE"}, rows)
261+ return table(cmd.OutOrStdout(), []string{"ID", "RESULT", "WORKFLOW", "EVENT", "REF", "COMMIT", "ACTOR", "CREATED", "TITLE"}, rows)
262262 }
263263 return cmd
264264 }
@@ -357,6 +357,7 @@ func printRun(w io.Writer, r *ermite.GetRunResponse) error {
357357 pairs := [][2]string{
358358 {"run", fmt.Sprintf("%d (#%d of %s)", run.GetId(), run.GetNumber(), run.GetWorkflowName())},
359359 {"result", runResult(run.GetStatus(), run.GetConclusion())},
360+ {"event", run.GetEvent()},
360361 {"ref", run.GetRef()},
361362 {"commit", run.GetSha()},
362363 {"title", run.GetTitle()},
M internal/app/cli_version.go +1 -0
@@ -119,5 +119,6 @@ var featureUse = map[string]string{
119119 client.FeatureCIRuns: "`ermite run` needs: the commands will fail on this server",
120120 client.FeatureCIRunners: "`ermite runner` needs: the commands will fail on this server",
121121 client.FeatureCISecrets: "`ermite secret`, `variable` and `environment` need: the commands will fail on this server",
122+ client.FeatureCIDispatch: "`ermite workflow run` needs: the command will fail on this server",
122123 client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
123124 }
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
1717 )
1818
1919 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "passkeys"}
20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "passkeys"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, passkeys\n", res.stdout)
48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, passkeys\n", res.stdout)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","passkeys"],"missing_features":[]}}`, res.stdout)
52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","passkeys"],"missing_features":[]}}`, res.stdout)
5353 }
5454
5555 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "passkeys")
56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "passkeys")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 f = versionFake()
A internal/app/cli_workflow.go +85 -0
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "fmt"
7+ "strings"
8+
9+ "connectrpc.com/connect"
10+ "github.com/spf13/cobra"
11+
12+ "bnema.dev/ermite-api/gen/ermite"
13+)
14+
15+// `ermite workflow run`: start a workflow that has a workflow_dispatch trigger,
16+// like `gh workflow run`.
17+
18+func newWorkflowCmd(env *cliEnv) *cobra.Command {
19+ wf := &cobra.Command{
20+ Use: "workflow",
21+ Short: "Start workflows by hand",
22+ }
23+ wf.AddCommand(newWorkflowRunCmd(env))
24+ return wf
25+}
26+
27+// parseInputFlags reads the -f key=value flags. The first = separates the name
28+// from the value, so a value may hold =; a repeated name takes the last value.
29+func parseInputFlags(flags []string) (map[string]string, error) {
30+ if len(flags) == 0 {
31+ return nil, nil
32+ }
33+ out := make(map[string]string, len(flags))
34+ for _, f := range flags {
35+ k, v, ok := strings.Cut(f, "=")
36+ if !ok || k == "" {
37+ return nil, fmt.Errorf("invalid input %q: use -f name=value", f)
38+ }
39+ out[k] = v
40+ }
41+ return out, nil
42+}
43+
44+func newWorkflowRunCmd(env *cliEnv) *cobra.Command {
45+ var repo, ref string
46+ var fields []string
47+ cmd := &cobra.Command{
48+ Use: "run <workflow file> --repo <repo>",
49+ Short: "Run a workflow that has a workflow_dispatch trigger",
50+ Long: "Start a run of a workflow file on a branch or a tag (the default branch without --ref). The workflow is read at\n" +
51+ "that ref and must have a workflow_dispatch trigger; -f name=value gives the inputs it declares (checked by type,\n" +
52+ "required inputs and choices). The workflow file is its path or its file name. Needs the ci:write scope." + jsonRunHelp,
53+ Example: " ermite workflow run release.yml --repo app -f version=1.2.3\n" +
54+ " ermite workflow run .github/workflows/ci.yml --repo app --ref feature/x",
55+ Args: cobra.ExactArgs(1),
56+ ValidArgsFunction: cobra.NoFileCompletions,
57+ }
58+ addRepoFlag(cmd, env, &repo)
59+ cmd.Flags().StringVar(&ref, "ref", "", "the branch or tag to run on (default: the default branch)")
60+ cmd.Flags().StringArrayVarP(&fields, "field", "f", nil, "an input of the workflow as name=value (repeatable)")
61+ asJSON := addJSONFlag(cmd)
62+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
63+ inputs, err := parseInputFlags(fields)
64+ if err != nil {
65+ return err
66+ }
67+ c, err := env.client(cmd)
68+ if err != nil {
69+ return err
70+ }
71+ res, err := c.Runs().DispatchWorkflow(env.ctx(cmd), connect.NewRequest(&ermite.DispatchWorkflowRequest{
72+ Repo: repo, WorkflowPath: args[0], Ref: ref, Inputs: inputs,
73+ }))
74+ if err != nil {
75+ return err
76+ }
77+ run := res.Msg.GetRun()
78+ if *asJSON {
79+ return writeJSON(cmd.OutOrStdout(), toJSONRun(run))
80+ }
81+ noticef(cmd, "run %d of %s started on %s", run.GetId(), run.GetWorkflowName(), refText(run.GetRef()))
82+ return nil
83+ }
84+ return cmd
85+}
M internal/app/forge.go +3 -2
@@ -256,6 +256,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
256256 Proxy: gitremote.ProxyEnv(os.Getenv),
257257 })),
258258 Tokens: store.Tokens(),
259+ RefTips: store.CI(),
259260 Settings: f.Settings,
260261 Workflows: opts.workflows(githubraw.New(githubraw.Config{})),
261262 }
@@ -264,7 +265,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
264265 opts.ci(&ciCfg)
265266 }
266267 f.CI = usecase.NewCI(ctx, ciCfg)
267- f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git)
268+ f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git).WithRefTips(store.CI())
268269 f.Repos.WithCIRunCleaner(f.CI)
269270 f.CI.Start()
270271 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
@@ -284,7 +285,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
284285 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
285286 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
286287 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
287- CI: f.CI, Checks: f.CI, PullRequests: f.CI,
288+ CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
288289 })
289290 f.CI.SetPullGate(f.MergeRequests)
290291 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
M internal/app/root.go +1 -1
@@ -32,7 +32,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
3232 newServeCmd(), newHookCmd(),
3333 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
3434 newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
35- newRunCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env),
35+ newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env),
3636 )
3737 return root
3838 }
A internal/domain/ci_cron.go +134 -0
@@ -0,0 +1,134 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "strconv"
8+ "strings"
9+ "time"
10+)
11+
12+// Cron is a parsed "cron:" of a schedule trigger: five fields (minute, hour, day
13+// of month, month, day of week), evaluated in UTC (CI-3 D5; the "timezone" key
14+// is ignored). The syntax is that of GitHub's schedule: "*", numbers, lists
15+// "a,b", ranges "a-b", steps "*/n", "a/n" and "a-b/n", the names jan-dec and
16+// sun-sat in any case; the day of week is 0-6 (Sunday is 0).
17+//
18+// As in Vixie cron, when both the day of month and the day of week are
19+// restricted (neither starts with "*") a day matches if either does; otherwise
20+// both must.
21+type Cron struct {
22+ minute, hour, dom, month, dow uint64 // bit sets
23+ domStar, dowStar bool
24+}
25+
26+type cronField struct {
27+ min, max int
28+ names map[string]int
29+}
30+
31+var (
32+ cronMinuteField = cronField{0, 59, nil}
33+ cronHourField = cronField{0, 23, nil}
34+ cronDomField = cronField{1, 31, nil}
35+ cronMonthField = cronField{1, 12, map[string]int{"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12}}
36+ cronDowField = cronField{0, 6, map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}}
37+)
38+
39+// ParseCron parses the five fields of a cron expression separated by spaces.
40+func ParseCron(s string) (Cron, error) {
41+ parts := strings.Fields(s)
42+ if len(parts) != 5 {
43+ return Cron{}, fmt.Errorf("%w: cron %q must have 5 fields: minute hour day-of-month month day-of-week", ErrInvalid, s)
44+ }
45+ var c Cron
46+ var err error
47+ for i, f := range []struct {
48+ spec cronField
49+ dst *uint64
50+ }{
51+ {cronMinuteField, &c.minute}, {cronHourField, &c.hour}, {cronDomField, &c.dom}, {cronMonthField, &c.month}, {cronDowField, &c.dow},
52+ } {
53+ if *f.dst, err = parseCronField(parts[i], f.spec); err != nil {
54+ return Cron{}, fmt.Errorf("%w: cron %q: %v", ErrInvalid, s, err)
55+ }
56+ }
57+ c.domStar, c.dowStar = strings.HasPrefix(parts[2], "*"), strings.HasPrefix(parts[4], "*")
58+ return c, nil
59+}
60+
61+func parseCronField(s string, f cronField) (uint64, error) {
62+ var set uint64
63+ for _, item := range strings.Split(s, ",") {
64+ if item == "" {
65+ return 0, fmt.Errorf("empty item in %q", s)
66+ }
67+ rangePart, stepPart, hasStep := strings.Cut(item, "/")
68+ step := 1
69+ if hasStep {
70+ n, err := strconv.Atoi(stepPart)
71+ if err != nil || n < 1 || strings.Trim(stepPart, "0123456789") != "" {
72+ return 0, fmt.Errorf("invalid step %q", stepPart)
73+ }
74+ // A step larger than the field is accepted, as cron does, and keeps
75+ // only the first value: it is clamped to the size of the field, which
76+ // has the same meaning and cannot overflow the loop below.
77+ step = min(n, f.max-f.min+1)
78+ }
79+ lo, hi := f.min, f.max
80+ if rangePart != "*" {
81+ a, b, isRange := strings.Cut(rangePart, "-")
82+ var err error
83+ if lo, err = cronValue(a, f); err != nil {
84+ return 0, err
85+ }
86+ switch {
87+ case isRange:
88+ if hi, err = cronValue(b, f); err != nil {
89+ return 0, err
90+ }
91+ if hi < lo {
92+ return 0, fmt.Errorf("range %q goes backwards", rangePart)
93+ }
94+ case hasStep:
95+ // "a/n" is "a-max/n".
96+ default:
97+ hi = lo
98+ }
99+ }
100+ for v := lo; v <= hi; v += step {
101+ set |= 1 << uint(v)
102+ if step > hi-v {
103+ break // the next value is past the range (and v+step could overflow)
104+ }
105+ }
106+ }
107+ return set, nil
108+}
109+
110+func cronValue(s string, f cronField) (int, error) {
111+ if v, ok := f.names[strings.ToLower(s)]; ok {
112+ return v, nil
113+ }
114+ n, err := strconv.Atoi(s)
115+ if err != nil || strings.Trim(s, "0123456789") != "" || n < f.min || n > f.max {
116+ return 0, fmt.Errorf("invalid value %q (%d-%d)", s, f.min, f.max)
117+ }
118+ return n, nil
119+}
120+
121+// Matches reports whether the minute of t (in UTC) is one the expression fires
122+// at.
123+func (c Cron) Matches(t time.Time) bool {
124+ t = t.UTC()
125+ if c.minute&(1<<uint(t.Minute())) == 0 || c.hour&(1<<uint(t.Hour())) == 0 || c.month&(1<<uint(t.Month())) == 0 {
126+ return false
127+ }
128+ domOK := c.dom&(1<<uint(t.Day())) != 0
129+ dowOK := c.dow&(1<<uint(t.Weekday())) != 0
130+ if !c.domStar && !c.dowStar {
131+ return domOK || dowOK
132+ }
133+ return domOK && dowOK
134+}
A internal/domain/ci_cron_test.go +107 -0
@@ -0,0 +1,107 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func at(t *testing.T, s string) time.Time {
16+ t.Helper()
17+ v, err := time.Parse("2006-01-02 15:04", s)
18+ require.NoError(t, err)
19+ return v
20+}
21+
22+func TestCronMatches(t *testing.T) {
23+ tests := []struct {
24+ spec string
25+ yes []string
26+ no []string
27+ extra string
28+ }{
29+ {"* * * * *", []string{"2026-03-04 05:06", "2026-12-31 23:59"}, nil, ""},
30+ {"*/15 * * * *", []string{"2026-03-04 05:00", "2026-03-04 05:45"}, []string{"2026-03-04 05:46", "2026-03-04 05:14"}, ""},
31+ {"30 4 * * *", []string{"2026-03-04 04:30"}, []string{"2026-03-04 04:31", "2026-03-04 05:30"}, ""},
32+ {"0 0 1 1 *", []string{"2027-01-01 00:00"}, []string{"2027-01-02 00:00", "2027-02-01 00:00"}, ""},
33+ {"5,10-12 */6 * * *", []string{"2026-03-04 06:05", "2026-03-04 12:11"}, []string{"2026-03-04 06:06", "2026-03-04 07:05"}, ""},
34+ {"10/20 * * * *", []string{"2026-03-04 00:10", "2026-03-04 00:30", "2026-03-04 00:50"}, []string{"2026-03-04 00:00", "2026-03-04 00:20"}, "a/n runs from a to the maximum"},
35+ {"0 12 * JAN-mar mon-fri", []string{"2026-02-17 12:00"}, []string{"2026-02-21 12:00", "2026-04-01 12:00"}, "names in any case"},
36+ {"0 0 * * 0", []string{"2026-03-01 00:00"}, []string{"2026-03-02 00:00"}, "0 is Sunday"},
37+ // Both day fields restricted: either matches (2026-03-13 is a Friday).
38+ {"0 0 13 * fri", []string{"2026-03-13 00:00", "2026-03-20 00:00", "2026-04-13 00:00"}, []string{"2026-03-14 00:00"}, "day of month OR day of week"},
39+ // One of them a star: both must.
40+ {"0 0 13 * *", []string{"2026-03-13 00:00"}, []string{"2026-03-20 00:00"}, ""},
41+ {"0 0 * * fri", []string{"2026-03-13 00:00"}, []string{"2026-03-12 00:00"}, ""},
42+ }
43+ for _, tt := range tests {
44+ c, err := domain.ParseCron(tt.spec)
45+ require.NoError(t, err, tt.spec)
46+ for _, y := range tt.yes {
47+ assert.True(t, c.Matches(at(t, y)), "%s at %s %s", tt.spec, y, tt.extra)
48+ }
49+ for _, n := range tt.no {
50+ assert.False(t, c.Matches(at(t, n)), "%s at %s %s", tt.spec, n, tt.extra)
51+ }
52+ }
53+ // Only the UTC minute counts, whatever the zone of the time given.
54+ c, err := domain.ParseCron("0 10 * * *")
55+ require.NoError(t, err)
56+ paris := time.FixedZone("CEST", 2*3600)
57+ assert.True(t, c.Matches(time.Date(2026, 6, 1, 12, 0, 0, 0, paris)))
58+ assert.False(t, c.Matches(time.Date(2026, 6, 1, 10, 0, 0, 0, paris)))
59+}
60+
61+func TestCronLeapDay(t *testing.T) {
62+ c, err := domain.ParseCron("0 0 29 2 *")
63+ require.NoError(t, err)
64+ assert.True(t, c.Matches(at(t, "2028-02-29 00:00")), "2028 is a leap year")
65+ assert.False(t, c.Matches(at(t, "2028-02-28 00:00")))
66+ assert.False(t, c.Matches(at(t, "2028-03-01 00:00")))
67+ for d := at(t, "2027-02-01 00:00"); d.Month() == time.February; d = d.AddDate(0, 0, 1) {
68+ assert.False(t, c.Matches(d), "no 29 February in 2027: %s", d)
69+ }
70+ any31, err := domain.ParseCron("0 0 31 * *")
71+ require.NoError(t, err)
72+ assert.True(t, any31.Matches(at(t, "2026-03-31 00:00")))
73+ assert.False(t, any31.Matches(at(t, "2026-04-30 00:00")), "no 31st in April")
74+}
75+
76+// A step larger than the field keeps its first value only, and cannot overflow.
77+func TestCronHugeSteps(t *testing.T) {
78+ for _, spec := range []string{"59/9223372036854775807 * * * *", "*/9223372036854775807 * * * *", "0-30/100 * * * *"} {
79+ c, err := domain.ParseCron(spec)
80+ require.NoError(t, err, spec)
81+ var got []int
82+ for m := range 60 {
83+ if c.Matches(at(t, "2026-03-04 05:00").Add(time.Duration(m) * time.Minute)) {
84+ got = append(got, m)
85+ }
86+ }
87+ switch spec[:2] {
88+ case "59":
89+ assert.Equal(t, []int{59}, got, spec)
90+ default:
91+ assert.Equal(t, []int{0}, got, spec)
92+ }
93+ }
94+ _, err := domain.ParseCron("*/99999999999999999999 * * * *")
95+ require.Error(t, err, "beyond an int64")
96+}
97+
98+func TestParseCronRefuses(t *testing.T) {
99+ for _, bad := range []string{
100+ "", "* * * *", "* * * * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "* * 32 * *", "* * * 13 *", "* * * * 7",
101+ "*/0 * * * *", "*/x * * * *", "5-3 * * * *", "1,,2 * * * *", "-1 * * * *", "1- * * * *", "foo * * * *", "* * * jun-foo *", "+5 * * * *", "0x5 * * * *",
102+ } {
103+ _, err := domain.ParseCron(bad)
104+ require.Error(t, err, bad)
105+ assert.ErrorIs(t, err, domain.ErrInvalid, bad)
106+ }
107+}
A internal/domain/ci_tip.go +42 -0
@@ -0,0 +1,42 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+// CIRefTip records who moves a branch or a tag to an object (CI-3, D1): the
6+// actor and pusher of the push or merge that sets Ref to Sha, and whether that
7+// principal was trusted (TrustedActor).
8+//
9+// It is what a scheduled run takes its actor and its trust from (GitHub runs a
10+// schedule as the author of the last commit of the default branch), and what a
11+// dispatched run's trust is capped by: the default branch is not necessarily
12+// the owner's work (a token limited to some branches can push the first branch
13+// of an empty repository, which becomes the default branch), and an agent's
14+// branch is not the owner's work either.
15+//
16+// The record is written BEFORE the ref moves (in the pre-receive decision of a
17+// push, and inside the merge before it updates the target), so that no moment
18+// exists when the tip is the new commit and its record the previous pusher's;
19+// writing it after would let a slow post-receive of an agent's push leave the
20+// owner's record on a tip the agent has since moved back to the same commit
21+// (owner A, agent B, agent A). It names the object it was written for, and is
22+// valid only while the ref still points to it. A push that is refused after the
23+// record was written leaves a record that matches no tip: not trusted (fails
24+// closed). A tip with no record (a push from before records existed, a record
25+// that could not be written) is not trusted either.
26+//
27+// Two concurrent pushes of the same object by the owner and by an agent can
28+// leave the owner's record: the object is the same and the owner pushed it, so
29+// the content is endorsed by the owner. That case is accepted.
30+type CIRefTip struct {
31+ Repo RepoName
32+ // Ref is the full name: "refs/heads/main" or "refs/tags/v1".
33+ Ref string
34+ // Sha is the object the ref is set to (the tag object for an annotated tag).
35+ Sha string
36+ // Actor is who the run shows, as for a push (authorOf).
37+ Actor string
38+ // Pusher is the principal whose rights the actions of other repositories are
39+ // resolved with.
40+ Pusher CIPusher
41+ Trusted bool
42+}
M internal/ports/ci.go +30 -0
@@ -82,6 +82,19 @@ type CIRepoTokenStore interface {
8282 GetRepoToken(ctx context.Context, id string) (domain.CIRepoToken, error)
8383 }
8484
85+// CIRefTipStore keeps, per branch or tag (full ref name), who moves it to its
86+// object (domain.CIRefTip). The record is written before the ref moves.
87+// *kvstore.CI implements it; the records go with the repository.
88+type CIRefTipStore interface {
89+ // PutRefTip stores the record of a ref, replacing the earlier one. A
90+ // repository that does not exist is domain.ErrNotFound.
91+ PutRefTip(ctx context.Context, t domain.CIRefTip) error
92+ // GetRefTip returns the record of a ref, or domain.ErrNotFound.
93+ GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error)
94+ // DeleteRefTip removes the record of a ref; a missing one is not an error.
95+ DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error
96+}
97+
8598 // CIStore keeps CI runs, jobs and checks. The store derives its own indexes
8699 // (the queue of ready jobs, the active jobs, the open runs, the job tokens)
87100 // from the records it writes.
@@ -170,6 +183,9 @@ type CIStore interface {
170183 type CIRunCleaner interface {
171184 RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error)
172185 RemoveLogs(ctx context.Context, runIDs []int64) error
186+ // ForgetRepo drops what CI keeps in memory for the deleted repository (its
187+ // cached schedules).
188+ ForgetRepo(repo domain.RepoName)
173189 }
174190
175191 // CIChecksReader is the read side of the checks, for the merge rule and the
@@ -255,6 +271,16 @@ type CISummaryStore interface {
255271 RunIDs(ctx context.Context) ([]int64, error)
256272 }
257273
274+// DispatchParams are the arguments of RunService.DispatchWorkflow.
275+type DispatchParams struct {
276+ // WorkflowPath is the path of the workflow file, or its file name.
277+ WorkflowPath string
278+ // Ref is a branch or a tag, short or full name; empty is the default branch.
279+ Ref string
280+ // Inputs are the values of the inputs by name, as text.
281+ Inputs map[string]string
282+}
283+
258284 // WorkflowTrigger starts the workflows that match pushed refs. OnPush only
259285 // queues the work and returns: planning happens in the background and its
260286 // errors never reach the push.
@@ -663,6 +689,10 @@ type RunService interface {
663689 GetJobLog(ctx context.Context, p domain.Principal, repo domain.RepoName, params GetJobLogParams, send func(LogBatch) error) error
664690 CancelRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64) (domain.CIRun, error)
665691 RerunRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error)
692+ // DispatchWorkflow starts a run of a workflow file by hand (workflow_dispatch).
693+ // The caller needs ci:write on the repository, or is a job token with the
694+ // permission actions: write. The run is returned queued.
695+ DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params DispatchParams) (domain.CIRun, error)
666696 ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ChecksResult, error)
667697 // ListAnnotations returns the annotations of a run attempt (0 for the
668698 // latest), of one job when jobID is not empty. Each job gives those of the
A internal/ports/mocks/CIRefTipStore.go +240 -0
@@ -0,0 +1,240 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewCIRefTipStore creates a new instance of CIRefTipStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewCIRefTipStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *CIRefTipStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &CIRefTipStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// CIRefTipStore is an autogenerated mock type for the CIRefTipStore type
38+type CIRefTipStore struct {
39+ mock.Mock
40+}
41+
42+type CIRefTipStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *CIRefTipStore) EXPECT() *CIRefTipStore_Expecter {
47+ return &CIRefTipStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// DeleteRefTip provides a mock function for the type CIRefTipStore
51+func (_mock *CIRefTipStore) DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error {
52+ ret := _mock.Called(ctx, repo, ref)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for DeleteRefTip")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) error); ok {
60+ r0 = returnFunc(ctx, repo, ref)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// CIRefTipStore_DeleteRefTip_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRefTip'
68+type CIRefTipStore_DeleteRefTip_Call struct {
69+ *mock.Call
70+}
71+
72+// DeleteRefTip is a helper method to define mock.On call
73+// - ctx context.Context
74+// - repo domain.RepoName
75+// - ref string
76+func (_e *CIRefTipStore_Expecter) DeleteRefTip(ctx any, repo any, ref any) *CIRefTipStore_DeleteRefTip_Call {
77+ return &CIRefTipStore_DeleteRefTip_Call{Call: _e.mock.On("DeleteRefTip", ctx, repo, ref)}
78+}
79+
80+func (_c *CIRefTipStore_DeleteRefTip_Call) Run(run func(ctx context.Context, repo domain.RepoName, ref string)) *CIRefTipStore_DeleteRefTip_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.RepoName
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.RepoName)
89+ }
90+ var arg2 string
91+ if args[2] != nil {
92+ arg2 = args[2].(string)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *CIRefTipStore_DeleteRefTip_Call) Return(err error) *CIRefTipStore_DeleteRefTip_Call {
104+ _c.Call.Return(err)
105+ return _c
106+}
107+
108+func (_c *CIRefTipStore_DeleteRefTip_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, ref string) error) *CIRefTipStore_DeleteRefTip_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
112+
113+// GetRefTip provides a mock function for the type CIRefTipStore
114+func (_mock *CIRefTipStore) GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error) {
115+ ret := _mock.Called(ctx, repo, ref)
116+
117+ if len(ret) == 0 {
118+ panic("no return value specified for GetRefTip")
119+ }
120+
121+ var r0 domain.CIRefTip
122+ var r1 error
123+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) (domain.CIRefTip, error)); ok {
124+ return returnFunc(ctx, repo, ref)
125+ }
126+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) domain.CIRefTip); ok {
127+ r0 = returnFunc(ctx, repo, ref)
128+ } else {
129+ r0 = ret.Get(0).(domain.CIRefTip)
130+ }
131+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string) error); ok {
132+ r1 = returnFunc(ctx, repo, ref)
133+ } else {
134+ r1 = ret.Error(1)
135+ }
136+ return r0, r1
137+}
138+
139+// CIRefTipStore_GetRefTip_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRefTip'
140+type CIRefTipStore_GetRefTip_Call struct {
141+ *mock.Call
142+}
143+
144+// GetRefTip is a helper method to define mock.On call
145+// - ctx context.Context
146+// - repo domain.RepoName
147+// - ref string
148+func (_e *CIRefTipStore_Expecter) GetRefTip(ctx any, repo any, ref any) *CIRefTipStore_GetRefTip_Call {
149+ return &CIRefTipStore_GetRefTip_Call{Call: _e.mock.On("GetRefTip", ctx, repo, ref)}
150+}
151+
152+func (_c *CIRefTipStore_GetRefTip_Call) Run(run func(ctx context.Context, repo domain.RepoName, ref string)) *CIRefTipStore_GetRefTip_Call {
153+ _c.Call.Run(func(args mock.Arguments) {
154+ var arg0 context.Context
155+ if args[0] != nil {
156+ arg0 = args[0].(context.Context)
157+ }
158+ var arg1 domain.RepoName
159+ if args[1] != nil {
160+ arg1 = args[1].(domain.RepoName)
161+ }
162+ var arg2 string
163+ if args[2] != nil {
164+ arg2 = args[2].(string)
165+ }
166+ run(
167+ arg0,
168+ arg1,
169+ arg2,
170+ )
171+ })
172+ return _c
173+}
174+
175+func (_c *CIRefTipStore_GetRefTip_Call) Return(cIRefTip domain.CIRefTip, err error) *CIRefTipStore_GetRefTip_Call {
176+ _c.Call.Return(cIRefTip, err)
177+ return _c
178+}
179+
180+func (_c *CIRefTipStore_GetRefTip_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error)) *CIRefTipStore_GetRefTip_Call {
181+ _c.Call.Return(run)
182+ return _c
183+}
184+
185+// PutRefTip provides a mock function for the type CIRefTipStore
186+func (_mock *CIRefTipStore) PutRefTip(ctx context.Context, t domain.CIRefTip) error {
187+ ret := _mock.Called(ctx, t)
188+
189+ if len(ret) == 0 {
190+ panic("no return value specified for PutRefTip")
191+ }
192+
193+ var r0 error
194+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.CIRefTip) error); ok {
195+ r0 = returnFunc(ctx, t)
196+ } else {
197+ r0 = ret.Error(0)
198+ }
199+ return r0
200+}
201+
202+// CIRefTipStore_PutRefTip_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutRefTip'
203+type CIRefTipStore_PutRefTip_Call struct {
204+ *mock.Call
205+}
206+
207+// PutRefTip is a helper method to define mock.On call
208+// - ctx context.Context
209+// - t domain.CIRefTip
210+func (_e *CIRefTipStore_Expecter) PutRefTip(ctx any, t any) *CIRefTipStore_PutRefTip_Call {
211+ return &CIRefTipStore_PutRefTip_Call{Call: _e.mock.On("PutRefTip", ctx, t)}
212+}
213+
214+func (_c *CIRefTipStore_PutRefTip_Call) Run(run func(ctx context.Context, t domain.CIRefTip)) *CIRefTipStore_PutRefTip_Call {
215+ _c.Call.Run(func(args mock.Arguments) {
216+ var arg0 context.Context
217+ if args[0] != nil {
218+ arg0 = args[0].(context.Context)
219+ }
220+ var arg1 domain.CIRefTip
221+ if args[1] != nil {
222+ arg1 = args[1].(domain.CIRefTip)
223+ }
224+ run(
225+ arg0,
226+ arg1,
227+ )
228+ })
229+ return _c
230+}
231+
232+func (_c *CIRefTipStore_PutRefTip_Call) Return(err error) *CIRefTipStore_PutRefTip_Call {
233+ _c.Call.Return(err)
234+ return _c
235+}
236+
237+func (_c *CIRefTipStore_PutRefTip_Call) RunAndReturn(run func(ctx context.Context, t domain.CIRefTip) error) *CIRefTipStore_PutRefTip_Call {
238+ _c.Call.Return(run)
239+ return _c
240+}
M internal/ports/mocks/CIRunCleaner.go +40 -0
@@ -47,6 +47,46 @@ func (_m *CIRunCleaner) EXPECT() *CIRunCleaner_Expecter {
4747 return &CIRunCleaner_Expecter{mock: &_m.Mock}
4848 }
4949
50+// ForgetRepo provides a mock function for the type CIRunCleaner
51+func (_mock *CIRunCleaner) ForgetRepo(repo domain.RepoName) {
52+ _mock.Called(repo)
53+ return
54+}
55+
56+// CIRunCleaner_ForgetRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgetRepo'
57+type CIRunCleaner_ForgetRepo_Call struct {
58+ *mock.Call
59+}
60+
61+// ForgetRepo is a helper method to define mock.On call
62+// - repo domain.RepoName
63+func (_e *CIRunCleaner_Expecter) ForgetRepo(repo any) *CIRunCleaner_ForgetRepo_Call {
64+ return &CIRunCleaner_ForgetRepo_Call{Call: _e.mock.On("ForgetRepo", repo)}
65+}
66+
67+func (_c *CIRunCleaner_ForgetRepo_Call) Run(run func(repo domain.RepoName)) *CIRunCleaner_ForgetRepo_Call {
68+ _c.Call.Run(func(args mock.Arguments) {
69+ var arg0 domain.RepoName
70+ if args[0] != nil {
71+ arg0 = args[0].(domain.RepoName)
72+ }
73+ run(
74+ arg0,
75+ )
76+ })
77+ return _c
78+}
79+
80+func (_c *CIRunCleaner_ForgetRepo_Call) Return() *CIRunCleaner_ForgetRepo_Call {
81+ _c.Call.Return()
82+ return _c
83+}
84+
85+func (_c *CIRunCleaner_ForgetRepo_Call) RunAndReturn(run func(repo domain.RepoName)) *CIRunCleaner_ForgetRepo_Call {
86+ _c.Run(run)
87+ return _c
88+}
89+
5090 // RemoveLogs provides a mock function for the type CIRunCleaner
5191 func (_mock *CIRunCleaner) RemoveLogs(ctx context.Context, runIDs []int64) error {
5292 ret := _mock.Called(ctx, runIDs)
M internal/ports/mocks/RunService.go +78 -0
@@ -126,6 +126,84 @@ func (_c *RunService_CancelRun_Call) RunAndReturn(run func(ctx context.Context,
126126 return _c
127127 }
128128
129+// DispatchWorkflow provides a mock function for the type RunService
130+func (_mock *RunService) DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.DispatchParams) (domain.CIRun, error) {
131+ ret := _mock.Called(ctx, p, repo, params)
132+
133+ if len(ret) == 0 {
134+ panic("no return value specified for DispatchWorkflow")
135+ }
136+
137+ var r0 domain.CIRun
138+ var r1 error
139+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.DispatchParams) (domain.CIRun, error)); ok {
140+ return returnFunc(ctx, p, repo, params)
141+ }
142+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.DispatchParams) domain.CIRun); ok {
143+ r0 = returnFunc(ctx, p, repo, params)
144+ } else {
145+ r0 = ret.Get(0).(domain.CIRun)
146+ }
147+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.DispatchParams) error); ok {
148+ r1 = returnFunc(ctx, p, repo, params)
149+ } else {
150+ r1 = ret.Error(1)
151+ }
152+ return r0, r1
153+}
154+
155+// RunService_DispatchWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DispatchWorkflow'
156+type RunService_DispatchWorkflow_Call struct {
157+ *mock.Call
158+}
159+
160+// DispatchWorkflow is a helper method to define mock.On call
161+// - ctx context.Context
162+// - p domain.Principal
163+// - repo domain.RepoName
164+// - params ports.DispatchParams
165+func (_e *RunService_Expecter) DispatchWorkflow(ctx any, p any, repo any, params any) *RunService_DispatchWorkflow_Call {
166+ return &RunService_DispatchWorkflow_Call{Call: _e.mock.On("DispatchWorkflow", ctx, p, repo, params)}
167+}
168+
169+func (_c *RunService_DispatchWorkflow_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.DispatchParams)) *RunService_DispatchWorkflow_Call {
170+ _c.Call.Run(func(args mock.Arguments) {
171+ var arg0 context.Context
172+ if args[0] != nil {
173+ arg0 = args[0].(context.Context)
174+ }
175+ var arg1 domain.Principal
176+ if args[1] != nil {
177+ arg1 = args[1].(domain.Principal)
178+ }
179+ var arg2 domain.RepoName
180+ if args[2] != nil {
181+ arg2 = args[2].(domain.RepoName)
182+ }
183+ var arg3 ports.DispatchParams
184+ if args[3] != nil {
185+ arg3 = args[3].(ports.DispatchParams)
186+ }
187+ run(
188+ arg0,
189+ arg1,
190+ arg2,
191+ arg3,
192+ )
193+ })
194+ return _c
195+}
196+
197+func (_c *RunService_DispatchWorkflow_Call) Return(cIRun domain.CIRun, err error) *RunService_DispatchWorkflow_Call {
198+ _c.Call.Return(cIRun, err)
199+ return _c
200+}
201+
202+func (_c *RunService_DispatchWorkflow_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.DispatchParams) (domain.CIRun, error)) *RunService_DispatchWorkflow_Call {
203+ _c.Call.Return(run)
204+ return _c
205+}
206+
129207 // GetJob provides a mock function for the type RunService
130208 func (_mock *RunService) GetJob(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.JobView, error) {
131209 ret := _mock.Called(ctx, p, repo, jobID, attempt)
M internal/usecase/ci.go +39 -3
@@ -141,6 +141,27 @@ type CIConfig struct {
141141 // transaction removes (default 256, far below the size limit of a kv batch);
142142 // the others go with the next decisions and the reconciliation.
143143 ConcurrencyPruneBatch int
144+ // ScheduleTick is how often the scheduler looks at the clock (default one
145+ // second) and ScheduleBudget the repositories it handles per tick (default
146+ // 64); see ci_schedule.go.
147+ ScheduleTick time.Duration
148+ ScheduleBudget int
149+ // RefTips holds who moves each branch and tag to its object, written before
150+ // the ref moves (Pushes, MergeRequests), for the actor and trust of scheduled
151+ // runs and the trust of dispatched ones (ci_tips.go). Nil: they are never
152+ // trusted. Leave it unset rather than assigning a typed nil pointer.
153+ RefTips ports.CIRefTipStore
154+ // DispatchResolved, when set, is called by DispatchWorkflow after it resolved
155+ // its ref and before it judges the trust of the run. Tests use it to move the
156+ // ref in between.
157+ DispatchResolved func()
158+ // RunEventHandled, when set, is called after the workflows waiting for a
159+ // "requested" or "completed" event of a run were started (or none matched).
160+ // Tests use it as a barrier.
161+ RunEventHandled func(runID int64, action string)
162+ // ScheduleVisit, when set, is called with each repository the scheduler
163+ // looks at. Tests use it.
164+ ScheduleVisit func(repo domain.RepoName)
144165 }
145166
146167 func (c CIConfig) withDefaults() CIConfig {
@@ -174,6 +195,10 @@ func (c CIConfig) withDefaults() CIConfig {
174195 if c.ConcurrencyPruneBatch <= 0 {
175196 c.ConcurrencyPruneBatch = DefaultCIConcurrencyPruneBatch
176197 }
198+ def(&c.ScheduleTick, DefaultCIScheduleTick)
199+ if c.ScheduleBudget <= 0 {
200+ c.ScheduleBudget = DefaultCIScheduleBudget
201+ }
177202 return c
178203 }
179204
@@ -188,7 +213,11 @@ type CI struct {
188213 cfg CIConfig
189214 boot time.Time
190215 queue chan ciTrigger
191- pull *pullQueue // the work of the merge requests (ci_pull.go)
216+ // runEvents are the requested and completed events of runs, for the
217+ // workflow_run workflows (ci_workflowrun.go).
218+ runEvents chan runEvent
219+ sched *scheduler // the schedule trigger (ci_schedule.go)
220+ pull *pullQueue // the work of the merge requests (ci_pull.go)
192221
193222 pullFileLists pullFilesCache // the files of the merge requests (ci_pullfiles.go)
194223
@@ -243,7 +272,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
243272 cfg = cfg.withDefaults()
244273 run, stop := context.WithCancel(context.WithoutCancel(ctx))
245274 return &CI{
246- cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts),
275+ cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts),
247276 ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency),
248277 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
249278 archiveTokens: map[string]map[string]string{},
@@ -254,7 +283,9 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
254283 // Start runs the trigger worker and the watchdog, and plans again the runs
255284 // that were open when the server stopped.
256285 func (c *CI) Start() {
257- c.wg.Add(4)
286+ c.wg.Add(6)
287+ go c.scheduleLoop()
288+ go c.runEventLoop()
258289 go c.pullLoop()
259290 go c.refreshLoop()
260291 go c.triggerLoop()
@@ -426,6 +457,7 @@ func (rs *runState) flush(ctx context.Context) error {
426457 now := c.now()
427458 var w ports.CIWrite
428459 var changedJobs []int
460+ completing := false
429461 for i := range rs.jobs {
430462 if rs.dirty[rs.jobs[i].ID] {
431463 w.Jobs = append(w.Jobs, rs.jobs[i])
@@ -439,6 +471,7 @@ func (rs *runState) flush(ctx context.Context) error {
439471 }
440472 }
441473 if next := rs.recompute(now); next != rs.run {
474+ completing = rs.run.Status != domain.CICompleted && next.Status == domain.CICompleted
442475 rs.run, rs.runDirty = next, true
443476 }
444477 }
@@ -486,6 +519,9 @@ func (rs *runState) flush(ctx context.Context) error {
486519 if changedRun {
487520 c.cfg.Bus.Publish(ctx, domain.RunUpdated{Run: rs.run, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
488521 }
522+ if completing {
523+ c.onRunEvent(ctx, rs.run, runCompleted)
524+ }
489525 return nil
490526 }
491527
M internal/usecase/ci_concurrency.go +1 -1
@@ -616,7 +616,7 @@ func (rs *runState) evalRunConcurrency(ctx context.Context, wf *workflow.Workflo
616616 if err != nil {
617617 return domain.CIConcurrency{}, err
618618 }
619- env := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": expr.NewDictionary(), "vars": vars}}
619+ env := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": rs.inputsOf(domain.CIJob{}), "vars": vars}}
620620 return evalConcurrency(ctx, wf.Concurrency, env)
621621 }
622622
A internal/usecase/ci_dispatch.go +232 -0
@@ -0,0 +1,232 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "path"
11+ "strings"
12+
13+ "bnema.dev/ermite-actions/workflow"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// jobTokenActor is the actor of a run dispatched with a job token, GitHub's name
19+// for the account behind a GITHUB_TOKEN.
20+const jobTokenActor = "github-actions[bot]"
21+
22+// DispatchWorkflow starts a run of a workflow file by hand (workflow_dispatch,
23+// CI-3 D5), like `gh workflow run`.
24+//
25+// Who may: a principal with ci:write on the repository, or the holder of a job
26+// token whose permissions include "actions: write" (GitHub's one exception to
27+// "events made with GITHUB_TOKEN start no run"; a token of a run that is not
28+// trusted never has it, TokenPermissions lowers it, so a job token that gets
29+// here belongs to a trusted run). A token limited to branch prefixes may only
30+// dispatch on a branch under them, and its run is not trusted (D1). Whoever
31+// calls, the run is trusted only when the ref it runs on was moved to its commit
32+// by a trusted principal (ci_tips.go).
33+//
34+// The workflow is read at ref (the default branch when empty) and must declare
35+// workflow_dispatch; the inputs are checked against its declaration.
36+func (c *CI) DispatchWorkflow(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.DispatchParams) (domain.CIRun, error) {
37+ repo, actor, pusher, trusted, err := c.dispatchCaller(ctx, p, name)
38+ if err != nil {
39+ return domain.CIRun{}, err
40+ }
41+ wfPath, err := dispatchPath(params.WorkflowPath)
42+ if err != nil {
43+ return domain.CIRun{}, err
44+ }
45+ pin, err := c.dispatchRef(ctx, name, params.Ref)
46+ if err != nil {
47+ return domain.CIRun{}, err
48+ }
49+ ref, commit := pin.ref, pin.commit
50+ if c.cfg.DispatchResolved != nil {
51+ c.cfg.DispatchResolved()
52+ }
53+ if !p.AllowsRef(ref) {
54+ return domain.CIRun{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, ref)
55+ }
56+ content, err := c.readWorkflowAt(ctx, name, commit, wfPath)
57+ if err != nil {
58+ return domain.CIRun{}, err
59+ }
60+ wf, perrs := workflow.Parse(wfPath, content)
61+ if len(perrs) > 0 {
62+ lines := make([]string, 0, len(perrs))
63+ for _, e := range perrs {
64+ lines = append(lines, e.Error())
65+ }
66+ return domain.CIRun{}, fmt.Errorf("%w: %s is not a valid workflow:\n%s", domain.ErrInvalid, wfPath, strings.Join(lines, "\n"))
67+ }
68+ ev := wf.On.Event(eventDispatch)
69+ if ev == nil {
70+ return domain.CIRun{}, fmt.Errorf("%w: %s does not have a %s trigger", domain.ErrPreconditionFailed, wfPath, eventDispatch)
71+ }
72+ values, err := resolveDispatchInputs(ev.Inputs, params.Inputs)
73+ if err != nil {
74+ return domain.CIRun{}, err
75+ }
76+
77+ f := pushFacts{
78+ update: domain.RefUpdate{Ref: ref, Old: domain.ZeroOID, New: commit.String()}, commit: commit,
79+ custom: &customEvent{name: eventDispatch, title: workflowName(wf, wfPath), inputs: values},
80+ }
81+ if def, _, err := c.cfg.Repos.DefaultBranch(ctx, name); err == nil {
82+ f.defaultBranch = def
83+ }
84+ // The run executes the code of the ref, which the caller did not necessarily
85+ // write: it is trusted only if the caller is AND whoever moved the ref to this
86+ // commit was (domain.CIRefTip), as a re-run of an agent's run by the owner
87+ // does not unlock secrets. A ref with no record is not trusted. This holds for
88+ // every caller, the owner and a job token alike.
89+ trusted = trusted && c.refTrusted(ctx, name, pin)
90+ t := ciTrigger{repo: name, actor: actor, pusher: pusher, trusted: trusted, at: c.now()}
91+ if problem := c.matrixProblem(ctx, repo, t, f, wfPath, wf); problem.text != "" {
92+ return domain.CIRun{}, fmt.Errorf("%w: %s", domain.ErrInvalid, problem.text)
93+ }
94+ run, err := c.launchRun(ctx, repo, t, f, wfPath, string(content), wf)
95+ if err != nil {
96+ return domain.CIRun{}, err
97+ }
98+ if run.ID == 0 {
99+ return domain.CIRun{}, domain.ErrNotFound
100+ }
101+ ctxLog(ctx).Info().Str("repo", name.String()).Int64("run_id", run.ID).Str("workflow", wfPath).Str("ref", ref).
102+ Str("principal", p.Label()).Bool("trusted", trusted).Msg("ci workflow dispatched")
103+ return run, nil
104+}
105+
106+// dispatchCaller applies the access rule of a dispatch and returns who the run
107+// is for.
108+func (c *CI) dispatchCaller(ctx context.Context, p domain.Principal, name domain.RepoName) (repo domain.Repo, actor string, pusher domain.CIPusher, trusted bool, err error) {
109+ if p.ViaJobToken() {
110+ repo, err = lookupRepo(ctx, c.cfg.Meta, name)
111+ if err != nil {
112+ return repo, "", pusher, false, err
113+ }
114+ // The permission is judged on its own: a job of this repository with
115+ // actions: write may dispatch even when its contents permission is none
116+ // (a private repository is then unreadable to it). JobPermits is true only
117+ // for the job's own token on its own repository, so any other caller of
118+ // a repository it cannot read still gets not found.
119+ switch {
120+ case p.JobPermits("actions", domain.PermissionWrite, name):
121+ return repo, jobTokenActor, domain.PusherOf(p), true, nil
122+ case !canRead(p, repo):
123+ return repo, "", pusher, false, domain.ErrNotFound
124+ }
125+ return repo, "", pusher, false, fmt.Errorf("%w: a job token needs the permission actions: write to dispatch a workflow", domain.ErrForbidden)
126+ }
127+ repo, err = c.writableRepo(ctx, p, name)
128+ if err != nil {
129+ return repo, "", pusher, false, err
130+ }
131+ return repo, authorOf(p), domain.PusherOf(p), domain.TrustedActor(p), nil
132+}
133+
134+// dispatchPath checks the workflow file of a dispatch: a file of
135+// .github/workflows, named by its path or by its file name.
136+func dispatchPath(s string) (string, error) {
137+ s = strings.TrimSpace(s)
138+ if s == "" {
139+ return "", fmt.Errorf("%w: the workflow file is required", domain.ErrInvalid)
140+ }
141+ if !strings.Contains(s, "/") {
142+ s = workflowDir + "/" + s
143+ }
144+ ext := strings.ToLower(path.Ext(s))
145+ if path.Dir(s) != workflowDir || (ext != ".yml" && ext != ".yaml") {
146+ return "", fmt.Errorf("%w: %q is not a workflow file of %s", domain.ErrInvalid, s, workflowDir)
147+ }
148+ return s, nil
149+}
150+
151+// pinnedRef is what a dispatch resolved its ref to, once: the full name, the
152+// commit the run executes and the object the ref points to (the commit for a
153+// branch and a lightweight tag, the tag object for an annotated tag). The trust
154+// of the run is judged on exactly these, never on the ref as it is read later
155+// (ci_tips.go).
156+type pinnedRef struct {
157+ ref string
158+ commit, object domain.ObjectID
159+}
160+
161+// dispatchRef resolves the ref of a dispatch: a branch or a tag, by short or
162+// full name; the default branch when empty. A tag is resolved to its commit and
163+// its own object together; a tag that moves between the two reads is refused,
164+// so the commit and the object always belong to one state of the ref.
165+func (c *CI) dispatchRef(ctx context.Context, name domain.RepoName, given string) (pinnedRef, error) {
166+ given = strings.TrimSpace(given)
167+ if given == "" {
168+ def, exists, err := c.cfg.Repos.DefaultBranch(ctx, name)
169+ if err != nil {
170+ return pinnedRef{}, err
171+ }
172+ if !exists {
173+ return pinnedRef{}, fmt.Errorf("%w: the default branch %q does not exist", domain.ErrNotFound, def)
174+ }
175+ given = "refs/heads/" + def
176+ }
177+ rev, err := domain.ParseRevision(given)
178+ if err != nil {
179+ return pinnedRef{}, err
180+ }
181+ res, err := c.cfg.Reader.ResolveRevision(ctx, name, rev)
182+ if err != nil {
183+ return pinnedRef{}, err
184+ }
185+ pin := pinnedRef{ref: res.Ref, commit: res.CommitID, object: res.CommitID}
186+ switch {
187+ case strings.HasPrefix(res.Ref, "refs/heads/"):
188+ return pin, nil
189+ case !strings.HasPrefix(res.Ref, "refs/tags/"):
190+ return pinnedRef{}, fmt.Errorf("%w: %q is not a branch or a tag", domain.ErrInvalid, given)
191+ }
192+ refs, err := c.cfg.Reader.ListRefs(ctx, name)
193+ if err != nil {
194+ return pinnedRef{}, err
195+ }
196+ for _, r := range refs {
197+ if r.Name != res.Ref {
198+ continue
199+ }
200+ if r.CommitID != res.CommitID || r.ID.IsZero() {
201+ break // moved between the two reads
202+ }
203+ pin.object = r.ID
204+ return pin, nil
205+ }
206+ return pinnedRef{}, fmt.Errorf("%w: tag %q moved while it was resolved, try again", domain.ErrConflict, res.Ref)
207+}
208+
209+// readWorkflowAt reads a workflow file at a commit, bounded; a missing file is
210+// domain.ErrNotFound.
211+func (c *CI) readWorkflowAt(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, wfPath string) ([]byte, error) {
212+ tp, err := domain.ParseTreePath(wfPath)
213+ if err != nil {
214+ return nil, err
215+ }
216+ _, rc, err := c.cfg.Reader.OpenBlob(ctx, repo, domain.RevisionOf(commit), tp)
217+ if err != nil {
218+ if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrNotFile) {
219+ return nil, fmt.Errorf("%w: workflow %s", domain.ErrNotFound, wfPath)
220+ }
221+ return nil, fmt.Errorf("read %s: %w", wfPath, err)
222+ }
223+ content, err := io.ReadAll(io.LimitReader(rc, maxCIWorkflowBytes+1))
224+ _ = rc.Close()
225+ if err != nil {
226+ return nil, fmt.Errorf("read %s: %w", wfPath, err)
227+ }
228+ if len(content) > maxCIWorkflowBytes {
229+ return nil, fmt.Errorf("%w: %s is larger than 1 MiB", domain.ErrInvalid, wfPath)
230+ }
231+ return content, nil
232+}
A internal/usecase/ci_events.go +274 -0
@@ -0,0 +1,274 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "fmt"
7+ "math"
8+ "slices"
9+ "strconv"
10+ "strings"
11+
12+ "bnema.dev/ermite-actions/expr"
13+ "bnema.dev/ermite-actions/workflow"
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// The events workflow_dispatch, schedule and workflow_run (CI-3, D5). They share
18+// the run machinery of the push (startRun); what differs is the github context
19+// and, for workflow_dispatch, the typed inputs.
20+
21+const (
22+ eventDispatch = "workflow_dispatch"
23+ eventSchedule = "schedule"
24+ eventWorkflowRun = "workflow_run"
25+
26+ // maxDispatchInputs is the number of top-level inputs of a workflow_dispatch
27+ // (docs.github.com, "Events that trigger workflows", workflow_dispatch: up to
28+ // 25 top-level inputs; recalled from memory, not fetched), and
29+ // maxDispatchPayload the size of their values together in characters (the
30+ // REST reference of "Create a workflow dispatch event": at most 65,535).
31+ maxDispatchInputs = 25
32+ maxDispatchPayload = 65535
33+)
34+
35+// customEvent is what a run of workflow_dispatch, schedule or workflow_run is
36+// for, beside the commit (pushFacts.custom).
37+type customEvent struct {
38+ name string
39+ title string
40+ // inputs are the values of github.event.inputs (strings), nil when the
41+ // workflow declares none (workflow_dispatch).
42+ inputs map[string]string
43+ // cron is the schedule entry that fired (schedule).
44+ cron string
45+ // triggered is the run a workflow_run is for.
46+ triggered *workflowRunFacts
47+}
48+
49+// workflowRunFacts is the triggering run of a workflow_run.
50+type workflowRunFacts struct {
51+ run domain.CIRun
52+ // action is "requested" or "completed".
53+ action string
54+ // headBranch is the branch of the triggering run: the source branch of a
55+ // merge request, else the branch or tag the run is on.
56+ headBranch string
57+}
58+
59+// resolveDispatchInputs checks the values given to a workflow_dispatch against
60+// the inputs the workflow declares and returns github.event.inputs: the value
61+// given, else the default, else (for a choice) its first option; an optional
62+// input with none of them is absent. Errors are domain.ErrInvalid.
63+func resolveDispatchInputs(defs []workflow.Input, given map[string]string) (map[string]string, error) {
64+ invalid := func(format string, args ...any) error {
65+ return fmt.Errorf("%w: %s", domain.ErrInvalid, fmt.Sprintf(format, args...))
66+ }
67+ if len(defs) > maxDispatchInputs {
68+ return nil, invalid("the workflow declares %d inputs: at most %d are allowed", len(defs), maxDispatchInputs)
69+ }
70+ var unknown []string
71+ for k := range given {
72+ if !slices.ContainsFunc(defs, func(in workflow.Input) bool { return in.Name == k }) {
73+ unknown = append(unknown, k)
74+ }
75+ }
76+ if len(unknown) > 0 {
77+ slices.Sort(unknown)
78+ return nil, invalid("Unexpected inputs provided: [%s]", strings.Join(unknown, ", "))
79+ }
80+ out := map[string]string{}
81+ size := 0
82+ for _, in := range defs {
83+ v, ok := given[in.Name]
84+ if ok && v == "" && in.Required && !in.HasDefault {
85+ ok = false
86+ }
87+ if !ok {
88+ switch {
89+ case in.HasDefault:
90+ v, ok = in.Default, true
91+ case in.Required:
92+ return nil, invalid("Required input '%s' not provided", in.Name)
93+ case in.Type == "choice" && len(in.Options) > 0:
94+ v, ok = in.Options[0], true
95+ }
96+ }
97+ if !ok {
98+ continue
99+ }
100+ if err := checkInputValue(in, v); err != nil {
101+ return nil, err
102+ }
103+ size += len(in.Name) + len(v)
104+ out[in.Name] = v
105+ }
106+ if size > maxDispatchPayload {
107+ return nil, invalid("the inputs are larger than %d characters", maxDispatchPayload)
108+ }
109+ return out, nil
110+}
111+
112+// checkInputValue checks the text of one input against its type.
113+func checkInputValue(in workflow.Input, v string) error {
114+ bad := func(what string) error {
115+ return fmt.Errorf("%w: input '%s' %s", domain.ErrInvalid, in.Name, what)
116+ }
117+ switch in.Type {
118+ case "boolean":
119+ if v != "true" && v != "false" {
120+ return bad("must be 'true' or 'false'")
121+ }
122+ case "number":
123+ if _, ok := parseInputNumber(v); !ok {
124+ return bad("must be a number")
125+ }
126+ case "choice":
127+ if !slices.Contains(in.Options, v) {
128+ return bad("must be one of: " + strings.Join(in.Options, ", "))
129+ }
130+ case "environment":
131+ if _, err := domain.ParseEnvironmentName(v); err != nil {
132+ return bad("must be the name of an environment")
133+ }
134+ }
135+ return nil
136+}
137+
138+// parseInputNumber reads a decimal number: no hexadecimal, no underscores, no
139+// NaN or infinity.
140+func parseInputNumber(v string) (float64, bool) {
141+ v = strings.TrimSpace(v)
142+ if v == "" || strings.ContainsAny(v, "xX_pP") {
143+ return 0, false
144+ }
145+ f, err := strconv.ParseFloat(v, 64)
146+ if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
147+ return 0, false
148+ }
149+ return f, true
150+}
151+
152+// typedInputs is the "inputs" context of a workflow_dispatch run: each declared
153+// input with the type of its declaration (boolean, number or string), the zero
154+// of the type when it has no value.
155+func typedInputs(defs []workflow.Input, values map[string]string) *expr.Dictionary {
156+ d := expr.NewDictionary()
157+ for _, in := range defs {
158+ s, ok := values[in.Name]
159+ switch in.Type {
160+ case "boolean":
161+ d.Add(in.Name, expr.Boolean(ok && s == "true"))
162+ case "number":
163+ f, _ := parseInputNumber(s)
164+ d.Add(in.Name, expr.Number(f))
165+ default:
166+ d.Add(in.Name, expr.String(s))
167+ }
168+ }
169+ return d
170+}
171+
172+// eventInputs is the "inputs" context of the top level of a run: the typed
173+// inputs of a workflow_dispatch run (read back from github.event.inputs of its
174+// github context), empty for every other event.
175+func eventInputs(wf *workflow.Workflow, githubJSON string) *expr.Dictionary {
176+ if wf == nil || wf.On == nil || !strings.Contains(githubJSON, `"event_name":"`+eventDispatch+`"`) {
177+ return expr.NewDictionary()
178+ }
179+ ev := wf.On.Event(eventDispatch)
180+ if ev == nil {
181+ return expr.NewDictionary()
182+ }
183+ values := map[string]string{}
184+ if v, err := expr.FromJSON([]byte(githubJSON)); err == nil {
185+ if gh, ok := v.(*expr.Dictionary); ok {
186+ if e, ok := gh.Get("event"); ok {
187+ if ed, ok := e.(*expr.Dictionary); ok {
188+ if in, ok := ed.Get("inputs"); ok {
189+ if id, ok := in.(*expr.Dictionary); ok {
190+ for _, p := range id.Pairs() {
191+ values[p.Key] = expr.CoerceString(p.Value)
192+ }
193+ }
194+ }
195+ }
196+ }
197+ }
198+ }
199+ return typedInputs(ev.Inputs, values)
200+}
201+
202+// customGithubJSON is the github context of a workflow_dispatch, schedule or
203+// workflow_run run.
204+func (c *CI) customGithubJSON(repo domain.Repo, run domain.CIRun, f pushFacts) string {
205+ u := c.urls(repo)
206+ id := c.identity(run.Actor)
207+ cu := f.custom
208+ event := map[string]any{
209+ "repository": c.repoObj(repo, u, f.defaultBranch, run.CreatedAt),
210+ "sender": u.userObj(id, false),
211+ }
212+ switch cu.name {
213+ case eventDispatch:
214+ event["ref"] = run.Ref
215+ event["workflow"] = run.WorkflowPath
216+ if cu.inputs != nil {
217+ event["inputs"] = cu.inputs
218+ } else {
219+ event["inputs"] = nil
220+ }
221+ case eventSchedule:
222+ event["schedule"] = cu.cron
223+ case eventWorkflowRun:
224+ t := cu.triggered
225+ event["action"] = t.action
226+ event["workflow_run"] = c.workflowRunObj(repo, u, t)
227+ event["workflow"] = c.workflowObj(u, t.run.WorkflowPath, t.run.WorkflowName, t.run.CreatedAt)
228+ }
229+ refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"
230+ if tag, ok := strings.CutPrefix(run.Ref, "refs/tags/"); ok {
231+ refName, refType = tag, "tag"
232+ }
233+ return c.githubContext(repo, run, ghEvent{name: cu.name, refName: refName, refType: refType, payload: event})
234+}
235+
236+// workflowObj is the "workflow" object of a payload.
237+func (c *CI) workflowObj(u ghURLs, path, name string, at interface{ Unix() int64 }) map[string]any {
238+ id := stableID("workflow", u.full+"\x00"+path)
239+ return map[string]any{
240+ "id": id, "node_id": nodeID("02", "Workflow", id), "name": name, "path": path, "state": "active",
241+ "url": u.api + "/repos/" + u.full + "/actions/workflows/" + strconv.FormatInt(id, 10),
242+ "html_url": u.repo + "/blob/HEAD/" + path,
243+ "badge_url": u.repo + "/workflows/" + path + "/badge.svg",
244+ "created_at": at.Unix(), "updated_at": at.Unix(),
245+ }
246+}
247+
248+// workflowRunObj is the "workflow_run" object of the payload: the fields ermite
249+// can fill about the triggering run.
250+func (c *CI) workflowRunObj(repo domain.Repo, u ghURLs, t *workflowRunFacts) map[string]any {
251+ r := t.run
252+ var conclusion any
253+ if r.Conclusion != domain.CINoConclusion {
254+ conclusion = string(r.Conclusion)
255+ }
256+ id := strconv.FormatInt(r.ID, 10)
257+ actor := u.userObj(c.identity(r.Actor), false)
258+ obj := map[string]any{
259+ "id": r.ID, "name": r.WorkflowName, "display_title": r.Title, "head_branch": t.headBranch, "head_sha": r.CheckSha(),
260+ "path": r.WorkflowPath, "run_number": r.Number, "run_attempt": r.Attempt, "event": r.Event,
261+ "status": string(r.Status), "conclusion": conclusion,
262+ "workflow_id": stableID("workflow", u.full+"\x00"+r.WorkflowPath),
263+ "url": u.api + "/repos/" + u.full + "/actions/runs/" + id,
264+ "html_url": u.repo + "/actions/runs/" + id,
265+ "actor": actor, "triggering_actor": actor,
266+ "created_at": r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"),
267+ "updated_at": c.now().Format("2006-01-02T15:04:05Z07:00"),
268+ "repository": c.repoObj(repo, u, "", r.CreatedAt),
269+ }
270+ if !r.StartedAt.IsZero() {
271+ obj["run_started_at"] = r.StartedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
272+ }
273+ return obj
274+}
A internal/usecase/ci_events_test.go +73 -0
@@ -0,0 +1,73 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "fmt"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite-actions/workflow"
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/usecase"
15+)
16+
17+func dispatchInputs(t *testing.T, inputs string) []workflow.Input {
18+ t.Helper()
19+ wf, errs := workflow.Parse("d.yml", []byte("on:\n workflow_dispatch:\n inputs:\n"+inputs+"\njobs:\n j:\n runs-on: x\n steps:\n - run: 'true'\n"))
20+ require.Empty(t, errs)
21+ return wf.On.Event("workflow_dispatch").Inputs
22+}
23+
24+func TestResolveDispatchInputs(t *testing.T) {
25+ defs := dispatchInputs(t, ` s: {type: string, required: true}
26+ b: {type: boolean, default: true}
27+ n: {type: number, default: 5}
28+ c: {type: choice, options: [x, y]}
29+ e: {type: environment}
30+ o: {description: optional}
31+`)
32+ got, err := usecase.ResolveDispatchInputs(defs, map[string]string{"s": "v"})
33+ require.NoError(t, err)
34+ assert.Equal(t, map[string]string{"s": "v", "b": "true", "n": "5", "c": "x"}, got, "defaults, first option of a choice, optional inputs absent")
35+
36+ got, err = usecase.ResolveDispatchInputs(defs, map[string]string{"s": "v", "b": "false", "n": "-1.5", "c": "y", "e": "prod", "o": ""})
37+ require.NoError(t, err)
38+ assert.Equal(t, map[string]string{"s": "v", "b": "false", "n": "-1.5", "c": "y", "e": "prod", "o": ""}, got)
39+
40+ for name, in := range map[string]map[string]string{
41+ "required missing": {},
42+ "required empty": {"s": ""},
43+ "unknown": {"s": "v", "zz": "1"},
44+ "boolean": {"s": "v", "b": "yes"},
45+ "number": {"s": "v", "n": "five"},
46+ "number hex": {"s": "v", "n": "0x1"},
47+ "number nan": {"s": "v", "n": "NaN"},
48+ "choice": {"s": "v", "c": "z"},
49+ "environment": {"s": "v", "e": " padded"},
50+ } {
51+ _, err := usecase.ResolveDispatchInputs(defs, in)
52+ require.Error(t, err, name)
53+ assert.ErrorIs(t, err, domain.ErrInvalid, name)
54+ }
55+}
56+
57+func TestResolveDispatchInputsLimits(t *testing.T) {
58+ var spec string
59+ for i := range 26 {
60+ spec += fmt.Sprintf(" i%d: {}\n", i)
61+ }
62+ _, err := usecase.ResolveDispatchInputs(dispatchInputs(t, spec), nil)
63+ require.Error(t, err, "more than 25 inputs")
64+ assert.Contains(t, err.Error(), "at most 25")
65+
66+ defs := dispatchInputs(t, " a: {}\n")
67+ big := make([]byte, 65535)
68+ for i := range big {
69+ big[i] = 'x'
70+ }
71+ _, err = usecase.ResolveDispatchInputs(defs, map[string]string{"a": string(big)})
72+ require.Error(t, err, "payload over 65,535 characters")
73+}
M internal/usecase/ci_github.go +3 -0
@@ -267,6 +267,9 @@ func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun,
267267 if f.pull != nil {
268268 return c.pullGithubJSON(repo, run, f)
269269 }
270+ if f.custom != nil {
271+ return c.customGithubJSON(repo, run, f)
272+ }
270273 u := c.urls(repo)
271274 id := c.identity(run.Actor)
272275 refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"
M internal/usecase/ci_reusable.go +7 -0
@@ -484,6 +484,13 @@ func (rs *runState) inputsOf(j domain.CIJob) *expr.Dictionary {
484484 return d
485485 }
486486 }
487+ return expr.NewDictionary()
488+ }
489+ if j.Scope == "" && rs.run.Event == eventDispatch {
490+ // The inputs of a workflow_dispatch run, typed by their declaration.
491+ if wf, err := rs.c.flows.get(rs.run.ID, rs.run.WorkflowPath, rs.src.WorkflowYAML); err == nil {
492+ return eventInputs(wf, rs.src.GithubJSON)
493+ }
487494 }
488495 return expr.NewDictionary()
489496 }
A internal/usecase/ci_schedule.go +443 -0
@@ -0,0 +1,443 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "runtime/debug"
10+ "slices"
11+ "strings"
12+ "sync"
13+ "time"
14+
15+ "github.com/bnema/zerowrap"
16+
17+ "bnema.dev/ermite-actions/workflow"
18+ "bnema.dev/ermite/internal/domain"
19+)
20+
21+// The schedule event (CI-3, D5). A scheduler goroutine looks, once per minute of
22+// its clock, at the "cron" entries of the workflows of every repository's default
23+// branch and starts the ones that match that minute (UTC; "timezone" is ignored).
24+//
25+// - The run is on the latest commit of the default branch. Its actor and its
26+// trust are those of whoever pushed that commit (ci_tips.go), as on GitHub:
27+// the default branch is not necessarily the owner's work.
28+// - A minute is looked at once, when the clock first reaches it. Ticks missed
29+// while ermite was down or busy are not made up: a pass that is still going
30+// when the next minute starts is dropped, as GitHub drops late runs.
31+// - The schedules of a repository are read once and cached with the commit
32+// they were read at. A push (of any principal, a job token's included, which
33+// starts no run but may change the file) marks the repository, and the
34+// cache is dropped when its default branch moved. A repository that no
35+// longer exists is forgotten at the next pass.
36+// - The work is bounded: at most ScheduleBudget repositories are looked at per
37+// tick, so a pass over many repositories spreads over several ticks (and
38+// never starts a run late for a minute that is over). A pass dropped
39+// before its end leaves a cursor: the next pass starts after the last
40+// repository handled, so that no repository is starved.
41+//
42+// The cron entries of one workflow that match the same minute start one run of
43+// it (the first in file order); the event payload is that entry's text.
44+
45+const (
46+ // DefaultCIScheduleTick is how often the scheduler looks at its clock.
47+ DefaultCIScheduleTick = time.Second
48+ // DefaultCIScheduleBudget is the repositories the scheduler handles at most
49+ // per tick.
50+ DefaultCIScheduleBudget = 64
51+)
52+
53+// scheduledCron is one cron entry of a workflow.
54+type scheduledCron struct {
55+ spec string
56+ cron domain.Cron
57+}
58+
59+// scheduledWorkflow is a workflow with at least one valid cron entry.
60+type scheduledWorkflow struct {
61+ path string
62+ yaml []byte
63+ crons []scheduledCron
64+}
65+
66+// scheduleEntry is what was read from one repository at tip.
67+type scheduleEntry struct {
68+ tip domain.ObjectID
69+ files []scheduledWorkflow
70+}
71+
72+// schedulePass is the look at one minute over the repositories.
73+type schedulePass struct {
74+ minute time.Time
75+ repos []domain.RepoName
76+ next int
77+}
78+
79+type scheduler struct {
80+ mu sync.Mutex
81+ dirty map[domain.RepoName]struct{}
82+ cache map[domain.RepoName]*scheduleEntry
83+ // passed is the minute of the last pass that went through every repository.
84+ passed time.Time
85+
86+ // The fields below belong to the scheduler goroutine.
87+ last time.Time // the latest minute a pass was started for
88+ pass *schedulePass
89+ // cursor is the last repository a pass handled when it was dropped before
90+ // the end; the next pass starts after it, so that repositories beyond the
91+ // budget of a tick are not starved by the ones that come first. Zero when
92+ // the last pass went through every repository.
93+ cursor domain.RepoName
94+}
95+
96+func newScheduler(boot time.Time) *scheduler {
97+ return &scheduler{
98+ dirty: map[domain.RepoName]struct{}{}, cache: map[domain.RepoName]*scheduleEntry{},
99+ // The minute ermite starts in belongs to the process before it.
100+ last: boot.UTC().Truncate(time.Minute),
101+ }
102+}
103+
104+func (s *scheduler) get(repo domain.RepoName) *scheduleEntry {
105+ s.mu.Lock()
106+ defer s.mu.Unlock()
107+ return s.cache[repo]
108+}
109+
110+func (s *scheduler) put(repo domain.RepoName, e *scheduleEntry) {
111+ s.mu.Lock()
112+ defer s.mu.Unlock()
113+ s.cache[repo] = e
114+}
115+
116+func (s *scheduler) drop(repo domain.RepoName) {
117+ s.mu.Lock()
118+ defer s.mu.Unlock()
119+ delete(s.cache, repo)
120+}
121+
122+// markDirty remembers that the default branch of a repository may have moved.
123+func (s *scheduler) markDirty(repo domain.RepoName) {
124+ s.mu.Lock()
125+ defer s.mu.Unlock()
126+ if _, cached := s.cache[repo]; cached {
127+ s.dirty[repo] = struct{}{}
128+ }
129+}
130+
131+func (s *scheduler) forgetDirty(repo domain.RepoName) {
132+ s.mu.Lock()
133+ defer s.mu.Unlock()
134+ delete(s.dirty, repo)
135+}
136+
137+// takeDirty removes and returns at most n marked repositories.
138+func (s *scheduler) takeDirty(n int) []domain.RepoName {
139+ s.mu.Lock()
140+ defer s.mu.Unlock()
141+ var out []domain.RepoName
142+ for r := range s.dirty {
143+ if len(out) == n {
144+ break
145+ }
146+ out = append(out, r)
147+ delete(s.dirty, r)
148+ }
149+ return out
150+}
151+
152+// prune forgets the repositories that are not in keep.
153+func (s *scheduler) prune(keep []domain.RepoName) {
154+ live := make(map[domain.RepoName]struct{}, len(keep))
155+ for _, r := range keep {
156+ live[r] = struct{}{}
157+ }
158+ s.mu.Lock()
159+ defer s.mu.Unlock()
160+ for r := range s.cache {
161+ if _, ok := live[r]; !ok {
162+ delete(s.cache, r)
163+ }
164+ }
165+ for r := range s.dirty {
166+ if _, ok := live[r]; !ok {
167+ delete(s.dirty, r)
168+ }
169+ }
170+}
171+
172+// CachedSchedules is the number of scheduled workflows the scheduler holds for a
173+// repository, -1 when it holds nothing for it. For tests and diagnostics.
174+func (c *CI) CachedSchedules(repo domain.RepoName) int {
175+ if e := c.sched.get(repo); e != nil {
176+ return len(e.files)
177+ }
178+ return -1
179+}
180+
181+// SchedulePassed is the minute of the last pass of the scheduler that went
182+// through every repository (zero before the first). For tests and diagnostics.
183+func (c *CI) SchedulePassed() time.Time {
184+ c.sched.mu.Lock()
185+ defer c.sched.mu.Unlock()
186+ return c.sched.passed
187+}
188+
189+// markSchedulesDirty tells the scheduler that these updates may have moved the
190+// default branch of the repository.
191+func (c *CI) markSchedulesDirty(repo domain.RepoName, updates []domain.RefUpdate) {
192+ for _, u := range updates {
193+ if strings.HasPrefix(u.Ref, "refs/heads/") {
194+ c.sched.markDirty(repo)
195+ return
196+ }
197+ }
198+}
199+
200+// ScheduleNow does one tick of the scheduler on the calling goroutine, with the
201+// clock of the configuration. It is for tests (with a ScheduleTick too long to
202+// fire) and operators' tooling; the server never needs to call it. It must not
203+// run at the same time as the scheduler goroutine.
204+func (c *CI) ScheduleNow(ctx context.Context) {
205+ c.guardSchedule(func(context.Context) { c.scheduleTick(ctx) })
206+}
207+
208+func (c *CI) scheduleLoop() {
209+ defer c.wg.Done()
210+ t := time.NewTicker(c.cfg.ScheduleTick)
211+ defer t.Stop()
212+ for {
213+ select {
214+ case <-c.ctx.Done():
215+ return
216+ case <-t.C:
217+ }
218+ c.guardSchedule(c.scheduleTick)
219+ }
220+}
221+
222+func (c *CI) guardSchedule(fn func(context.Context)) {
223+ log := zerowrap.FromCtx(c.ctx)
224+ defer func() {
225+ if r := recover(); r != nil {
226+ log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Msg("ci scheduler panicked")
227+ }
228+ }()
229+ fn(c.ctx)
230+}
231+
232+// scheduleTick does one tick of work: it checks the repositories whose default
233+// branch may have moved, starts a pass when the clock reached a new minute, and
234+// carries on the pass within the budget.
235+func (c *CI) scheduleTick(ctx context.Context) {
236+ s := c.sched
237+ budget := c.cfg.ScheduleBudget
238+ for _, repo := range s.takeDirty(budget) {
239+ budget--
240+ if s.get(repo) != nil && !c.scheduleCurrent(ctx, repo) {
241+ s.drop(repo)
242+ }
243+ }
244+ if minute := c.now().Truncate(time.Minute); minute.After(s.last) {
245+ // A pass still going belongs to a minute that is over: it is dropped, and
246+ // so is every minute between.
247+ s.last = minute
248+ s.pass = c.startPass(ctx, minute)
249+ }
250+ p := s.pass
251+ for p != nil && p.next < len(p.repos) && budget > 0 && ctx.Err() == nil {
252+ repo := p.repos[p.next]
253+ p.next++
254+ s.cursor = repo
255+ budget--
256+ if c.cfg.ScheduleVisit != nil {
257+ c.cfg.ScheduleVisit(repo)
258+ }
259+ if err := c.scheduleRepo(ctx, repo, p.minute); err != nil && ctx.Err() == nil {
260+ ctxLog(ctx).Error().Err(err).Str("repo", repo.String()).Msg("ci schedule failed")
261+ }
262+ }
263+ if p != nil && p.next >= len(p.repos) {
264+ s.mu.Lock()
265+ s.passed = p.minute
266+ s.mu.Unlock()
267+ s.pass, s.cursor = nil, domain.RepoName{}
268+ }
269+}
270+
271+// startPass lists the repositories to look at for a minute and forgets the
272+// ones that are gone. Without a runner CI is off and nothing is looked at.
273+func (c *CI) startPass(ctx context.Context, minute time.Time) *schedulePass {
274+ p := &schedulePass{minute: minute}
275+ repos, err := c.cfg.Meta.List(ctx)
276+ if err != nil {
277+ ctxLog(ctx).Error().Err(err).Msg("ci schedule: list the repositories")
278+ return p
279+ }
280+ names := make([]domain.RepoName, 0, len(repos))
281+ for _, r := range repos {
282+ names = append(names, r.Name)
283+ }
284+ c.sched.prune(names)
285+ if runners, err := c.cfg.Store.ListRunners(ctx); err != nil || len(runners) == 0 {
286+ return p
287+ }
288+ // Start after the repository the dropped pass stopped at (the list is in
289+ // name order), and wrap around.
290+ start := 0
291+ if !c.sched.cursor.IsZero() {
292+ for start < len(names) && names[start].String() <= c.sched.cursor.String() {
293+ start++
294+ }
295+ }
296+ p.repos = append(slices.Clone(names[start:]), names[:start]...)
297+ return p
298+}
299+
300+// scheduleCurrent reports whether the cached schedules of a repository were
301+// read at the tip of its default branch as it is now.
302+func (c *CI) scheduleCurrent(ctx context.Context, repo domain.RepoName) bool {
303+ e := c.sched.get(repo)
304+ if e == nil {
305+ return false
306+ }
307+ tip, ok := c.defaultTip(ctx, repo)
308+ return ok && tip == e.tip
309+}
310+
311+// defaultTip is the latest commit of the default branch.
312+func (c *CI) defaultTip(ctx context.Context, repo domain.RepoName) (domain.ObjectID, bool) {
313+ def, exists, err := c.cfg.Repos.DefaultBranch(ctx, repo)
314+ if err != nil || !exists {
315+ return domain.ObjectID{}, false
316+ }
317+ tip, err := c.cfg.Git.BranchHead(ctx, repo, def)
318+ if err != nil {
319+ return domain.ObjectID{}, false
320+ }
321+ return tip, true
322+}
323+
324+// loadSchedules reads the cron entries of the workflows at the tip of the
325+// default branch. A workflow that does not parse is skipped (the push of the
326+// file reported it).
327+func (c *CI) loadSchedules(ctx context.Context, repo domain.RepoName) (*scheduleEntry, error) {
328+ tip, ok := c.defaultTip(ctx, repo)
329+ if !ok {
330+ return &scheduleEntry{}, nil // no repository, no default branch, nothing to schedule
331+ }
332+ e := &scheduleEntry{tip: tip}
333+ files, err := c.workflowFiles(ctx, repo, tip)
334+ if err != nil {
335+ return nil, err
336+ }
337+ for _, f := range files {
338+ if f.problem != "" {
339+ continue
340+ }
341+ wf, perrs := workflow.Parse(f.path, f.content)
342+ if len(perrs) > 0 || wf.On == nil {
343+ continue
344+ }
345+ ev := wf.On.Event(eventSchedule)
346+ if ev == nil {
347+ continue
348+ }
349+ sw := scheduledWorkflow{path: f.path, yaml: f.content}
350+ for _, sc := range ev.Schedule {
351+ cron, err := domain.ParseCron(sc.Cron)
352+ if err != nil {
353+ continue
354+ }
355+ sw.crons = append(sw.crons, scheduledCron{spec: sc.Cron, cron: cron})
356+ }
357+ if len(sw.crons) > 0 {
358+ e.files = append(e.files, sw)
359+ }
360+ }
361+ return e, nil
362+}
363+
364+// dueFor returns, for each workflow of the entry that has an entry matching the
365+// minute, the text of the first one that does.
366+func dueFor(e *scheduleEntry, minute time.Time) (idx []int, specs []string) {
367+ for i, f := range e.files {
368+ for _, sc := range f.crons {
369+ if sc.cron.Matches(minute) {
370+ idx, specs = append(idx, i), append(specs, sc.spec)
371+ break
372+ }
373+ }
374+ }
375+ return idx, specs
376+}
377+
378+// scheduleRepo starts the scheduled workflows of a repository that are due at
379+// the minute.
380+func (c *CI) scheduleRepo(ctx context.Context, repo domain.RepoName, minute time.Time) error {
381+ // The cache is of the commit the run will be on: it is read again when the
382+ // default branch moved (a push marked it, or the default branch was changed).
383+ tip, ok := c.defaultTip(ctx, repo)
384+ if !ok {
385+ c.sched.drop(repo)
386+ return nil
387+ }
388+ e := c.sched.get(repo)
389+ if e == nil || e.tip != tip {
390+ var err error
391+ if e, err = c.loadSchedules(ctx, repo); err != nil {
392+ return fmt.Errorf("read the schedules: %w", err)
393+ }
394+ c.sched.put(repo, e)
395+ }
396+ idx, specs := dueFor(e, minute)
397+ if len(idx) == 0 {
398+ return nil
399+ }
400+ meta, err := c.cfg.Meta.Get(ctx, repo)
401+ if err != nil {
402+ c.sched.drop(repo)
403+ return nil // deleted meanwhile
404+ }
405+ def, _, err := c.cfg.Repos.DefaultBranch(ctx, repo)
406+ if err != nil {
407+ return nil
408+ }
409+ commits, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, e.tip, 1)
410+ if err != nil || len(commits) == 0 {
411+ return fmt.Errorf("read the head commit of %s: %w", def, err)
412+ }
413+ var errs []error
414+ for n, i := range idx {
415+ errs = append(errs, c.fireSchedule(ctx, meta, def, e, commits[0], e.files[i], specs[n]))
416+ }
417+ return errors.Join(errs...)
418+}
419+
420+// fireSchedule starts the run of one scheduled workflow.
421+func (c *CI) fireSchedule(ctx context.Context, repo domain.Repo, def string, e *scheduleEntry, head domain.Commit, sw scheduledWorkflow, spec string) error {
422+ wf, perrs := workflow.Parse(sw.path, sw.yaml)
423+ if len(perrs) > 0 {
424+ return nil
425+ }
426+ facts := pushFacts{
427+ update: domain.RefUpdate{Ref: "refs/heads/" + def, Old: domain.ZeroOID, New: e.tip.String()}, commit: e.tip, head: head,
428+ defaultBranch: def, custom: &customEvent{name: eventSchedule, cron: spec},
429+ }
430+ // Whoever moved the default branch to this commit, with their trust
431+ // (ci_tips.go); the owner's name and no trust when that is not known.
432+ actor, pusher, trusted := c.scheduleActor(ctx, repo.Name, def, e.tip)
433+ t := ciTrigger{repo: repo.Name, actor: actor, pusher: pusher, trusted: trusted, at: c.now()}
434+ if problem := c.matrixProblem(ctx, repo, t, facts, sw.path, wf); problem.text != "" {
435+ name := sw.path
436+ if problem.named {
437+ name = workflowName(wf, sw.path)
438+ }
439+ return c.startInvalid(ctx, repo, t, facts, sw.path, name, problem.text)
440+ }
441+ _, err := c.launchRun(ctx, repo, t, facts, sw.path, string(sw.yaml), wf)
442+ return err
443+}
A internal/usecase/ci_tips.go +99 -0
@@ -0,0 +1,99 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strings"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// Who moves a branch or a tag (CI-3, D1; domain.CIRefTip). A scheduled run has
16+// no pusher of its own: like GitHub's, it is the run of the last commit of the
17+// default branch, and it takes the actor and the trust of whoever moved that
18+// branch to that commit. A dispatched run is capped by the trust of the ref it
19+// runs on. Without this a token limited to some branches could push the first
20+// branch of an empty repository (which becomes the default branch) with a cron
21+// workflow, and have it run as the owner with the owner's secrets.
22+//
23+// The record is written BEFORE the ref moves: by the pre-receive decision of a
24+// push (Pushes.Check) and by a merge before it updates the target, under the
25+// merge lock. Written after, a delayed write could leave an earlier pusher's
26+// record on a tip moved back to the same commit (owner A, agent B, agent A).
27+// Writing it is part of the decision: when it fails the push or the merge is
28+// refused, never let through unrecorded. A push that is refused later leaves a
29+// record that names an object the ref does not have, which is not trusted.
30+
31+// recordRefTips stores who is about to move the branches and tags of a push or a
32+// merge, and removes the records of the refs it deletes. actor is what the runs
33+// show, pusher and trusted are as for a push run. refs/pull/* is ermite's own
34+// and has none.
35+func recordRefTips(ctx context.Context, tips ports.CIRefTipStore, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) error {
36+ if tips == nil {
37+ return nil
38+ }
39+ for _, u := range updates {
40+ if !strings.HasPrefix(u.Ref, "refs/heads/") && !strings.HasPrefix(u.Ref, "refs/tags/") {
41+ continue
42+ }
43+ var err error
44+ if u.IsDelete() {
45+ err = tips.DeleteRefTip(ctx, repo, u.Ref)
46+ } else {
47+ err = tips.PutRefTip(ctx, domain.CIRefTip{Repo: repo, Ref: u.Ref, Sha: u.New, Actor: actor, Pusher: pusher, Trusted: trusted})
48+ }
49+ if errors.Is(err, domain.ErrNotFound) {
50+ continue // the repository is gone: nothing to record, nothing to run
51+ }
52+ if err != nil {
53+ return fmt.Errorf("record who pushes %s: %w", u.Ref, err)
54+ }
55+ }
56+ return nil
57+}
58+
59+// refTip returns the record of a ref when it names the object the ref has now;
60+// ok is false when there is none, it is for another object, or it cannot be
61+// read (logged): the caller then treats the ref as not trusted.
62+func (c *CI) refTip(ctx context.Context, repo domain.RepoName, ref string, objects ...domain.ObjectID) (domain.CIRefTip, bool) {
63+ if c.cfg.RefTips == nil {
64+ return domain.CIRefTip{}, false
65+ }
66+ t, err := c.cfg.RefTips.GetRefTip(ctx, repo, ref)
67+ if err != nil {
68+ if !errors.Is(err, domain.ErrNotFound) {
69+ ctxLog(ctx).Error().Err(err).Str("repo", repo.String()).Str("ref", ref).Msg("ci: read who pushed a ref: it is not trusted")
70+ }
71+ return domain.CIRefTip{}, false
72+ }
73+ for _, o := range objects {
74+ if !o.IsZero() && t.Sha == o.String() {
75+ return t, true
76+ }
77+ }
78+ return domain.CIRefTip{}, false
79+}
80+
81+// scheduleActor returns the actor, the pusher and the trust of a scheduled run
82+// on the tip of a branch: those of the record of the branch when it names this
83+// very commit; otherwise the owner's name, no pusher and no trust.
84+func (c *CI) scheduleActor(ctx context.Context, repo domain.RepoName, branch string, tip domain.ObjectID) (actor string, pusher domain.CIPusher, trusted bool) {
85+ if t, ok := c.refTip(ctx, repo, "refs/heads/"+branch, tip); ok {
86+ return t.Actor, t.Pusher, t.Trusted
87+ }
88+ return authorOf(domain.Owner()), domain.CIPusher{}, false
89+}
90+
91+// refTrusted reports whether the ref was set to exactly the pinned object by a
92+// trusted principal. The record must name the object the dispatch resolved (the
93+// commit, or the tag object of an annotated tag), which the dispatch captured
94+// together with the commit it runs: the refs are not read again here, so a tag
95+// retargeted since cannot lend its new record to the old commit.
96+func (c *CI) refTrusted(ctx context.Context, repo domain.RepoName, pin pinnedRef) bool {
97+ t, ok := c.refTip(ctx, repo, pin.ref, pin.object)
98+ return ok && t.Trusted
99+}
M internal/usecase/ci_trigger.go +34 -10
@@ -54,6 +54,9 @@ type ciTrigger struct {
5454 // with GITHUB_TOKEN start no run): that is what stops a workflow that pushes
5555 // from running itself again.
5656 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
57+ // Whoever pushed, the workflow files of the default branch may have changed.
58+ // (Who pushed was recorded before the refs moved: Pushes.Check.)
59+ c.markSchedulesDirty(repo, updates)
5760 // The merge requests follow the push whoever made it (their refs); only a
5861 // push that is not a job's starts pull_request runs.
5962 defer c.onBranchesMoved(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher), !pusher.ViaJobToken())
@@ -72,6 +75,7 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
7275 // merging. For the same reason the run is trusted (gets secrets) only when the
7376 // merger is: the displayed actor is not the one that decides.
7477 func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {
78+ c.markSchedulesDirty(repo, updates)
7579 // The merge moved a target branch: the test merges of the other requests
7680 // into it are computed again (a merge starts no synchronize run), after the
7781 // push workflows are queued.
@@ -181,13 +185,19 @@ type pushFacts struct {
181185 // refs/pull/<n>/merge (or /head), commit the test merge commit (or the
182186 // head) and head the head commit.
183187 pull *pullFacts
188+ // custom is set for the events that are not a push or a merge request:
189+ // workflow_dispatch, schedule and workflow_run (ci_events.go).
190+ custom *customEvent
184191 }
185192
186193 // eventName is the event of the run, as the run records and github.event_name
187194 // show it.
188195 func (f pushFacts) eventName() string {
189- if f.pull != nil {
196+ switch {
197+ case f.pull != nil:
190198 return "pull_request"
199+ case f.custom != nil:
200+ return f.custom.name
191201 }
192202 return "push"
193203 }
@@ -195,8 +205,11 @@ func (f pushFacts) eventName() string {
195205 // runTitle is the title of a run without run-name: the commit message of a
196206 // push, the title of the merge request for a pull_request.
197207 func (f pushFacts) runTitle() string {
198- if f.pull != nil {
208+ switch {
209+ case f.pull != nil:
199210 return firstLine(f.pull.mr.Title.String())
211+ case f.custom != nil && f.custom.title != "":
212+ return firstLine(f.custom.title)
200213 }
201214 return firstLine(f.head.Message)
202215 }
@@ -469,6 +482,8 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
469482 return err
470483 }
471484 c.publishRun(ctx, repo, draft.Run)
485+ c.onRunEvent(ctx, draft.Run, runRequested)
486+ c.onRunEvent(ctx, draft.Run, runCompleted)
472487 log := logRun(ctx, draft.Run)
473488 log.Info().Str("workflow", wfPath).Msg("ci run failed: invalid workflow file")
474489 return nil
@@ -481,6 +496,13 @@ func (c *CI) publishRun(ctx context.Context, repo domain.Repo, run domain.CIRun)
481496 // startRun creates the run of a matching workflow with one waiting record per
482497 // job of the file, then plans the jobs that can start.
483498 func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error {
499+ _, err := c.launchRun(ctx, repo, t, f, wfPath, yaml, wf)
500+ return err
501+}
502+
503+// launchRun is startRun that also returns the run it created, as it was when
504+// created (the zero run when the repository or the merge request is gone).
505+func (c *CI) launchRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) (domain.CIRun, error) {
484506 now := c.now()
485507 name := workflowName(wf, wfPath)
486508 // The title of a run without run-name is the commit message for a branch.
@@ -498,7 +520,7 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
498520 }
499521 vars, err := c.varsContext(ctx, repo.Name)
500522 if err != nil {
501- return err
523+ return domain.CIRun{}, err
502524 }
503525 draft, err := c.createRun(ctx, f, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
504526 run := domain.CIRun{
@@ -527,18 +549,19 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
527549 return d
528550 })
529551 if errors.Is(err, domain.ErrNotFound) || errors.Is(err, errPullClosed) {
530- return nil
552+ return domain.CIRun{}, nil
531553 }
532554 if err != nil {
533- return err
555+ return domain.CIRun{}, err
534556 }
535557 c.publishRun(ctx, repo, draft.Run)
536558 for _, j := range draft.Jobs {
537559 c.cfg.Bus.Publish(ctx, domain.JobUpdated{Job: j, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
538560 }
561+ c.onRunEvent(ctx, draft.Run, runRequested)
539562 log := logRun(ctx, draft.Run)
540- log.Info().Str("workflow", wfPath).Str("ref", f.update.Ref).Int("jobs", len(draft.Jobs)).Msg("ci run created")
541- return c.advanceRun(ctx, draft.Run.ID)
563+ log.Info().Str("workflow", wfPath).Str("ref", f.update.Ref).Str("event", draft.Run.Event).Int("jobs", len(draft.Jobs)).Msg("ci run created")
564+ return draft.Run, c.advanceRun(ctx, draft.Run.ID)
542565 }
543566
544567 // workflowName is the name of a workflow, the path of its file when it has no
@@ -620,7 +643,7 @@ func (c *CI) evalRunName(ctx context.Context, wf *workflow.Workflow, githubJSON
620643 return "", false
621644 }
622645 env := template.Env{Contexts: map[string]expr.Value{
623- "github": gh, "inputs": expr.NewDictionary(), "vars": vars,
646+ "github": gh, "inputs": eventInputs(wf, githubJSON), "vars": vars,
624647 }}
625648 name, err := template.EvaluateString(ctx, wf.RunName, env)
626649 if err != nil {
@@ -652,7 +675,8 @@ func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f
652675 return matrixIssue{} // nothing to expand: no context to build
653676 }
654677 probe := domain.CIRun{Repo: repo.Name, Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, WorkflowPath: wfPath, WorkflowName: wf.Name, CreatedAt: c.now()}
655- gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f)))
678+ ghJSON := c.githubJSON(ctx, repo, probe, f)
679+ gh, err := expr.FromJSON([]byte(ghJSON))
656680 if err != nil {
657681 return matrixIssue{}
658682 }
@@ -663,7 +687,7 @@ func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f
663687 vars = expr.NewDictionary()
664688 }
665689 env := template.Env{Contexts: map[string]expr.Value{
666- "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": vars,
690+ "github": gh, "needs": expr.NewDictionary(), "inputs": eventInputs(wf, ghJSON), "vars": vars,
667691 }}
668692 for _, j := range wf.Jobs {
669693 _, err := j.EvaluateStrategy(ctx, env)
M internal/usecase/ci_watch.go +7 -0
@@ -307,6 +307,13 @@ func (c *CI) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error)
307307 return c.cfg.Store.RunIDs(ctx, repo)
308308 }
309309
310+// ForgetRepo drops what CI holds in memory for a repository that was deleted:
311+// its cached schedules, so that it is never scheduled again (D12).
312+func (c *CI) ForgetRepo(repo domain.RepoName) {
313+ c.sched.drop(repo)
314+ c.sched.forgetDirty(repo)
315+}
316+
310317 // RemoveLogs deletes the logs and the summaries of runs of a repository that
311318 // was deleted.
312319 func (c *CI) RemoveLogs(ctx context.Context, runIDs []int64) error {
A internal/usecase/ci_workflowrun.go +252 -0
@@ -0,0 +1,252 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "runtime/debug"
11+ "slices"
12+ "strings"
13+
14+ "github.com/bnema/zerowrap"
15+
16+ "bnema.dev/ermite-actions/workflow"
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+// The workflow_run event (CI-3, D5). A run that is created ("requested") or
21+// completed ("completed") triggers the workflows of the default branch that list
22+// its workflow by name under workflow_run.workflows.
23+//
24+// The triggered run is read from the default branch's latest commit and runs on
25+// it. It inherits the actor, the pusher and the trusted flag of the triggering
26+// run, and never gets more: an agent's untrusted run cannot launder secrets
27+// through a workflow_run (D1). A job token's push starts no run, but a run
28+// started by a push of anyone else triggers workflow_run workflows even when the
29+// triggering run was itself a workflow_run, up to the chain limit.
30+const (
31+ // maxWorkflowRunDepth is the number of workflow_run levels in a chain:
32+ // docs.github.com ("Events that trigger workflows", workflow_run): "You can't
33+ // use workflow_run to chain together more than three levels of workflows"
34+ // (A -> B -> C -> D runs, E does not). Recalled from memory, not fetched.
35+ maxWorkflowRunDepth = 3
36+ runRequested = "requested"
37+ runCompleted = "completed"
38+)
39+
40+// runEvent is a change of a run that workflow_run workflows may be waiting for.
41+type runEvent struct {
42+ run domain.CIRun
43+ action string
44+ log zerowrap.Logger
45+}
46+
47+// onRunEvent queues the "requested" or "completed" event of a run and returns:
48+// a worker handles it, so the run's lock is never held while workflows are read.
49+// A full queue drops it, logged, like the queue of pushes.
50+func (c *CI) onRunEvent(ctx context.Context, run domain.CIRun, action string) {
51+ c.mu.Lock()
52+ closed := c.closed
53+ c.mu.Unlock()
54+ if closed {
55+ return
56+ }
57+ e := runEvent{run: run, action: action, log: zerowrap.FromCtx(ctx)}
58+ select {
59+ case c.runEvents <- e:
60+ default:
61+ e.log.Error().Int64("run_id", run.ID).Str("action", action).Msg("ci workflow_run queue is full: the workflows waiting for this run are not run")
62+ }
63+}
64+
65+func (c *CI) runEventLoop() {
66+ defer c.wg.Done()
67+ for {
68+ select {
69+ case <-c.ctx.Done():
70+ return
71+ case e := <-c.runEvents:
72+ c.planRunEvent(e)
73+ }
74+ }
75+}
76+
77+// planRunEvent handles one event; a panic or an error is logged and never
78+// escapes.
79+func (c *CI) planRunEvent(e runEvent) {
80+ ctx := zerowrap.WithCtx(c.ctx, e.log)
81+ defer func() {
82+ if r := recover(); r != nil {
83+ e.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Int64("run_id", e.run.ID).Msg("ci workflow_run panicked")
84+ }
85+ if c.cfg.RunEventHandled != nil {
86+ c.cfg.RunEventHandled(e.run.ID, e.action)
87+ }
88+ }()
89+ if err := c.handleRunEvent(ctx, e); err != nil && ctx.Err() == nil {
90+ e.log.Error().Err(err).Int64("run_id", e.run.ID).Str("action", e.action).Msg("ci workflow_run failed")
91+ }
92+}
93+
94+// runLinks are the two facts of the github context of a run that workflow_run
95+// reads back: the head branch of a merge request, and the run that triggered a
96+// workflow_run run.
97+type runLinks struct {
98+ HeadRef string `json:"head_ref"`
99+ Event struct {
100+ WorkflowRun struct {
101+ ID int64 `json:"id"`
102+ } `json:"workflow_run"`
103+ } `json:"event"`
104+}
105+
106+func (c *CI) runLinks(ctx context.Context, runID int64) (runLinks, domain.CIRunSource, error) {
107+ src, err := c.cfg.Store.GetRunSource(ctx, runID)
108+ if err != nil {
109+ return runLinks{}, src, err
110+ }
111+ var l runLinks
112+ if err := json.Unmarshal([]byte(src.GithubJSON), &l); err != nil {
113+ return runLinks{}, src, nil // a context that does not read: no link
114+ }
115+ return l, src, nil
116+}
117+
118+// headBranchOf is workflow_run.head_branch: the source branch of a merge
119+// request, else the branch or tag of the run.
120+func headBranchOf(run domain.CIRun, l runLinks) string {
121+ if l.HeadRef != "" {
122+ return l.HeadRef
123+ }
124+ return strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")
125+}
126+
127+// workflowRunDepth is the number of workflow_run levels above and including run:
128+// 0 for a run that is not a workflow_run run. The walk stops at a triggering
129+// run that cannot be found, which counts for nothing more.
130+func (c *CI) workflowRunDepth(ctx context.Context, run domain.CIRun) int {
131+ depth := 0
132+ for cur := run; cur.Event == eventWorkflowRun && depth <= maxWorkflowRunDepth; {
133+ depth++
134+ l, _, err := c.runLinks(ctx, cur.ID)
135+ if err != nil || l.Event.WorkflowRun.ID == 0 {
136+ break
137+ }
138+ parent, err := c.cfg.Store.GetRun(ctx, cur.Repo, l.Event.WorkflowRun.ID)
139+ if err != nil {
140+ break
141+ }
142+ cur = parent
143+ }
144+ return depth
145+}
146+
147+// matchWorkflowRun reports whether the workflow runs for the action of a run of
148+// the workflow called name on headBranch: the event is listed, the name is in
149+// "workflows", the action is one of its "types" (requested and completed when it
150+// lists none; ermite has no in_progress activity), and the branches or
151+// branches-ignore filter, which applies to the head branch of the triggering
152+// run, lets it through (the filters of the push event, under another name).
153+func matchWorkflowRun(wf *workflow.Workflow, name, action, headBranch string) bool {
154+ if wf == nil || wf.On == nil {
155+ return false
156+ }
157+ ev := wf.On.Event(eventWorkflowRun)
158+ if ev == nil || !slices.Contains(ev.Workflows, name) {
159+ return false
160+ }
161+ types := ev.Types
162+ if types == nil {
163+ types = []string{runRequested, runCompleted}
164+ }
165+ if !slices.Contains(types, action) {
166+ return false
167+ }
168+ as := *ev
169+ as.Name = "push"
170+ probe := &workflow.Workflow{On: &workflow.On{Events: []*workflow.Event{&as}}}
171+ return probe.MatchPush(workflow.PushInfo{Ref: "refs/heads/" + headBranch})
172+}
173+
174+// handleRunEvent starts the workflow_run workflows of the default branch that
175+// match the event.
176+func (c *CI) handleRunEvent(ctx context.Context, e runEvent) error {
177+ runners, err := c.cfg.Store.ListRunners(ctx)
178+ if err != nil {
179+ return fmt.Errorf("list the runners: %w", err)
180+ }
181+ if len(runners) == 0 {
182+ return nil
183+ }
184+ if c.workflowRunDepth(ctx, e.run) >= maxWorkflowRunDepth {
185+ e.log.Debug().Int64("run_id", e.run.ID).Msg("workflow_run chain limit reached: no run is triggered")
186+ return nil
187+ }
188+ repo, err := c.cfg.Meta.Get(ctx, e.run.Repo)
189+ if err != nil {
190+ return nil // deleted meanwhile
191+ }
192+ def, exists, err := c.cfg.Repos.DefaultBranch(ctx, repo.Name)
193+ if err != nil || !exists {
194+ return nil
195+ }
196+ tip, err := c.cfg.Git.BranchHead(ctx, repo.Name, def)
197+ if err != nil {
198+ return nil
199+ }
200+ files, err := c.workflowFiles(ctx, repo.Name, tip)
201+ if err != nil || len(files) == 0 {
202+ return err
203+ }
204+ links, src, err := c.runLinks(ctx, e.run.ID)
205+ if err != nil {
206+ return nil // the run is gone
207+ }
208+ head := headBranchOf(e.run, links)
209+
210+ var matching []workflowFile
211+ var parsed []*workflow.Workflow
212+ for _, f := range files {
213+ if f.problem != "" {
214+ continue
215+ }
216+ wf, perrs := workflow.Parse(f.path, f.content)
217+ if len(perrs) > 0 || !matchWorkflowRun(wf, e.run.WorkflowName, e.action, head) {
218+ continue
219+ }
220+ matching, parsed = append(matching, f), append(parsed, wf)
221+ }
222+ if len(matching) == 0 {
223+ return nil
224+ }
225+ commits, _, err := c.cfg.Git.ListRange(ctx, repo.Name, domain.ObjectID{}, tip, 1)
226+ if err != nil || len(commits) == 0 {
227+ return fmt.Errorf("read the head commit of %s: %w", def, err)
228+ }
229+ facts := pushFacts{
230+ update: domain.RefUpdate{Ref: "refs/heads/" + def, Old: domain.ZeroOID, New: tip.String()}, commit: tip, head: commits[0],
231+ defaultBranch: def,
232+ custom: &customEvent{name: eventWorkflowRun, triggered: &workflowRunFacts{run: e.run, action: e.action, headBranch: head}},
233+ }
234+ // The actor, the pusher and the trust of the triggering run, never more.
235+ t := ciTrigger{repo: repo.Name, actor: e.run.Actor, pusher: src.Pusher, trusted: e.run.Trusted, at: c.now(), log: e.log}
236+ var errs []error
237+ for i, f := range matching {
238+ wf := parsed[i]
239+ if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem.text != "" {
240+ name := f.path
241+ if problem.named {
242+ name = workflowName(wf, f.path)
243+ }
244+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, name, problem.text))
245+ continue
246+ }
247+ if _, err := c.launchRun(ctx, repo, t, facts, f.path, string(f.content), wf); err != nil && !errors.Is(err, context.Canceled) {
248+ errs = append(errs, err)
249+ }
250+ }
251+ return errors.Join(errs...)
252+}
A internal/usecase/ci_workflowrun_test.go +34 -0
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "bnema.dev/ermite/internal/usecase"
11+)
12+
13+func TestMatchWorkflowRun(t *testing.T) {
14+ def := parsePR(t, " workflow_run:\n workflows: [ci, 'Build and test']\n")
15+ assert.True(t, usecase.MatchWorkflowRun(def, "ci", "completed", "main"))
16+ assert.True(t, usecase.MatchWorkflowRun(def, "Build and test", "requested", "x/y"), "no types: both activities")
17+ assert.False(t, usecase.MatchWorkflowRun(def, "other", "completed", "main"), "the name must be listed")
18+ assert.False(t, usecase.MatchWorkflowRun(def, "CI", "completed", "main"), "names are exact")
19+ assert.False(t, usecase.MatchWorkflowRun(def, "ci", "in_progress", "main"), "ermite has no in_progress activity")
20+ assert.False(t, usecase.MatchWorkflowRun(parsePR(t, " push:\n"), "ci", "completed", "main"))
21+
22+ typed := parsePR(t, " workflow_run:\n workflows: [ci]\n types: [completed]\n")
23+ assert.True(t, usecase.MatchWorkflowRun(typed, "ci", "completed", "main"))
24+ assert.False(t, usecase.MatchWorkflowRun(typed, "ci", "requested", "main"))
25+
26+ branches := parsePR(t, " workflow_run:\n workflows: [ci]\n branches: [main, 'release/**']\n")
27+ assert.True(t, usecase.MatchWorkflowRun(branches, "ci", "completed", "main"))
28+ assert.True(t, usecase.MatchWorkflowRun(branches, "ci", "completed", "release/1.0"))
29+ assert.False(t, usecase.MatchWorkflowRun(branches, "ci", "completed", "feature"), "the head branch of the triggering run is filtered")
30+
31+ ignore := parsePR(t, " workflow_run:\n workflows: [ci]\n branches-ignore: [wip/**]\n")
32+ assert.True(t, usecase.MatchWorkflowRun(ignore, "ci", "completed", "main"))
33+ assert.False(t, usecase.MatchWorkflowRun(ignore, "ci", "completed", "wip/x"))
34+}
M internal/usecase/export_test.go +10 -0
@@ -66,6 +66,16 @@ func MatchPullRequest(wf *workflow.Workflow, action, base string, files []string
6666 return matchPullRequest(wf, action, base, files, truncated)
6767 }
6868
69+// MatchWorkflowRun exposes the workflow_run filter to the tests.
70+func MatchWorkflowRun(wf *workflow.Workflow, name, action, headBranch string) bool {
71+ return matchWorkflowRun(wf, name, action, headBranch)
72+}
73+
74+// ResolveDispatchInputs exposes resolveDispatchInputs to the tests.
75+func ResolveDispatchInputs(defs []workflow.Input, given map[string]string) (map[string]string, error) {
76+ return resolveDispatchInputs(defs, given)
77+}
78+
6979 // PullFilesLink exposes the Link header of the files endpoint to the tests.
7080 func PullFilesLink(base string, page, perPage, total int) string {
7181 return pullFilesLink(base, page, perPage, total)
M internal/usecase/mergerequests.go +22 -5
@@ -52,6 +52,7 @@ type MergeRequests struct {
5252 ci ports.WorkflowTrigger // nil: no CI
5353 checks ports.CIChecksReader // nil: no CI, so no check blocks a merge
5454 pulls ports.PullRequestTrigger // nil: no pull_request workflows
55+ tips ports.CIRefTipStore // nil: who merges is not recorded
5556
5657 mu sync.Mutex
5758 locks map[domain.RepoName]chan struct{}
@@ -87,13 +88,17 @@ type MergeRequestsConfig struct {
8788 // closed or merged (the push of its source branch reaches CI instead).
8889 // Optional.
8990 PullRequests ports.PullRequestTrigger
91+ // RefTips records who moves the target branch (and deletes the record of a
92+ // deleted source branch) before the merge does, as a push does
93+ // (domain.CIRefTip). Optional.
94+ RefTips ports.CIRefTipStore
9095 }
9196
9297 // NewMergeRequests builds the merge request use cases.
9398 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
9499 return &MergeRequests{
95100 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
96- committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests,
101+ committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips,
97102 locks: make(map[domain.RepoName]chan struct{}),
98103 }
99104 }
@@ -796,7 +801,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
796801 }
797802 }
798803
799- merged, err := m.moveTarget(ctx, mr, mode, tip, head)
804+ merged, err := m.moveTarget(ctx, mr, mode, tip, head, p)
800805 if err != nil {
801806 return domain.MergeRequest{}, false, err
802807 }
@@ -812,7 +817,12 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
812817 updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
813818 deleted := false
814819 if params.DeleteSource {
815- // The merge is done and recorded: a failure here keeps the branch.
820+ // The merge is done and recorded: a failure here keeps the branch. Who
821+ // pushed the source goes first (the branch is gone either way for a
822+ // schedule; a kept branch merely has no record: not trusted).
823+ if err := recordRefTips(ctx, m.tips, name, []domain.RefUpdate{{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}}, "", domain.CIPusher{}, false); err != nil {
824+ log.Warn().Err(err).Str("repo", name.String()).Str("branch", mr.Source).Msg("the record of the source branch was kept")
825+ }
816826 switch err := m.git.DeleteBranch(context.WithoutCancel(ctx), name, mr.Source, head); {
817827 case err != nil:
818828 log.Warn().Err(err).Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).
@@ -835,7 +845,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
835845 if m.ci != nil {
836846 // GitHub runs the "push" workflows of a merge, with the owner as actor;
837847 // the run is trusted with what the merging principal may read.
838- m.ci.OnMerge(ctx, name, updates[:1], p)
848+ m.ci.OnMerge(ctx, name, updates, p)
839849 }
840850 return mr, deleted, nil
841851 }
@@ -931,7 +941,7 @@ func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) {
931941
932942 // moveTarget moves the target branch from tip to the head (fast-forward) or to
933943 // a new signed squash commit, and returns the new tip.
934-func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) {
944+func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID, p domain.Principal) (domain.ObjectID, error) {
935945 newTip := head
936946 if mode == domain.MergeModeSquash {
937947 commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits)
@@ -954,6 +964,13 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
954964 return domain.ObjectID{}, err
955965 }
956966 }
967+ // The target is the merger's from the moment it moves: recorded before it
968+ // does, under the merge lock (domain.CIRefTip). The run shows the owner, as a
969+ // merge's push does on GitHub.
970+ if err := recordRefTips(ctx, m.tips, mr.Repo, []domain.RefUpdate{{Old: tip.String(), New: newTip.String(), Ref: "refs/heads/" + mr.Target}},
971+ authorOf(domain.Owner()), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
972+ return domain.ObjectID{}, err
973+ }
957974 err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)
958975 if errors.Is(err, domain.ErrConflict) {
959976 return domain.ObjectID{}, fmt.Errorf("%w: the target branch %q moved during the merge, try again", domain.ErrPreconditionFailed, mr.Target)
M internal/usecase/pushes.go +16 -0
@@ -30,6 +30,7 @@ type Pushes struct {
3030 fresh ports.RepoFreshener // nil: the reader needs no telling
3131 ci ports.WorkflowTrigger // nil: no CI
3232 wf ports.WorkflowGuard // nil: pushes by job tokens are refused
33+ tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
3334 }
3435
3536 var (
@@ -58,6 +59,14 @@ func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
5859 return u
5960 }
6061
62+// WithRefTips makes Check record who is about to move each branch and tag
63+// (domain.CIRefTip), before Git moves it. Call it before the first push; it
64+// returns u.
65+func (u *Pushes) WithRefTips(t ports.CIRefTipStore) *Pushes {
66+ u.tips = t
67+ return u
68+}
69+
6170 // WithWorkflowGuard lets Check tell whether a push changes workflow files, which
6271 // a push by a job token may not do. Without it a push by a job token is
6372 // refused, as it cannot be judged. Call it before the first push; it returns u.
@@ -166,6 +175,13 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
166175 return err
167176 }
168177 }
178+ // Last, once the push is accepted: who pushes the branches and tags is
179+ // recorded before Git moves them, so that a tip never has the record of an
180+ // earlier pusher at the same commit (domain.CIRefTip). A record that cannot
181+ // be written refuses the push, as it cannot be run later with a known trust.
182+ if err := recordRefTips(ctx, u.tips, name, updates, authorOf(p), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
183+ return err
184+ }
169185 log := zerowrap.FromCtx(ctx)
170186 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
171187 return nil
M internal/usecase/repos_write.go +3 -0
@@ -203,6 +203,9 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo
203203 if err := r.meta.Delete(ctx, name); err != nil {
204204 return fmt.Errorf("delete metadata of %q: %w", name, err)
205205 }
206+ if r.ci != nil {
207+ r.ci.ForgetRepo(name)
208+ }
206209 if len(runIDs) > 0 {
207210 if err := r.ci.RemoveLogs(context.WithoutCancel(ctx), runIDs); err != nil {
208211 log.Warn().Err(err).Str("repo", name.String()).Msg("remove the CI logs of the repository; the sweep will")
M proto/ermite/storage/ci.proto +15 -0
@@ -416,6 +416,21 @@ message CIConcurrencyRecord {
416416 CIConcurrencyEntry pending = 2;
417417 }
418418
419+// CIRefTip is stored under "cireftip/<repo>/<full ref name>" (a branch or a
420+// tag): who moves the ref to the object sha, so that a scheduled or dispatched
421+// run on it takes their actor and trust (domain.CIRefTip). It is written before
422+// the ref moves, and names the object it is for: a record of another object
423+// than the ref's is not valid.
424+message CIRefTip {
425+ string sha = 1;
426+ // The actor the run shows ("owner", "root" or a token label).
427+ string actor = 2;
428+ bool trusted = 3;
429+ // As CIRunSource: "root", "owner", "token" or empty.
430+ string pusher_kind = 4;
431+ string pusher_token_id = 5;
432+}
433+
419434 // CIConcurrencyMark is stored under
420435 // "concurmark/<repo>/<escaped key(group)>/<run:020d>/<attempt:010d>/<job id or ->":
421436 // where an entry stands in its group. It is written in the same batch as the

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close