detached 3690ef9

feat(ci): match GitHub's github and job contexts, push payload and run titles

Close the differences found by the first conformance compare against the
github.com recordings (ermite-conformance).

github context: add actor_id, repository_id, repository_owner_id,
repository_visibility, secret_source and artifact_cache_size_limit; send the
keys in GitHub's order. Ids are stable numbers derived from the login and
owner/repo; visibility comes from the repository.

event payload (push): repository has GitHub's 83 keys and sender 19 (real,
derived or constant per key, documented in ci_github.go); commits and
head_commit lose added/removed/modified and author/committer username, as in
the recordings. A tag event carries the commits the default branch lacks. The
compare URL of several new commits is <first>^...<last>.

URLs: commit, compare, html_url and clone_url use <server>/<owner>/<repo>,
repositoryUrl is git://<host>/<owner>/<repo>.git, api_url is the API base and
graphql_url <base>/graphql. The web redirects GET/HEAD /<owner>/<repo>[...]
to /<repo>[...] when the owner is ERMITE_OWNER and the path is no page.

job context: the server sends check_run_id, workflow_ref, workflow_sha,
workflow_repository and workflow_file_path in contexts.job (the runner adds
status and copies the keys). matrix is null for a job without a matrix.
github.job is empty while a job is evaluated, as on github.com.

run title: Run.title is the evaluated run-name (github, inputs, vars), else the
first line of the head commit message.

planner: step continue-on-error is no longer refused (the runner runs it).
Job-level continue-on-error is evaluated at planning: the run does not fail,
fail-fast does not cancel the siblings and needs.<job>.result is success; the
job keeps its failure conclusion. An over-256 matrix is an invalid workflow
(failed run, no job). step timeout-minutes stays refused: the runner has none.
commit
3690ef935351c1cc6f16d25040955f84ed7867ce
author
brice · · 2026-10-12 23:44 UTC
parent
45bfc5b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +1278 -117
M gen/ermite/storage/ci.pb.go +17 -5 generated file: collapsed
M internal/adapters/kvstore/ci.go +2 -2
@@ -319,7 +319,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
319319 Isolation: encodeIsolation(j.Isolation), Steps: steps,
320320 CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt),
321321 Index: int32(j.Index), Combo: int32(j.Combo), MatrixJson: j.MatrixJSON, ContextsJson: j.ContextsJSON, ComboTotal: int32(j.ComboTotal), //nolint:gosec // bounded by the 256 job limit
322- TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning
322+ TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, ContinueOnError: j.ContinueOnError, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning
323323 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
324324 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
325325 TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,
@@ -348,7 +348,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) {
348348 Isolation: decodeIsolation(pb.GetIsolation()),
349349 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
350350 Index: int(pb.GetIndex()), Combo: int(pb.GetCombo()), Planned: pb.GetPlanned(), MatrixJSON: pb.GetMatrixJson(), ContextsJSON: pb.GetContextsJson(),
351- ComboTotal: int(pb.GetComboTotal()), TimeoutMinutes: pb.GetTimeoutMinutes(), FailFast: pb.GetFailFast(),
351+ ComboTotal: int(pb.GetComboTotal()), TimeoutMinutes: pb.GetTimeoutMinutes(), FailFast: pb.GetFailFast(), ContinueOnError: pb.GetContinueOnError(),
352352 MaxParallel: int(pb.GetMaxParallel()), Outputs: pb.GetOutputs(),
353353 TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()),
354354 AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()),
@@ -319,7 +319,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
319 Isolation: encodeIsolation(j.Isolation), Steps: steps,319 Isolation: encodeIsolation(j.Isolation), Steps: steps,
320 CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt),320 CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt),
321 Index: int32(j.Index), Combo: int32(j.Combo), MatrixJson: j.MatrixJSON, ContextsJson: j.ContextsJSON, ComboTotal: int32(j.ComboTotal), //nolint:gosec // bounded by the 256 job limit321 Index: int32(j.Index), Combo: int32(j.Combo), MatrixJson: j.MatrixJSON, ContextsJson: j.ContextsJSON, ComboTotal: int32(j.ComboTotal), //nolint:gosec // bounded by the 256 job limit
322- TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning322+ TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, ContinueOnError: j.ContinueOnError, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning
323 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),323 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
324 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3324 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
325 TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,325 TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,
@@ -348,7 +348,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) {
348 Isolation: decodeIsolation(pb.GetIsolation()),348 Isolation: decodeIsolation(pb.GetIsolation()),
349 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),349 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
350 Index: int(pb.GetIndex()), Combo: int(pb.GetCombo()), Planned: pb.GetPlanned(), MatrixJSON: pb.GetMatrixJson(), ContextsJSON: pb.GetContextsJson(),350 Index: int(pb.GetIndex()), Combo: int(pb.GetCombo()), Planned: pb.GetPlanned(), MatrixJSON: pb.GetMatrixJson(), ContextsJSON: pb.GetContextsJson(),
351- ComboTotal: int(pb.GetComboTotal()), TimeoutMinutes: pb.GetTimeoutMinutes(), FailFast: pb.GetFailFast(),351+ ComboTotal: int(pb.GetComboTotal()), TimeoutMinutes: pb.GetTimeoutMinutes(), FailFast: pb.GetFailFast(), ContinueOnError: pb.GetContinueOnError(),
352 MaxParallel: int(pb.GetMaxParallel()), Outputs: pb.GetOutputs(),352 MaxParallel: int(pb.GetMaxParallel()), Outputs: pb.GetOutputs(),
353 TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()),353 TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()),
354 AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()),354 AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()),
M internal/adapters/web/web.go +39 -1
@@ -65,6 +65,10 @@ type Config struct {
6565 // Heartbeat is how often an idle event stream gets a comment line;
6666 // DefaultHeartbeat when zero.
6767 Heartbeat time.Duration
68+ // Owner is the owner login the GitHub-shaped URLs of CI show
69+ // (<server>/<owner>/<repo>/...). A GET of such a URL that is not a page
70+ // by itself is redirected to /<repo>/.... Empty: no redirect.
71+ Owner string
6872 // PublicURL is the base URL of the instance, without a trailing slash:
6973 // the clone URLs are built on it, and its host is the instance name.
7074 PublicURL string
@@ -184,7 +188,7 @@ func (s *Server) routes() *http.ServeMux {
184188 mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false)))
185189 mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true)))
186190 }
187- mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
191+ mux.HandleFunc(catchAll, s.page(s.notFound))
188192 return mux
189193 }
190194
@@ -197,9 +201,43 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
197201 if !s.gate(w, r) {
198202 return
199203 }
204+ if to, ok := s.ownerRedirect(r); ok {
205+ http.Redirect(w, r, to, http.StatusPermanentRedirect)
206+ return
207+ }
200208 s.mux.ServeHTTP(w, r)
201209 }
202210
211+// catchAll is the pattern of the paths that are no page.
212+const catchAll = "GET /{repo}/{rest...}"
213+
214+// ownerRedirect maps /<owner>/<repo>[.git][/rest] to /<repo>[.git][/rest]: the
215+// URLs that the github context of CI shows (html_url, commit and compare
216+// URLs, clone_url) have the owner of the instance in front, like GitHub's. A
217+// path is only redirected when it would be a 404 otherwise, so that a
218+// repository named like the owner keeps its own pages.
219+func (s *Server) ownerRedirect(r *http.Request) (string, bool) {
220+ if s.cfg.Owner == "" || (r.Method != http.MethodGet && r.Method != http.MethodHead) {
221+ return "", false
222+ }
223+ first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
224+ if !ok || first != s.cfg.Owner || rest == "" {
225+ return "", false
226+ }
227+ repo, _, _ := strings.Cut(rest, "/")
228+ if _, err := domain.ParseRepoName(strings.TrimSuffix(repo, ".git")); err != nil {
229+ return "", false
230+ }
231+ if _, pattern := s.mux.Handler(r); pattern != catchAll {
232+ return "", false
233+ }
234+ to := "/" + rest
235+ if r.URL.RawQuery != "" {
236+ to += "?" + r.URL.RawQuery
237+ }
238+ return to, true
239+}
240+
203241 // handler builds a page. It returns the component and its status, or an
204242 // error, which becomes the 404 or 500 page.
205243 type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)
@@ -65,6 +65,10 @@ type Config struct {
65 // Heartbeat is how often an idle event stream gets a comment line;65 // Heartbeat is how often an idle event stream gets a comment line;
66 // DefaultHeartbeat when zero.66 // DefaultHeartbeat when zero.
67 Heartbeat time.Duration67 Heartbeat time.Duration
68+ // Owner is the owner login the GitHub-shaped URLs of CI show
69+ // (<server>/<owner>/<repo>/...). A GET of such a URL that is not a page
70+ // by itself is redirected to /<repo>/.... Empty: no redirect.
71+ Owner string
68 // PublicURL is the base URL of the instance, without a trailing slash:72 // PublicURL is the base URL of the instance, without a trailing slash:
69 // the clone URLs are built on it, and its host is the instance name.73 // the clone URLs are built on it, and its host is the instance name.
70 PublicURL string74 PublicURL string
@@ -184,7 +188,7 @@ func (s *Server) routes() *http.ServeMux {
184 mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false)))188 mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false)))
185 mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true)))189 mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true)))
186 }190 }
187- mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))191+ mux.HandleFunc(catchAll, s.page(s.notFound))
188 return mux192 return mux
189 }193 }
190 194
@@ -197,9 +201,43 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
197 if !s.gate(w, r) {201 if !s.gate(w, r) {
198 return202 return
199 }203 }
204+ if to, ok := s.ownerRedirect(r); ok {
205+ http.Redirect(w, r, to, http.StatusPermanentRedirect)
206+ return
207+ }
200 s.mux.ServeHTTP(w, r)208 s.mux.ServeHTTP(w, r)
201 }209 }
202 210
211+// catchAll is the pattern of the paths that are no page.
212+const catchAll = "GET /{repo}/{rest...}"
213+
214+// ownerRedirect maps /<owner>/<repo>[.git][/rest] to /<repo>[.git][/rest]: the
215+// URLs that the github context of CI shows (html_url, commit and compare
216+// URLs, clone_url) have the owner of the instance in front, like GitHub's. A
217+// path is only redirected when it would be a 404 otherwise, so that a
218+// repository named like the owner keeps its own pages.
219+func (s *Server) ownerRedirect(r *http.Request) (string, bool) {
220+ if s.cfg.Owner == "" || (r.Method != http.MethodGet && r.Method != http.MethodHead) {
221+ return "", false
222+ }
223+ first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
224+ if !ok || first != s.cfg.Owner || rest == "" {
225+ return "", false
226+ }
227+ repo, _, _ := strings.Cut(rest, "/")
228+ if _, err := domain.ParseRepoName(strings.TrimSuffix(repo, ".git")); err != nil {
229+ return "", false
230+ }
231+ if _, pattern := s.mux.Handler(r); pattern != catchAll {
232+ return "", false
233+ }
234+ to := "/" + rest
235+ if r.URL.RawQuery != "" {
236+ to += "?" + r.URL.RawQuery
237+ }
238+ return to, true
239+}
240+
203 // handler builds a page. It returns the component and its status, or an241 // handler builds a page. It returns the component and its status, or an
204 // error, which becomes the 404 or 500 page.242 // error, which becomes the 404 or 500 page.
205 type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)243 type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)
M internal/app/ci_flow_test.go +2 -2
@@ -668,7 +668,7 @@ func TestCIGithubContextOfAPush(t *testing.T) {
668668 }
669669
670670 gh, event := ev()
671- assert.Equal(t, publicOrigin+"/proj/commit/"+first[:12], event["compare"], "a created ref compares as its commit")
671+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+first[:12], event["compare"], "a created ref compares as its commit")
672672 assert.Equal(t, true, event["created"])
673673 assert.Equal(t, domain.ZeroOID, event["before"])
674674 repo := event["repository"].(map[string]any)
@@ -681,6 +681,6 @@ func TestCIGithubContextOfAPush(t *testing.T) {
681681
682682 second := c.push("main", map[string]string{"a.txt": "a\n"}, "")
683683 _, event = ev()
684- assert.Equal(t, publicOrigin+"/proj/compare/"+first[:12]+"..."+second[:12], event["compare"])
684+ assert.Equal(t, publicOrigin+"/owner/proj/compare/"+first[:12]+"..."+second[:12], event["compare"])
685685 assert.Equal(t, false, event["created"])
686686 }
@@ -668,7 +668,7 @@ func TestCIGithubContextOfAPush(t *testing.T) {
668 }668 }
669 669
670 gh, event := ev()670 gh, event := ev()
671- assert.Equal(t, publicOrigin+"/proj/commit/"+first[:12], event["compare"], "a created ref compares as its commit")671+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+first[:12], event["compare"], "a created ref compares as its commit")
672 assert.Equal(t, true, event["created"])672 assert.Equal(t, true, event["created"])
673 assert.Equal(t, domain.ZeroOID, event["before"])673 assert.Equal(t, domain.ZeroOID, event["before"])
674 repo := event["repository"].(map[string]any)674 repo := event["repository"].(map[string]any)
@@ -681,6 +681,6 @@ func TestCIGithubContextOfAPush(t *testing.T) {
681 681
682 second := c.push("main", map[string]string{"a.txt": "a\n"}, "")682 second := c.push("main", map[string]string{"a.txt": "a\n"}, "")
683 _, event = ev()683 _, event = ev()
684- assert.Equal(t, publicOrigin+"/proj/compare/"+first[:12]+"..."+second[:12], event["compare"])684+ assert.Equal(t, publicOrigin+"/owner/proj/compare/"+first[:12]+"..."+second[:12], event["compare"])
685 assert.Equal(t, false, event["created"])685 assert.Equal(t, false, event["created"])
686 }686 }
A internal/app/ci_parity_test.go +467 -0
@@ -0,0 +1,467 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "encoding/json"
7+ "net/http"
8+ "os"
9+ "slices"
10+ "strings"
11+ "testing"
12+
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ pb "bnema.dev/ermite-api/gen/ermite"
18+)
19+
20+// conformance is testdata/conformance/ctx-job-runner.contexts.json: the
21+// contexts github.com gave to the ctx-job-runner scenario of ermite-conformance
22+// (see its _source).
23+type conformance struct {
24+ GithubKeyOrder []string `json:"_github_key_order"`
25+ JobKeyOrder []string `json:"_job_key_order"`
26+ RunnerOwned []string `json:"_runner_owned_github_keys"`
27+ Github map[string]any `json:"github"`
28+ Job map[string]any `json:"job"`
29+ Matrix any `json:"matrix"`
30+ Strategy map[string]any `json:"strategy"`
31+}
32+
33+func loadConformance(t *testing.T) conformance {
34+ t.Helper()
35+ b, err := os.ReadFile("testdata/conformance/ctx-job-runner.contexts.json")
36+ require.NoError(t, err)
37+ var c conformance
38+ require.NoError(t, json.Unmarshal(b, &c))
39+ return c
40+}
41+
42+// numericPlaceholders are the placeholders of the normaliser of
43+// ermite-conformance for values that are numbers (or times) on github.com: ids,
44+// times and the repository size. In the raw bundle of the same scenario id,
45+// created_at, pushed_at and size are JSON numbers; the fixture only has the
46+// placeholder, a string. They are matched by "number or string".
47+var numericPlaceholders = []string{"<id:", "<time>", "<size>", "<actor_id:", "<repository_id:", "<repository_owner_id:", "<check_run_id:"}
48+
49+// shape is the type of each value of an object. A placeholder of a number
50+// has the type "scalar".
51+func shape(m map[string]any) map[string]string {
52+ out := map[string]string{}
53+ for k, v := range m {
54+ if s, ok := v.(string); ok && slices.ContainsFunc(numericPlaceholders, func(p string) bool { return strings.HasPrefix(s, p) }) {
55+ out[k] = "scalar"
56+ continue
57+ }
58+ switch v.(type) {
59+ case nil:
60+ out[k] = "null"
61+ case string:
62+ out[k] = "string"
63+ case float64:
64+ out[k] = "number"
65+ case bool:
66+ out[k] = "bool"
67+ case []any:
68+ out[k] = "array"
69+ case map[string]any:
70+ out[k] = "object"
71+ }
72+ }
73+ return out
74+}
75+
76+// orderedKeys returns the keys of a JSON object in document order.
77+func orderedKeys(t *testing.T, raw json.RawMessage) []string {
78+ t.Helper()
79+ dec := json.NewDecoder(strings.NewReader(string(raw)))
80+ tok, err := dec.Token()
81+ require.NoError(t, err)
82+ require.Equal(t, json.Delim('{'), tok)
83+ var keys []string
84+ for dec.More() {
85+ k, err := dec.Token()
86+ require.NoError(t, err)
87+ keys = append(keys, k.(string))
88+ var skip json.RawMessage
89+ require.NoError(t, dec.Decode(&skip))
90+ }
91+ return keys
92+}
93+
94+// sameShape compares the shape of a recorded object with ours: a "scalar"
95+// (numeric placeholder) accepts a number or a string. The description is a
96+// property of the recorded repository, which ermite's test repository lacks.
97+func sameShape(t *testing.T, want, got map[string]any, what string) {
98+ t.Helper()
99+ ws, gs := shape(want), shape(got)
100+ for k, wt := range ws {
101+ if k == "description" {
102+ continue
103+ }
104+ if wt == "scalar" {
105+ assert.Contains(t, []string{"number", "string"}, gs[k], "%s.%s", what, k)
106+ continue
107+ }
108+ assert.Equal(t, wt, gs[k], "%s.%s", what, k)
109+ }
110+}
111+
112+// The github and job contexts of a push have the keys, the key order and the
113+// value types of the ones github.com recorded for the same scenario shape
114+// (ermite-conformance, ctx-job-runner), apart from the keys the runner adds.
115+func TestCIContextsMatchTheGitHubRecording(t *testing.T) {
116+ want := loadConformance(t)
117+ c := newCIHarness(t, nil)
118+ runner := c.registerRunner("alpha", "ubuntu-latest")
119+ c.push("main", map[string]string{".github/workflows/ci.yml": workflowWith("branches: [main]")}, "")
120+ job := runner.await()
121+ var raw map[string]json.RawMessage
122+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &raw))
123+ var ctxs map[string]map[string]any
124+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
125+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
126+
127+ t.Run("github", func(t *testing.T) {
128+ gh := ctxs["github"]
129+ // The runner owns some keys and adds job and token; the server sends the rest.
130+ serverKeys := slices.DeleteFunc(slices.Clone(want.GithubKeyOrder), func(k string) bool { return slices.Contains(want.RunnerOwned, k) })
131+ assert.Equal(t, serverKeys, orderedKeys(t, raw["github"]), "key set and order")
132+ serverGithub := map[string]any{}
133+ for k, v := range want.Github {
134+ if !slices.Contains(want.RunnerOwned, k) {
135+ serverGithub[k] = v
136+ }
137+ }
138+ sameShape(t, serverGithub, gh, "github")
139+ assert.Equal(t, "Actions", gh["secret_source"])
140+ assert.Equal(t, "10", gh["artifact_cache_size_limit"])
141+ assert.Equal(t, "private", gh["repository_visibility"], "from the repository")
142+ assert.Equal(t, "owner", gh["actor"])
143+ assert.Equal(t, "owner", gh["repository_owner"])
144+ assert.Regexp(t, `^[1-9][0-9]*$`, gh["actor_id"])
145+ assert.Regexp(t, `^[1-9][0-9]*$`, gh["repository_id"])
146+ assert.Regexp(t, `^[1-9][0-9]*$`, gh["repository_owner_id"])
147+ assert.Equal(t, gh["actor_id"], gh["repository_owner_id"], "the owner pushed")
148+ assert.Equal(t, "git://example.test/owner/proj.git", gh["repositoryUrl"], "git://<host>/<owner>/<repo>.git, as GitHub's")
149+ })
150+
151+ t.Run("event", func(t *testing.T) {
152+ ev := ctxs["github"]["event"].(map[string]any)
153+ wev := want.Github["event"].(map[string]any)
154+ assert.ElementsMatch(t, keysSlice(wev), keysSlice(ev), "event keys")
155+ sameShape(t, wev, ev, "event")
156+ repo, wrepo := ev["repository"].(map[string]any), wev["repository"].(map[string]any)
157+ assert.Len(t, wrepo, 83, "the recording has 83 repository keys")
158+ assert.ElementsMatch(t, keysSlice(wrepo), keysSlice(repo), "repository keys")
159+ sameShape(t, wrepo, repo, "event.repository")
160+ assert.ElementsMatch(t, keysSlice(wrepo["owner"].(map[string]any)), keysSlice(repo["owner"].(map[string]any)), "repository.owner keys")
161+ sender, wsender := ev["sender"].(map[string]any), wev["sender"].(map[string]any)
162+ assert.Len(t, wsender, 19)
163+ assert.ElementsMatch(t, keysSlice(wsender), keysSlice(sender), "sender keys")
164+ sameShape(t, wsender, sender, "event.sender")
165+ assert.ElementsMatch(t, keysSlice(wev["pusher"].(map[string]any)), keysSlice(ev["pusher"].(map[string]any)))
166+ for _, got := range []map[string]any{ev["head_commit"].(map[string]any), ev["commits"].([]any)[0].(map[string]any)} {
167+ wc := wev["head_commit"].(map[string]any)
168+ assert.ElementsMatch(t, keysSlice(wc), keysSlice(got), "commit keys: no added/removed/modified/username")
169+ assert.ElementsMatch(t, keysSlice(wc["author"].(map[string]any)), keysSlice(got["author"].(map[string]any)))
170+ assert.ElementsMatch(t, keysSlice(wc["committer"].(map[string]any)), keysSlice(got["committer"].(map[string]any)))
171+ assert.True(t, strings.HasPrefix(got["url"].(string), publicOrigin+"/owner/proj/commit/"), got["url"])
172+ }
173+ assert.Equal(t, "proj", repo["name"])
174+ assert.Equal(t, "owner/proj", repo["full_name"])
175+ assert.Equal(t, true, repo["private"])
176+ assert.Equal(t, "private", repo["visibility"])
177+ assert.Equal(t, "main", repo["default_branch"])
178+ assert.Equal(t, ctxs["github"]["repository_id"], jsonNumber(repo["id"]), "repository_id is repository.id")
179+ assert.Equal(t, ctxs["github"]["actor_id"], jsonNumber(sender["id"]))
180+ assert.Equal(t, ctxs["github"]["repository_owner_id"], jsonNumber(repo["owner"].(map[string]any)["id"]))
181+ })
182+
183+ t.Run("job", func(t *testing.T) {
184+ j := ctxs["job"]
185+ // The runner adds status first; the server sends the rest, in GitHub's order.
186+ serverKeys := slices.DeleteFunc(slices.Clone(want.JobKeyOrder), func(k string) bool { return k == "status" })
187+ assert.Equal(t, serverKeys, orderedKeys(t, raw["job"]))
188+ serverJob := map[string]any{}
189+ for k, v := range want.Job {
190+ if k != "status" {
191+ serverJob[k] = v
192+ }
193+ }
194+ sameShape(t, serverJob, j, "job")
195+ assert.Equal(t, ".github/workflows/ci.yml", j["workflow_file_path"])
196+ assert.Equal(t, "owner/proj", j["workflow_repository"])
197+ assert.Equal(t, "owner/proj/.github/workflows/ci.yml@refs/heads/main", j["workflow_ref"])
198+ assert.Equal(t, ctxs["github"]["sha"], j["workflow_sha"])
199+ assert.Equal(t, ctxs["github"]["workflow_ref"], j["workflow_ref"])
200+ })
201+
202+ t.Run("matrix is null without a matrix", func(t *testing.T) {
203+ assert.Equal(t, "null", string(raw["matrix"]))
204+ assert.Nil(t, want.Matrix)
205+ assert.ElementsMatch(t, keysSlice(want.Strategy), keysSlice(ctxs["strategy"]))
206+ })
207+}
208+
209+func keysSlice(m map[string]any) []string {
210+ out := make([]string, 0, len(m))
211+ for k := range m {
212+ out = append(out, k)
213+ }
214+ return out
215+}
216+
217+func jsonNumber(v any) string {
218+ b, _ := json.Marshal(v)
219+ return string(b)
220+}
221+
222+// The run title is the evaluated run-name, else the head commit message.
223+func TestCIRunTitle(t *testing.T) {
224+ c := newCIHarness(t, nil)
225+ c.registerRunner("alpha", "ubuntu-latest")
226+ c.push("main", map[string]string{".github/workflows/named.yml": `name: Named
227+run-name: "run ${{ github.event_name }} on ${{ github.ref_type }} by ${{ github.actor == '' && 'nobody' || 'someone' }}"
228+on: push
229+jobs:
230+ j:
231+ runs-on: ubuntu-latest
232+ steps:
233+ - run: echo hi
234+`, ".github/workflows/plain.yml": workflowWith("branches: [main]")}, "the head commit\n\nbody")
235+ c.waitFor("two runs", func() bool { return len(c.runs()) == 2 })
236+ titles := map[string]string{}
237+ for _, r := range c.runs() {
238+ titles[r.GetWorkflowPath()] = r.GetTitle()
239+ }
240+ assert.Equal(t, map[string]string{
241+ ".github/workflows/named.yml": "run push on branch by someone",
242+ ".github/workflows/plain.yml": "the head commit",
243+ }, titles)
244+}
245+
246+// An over-256 matrix is an invalid workflow, like on github.com
247+// (matrix-over-256): a failed run with no job.
248+func TestCIMatrixOver256IsAnInvalidWorkflow(t *testing.T) {
249+ c := newCIHarness(t, nil)
250+ c.registerRunner("alpha", "ubuntu-latest")
251+ var a, b []string
252+ for i := 1; i <= 17; i++ {
253+ a = append(a, itoa(i))
254+ }
255+ for i := 1; i <= 16; i++ {
256+ b = append(b, itoa(i))
257+ }
258+ c.push("main", map[string]string{".github/workflows/m.yml": `on: push
259+jobs:
260+ too-many:
261+ runs-on: ubuntu-latest
262+ strategy:
263+ matrix:
264+ a: [` + strings.Join(a, ", ") + `]
265+ b: [` + strings.Join(b, ", ") + `]
266+ steps:
267+ - run: echo
268+`}, "")
269+ c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
270+ run := c.getRun(c.runs()[0].GetId())
271+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run.GetRun().GetConclusion())
272+ assert.Empty(t, run.GetJobs(), "no job")
273+ assert.Contains(t, run.GetRun().GetError(), "Strategy for job 'too-many' produced 272 configurations which exceeds the maximum of 256 configurations")
274+}
275+
276+func itoa(i int) string { b, _ := json.Marshal(i); return string(b) }
277+
278+// A step with continue-on-error is no longer refused; the runner runs it.
279+func TestCIStepContinueOnErrorIsPlanned(t *testing.T) {
280+ c := newCIHarness(t, nil)
281+ runner := c.registerRunner("alpha", "ubuntu-latest")
282+ c.push("main", map[string]string{".github/workflows/s.yml": `on: push
283+jobs:
284+ j:
285+ runs-on: ubuntu-latest
286+ steps:
287+ - continue-on-error: ${{ github.event_name == 'push' }}
288+ run: exit 1
289+`}, "")
290+ job := runner.await()
291+ assert.Equal(t, "j", job.GetJobKey())
292+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
293+}
294+
295+// A step timeout stays refused as long as the runner has none.
296+func TestCIStepTimeoutIsStillRefused(t *testing.T) {
297+ c := newCIHarness(t, nil)
298+ c.registerRunner("alpha", "ubuntu-latest")
299+ c.push("main", map[string]string{".github/workflows/s.yml": `on: push
300+jobs:
301+ j:
302+ runs-on: ubuntu-latest
303+ steps:
304+ - timeout-minutes: 1
305+ run: echo
306+`}, "")
307+ c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
308+ assert.Equal(t, "not supported yet: timeout-minutes on a step", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError())
309+}
310+
311+// A failed job with continue-on-error does not fail the run, keeps its own
312+// failure conclusion, and shows as success to the jobs that need it (recorded:
313+// if-job-level-fail-effects, where the dependent job printed result=success).
314+func TestCIJobContinueOnError(t *testing.T) {
315+ c := newCIHarness(t, nil)
316+ runner := c.registerRunner("alpha", "ubuntu-latest")
317+ c.push("main", map[string]string{".github/workflows/c.yml": `on: push
318+jobs:
319+ soft:
320+ runs-on: ubuntu-latest
321+ continue-on-error: ${{ github.event_name == 'push' }}
322+ steps:
323+ - run: exit 5
324+ after:
325+ needs: soft
326+ runs-on: ubuntu-latest
327+ steps:
328+ - run: echo "result=${{ needs.soft.result }}"
329+`}, "")
330+ soft := runner.await()
331+ assert.Equal(t, "soft", soft.GetJobKey())
332+ runner.runJob(soft, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x")
333+ after := runner.await()
334+ assert.Equal(t, "after", after.GetJobKey(), "a needed job that failed softly does not skip its dependents")
335+ assert.Contains(t, after.GetContexts(), `"result":"success"`)
336+ runner.runJob(after, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
337+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
338+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.runs()[0].GetConclusion(), "the run does not fail")
339+ byKey := map[string]pb.CiConclusion{}
340+ for _, j := range c.getRun(c.runs()[0].GetId()).GetJobs() {
341+ byKey[j.GetName()] = j.GetConclusion()
342+ }
343+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, byKey["soft"], "the job itself failed")
344+}
345+
346+// A matrix leg with continue-on-error that fails does not cancel its siblings
347+// (fail-fast), and does not fail the run; a leg without it still does.
348+func TestCIJobContinueOnErrorLegDoesNotTriggerFailFast(t *testing.T) {
349+ c := newCIHarness(t, nil)
350+ runner := c.registerRunner("alpha", "ubuntu-latest")
351+ c.push("main", map[string]string{".github/workflows/m.yml": `on: push
352+jobs:
353+ build:
354+ runs-on: ubuntu-latest
355+ continue-on-error: ${{ matrix.experimental }}
356+ strategy:
357+ matrix:
358+ experimental: [true, false]
359+ steps:
360+ - run: echo ${{ matrix.experimental }}
361+`}, "")
362+ a, b := runner.await(), runner.await()
363+ soft, hard := a, b
364+ if strings.Contains(b.GetMatrix(), "true") {
365+ soft, hard = b, a
366+ }
367+ apiHard := runner.jobAPI(hard)
368+ _, err := apiHard.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: hard.GetJobId()}))
369+ require.NoError(t, err)
370+ runner.runJob(soft, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x")
371+ // The sibling must not have been asked to stop.
372+ watch, err := apiHard.runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: hard.GetJobId()}))
373+ require.NoError(t, err)
374+ done := make(chan *pb.CancelReason, 1)
375+ go func() {
376+ for watch.Receive() {
377+ if m := watch.Msg().GetCancel(); m != nil {
378+ r := m.GetReason()
379+ done <- &r
380+ return
381+ }
382+ }
383+ done <- nil
384+ }()
385+ runner.runJob(hard, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
386+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
387+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.runs()[0].GetConclusion())
388+ if r := <-done; r != nil {
389+ t.Fatalf("the sibling of a continue-on-error leg was cancelled: %v", *r)
390+ }
391+}
392+
393+// GitHub-shaped URLs, /<owner>/<repo>/..., redirect to ermite's pages, only for
394+// the owner of the instance and only where there is no page of its own.
395+func TestWebRedirectsOwnerURLs(t *testing.T) {
396+ c := newCIHarness(t, nil)
397+ b := browser{c.harness}
398+ c.push("main", map[string]string{"README": "x\n"}, "")
399+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
400+ for path, to := range map[string]string{
401+ "/owner/proj": "/proj",
402+ "/owner/proj/commit/" + sha: "/proj/commit/" + sha,
403+ "/owner/proj/compare/a...b?x=1": "/proj/compare/a...b?x=1",
404+ "/owner/proj.git/info/refs": "/proj.git/info/refs",
405+ } {
406+ res := b.get(path, nil)
407+ assert.Equal(t, http.StatusPermanentRedirect, res.status, path)
408+ assert.Equal(t, to, res.hdr.Get("Location"), path)
409+ }
410+ assert.Equal(t, http.StatusNotFound, b.get("/other/proj/commit/"+sha, nil).status, "not the owner")
411+ assert.Equal(t, http.StatusNotFound, b.get("/owner", nil).status, "a bare owner is no page")
412+ assert.Equal(t, http.StatusNotFound, b.get("/owner/Bad Name", nil).status)
413+ post := b.do("POST", "/owner/proj", map[string]string{"Origin": publicOrigin}, "")
414+ assert.Equal(t, http.StatusForbidden, post.status, "a write is stopped by the gate, never redirected")
415+ assert.Empty(t, post.hdr.Get("Location"))
416+}
417+
418+// github.job is empty while a job is evaluated, so a name that reads it loses
419+// it (recorded: ctx-github-names), but the job gets its key in the contexts.
420+func TestCIJobNameReadsAnEmptyGithubJob(t *testing.T) {
421+ c := newCIHarness(t, nil)
422+ runner := c.registerRunner("alpha", "ubuntu-latest")
423+ c.push("main", map[string]string{".github/workflows/n.yml": `on: push
424+jobs:
425+ first-job:
426+ name: Display name of ${{ github.job }}
427+ runs-on: ubuntu-latest
428+ steps:
429+ - run: echo hi
430+`}, "")
431+ job := runner.await()
432+ assert.Contains(t, job.GetContexts(), `"job":"first-job"`)
433+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
434+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
435+ assert.Equal(t, "Display name of", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetName())
436+}
437+
438+// A tag push carries, like a created branch, the commits the default branch
439+// lacks, newest as head_commit, and the compare URL of several commits is
440+// "<first>^...<last>" (recorded: push-tag, skip-directive-tag).
441+func TestCITagEventCarriesTheNewCommits(t *testing.T) {
442+ c := newCIHarness(t, nil)
443+ runner := c.registerRunner("alpha", "ubuntu-latest")
444+ c.push("main", map[string]string{".github/workflows/rel.yml": workflowWith("tags: ['v*']")}, "")
445+ c.mustGit(c.wt, "checkout", "-q", "-b", "rel")
446+ c.writeFile(c.wt, "a.txt", []byte("a\n")) // commits "add a.txt"
447+ first := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
448+ c.writeFile(c.wt, "b.txt", []byte("b\n")) // commits "add b.txt"
449+ last := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
450+ c.mustGit(c.wt, "tag", "v1")
451+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
452+
453+ job := runner.await()
454+ var ctxs map[string]map[string]any
455+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
456+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
457+ ev := ctxs["github"]["event"].(map[string]any)
458+ var msgs []string
459+ for _, cm := range ev["commits"].([]any) {
460+ msgs = append(msgs, cm.(map[string]any)["message"].(string))
461+ }
462+ assert.Equal(t, []string{"add a.txt", "add b.txt"}, msgs)
463+ assert.Equal(t, "add b.txt", ev["head_commit"].(map[string]any)["message"])
464+ assert.Equal(t, publicOrigin+"/owner/proj/compare/"+first[:12]+"^..."+last[:12], ev["compare"])
465+ assert.Equal(t, "tag", ctxs["github"]["ref_type"])
466+ assert.Equal(t, true, ev["created"])
467+}
@@ -0,0 +1,467 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "encoding/json"
7+ "net/http"
8+ "os"
9+ "slices"
10+ "strings"
11+ "testing"
12+
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ pb "bnema.dev/ermite-api/gen/ermite"
18+)
19+
20+// conformance is testdata/conformance/ctx-job-runner.contexts.json: the
21+// contexts github.com gave to the ctx-job-runner scenario of ermite-conformance
22+// (see its _source).
23+type conformance struct {
24+ GithubKeyOrder []string `json:"_github_key_order"`
25+ JobKeyOrder []string `json:"_job_key_order"`
26+ RunnerOwned []string `json:"_runner_owned_github_keys"`
27+ Github map[string]any `json:"github"`
28+ Job map[string]any `json:"job"`
29+ Matrix any `json:"matrix"`
30+ Strategy map[string]any `json:"strategy"`
31+}
32+
33+func loadConformance(t *testing.T) conformance {
34+ t.Helper()
35+ b, err := os.ReadFile("testdata/conformance/ctx-job-runner.contexts.json")
36+ require.NoError(t, err)
37+ var c conformance
38+ require.NoError(t, json.Unmarshal(b, &c))
39+ return c
40+}
41+
42+// numericPlaceholders are the placeholders of the normaliser of
43+// ermite-conformance for values that are numbers (or times) on github.com: ids,
44+// times and the repository size. In the raw bundle of the same scenario id,
45+// created_at, pushed_at and size are JSON numbers; the fixture only has the
46+// placeholder, a string. They are matched by "number or string".
47+var numericPlaceholders = []string{"<id:", "<time>", "<size>", "<actor_id:", "<repository_id:", "<repository_owner_id:", "<check_run_id:"}
48+
49+// shape is the type of each value of an object. A placeholder of a number
50+// has the type "scalar".
51+func shape(m map[string]any) map[string]string {
52+ out := map[string]string{}
53+ for k, v := range m {
54+ if s, ok := v.(string); ok && slices.ContainsFunc(numericPlaceholders, func(p string) bool { return strings.HasPrefix(s, p) }) {
55+ out[k] = "scalar"
56+ continue
57+ }
58+ switch v.(type) {
59+ case nil:
60+ out[k] = "null"
61+ case string:
62+ out[k] = "string"
63+ case float64:
64+ out[k] = "number"
65+ case bool:
66+ out[k] = "bool"
67+ case []any:
68+ out[k] = "array"
69+ case map[string]any:
70+ out[k] = "object"
71+ }
72+ }
73+ return out
74+}
75+
76+// orderedKeys returns the keys of a JSON object in document order.
77+func orderedKeys(t *testing.T, raw json.RawMessage) []string {
78+ t.Helper()
79+ dec := json.NewDecoder(strings.NewReader(string(raw)))
80+ tok, err := dec.Token()
81+ require.NoError(t, err)
82+ require.Equal(t, json.Delim('{'), tok)
83+ var keys []string
84+ for dec.More() {
85+ k, err := dec.Token()
86+ require.NoError(t, err)
87+ keys = append(keys, k.(string))
88+ var skip json.RawMessage
89+ require.NoError(t, dec.Decode(&skip))
90+ }
91+ return keys
92+}
93+
94+// sameShape compares the shape of a recorded object with ours: a "scalar"
95+// (numeric placeholder) accepts a number or a string. The description is a
96+// property of the recorded repository, which ermite's test repository lacks.
97+func sameShape(t *testing.T, want, got map[string]any, what string) {
98+ t.Helper()
99+ ws, gs := shape(want), shape(got)
100+ for k, wt := range ws {
101+ if k == "description" {
102+ continue
103+ }
104+ if wt == "scalar" {
105+ assert.Contains(t, []string{"number", "string"}, gs[k], "%s.%s", what, k)
106+ continue
107+ }
108+ assert.Equal(t, wt, gs[k], "%s.%s", what, k)
109+ }
110+}
111+
112+// The github and job contexts of a push have the keys, the key order and the
113+// value types of the ones github.com recorded for the same scenario shape
114+// (ermite-conformance, ctx-job-runner), apart from the keys the runner adds.
115+func TestCIContextsMatchTheGitHubRecording(t *testing.T) {
116+ want := loadConformance(t)
117+ c := newCIHarness(t, nil)
118+ runner := c.registerRunner("alpha", "ubuntu-latest")
119+ c.push("main", map[string]string{".github/workflows/ci.yml": workflowWith("branches: [main]")}, "")
120+ job := runner.await()
121+ var raw map[string]json.RawMessage
122+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &raw))
123+ var ctxs map[string]map[string]any
124+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
125+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
126+
127+ t.Run("github", func(t *testing.T) {
128+ gh := ctxs["github"]
129+ // The runner owns some keys and adds job and token; the server sends the rest.
130+ serverKeys := slices.DeleteFunc(slices.Clone(want.GithubKeyOrder), func(k string) bool { return slices.Contains(want.RunnerOwned, k) })
131+ assert.Equal(t, serverKeys, orderedKeys(t, raw["github"]), "key set and order")
132+ serverGithub := map[string]any{}
133+ for k, v := range want.Github {
134+ if !slices.Contains(want.RunnerOwned, k) {
135+ serverGithub[k] = v
136+ }
137+ }
138+ sameShape(t, serverGithub, gh, "github")
139+ assert.Equal(t, "Actions", gh["secret_source"])
140+ assert.Equal(t, "10", gh["artifact_cache_size_limit"])
141+ assert.Equal(t, "private", gh["repository_visibility"], "from the repository")
142+ assert.Equal(t, "owner", gh["actor"])
143+ assert.Equal(t, "owner", gh["repository_owner"])
144+ assert.Regexp(t, `^[1-9][0-9]*$`, gh["actor_id"])
145+ assert.Regexp(t, `^[1-9][0-9]*$`, gh["repository_id"])
146+ assert.Regexp(t, `^[1-9][0-9]*$`, gh["repository_owner_id"])
147+ assert.Equal(t, gh["actor_id"], gh["repository_owner_id"], "the owner pushed")
148+ assert.Equal(t, "git://example.test/owner/proj.git", gh["repositoryUrl"], "git://<host>/<owner>/<repo>.git, as GitHub's")
149+ })
150+
151+ t.Run("event", func(t *testing.T) {
152+ ev := ctxs["github"]["event"].(map[string]any)
153+ wev := want.Github["event"].(map[string]any)
154+ assert.ElementsMatch(t, keysSlice(wev), keysSlice(ev), "event keys")
155+ sameShape(t, wev, ev, "event")
156+ repo, wrepo := ev["repository"].(map[string]any), wev["repository"].(map[string]any)
157+ assert.Len(t, wrepo, 83, "the recording has 83 repository keys")
158+ assert.ElementsMatch(t, keysSlice(wrepo), keysSlice(repo), "repository keys")
159+ sameShape(t, wrepo, repo, "event.repository")
160+ assert.ElementsMatch(t, keysSlice(wrepo["owner"].(map[string]any)), keysSlice(repo["owner"].(map[string]any)), "repository.owner keys")
161+ sender, wsender := ev["sender"].(map[string]any), wev["sender"].(map[string]any)
162+ assert.Len(t, wsender, 19)
163+ assert.ElementsMatch(t, keysSlice(wsender), keysSlice(sender), "sender keys")
164+ sameShape(t, wsender, sender, "event.sender")
165+ assert.ElementsMatch(t, keysSlice(wev["pusher"].(map[string]any)), keysSlice(ev["pusher"].(map[string]any)))
166+ for _, got := range []map[string]any{ev["head_commit"].(map[string]any), ev["commits"].([]any)[0].(map[string]any)} {
167+ wc := wev["head_commit"].(map[string]any)
168+ assert.ElementsMatch(t, keysSlice(wc), keysSlice(got), "commit keys: no added/removed/modified/username")
169+ assert.ElementsMatch(t, keysSlice(wc["author"].(map[string]any)), keysSlice(got["author"].(map[string]any)))
170+ assert.ElementsMatch(t, keysSlice(wc["committer"].(map[string]any)), keysSlice(got["committer"].(map[string]any)))
171+ assert.True(t, strings.HasPrefix(got["url"].(string), publicOrigin+"/owner/proj/commit/"), got["url"])
172+ }
173+ assert.Equal(t, "proj", repo["name"])
174+ assert.Equal(t, "owner/proj", repo["full_name"])
175+ assert.Equal(t, true, repo["private"])
176+ assert.Equal(t, "private", repo["visibility"])
177+ assert.Equal(t, "main", repo["default_branch"])
178+ assert.Equal(t, ctxs["github"]["repository_id"], jsonNumber(repo["id"]), "repository_id is repository.id")
179+ assert.Equal(t, ctxs["github"]["actor_id"], jsonNumber(sender["id"]))
180+ assert.Equal(t, ctxs["github"]["repository_owner_id"], jsonNumber(repo["owner"].(map[string]any)["id"]))
181+ })
182+
183+ t.Run("job", func(t *testing.T) {
184+ j := ctxs["job"]
185+ // The runner adds status first; the server sends the rest, in GitHub's order.
186+ serverKeys := slices.DeleteFunc(slices.Clone(want.JobKeyOrder), func(k string) bool { return k == "status" })
187+ assert.Equal(t, serverKeys, orderedKeys(t, raw["job"]))
188+ serverJob := map[string]any{}
189+ for k, v := range want.Job {
190+ if k != "status" {
191+ serverJob[k] = v
192+ }
193+ }
194+ sameShape(t, serverJob, j, "job")
195+ assert.Equal(t, ".github/workflows/ci.yml", j["workflow_file_path"])
196+ assert.Equal(t, "owner/proj", j["workflow_repository"])
197+ assert.Equal(t, "owner/proj/.github/workflows/ci.yml@refs/heads/main", j["workflow_ref"])
198+ assert.Equal(t, ctxs["github"]["sha"], j["workflow_sha"])
199+ assert.Equal(t, ctxs["github"]["workflow_ref"], j["workflow_ref"])
200+ })
201+
202+ t.Run("matrix is null without a matrix", func(t *testing.T) {
203+ assert.Equal(t, "null", string(raw["matrix"]))
204+ assert.Nil(t, want.Matrix)
205+ assert.ElementsMatch(t, keysSlice(want.Strategy), keysSlice(ctxs["strategy"]))
206+ })
207+}
208+
209+func keysSlice(m map[string]any) []string {
210+ out := make([]string, 0, len(m))
211+ for k := range m {
212+ out = append(out, k)
213+ }
214+ return out
215+}
216+
217+func jsonNumber(v any) string {
218+ b, _ := json.Marshal(v)
219+ return string(b)
220+}
221+
222+// The run title is the evaluated run-name, else the head commit message.
223+func TestCIRunTitle(t *testing.T) {
224+ c := newCIHarness(t, nil)
225+ c.registerRunner("alpha", "ubuntu-latest")
226+ c.push("main", map[string]string{".github/workflows/named.yml": `name: Named
227+run-name: "run ${{ github.event_name }} on ${{ github.ref_type }} by ${{ github.actor == '' && 'nobody' || 'someone' }}"
228+on: push
229+jobs:
230+ j:
231+ runs-on: ubuntu-latest
232+ steps:
233+ - run: echo hi
234+`, ".github/workflows/plain.yml": workflowWith("branches: [main]")}, "the head commit\n\nbody")
235+ c.waitFor("two runs", func() bool { return len(c.runs()) == 2 })
236+ titles := map[string]string{}
237+ for _, r := range c.runs() {
238+ titles[r.GetWorkflowPath()] = r.GetTitle()
239+ }
240+ assert.Equal(t, map[string]string{
241+ ".github/workflows/named.yml": "run push on branch by someone",
242+ ".github/workflows/plain.yml": "the head commit",
243+ }, titles)
244+}
245+
246+// An over-256 matrix is an invalid workflow, like on github.com
247+// (matrix-over-256): a failed run with no job.
248+func TestCIMatrixOver256IsAnInvalidWorkflow(t *testing.T) {
249+ c := newCIHarness(t, nil)
250+ c.registerRunner("alpha", "ubuntu-latest")
251+ var a, b []string
252+ for i := 1; i <= 17; i++ {
253+ a = append(a, itoa(i))
254+ }
255+ for i := 1; i <= 16; i++ {
256+ b = append(b, itoa(i))
257+ }
258+ c.push("main", map[string]string{".github/workflows/m.yml": `on: push
259+jobs:
260+ too-many:
261+ runs-on: ubuntu-latest
262+ strategy:
263+ matrix:
264+ a: [` + strings.Join(a, ", ") + `]
265+ b: [` + strings.Join(b, ", ") + `]
266+ steps:
267+ - run: echo
268+`}, "")
269+ c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
270+ run := c.getRun(c.runs()[0].GetId())
271+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run.GetRun().GetConclusion())
272+ assert.Empty(t, run.GetJobs(), "no job")
273+ assert.Contains(t, run.GetRun().GetError(), "Strategy for job 'too-many' produced 272 configurations which exceeds the maximum of 256 configurations")
274+}
275+
276+func itoa(i int) string { b, _ := json.Marshal(i); return string(b) }
277+
278+// A step with continue-on-error is no longer refused; the runner runs it.
279+func TestCIStepContinueOnErrorIsPlanned(t *testing.T) {
280+ c := newCIHarness(t, nil)
281+ runner := c.registerRunner("alpha", "ubuntu-latest")
282+ c.push("main", map[string]string{".github/workflows/s.yml": `on: push
283+jobs:
284+ j:
285+ runs-on: ubuntu-latest
286+ steps:
287+ - continue-on-error: ${{ github.event_name == 'push' }}
288+ run: exit 1
289+`}, "")
290+ job := runner.await()
291+ assert.Equal(t, "j", job.GetJobKey())
292+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
293+}
294+
295+// A step timeout stays refused as long as the runner has none.
296+func TestCIStepTimeoutIsStillRefused(t *testing.T) {
297+ c := newCIHarness(t, nil)
298+ c.registerRunner("alpha", "ubuntu-latest")
299+ c.push("main", map[string]string{".github/workflows/s.yml": `on: push
300+jobs:
301+ j:
302+ runs-on: ubuntu-latest
303+ steps:
304+ - timeout-minutes: 1
305+ run: echo
306+`}, "")
307+ c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
308+ assert.Equal(t, "not supported yet: timeout-minutes on a step", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError())
309+}
310+
311+// A failed job with continue-on-error does not fail the run, keeps its own
312+// failure conclusion, and shows as success to the jobs that need it (recorded:
313+// if-job-level-fail-effects, where the dependent job printed result=success).
314+func TestCIJobContinueOnError(t *testing.T) {
315+ c := newCIHarness(t, nil)
316+ runner := c.registerRunner("alpha", "ubuntu-latest")
317+ c.push("main", map[string]string{".github/workflows/c.yml": `on: push
318+jobs:
319+ soft:
320+ runs-on: ubuntu-latest
321+ continue-on-error: ${{ github.event_name == 'push' }}
322+ steps:
323+ - run: exit 5
324+ after:
325+ needs: soft
326+ runs-on: ubuntu-latest
327+ steps:
328+ - run: echo "result=${{ needs.soft.result }}"
329+`}, "")
330+ soft := runner.await()
331+ assert.Equal(t, "soft", soft.GetJobKey())
332+ runner.runJob(soft, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x")
333+ after := runner.await()
334+ assert.Equal(t, "after", after.GetJobKey(), "a needed job that failed softly does not skip its dependents")
335+ assert.Contains(t, after.GetContexts(), `"result":"success"`)
336+ runner.runJob(after, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
337+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
338+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.runs()[0].GetConclusion(), "the run does not fail")
339+ byKey := map[string]pb.CiConclusion{}
340+ for _, j := range c.getRun(c.runs()[0].GetId()).GetJobs() {
341+ byKey[j.GetName()] = j.GetConclusion()
342+ }
343+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, byKey["soft"], "the job itself failed")
344+}
345+
346+// A matrix leg with continue-on-error that fails does not cancel its siblings
347+// (fail-fast), and does not fail the run; a leg without it still does.
348+func TestCIJobContinueOnErrorLegDoesNotTriggerFailFast(t *testing.T) {
349+ c := newCIHarness(t, nil)
350+ runner := c.registerRunner("alpha", "ubuntu-latest")
351+ c.push("main", map[string]string{".github/workflows/m.yml": `on: push
352+jobs:
353+ build:
354+ runs-on: ubuntu-latest
355+ continue-on-error: ${{ matrix.experimental }}
356+ strategy:
357+ matrix:
358+ experimental: [true, false]
359+ steps:
360+ - run: echo ${{ matrix.experimental }}
361+`}, "")
362+ a, b := runner.await(), runner.await()
363+ soft, hard := a, b
364+ if strings.Contains(b.GetMatrix(), "true") {
365+ soft, hard = b, a
366+ }
367+ apiHard := runner.jobAPI(hard)
368+ _, err := apiHard.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: hard.GetJobId()}))
369+ require.NoError(t, err)
370+ runner.runJob(soft, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x")
371+ // The sibling must not have been asked to stop.
372+ watch, err := apiHard.runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: hard.GetJobId()}))
373+ require.NoError(t, err)
374+ done := make(chan *pb.CancelReason, 1)
375+ go func() {
376+ for watch.Receive() {
377+ if m := watch.Msg().GetCancel(); m != nil {
378+ r := m.GetReason()
379+ done <- &r
380+ return
381+ }
382+ }
383+ done <- nil
384+ }()
385+ runner.runJob(hard, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
386+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
387+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.runs()[0].GetConclusion())
388+ if r := <-done; r != nil {
389+ t.Fatalf("the sibling of a continue-on-error leg was cancelled: %v", *r)
390+ }
391+}
392+
393+// GitHub-shaped URLs, /<owner>/<repo>/..., redirect to ermite's pages, only for
394+// the owner of the instance and only where there is no page of its own.
395+func TestWebRedirectsOwnerURLs(t *testing.T) {
396+ c := newCIHarness(t, nil)
397+ b := browser{c.harness}
398+ c.push("main", map[string]string{"README": "x\n"}, "")
399+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
400+ for path, to := range map[string]string{
401+ "/owner/proj": "/proj",
402+ "/owner/proj/commit/" + sha: "/proj/commit/" + sha,
403+ "/owner/proj/compare/a...b?x=1": "/proj/compare/a...b?x=1",
404+ "/owner/proj.git/info/refs": "/proj.git/info/refs",
405+ } {
406+ res := b.get(path, nil)
407+ assert.Equal(t, http.StatusPermanentRedirect, res.status, path)
408+ assert.Equal(t, to, res.hdr.Get("Location"), path)
409+ }
410+ assert.Equal(t, http.StatusNotFound, b.get("/other/proj/commit/"+sha, nil).status, "not the owner")
411+ assert.Equal(t, http.StatusNotFound, b.get("/owner", nil).status, "a bare owner is no page")
412+ assert.Equal(t, http.StatusNotFound, b.get("/owner/Bad Name", nil).status)
413+ post := b.do("POST", "/owner/proj", map[string]string{"Origin": publicOrigin}, "")
414+ assert.Equal(t, http.StatusForbidden, post.status, "a write is stopped by the gate, never redirected")
415+ assert.Empty(t, post.hdr.Get("Location"))
416+}
417+
418+// github.job is empty while a job is evaluated, so a name that reads it loses
419+// it (recorded: ctx-github-names), but the job gets its key in the contexts.
420+func TestCIJobNameReadsAnEmptyGithubJob(t *testing.T) {
421+ c := newCIHarness(t, nil)
422+ runner := c.registerRunner("alpha", "ubuntu-latest")
423+ c.push("main", map[string]string{".github/workflows/n.yml": `on: push
424+jobs:
425+ first-job:
426+ name: Display name of ${{ github.job }}
427+ runs-on: ubuntu-latest
428+ steps:
429+ - run: echo hi
430+`}, "")
431+ job := runner.await()
432+ assert.Contains(t, job.GetContexts(), `"job":"first-job"`)
433+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
434+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
435+ assert.Equal(t, "Display name of", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetName())
436+}
437+
438+// A tag push carries, like a created branch, the commits the default branch
439+// lacks, newest as head_commit, and the compare URL of several commits is
440+// "<first>^...<last>" (recorded: push-tag, skip-directive-tag).
441+func TestCITagEventCarriesTheNewCommits(t *testing.T) {
442+ c := newCIHarness(t, nil)
443+ runner := c.registerRunner("alpha", "ubuntu-latest")
444+ c.push("main", map[string]string{".github/workflows/rel.yml": workflowWith("tags: ['v*']")}, "")
445+ c.mustGit(c.wt, "checkout", "-q", "-b", "rel")
446+ c.writeFile(c.wt, "a.txt", []byte("a\n")) // commits "add a.txt"
447+ first := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
448+ c.writeFile(c.wt, "b.txt", []byte("b\n")) // commits "add b.txt"
449+ last := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
450+ c.mustGit(c.wt, "tag", "v1")
451+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
452+
453+ job := runner.await()
454+ var ctxs map[string]map[string]any
455+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
456+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
457+ ev := ctxs["github"]["event"].(map[string]any)
458+ var msgs []string
459+ for _, cm := range ev["commits"].([]any) {
460+ msgs = append(msgs, cm.(map[string]any)["message"].(string))
461+ }
462+ assert.Equal(t, []string{"add a.txt", "add b.txt"}, msgs)
463+ assert.Equal(t, "add b.txt", ev["head_commit"].(map[string]any)["message"])
464+ assert.Equal(t, publicOrigin+"/owner/proj/compare/"+first[:12]+"^..."+last[:12], ev["compare"])
465+ assert.Equal(t, "tag", ctxs["github"]["ref_type"])
466+ assert.Equal(t, true, ev["created"])
467+}
A internal/app/testdata/conformance/ctx-job-runner.contexts.json +307 -0
@@ -0,0 +1,307 @@
1+{
2+ "_github_key_order": [
3+ "token",
4+ "job",
5+ "ref",
6+ "sha",
7+ "repository",
8+ "repository_owner",
9+ "repository_owner_id",
10+ "repositoryUrl",
11+ "run_id",
12+ "run_number",
13+ "retention_days",
14+ "run_attempt",
15+ "artifact_cache_size_limit",
16+ "repository_visibility",
17+ "actor_id",
18+ "actor",
19+ "workflow",
20+ "head_ref",
21+ "base_ref",
22+ "event_name",
23+ "server_url",
24+ "api_url",
25+ "graphql_url",
26+ "ref_name",
27+ "ref_protected",
28+ "ref_type",
29+ "secret_source",
30+ "event",
31+ "workflow_ref",
32+ "workflow_sha",
33+ "repository_id",
34+ "triggering_actor",
35+ "workspace",
36+ "action",
37+ "event_path",
38+ "action_repository",
39+ "action_ref",
40+ "path",
41+ "env",
42+ "step_summary",
43+ "state",
44+ "output",
45+ "artifacts",
46+ "artifacts_list"
47+ ],
48+ "_job_key_order": [
49+ "check_run_id",
50+ "workflow_ref",
51+ "workflow_sha",
52+ "workflow_repository",
53+ "workflow_file_path",
54+ "status"
55+ ],
56+ "_runner_owned_github_keys": [
57+ "token",
58+ "workspace",
59+ "action",
60+ "event_path",
61+ "action_repository",
62+ "action_ref",
63+ "path",
64+ "env",
65+ "step_summary",
66+ "state",
67+ "output",
68+ "artifacts",
69+ "artifacts_list"
70+ ],
71+ "_source": "ermite-conformance recordings/ctx-job-runner.json (scenario ctx-job-runner, github.com, normalised by its normalize.go: <owner>, <repo>, <server>, <id:k>, <node_id>, <time>, <sha:pushN> are placeholders). Only github, job, matrix and strategy are kept. Keys are sorted by the normaliser, so key order is not asserted from this file. The _github_key_order and _job_key_order lists are the order of toJSON(github) and toJSON(job) in the raw (unnormalised) bundle of the same scenario; the _runner_owned_github_keys are set by the runner, not the server.",
72+ "github": {
73+ "action": "__run_4",
74+ "action_ref": "",
75+ "action_repository": "",
76+ "actor": "<owner>",
77+ "actor_id": "<actor_id:1>",
78+ "api_url": "<server>",
79+ "artifact_cache_size_limit": "10",
80+ "artifacts": "/home/runner/work/_temp/_runner_file_commands/artifacts_<uuid>",
81+ "artifacts_list": "/home/runner/work/_temp/_runner_file_commands/artifacts_list_<uuid>",
82+ "base_ref": "",
83+ "env": "/home/runner/work/_temp/_runner_file_commands/set_env_<uuid>",
84+ "event": {
85+ "after": "<sha:push1>",
86+ "base_ref": null,
87+ "before": "<sha:zero>",
88+ "commits": [
89+ {
90+ "author": {
91+ "email": "conformance@ermite.invalid",
92+ "name": "ermite-conformance"
93+ },
94+ "committer": {
95+ "email": "conformance@ermite.invalid",
96+ "name": "ermite-conformance"
97+ },
98+ "distinct": true,
99+ "id": "<sha:push1>",
100+ "message": "scenario ctx-job-runner push 1",
101+ "timestamp": "<time>",
102+ "tree_id": "<sha:other>",
103+ "url": "<server>/<owner>/<repo>/commit/<sha:push1>"
104+ }
105+ ],
106+ "compare": "<server>/<owner>/<repo>/commit/<sha12:push1>",
107+ "created": true,
108+ "deleted": false,
109+ "forced": false,
110+ "head_commit": {
111+ "author": {
112+ "email": "conformance@ermite.invalid",
113+ "name": "ermite-conformance"
114+ },
115+ "committer": {
116+ "email": "conformance@ermite.invalid",
117+ "name": "ermite-conformance"
118+ },
119+ "distinct": true,
120+ "id": "<sha:push1>",
121+ "message": "scenario ctx-job-runner push 1",
122+ "timestamp": "<time>",
123+ "tree_id": "<sha:other>",
124+ "url": "<server>/<owner>/<repo>/commit/<sha:push1>"
125+ },
126+ "pusher": {
127+ "email": "<email>",
128+ "name": "<owner>"
129+ },
130+ "ref": "refs/heads/<branch>",
131+ "repository": {
132+ "allow_forking": true,
133+ "archive_url": "<server>/repos/<owner>/<repo>/{archive_format}{/ref}",
134+ "archived": false,
135+ "assignees_url": "<server>/repos/<owner>/<repo>/assignees{/user}",
136+ "blobs_url": "<server>/repos/<owner>/<repo>/git/blobs{/sha}",
137+ "branches_url": "<server>/repos/<owner>/<repo>/branches{/branch}",
138+ "clone_url": "<server>/<owner>/<repo>.git",
139+ "collaborators_url": "<server>/repos/<owner>/<repo>/collaborators{/collaborator}",
140+ "comments_url": "<server>/repos/<owner>/<repo>/comments{/number}",
141+ "commits_url": "<server>/repos/<owner>/<repo>/commits{/sha}",
142+ "compare_url": "<server>/repos/<owner>/<repo>/compare/{base}...{head}",
143+ "contents_url": "<server>/repos/<owner>/<repo>/contents/{+path}",
144+ "contributors_url": "<server>/repos/<owner>/<repo>/contributors",
145+ "created_at": "<time>",
146+ "default_branch": "<default_branch>",
147+ "deployments_url": "<server>/repos/<owner>/<repo>/deployments",
148+ "description": "Conformance runs for ermite CI, generated",
149+ "disabled": false,
150+ "downloads_url": "<server>/repos/<owner>/<repo>/downloads",
151+ "events_url": "<server>/repos/<owner>/<repo>/events",
152+ "fork": false,
153+ "forks": 0,
154+ "forks_count": 0,
155+ "forks_url": "<server>/repos/<owner>/<repo>/forks",
156+ "full_name": "<owner>/<repo>",
157+ "git_commits_url": "<server>/repos/<owner>/<repo>/git/commits{/sha}",
158+ "git_refs_url": "<server>/repos/<owner>/<repo>/git/refs{/sha}",
159+ "git_tags_url": "<server>/repos/<owner>/<repo>/git/tags{/sha}",
160+ "git_url": "git://<server>/<owner>/<repo>.git",
161+ "has_discussions": false,
162+ "has_downloads": true,
163+ "has_issues": true,
164+ "has_pages": false,
165+ "has_projects": true,
166+ "has_pull_requests": true,
167+ "has_wiki": true,
168+ "homepage": null,
169+ "hooks_url": "<server>/repos/<owner>/<repo>/hooks",
170+ "html_url": "<server>/<owner>/<repo>",
171+ "id": "<id:1>",
172+ "is_template": false,
173+ "issue_comment_url": "<server>/repos/<owner>/<repo>/issues/comments{/number}",
174+ "issue_events_url": "<server>/repos/<owner>/<repo>/issues/events{/number}",
175+ "issues_url": "<server>/repos/<owner>/<repo>/issues{/number}",
176+ "keys_url": "<server>/repos/<owner>/<repo>/keys{/key_id}",
177+ "labels_url": "<server>/repos/<owner>/<repo>/labels{/name}",
178+ "language": null,
179+ "languages_url": "<server>/repos/<owner>/<repo>/languages",
180+ "license": null,
181+ "master_branch": "<default_branch>",
182+ "merges_url": "<server>/repos/<owner>/<repo>/merges",
183+ "milestones_url": "<server>/repos/<owner>/<repo>/milestones{/number}",
184+ "mirror_url": null,
185+ "name": "<repo>",
186+ "node_id": "<node_id>",
187+ "notifications_url": "<server>/repos/<owner>/<repo>/notifications{?since,all,participating}",
188+ "open_issues": 0,
189+ "open_issues_count": 0,
190+ "owner": {
191+ "avatar_url": "<avatar>",
192+ "email": "<email>",
193+ "events_url": "<server>/users/<owner>/events{/privacy}",
194+ "followers_url": "<server>/users/<owner>/followers",
195+ "following_url": "<server>/users/<owner>/following{/other_user}",
196+ "gists_url": "<server>/users/<owner>/gists{/gist_id}",
197+ "gravatar_id": "<avatar>",
198+ "html_url": "<server>/<owner>",
199+ "id": "<id:2>",
200+ "login": "<owner>",
201+ "name": "<owner>",
202+ "node_id": "<node_id>",
203+ "organizations_url": "<server>/users/<owner>/orgs",
204+ "received_events_url": "<server>/users/<owner>/received_events",
205+ "repos_url": "<server>/users/<owner>/repos",
206+ "site_admin": false,
207+ "starred_url": "<server>/users/<owner>/starred{/owner}{/repo}",
208+ "subscriptions_url": "<server>/users/<owner>/subscriptions",
209+ "type": "User",
210+ "url": "<server>/users/<owner>",
211+ "user_view_type": "public"
212+ },
213+ "private": false,
214+ "pull_request_creation_policy": "all",
215+ "pulls_url": "<server>/repos/<owner>/<repo>/pulls{/number}",
216+ "pushed_at": "<time>",
217+ "releases_url": "<server>/repos/<owner>/<repo>/releases{/id}",
218+ "size": "<size>",
219+ "ssh_url": "git@<server>:<owner>/<repo>.git",
220+ "stargazers": 0,
221+ "stargazers_count": 0,
222+ "stargazers_url": "<server>/repos/<owner>/<repo>/stargazers",
223+ "statuses_url": "<server>/repos/<owner>/<repo>/statuses/{sha}",
224+ "subscribers_url": "<server>/repos/<owner>/<repo>/subscribers",
225+ "subscription_url": "<server>/repos/<owner>/<repo>/subscription",
226+ "svn_url": "<server>/<owner>/<repo>",
227+ "tags_url": "<server>/repos/<owner>/<repo>/tags",
228+ "teams_url": "<server>/repos/<owner>/<repo>/teams",
229+ "topics": [],
230+ "trees_url": "<server>/repos/<owner>/<repo>/git/trees{/sha}",
231+ "updated_at": "<time>",
232+ "url": "<server>/repos/<owner>/<repo>",
233+ "visibility": "public",
234+ "watchers": 0,
235+ "watchers_count": 0,
236+ "web_commit_signoff_required": false
237+ },
238+ "sender": {
239+ "avatar_url": "<avatar>",
240+ "events_url": "<server>/users/<owner>/events{/privacy}",
241+ "followers_url": "<server>/users/<owner>/followers",
242+ "following_url": "<server>/users/<owner>/following{/other_user}",
243+ "gists_url": "<server>/users/<owner>/gists{/gist_id}",
244+ "gravatar_id": "<avatar>",
245+ "html_url": "<server>/<owner>",
246+ "id": "<id:2>",
247+ "login": "<owner>",
248+ "node_id": "<node_id>",
249+ "organizations_url": "<server>/users/<owner>/orgs",
250+ "received_events_url": "<server>/users/<owner>/received_events",
251+ "repos_url": "<server>/users/<owner>/repos",
252+ "site_admin": false,
253+ "starred_url": "<server>/users/<owner>/starred{/owner}{/repo}",
254+ "subscriptions_url": "<server>/users/<owner>/subscriptions",
255+ "type": "User",
256+ "url": "<server>/users/<owner>",
257+ "user_view_type": "public"
258+ }
259+ },
260+ "event_name": "push",
261+ "event_path": "/home/runner/work/_temp/_github_workflow/event.json",
262+ "graphql_url": "<server>/graphql",
263+ "head_ref": "",
264+ "job": "main",
265+ "output": "/home/runner/work/_temp/_runner_file_commands/set_output_<uuid>",
266+ "path": "/home/runner/work/_temp/_runner_file_commands/add_path_<uuid>",
267+ "ref": "refs/heads/<branch>",
268+ "ref_name": "<branch>",
269+ "ref_protected": false,
270+ "ref_type": "branch",
271+ "repository": "<owner>/<repo>",
272+ "repositoryUrl": "git://<server>/<owner>/<repo>.git",
273+ "repository_id": "<repository_id:1>",
274+ "repository_owner": "<owner>",
275+ "repository_owner_id": "<repository_owner_id:1>",
276+ "repository_visibility": "public",
277+ "retention_days": "90",
278+ "run_attempt": "1",
279+ "run_id": "<run_id:1>",
280+ "run_number": "<run_number:1>",
281+ "secret_source": "Actions",
282+ "server_url": "<server>",
283+ "sha": "<sha:push1>",
284+ "state": "/home/runner/work/_temp/_runner_file_commands/save_state_<uuid>",
285+ "step_summary": "/home/runner/work/_temp/_runner_file_commands/step_summary_<uuid>",
286+ "triggering_actor": "<owner>",
287+ "workflow": "ctx-job-runner",
288+ "workflow_ref": "<owner>/<repo>/.github/workflows/ci.yml@refs/heads/<branch>",
289+ "workflow_sha": "<sha:push1>",
290+ "workspace": "/home/runner/work/<repo>/<repo>"
291+ },
292+ "job": {
293+ "check_run_id": "<check_run_id:1>",
294+ "status": "failure",
295+ "workflow_file_path": ".github/workflows/ci.yml",
296+ "workflow_ref": "<owner>/<repo>/.github/workflows/ci.yml@refs/heads/<branch>",
297+ "workflow_repository": "<owner>/<repo>",
298+ "workflow_sha": "<sha:push1>"
299+ },
300+ "matrix": null,
301+ "strategy": {
302+ "fail-fast": true,
303+ "job-index": 0,
304+ "job-total": 1,
305+ "max-parallel": 1
306+ }
307+}
@@ -0,0 +1,307 @@
1+{
2+ "_github_key_order": [
3+ "token",
4+ "job",
5+ "ref",
6+ "sha",
7+ "repository",
8+ "repository_owner",
9+ "repository_owner_id",
10+ "repositoryUrl",
11+ "run_id",
12+ "run_number",
13+ "retention_days",
14+ "run_attempt",
15+ "artifact_cache_size_limit",
16+ "repository_visibility",
17+ "actor_id",
18+ "actor",
19+ "workflow",
20+ "head_ref",
21+ "base_ref",
22+ "event_name",
23+ "server_url",
24+ "api_url",
25+ "graphql_url",
26+ "ref_name",
27+ "ref_protected",
28+ "ref_type",
29+ "secret_source",
30+ "event",
31+ "workflow_ref",
32+ "workflow_sha",
33+ "repository_id",
34+ "triggering_actor",
35+ "workspace",
36+ "action",
37+ "event_path",
38+ "action_repository",
39+ "action_ref",
40+ "path",
41+ "env",
42+ "step_summary",
43+ "state",
44+ "output",
45+ "artifacts",
46+ "artifacts_list"
47+ ],
48+ "_job_key_order": [
49+ "check_run_id",
50+ "workflow_ref",
51+ "workflow_sha",
52+ "workflow_repository",
53+ "workflow_file_path",
54+ "status"
55+ ],
56+ "_runner_owned_github_keys": [
57+ "token",
58+ "workspace",
59+ "action",
60+ "event_path",
61+ "action_repository",
62+ "action_ref",
63+ "path",
64+ "env",
65+ "step_summary",
66+ "state",
67+ "output",
68+ "artifacts",
69+ "artifacts_list"
70+ ],
71+ "_source": "ermite-conformance recordings/ctx-job-runner.json (scenario ctx-job-runner, github.com, normalised by its normalize.go: <owner>, <repo>, <server>, <id:k>, <node_id>, <time>, <sha:pushN> are placeholders). Only github, job, matrix and strategy are kept. Keys are sorted by the normaliser, so key order is not asserted from this file. The _github_key_order and _job_key_order lists are the order of toJSON(github) and toJSON(job) in the raw (unnormalised) bundle of the same scenario; the _runner_owned_github_keys are set by the runner, not the server.",
72+ "github": {
73+ "action": "__run_4",
74+ "action_ref": "",
75+ "action_repository": "",
76+ "actor": "<owner>",
77+ "actor_id": "<actor_id:1>",
78+ "api_url": "<server>",
79+ "artifact_cache_size_limit": "10",
80+ "artifacts": "/home/runner/work/_temp/_runner_file_commands/artifacts_<uuid>",
81+ "artifacts_list": "/home/runner/work/_temp/_runner_file_commands/artifacts_list_<uuid>",
82+ "base_ref": "",
83+ "env": "/home/runner/work/_temp/_runner_file_commands/set_env_<uuid>",
84+ "event": {
85+ "after": "<sha:push1>",
86+ "base_ref": null,
87+ "before": "<sha:zero>",
88+ "commits": [
89+ {
90+ "author": {
91+ "email": "conformance@ermite.invalid",
92+ "name": "ermite-conformance"
93+ },
94+ "committer": {
95+ "email": "conformance@ermite.invalid",
96+ "name": "ermite-conformance"
97+ },
98+ "distinct": true,
99+ "id": "<sha:push1>",
100+ "message": "scenario ctx-job-runner push 1",
101+ "timestamp": "<time>",
102+ "tree_id": "<sha:other>",
103+ "url": "<server>/<owner>/<repo>/commit/<sha:push1>"
104+ }
105+ ],
106+ "compare": "<server>/<owner>/<repo>/commit/<sha12:push1>",
107+ "created": true,
108+ "deleted": false,
109+ "forced": false,
110+ "head_commit": {
111+ "author": {
112+ "email": "conformance@ermite.invalid",
113+ "name": "ermite-conformance"
114+ },
115+ "committer": {
116+ "email": "conformance@ermite.invalid",
117+ "name": "ermite-conformance"
118+ },
119+ "distinct": true,
120+ "id": "<sha:push1>",
121+ "message": "scenario ctx-job-runner push 1",
122+ "timestamp": "<time>",
123+ "tree_id": "<sha:other>",
124+ "url": "<server>/<owner>/<repo>/commit/<sha:push1>"
125+ },
126+ "pusher": {
127+ "email": "<email>",
128+ "name": "<owner>"
129+ },
130+ "ref": "refs/heads/<branch>",
131+ "repository": {
132+ "allow_forking": true,
133+ "archive_url": "<server>/repos/<owner>/<repo>/{archive_format}{/ref}",
134+ "archived": false,
135+ "assignees_url": "<server>/repos/<owner>/<repo>/assignees{/user}",
136+ "blobs_url": "<server>/repos/<owner>/<repo>/git/blobs{/sha}",
137+ "branches_url": "<server>/repos/<owner>/<repo>/branches{/branch}",
138+ "clone_url": "<server>/<owner>/<repo>.git",
139+ "collaborators_url": "<server>/repos/<owner>/<repo>/collaborators{/collaborator}",
140+ "comments_url": "<server>/repos/<owner>/<repo>/comments{/number}",
141+ "commits_url": "<server>/repos/<owner>/<repo>/commits{/sha}",
142+ "compare_url": "<server>/repos/<owner>/<repo>/compare/{base}...{head}",
143+ "contents_url": "<server>/repos/<owner>/<repo>/contents/{+path}",
144+ "contributors_url": "<server>/repos/<owner>/<repo>/contributors",
145+ "created_at": "<time>",
146+ "default_branch": "<default_branch>",
147+ "deployments_url": "<server>/repos/<owner>/<repo>/deployments",
148+ "description": "Conformance runs for ermite CI, generated",
149+ "disabled": false,
150+ "downloads_url": "<server>/repos/<owner>/<repo>/downloads",
151+ "events_url": "<server>/repos/<owner>/<repo>/events",
152+ "fork": false,
153+ "forks": 0,
154+ "forks_count": 0,
155+ "forks_url": "<server>/repos/<owner>/<repo>/forks",
156+ "full_name": "<owner>/<repo>",
157+ "git_commits_url": "<server>/repos/<owner>/<repo>/git/commits{/sha}",
158+ "git_refs_url": "<server>/repos/<owner>/<repo>/git/refs{/sha}",
159+ "git_tags_url": "<server>/repos/<owner>/<repo>/git/tags{/sha}",
160+ "git_url": "git://<server>/<owner>/<repo>.git",
161+ "has_discussions": false,
162+ "has_downloads": true,
163+ "has_issues": true,
164+ "has_pages": false,
165+ "has_projects": true,
166+ "has_pull_requests": true,
167+ "has_wiki": true,
168+ "homepage": null,
169+ "hooks_url": "<server>/repos/<owner>/<repo>/hooks",
170+ "html_url": "<server>/<owner>/<repo>",
171+ "id": "<id:1>",
172+ "is_template": false,
173+ "issue_comment_url": "<server>/repos/<owner>/<repo>/issues/comments{/number}",
174+ "issue_events_url": "<server>/repos/<owner>/<repo>/issues/events{/number}",
175+ "issues_url": "<server>/repos/<owner>/<repo>/issues{/number}",
176+ "keys_url": "<server>/repos/<owner>/<repo>/keys{/key_id}",
177+ "labels_url": "<server>/repos/<owner>/<repo>/labels{/name}",
178+ "language": null,
179+ "languages_url": "<server>/repos/<owner>/<repo>/languages",
180+ "license": null,
181+ "master_branch": "<default_branch>",
182+ "merges_url": "<server>/repos/<owner>/<repo>/merges",
183+ "milestones_url": "<server>/repos/<owner>/<repo>/milestones{/number}",
184+ "mirror_url": null,
185+ "name": "<repo>",
186+ "node_id": "<node_id>",
187+ "notifications_url": "<server>/repos/<owner>/<repo>/notifications{?since,all,participating}",
188+ "open_issues": 0,
189+ "open_issues_count": 0,
190+ "owner": {
191+ "avatar_url": "<avatar>",
192+ "email": "<email>",
193+ "events_url": "<server>/users/<owner>/events{/privacy}",
194+ "followers_url": "<server>/users/<owner>/followers",
195+ "following_url": "<server>/users/<owner>/following{/other_user}",
196+ "gists_url": "<server>/users/<owner>/gists{/gist_id}",
197+ "gravatar_id": "<avatar>",
198+ "html_url": "<server>/<owner>",
199+ "id": "<id:2>",
200+ "login": "<owner>",
201+ "name": "<owner>",
202+ "node_id": "<node_id>",
203+ "organizations_url": "<server>/users/<owner>/orgs",
204+ "received_events_url": "<server>/users/<owner>/received_events",
205+ "repos_url": "<server>/users/<owner>/repos",
206+ "site_admin": false,
207+ "starred_url": "<server>/users/<owner>/starred{/owner}{/repo}",
208+ "subscriptions_url": "<server>/users/<owner>/subscriptions",
209+ "type": "User",
210+ "url": "<server>/users/<owner>",
211+ "user_view_type": "public"
212+ },
213+ "private": false,
214+ "pull_request_creation_policy": "all",
215+ "pulls_url": "<server>/repos/<owner>/<repo>/pulls{/number}",
216+ "pushed_at": "<time>",
217+ "releases_url": "<server>/repos/<owner>/<repo>/releases{/id}",
218+ "size": "<size>",
219+ "ssh_url": "git@<server>:<owner>/<repo>.git",
220+ "stargazers": 0,
221+ "stargazers_count": 0,
222+ "stargazers_url": "<server>/repos/<owner>/<repo>/stargazers",
223+ "statuses_url": "<server>/repos/<owner>/<repo>/statuses/{sha}",
224+ "subscribers_url": "<server>/repos/<owner>/<repo>/subscribers",
225+ "subscription_url": "<server>/repos/<owner>/<repo>/subscription",
226+ "svn_url": "<server>/<owner>/<repo>",
227+ "tags_url": "<server>/repos/<owner>/<repo>/tags",
228+ "teams_url": "<server>/repos/<owner>/<repo>/teams",
229+ "topics": [],
230+ "trees_url": "<server>/repos/<owner>/<repo>/git/trees{/sha}",
231+ "updated_at": "<time>",
232+ "url": "<server>/repos/<owner>/<repo>",
233+ "visibility": "public",
234+ "watchers": 0,
235+ "watchers_count": 0,
236+ "web_commit_signoff_required": false
237+ },
238+ "sender": {
239+ "avatar_url": "<avatar>",
240+ "events_url": "<server>/users/<owner>/events{/privacy}",
241+ "followers_url": "<server>/users/<owner>/followers",
242+ "following_url": "<server>/users/<owner>/following{/other_user}",
243+ "gists_url": "<server>/users/<owner>/gists{/gist_id}",
244+ "gravatar_id": "<avatar>",
245+ "html_url": "<server>/<owner>",
246+ "id": "<id:2>",
247+ "login": "<owner>",
248+ "node_id": "<node_id>",
249+ "organizations_url": "<server>/users/<owner>/orgs",
250+ "received_events_url": "<server>/users/<owner>/received_events",
251+ "repos_url": "<server>/users/<owner>/repos",
252+ "site_admin": false,
253+ "starred_url": "<server>/users/<owner>/starred{/owner}{/repo}",
254+ "subscriptions_url": "<server>/users/<owner>/subscriptions",
255+ "type": "User",
256+ "url": "<server>/users/<owner>",
257+ "user_view_type": "public"
258+ }
259+ },
260+ "event_name": "push",
261+ "event_path": "/home/runner/work/_temp/_github_workflow/event.json",
262+ "graphql_url": "<server>/graphql",
263+ "head_ref": "",
264+ "job": "main",
265+ "output": "/home/runner/work/_temp/_runner_file_commands/set_output_<uuid>",
266+ "path": "/home/runner/work/_temp/_runner_file_commands/add_path_<uuid>",
267+ "ref": "refs/heads/<branch>",
268+ "ref_name": "<branch>",
269+ "ref_protected": false,
270+ "ref_type": "branch",
271+ "repository": "<owner>/<repo>",
272+ "repositoryUrl": "git://<server>/<owner>/<repo>.git",
273+ "repository_id": "<repository_id:1>",
274+ "repository_owner": "<owner>",
275+ "repository_owner_id": "<repository_owner_id:1>",
276+ "repository_visibility": "public",
277+ "retention_days": "90",
278+ "run_attempt": "1",
279+ "run_id": "<run_id:1>",
280+ "run_number": "<run_number:1>",
281+ "secret_source": "Actions",
282+ "server_url": "<server>",
283+ "sha": "<sha:push1>",
284+ "state": "/home/runner/work/_temp/_runner_file_commands/save_state_<uuid>",
285+ "step_summary": "/home/runner/work/_temp/_runner_file_commands/step_summary_<uuid>",
286+ "triggering_actor": "<owner>",
287+ "workflow": "ctx-job-runner",
288+ "workflow_ref": "<owner>/<repo>/.github/workflows/ci.yml@refs/heads/<branch>",
289+ "workflow_sha": "<sha:push1>",
290+ "workspace": "/home/runner/work/<repo>/<repo>"
291+ },
292+ "job": {
293+ "check_run_id": "<check_run_id:1>",
294+ "status": "failure",
295+ "workflow_file_path": ".github/workflows/ci.yml",
296+ "workflow_ref": "<owner>/<repo>/.github/workflows/ci.yml@refs/heads/<branch>",
297+ "workflow_repository": "<owner>/<repo>",
298+ "workflow_sha": "<sha:push1>"
299+ },
300+ "matrix": null,
301+ "strategy": {
302+ "fail-fast": true,
303+ "job-index": 0,
304+ "job-total": 1,
305+ "max-parallel": 1
306+ }
307+}
M internal/app/web.go +1 -0
@@ -41,6 +41,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
4141 MergeRequests: f.MergeRequests,
4242 Runs: f.CI,
4343 Events: f.EventLog,
44+ Owner: cfg.Owner,
4445 PublicURL: cfg.PublicURL,
4546 SourceURL: cfg.SourceURL,
4647 Version: serverVersion(),
@@ -41,6 +41,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
41 MergeRequests: f.MergeRequests,41 MergeRequests: f.MergeRequests,
42 Runs: f.CI,42 Runs: f.CI,
43 Events: f.EventLog,43 Events: f.EventLog,
44+ Owner: cfg.Owner,
44 PublicURL: cfg.PublicURL,45 PublicURL: cfg.PublicURL,
45 SourceURL: cfg.SourceURL,46 SourceURL: cfg.SourceURL,
46 Version: serverVersion(),47 Version: serverVersion(),
M internal/domain/ci.go +5 -0
@@ -245,6 +245,11 @@ type CIJob struct {
245245 // TimeoutMinutes is the timeout of the job, at least 1.
246246 TimeoutMinutes int32
247247 FailFast bool
248+ // ContinueOnError is the job's continue-on-error, evaluated when the job is
249+ // planned: its failure leaves the run, its siblings and the jobs that
250+ // need it unaffected (needs.<job>.result is success). The conclusion of
251+ // the job itself stays failure.
252+ ContinueOnError bool
248253 // MaxParallel is the max-parallel of the strategy, 0 for no limit.
249254 MaxParallel int
250255 Outputs map[string]string
@@ -245,6 +245,11 @@ type CIJob struct {
245 // TimeoutMinutes is the timeout of the job, at least 1.245 // TimeoutMinutes is the timeout of the job, at least 1.
246 TimeoutMinutes int32246 TimeoutMinutes int32
247 FailFast bool247 FailFast bool
248+ // ContinueOnError is the job's continue-on-error, evaluated when the job is
249+ // planned: its failure leaves the run, its siblings and the jobs that
250+ // need it unaffected (needs.<job>.result is success). The conclusion of
251+ // the job itself stays failure.
252+ ContinueOnError bool
248 // MaxParallel is the max-parallel of the strategy, 0 for no limit.253 // MaxParallel is the max-parallel of the strategy, 0 for no limit.
249 MaxParallel int254 MaxParallel int
250 Outputs map[string]string255 Outputs map[string]string
M internal/usecase/ci.go +2 -0
@@ -398,6 +398,8 @@ func (rs *runState) recompute(now time.Time) domain.CIRun {
398398 started = j.StartedAt
399399 }
400400 switch {
401+ case j.Conclusion.IsFailure() && j.ContinueOnError:
402+ // continue-on-error: the job failed, the run does not.
401403 case j.Conclusion.IsFailure():
402404 failed = true
403405 case j.Conclusion == domain.CICancelled:
@@ -398,6 +398,8 @@ func (rs *runState) recompute(now time.Time) domain.CIRun {
398 started = j.StartedAt398 started = j.StartedAt
399 }399 }
400 switch {400 switch {
401+ case j.Conclusion.IsFailure() && j.ContinueOnError:
402+ // continue-on-error: the job failed, the run does not.
401 case j.Conclusion.IsFailure():403 case j.Conclusion.IsFailure():
402 failed = true404 failed = true
403 case j.Conclusion == domain.CICancelled:405 case j.Conclusion == domain.CICancelled:
A internal/usecase/ci_github.go +312 -0
@@ -0,0 +1,312 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/sha256"
9+ "encoding/base64"
10+ "encoding/binary"
11+ "encoding/json"
12+ "net/url"
13+ "strconv"
14+ "strings"
15+ "time"
16+
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+// This file builds the "github" context of a push and the "job" keys the
21+// server sends, to match what github.com gives (recordings of
22+// ermite-conformance, normalised by its normalize.go).
23+//
24+// Value provenance, used in the comments below:
25+// - real: a value ermite has (repository name, visibility, description,
26+// dates, commits, default branch, configuration);
27+// - derived: computed from real values by a stable rule (ids, node ids,
28+// URLs);
29+// - constant: GitHub's value for a repository or account with no such
30+// feature, or a placeholder where ermite has no concept (no avatars,
31+// stars, forks, issues...).
32+
33+const (
34+ // ghSecretSource is github.secret_source: where the secrets of the job
35+ // come from. Constant: ermite has one source.
36+ ghSecretSource = "Actions"
37+ // ghCacheLimit is github.artifact_cache_size_limit, in GB, as recorded on
38+ // github.com. Constant.
39+ ghCacheLimit = "10"
40+ // apiPath is where the GitHub-shaped API base of the payload lives under
41+ // the public URL. github.api_url is that base, github.graphql_url is
42+ // <base>/graphql and the *_url keys of the repository and the users are
43+ // <base>/repos/... and <base>/users/..., as on github.com. ermite serves
44+ // none of them yet: they are identifiers in the shape of GitHub's.
45+ apiPath = "/-/github/api"
46+)
47+
48+// kv is a key and a value of an ordered JSON object.
49+type kv struct {
50+ k string
51+ v any
52+}
53+
54+// ordered is a JSON object that keeps the order of its keys, as the github
55+// context of GitHub does (toJSON(github) prints that order).
56+type ordered []kv
57+
58+func (o ordered) MarshalJSON() ([]byte, error) {
59+ var b bytes.Buffer
60+ b.WriteByte('{')
61+ for i, p := range o {
62+ if i > 0 {
63+ b.WriteByte(',')
64+ }
65+ k, err := json.Marshal(p.k)
66+ if err != nil {
67+ return nil, err
68+ }
69+ v, err := json.Marshal(p.v)
70+ if err != nil {
71+ return nil, err
72+ }
73+ b.Write(k)
74+ b.WriteByte(':')
75+ b.Write(v)
76+ }
77+ b.WriteByte('}')
78+ return b.Bytes(), nil
79+}
80+
81+// stableID is a positive number that depends only on the kind and the name:
82+// the id of a user or of a repository, which ermite does not number. It fits
83+// in a JavaScript number (GitHub's own ids are well below 2^53).
84+func stableID(kind, name string) int64 {
85+ sum := sha256.Sum256([]byte(kind + "\x00" + name))
86+ return int64(binary.BigEndian.Uint64(sum[:8])>>20) + 1<<20
87+}
88+
89+// nodeID is the legacy form of a GraphQL node id: base64 of "<n>:<Type><id>"
90+// (GitHub: "04:User5212137" is "MDQ6VXNlcjUyMTIxMzc=").
91+func nodeID(prefix, typ string, id int64) string {
92+ return base64.StdEncoding.EncodeToString([]byte(prefix + ":" + typ + strconv.FormatInt(id, 10)))
93+}
94+
95+// ghIdentity is the account a push is attributed to.
96+type ghIdentity struct {
97+ login string
98+ id int64
99+}
100+
101+// identity maps the actor of a run to a login: "owner" and "root" (the
102+// owner's session or the root token) are the owner of the forge, a token
103+// label is itself.
104+func (c *CI) identity(actor string) ghIdentity {
105+ login := actor
106+ if actor == "owner" || actor == "root" || actor == "" {
107+ login = c.cfg.Owner
108+ }
109+ return ghIdentity{login: login, id: stableID("user", login)}
110+}
111+
112+// ghURLs holds the URL bases of the payload of one repository.
113+type ghURLs struct {
114+ server string // github.server_url
115+ api string // github.api_url
116+ host string // host (and port) of the server, for git:// and ssh URLs
117+ owner string
118+ name string
119+ full string // <owner>/<repo>
120+ repo string // <server>/<owner>/<repo>, the page GitHub's html_url points at
121+}
122+
123+func (c *CI) urls(repo domain.Repo) ghURLs {
124+ u := ghURLs{server: c.cfg.PublicURL, owner: c.cfg.Owner, name: repo.Name.String()}
125+ if p, err := url.Parse(c.cfg.PublicURL); err == nil {
126+ u.host = p.Host
127+ }
128+ u.api = u.server + apiPath
129+ u.full = u.owner + "/" + u.name
130+ u.repo = u.server + "/" + u.full
131+ return u
132+}
133+
134+// userObj is the account object of an event (sender, repository owner).
135+// Derived: ids from the login; URLs from the API base. Constant: type, no
136+// avatar (ermite has none: "" is what GitHub gives for gravatar_id too), not
137+// a site admin.
138+func (u ghURLs) userObj(id ghIdentity, owner bool) map[string]any {
139+ api := u.api + "/users/" + id.login
140+ o := map[string]any{
141+ "avatar_url": "", "events_url": api + "/events{/privacy}", "followers_url": api + "/followers",
142+ "following_url": api + "/following{/other_user}", "gists_url": api + "/gists{/gist_id}", "gravatar_id": "",
143+ "html_url": u.server + "/" + id.login, "id": id.id, "login": id.login,
144+ "node_id": nodeID("04", "User", id.id),
145+ "organizations_url": api + "/orgs", "received_events_url": api + "/received_events", "repos_url": api + "/repos",
146+ "site_admin": false, "starred_url": api + "/starred{/owner}{/repo}", "subscriptions_url": api + "/subscriptions",
147+ "type": "User", "url": api, "user_view_type": "public",
148+ }
149+ if owner {
150+ o["email"], o["name"] = "", id.login
151+ }
152+ return o
153+}
154+
155+// repoLinks are the URL templates of the repository object, relative to
156+// <api>/repos/<owner>/<repo>. Derived (constant templates, as GitHub's).
157+var repoLinks = map[string]string{
158+ "archive_url": "/{archive_format}{/ref}", "assignees_url": "/assignees{/user}", "blobs_url": "/git/blobs{/sha}",
159+ "branches_url": "/branches{/branch}", "collaborators_url": "/collaborators{/collaborator}",
160+ "comments_url": "/comments{/number}", "commits_url": "/commits{/sha}", "compare_url": "/compare/{base}...{head}",
161+ "contents_url": "/contents/{+path}", "contributors_url": "/contributors", "deployments_url": "/deployments",
162+ "downloads_url": "/downloads", "events_url": "/events", "forks_url": "/forks", "git_commits_url": "/git/commits{/sha}",
163+ "git_refs_url": "/git/refs{/sha}", "git_tags_url": "/git/tags{/sha}", "hooks_url": "/hooks",
164+ "issue_comment_url": "/issues/comments{/number}", "issue_events_url": "/issues/events{/number}",
165+ "issues_url": "/issues{/number}", "keys_url": "/keys{/key_id}", "labels_url": "/labels{/name}",
166+ "languages_url": "/languages", "merges_url": "/merges", "milestones_url": "/milestones{/number}",
167+ "notifications_url": "/notifications{?since,all,participating}", "pulls_url": "/pulls{/number}",
168+ "releases_url": "/releases{/id}", "stargazers_url": "/stargazers", "statuses_url": "/statuses/{sha}",
169+ "subscribers_url": "/subscribers", "subscription_url": "/subscription", "tags_url": "/tags", "teams_url": "/teams",
170+ "trees_url": "/git/trees{/sha}", "url": "",
171+}
172+
173+// repoObj is event.repository: the 83 keys of GitHub's push payload.
174+//
175+// real: name, full_name, private, visibility, description, default_branch,
176+// master_branch, created_at, pushed_at, updated_at
177+// derived: id, node_id, owner, html_url, svn_url, clone_url, git_url,
178+// ssh_url, url and the other *_url keys
179+// constant: the rest (no forks, stars, issues, pages..., the features of a
180+// new GitHub repository; license, language, homepage and
181+// mirror_url null; size 0)
182+func (c *CI) repoObj(repo domain.Repo, u ghURLs, defaultBranch string, at time.Time) map[string]any {
183+ owner := c.identity("owner")
184+ id := stableID("repo", u.full)
185+ pushed := repo.PushedAt
186+ if pushed.IsZero() {
187+ pushed = at
188+ }
189+ var desc any
190+ if repo.Description != "" {
191+ desc = repo.Description
192+ }
193+ r := map[string]any{
194+ "allow_forking": true, "archived": false, "clone_url": u.repo + ".git",
195+ "created_at": repo.CreatedAt.Unix(), "default_branch": defaultBranch, "description": desc,
196+ "disabled": false, "fork": false, "forks": 0, "forks_count": 0, "full_name": u.full,
197+ "git_url": "git://" + u.host + "/" + u.full + ".git",
198+ "has_discussions": false, "has_downloads": true, "has_issues": true, "has_pages": false, "has_projects": true,
199+ "has_pull_requests": true, "has_wiki": true, "homepage": nil, "html_url": u.repo, "id": id,
200+ "is_template": false, "language": nil, "license": nil, "master_branch": defaultBranch, "mirror_url": nil,
201+ "name": u.name, "node_id": nodeID("010", "Repository", id), "open_issues": 0, "open_issues_count": 0,
202+ "owner": u.userObj(owner, true),
203+ "private": repo.Visibility == domain.VisibilityPrivate, "pull_request_creation_policy": "all",
204+ "pushed_at": pushed.Unix(), "size": 0, "ssh_url": "git@" + u.host + ":" + u.full + ".git",
205+ "stargazers": 0, "stargazers_count": 0, "svn_url": u.repo, "topics": []string{},
206+ "updated_at": pushed.UTC().Format(time.RFC3339), "visibility": string(repo.Visibility),
207+ "watchers": 0, "watchers_count": 0, "web_commit_signoff_required": false,
208+ }
209+ for k, suffix := range repoLinks {
210+ r[k] = u.api + "/repos/" + u.full + suffix
211+ }
212+ return r
213+}
214+
215+// commitJSON is one commit of the push event: GitHub's push payload has
216+// exactly these keys in the recordings (no added/removed/modified, no
217+// username).
218+func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, u ghURLs, cm domain.Commit) map[string]any {
219+ tree := ""
220+ if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil {
221+ tree = id.String()
222+ }
223+ return map[string]any{
224+ "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message,
225+ "timestamp": cm.Committer.Time.UTC().Format(time.RFC3339),
226+ "url": u.repo + "/commit/" + cm.ID.String(),
227+ "author": person(cm.Author.Name, cm.Author.Email), "committer": person(cm.Committer.Name, cm.Committer.Email),
228+ }
229+}
230+
231+func person(name, email string) map[string]any { return map[string]any{"name": name, "email": email} }
232+
233+// githubJSON builds the "github" context of the run as GitHub does for a push
234+// (the job-dependent keys, job and run_attempt, are filled in when a job is
235+// planned). Keys the runner owns (token, workspace, action, event_path, path,
236+// env, output, state, step_summary, artifacts...) are not here.
237+//
238+// The order of the keys is GitHub's, so that toJSON(github) prints the same.
239+// Ids and counters are strings, so is retention_days; "compare" has
240+// 12-character ids: the commit page for a created ref with one commit,
241+// "<first>^...<after>" for a created ref with several, "<before>...<after>" for
242+// an update.
243+func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts) string {
244+ u := c.urls(repo)
245+ id := c.identity(run.Actor)
246+ owner := c.identity("owner")
247+ refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"
248+ if f.isTag {
249+ refName, refType = strings.TrimPrefix(run.Ref, "refs/tags/"), "tag"
250+ }
251+ before := f.update.Old
252+ if f.created {
253+ before = domain.ZeroOID
254+ }
255+ compare := u.repo + "/compare/" + shortID(before) + "..." + shortID(f.update.New)
256+ switch {
257+ case f.created && len(f.commits) > 1:
258+ compare = u.repo + "/compare/" + shortID(f.commits[0].ID.String()) + "^..." + shortID(f.update.New)
259+ case f.created:
260+ compare = u.repo + "/commit/" + shortID(f.update.New)
261+ }
262+ commits := make([]any, 0, len(f.commits))
263+ for _, cm := range f.commits {
264+ commits = append(commits, c.commitJSON(ctx, repo, u, cm))
265+ }
266+ event := map[string]any{
267+ "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced,
268+ "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(ctx, repo, u, f.head),
269+ "pusher": map[string]any{"name": id.login, "email": ""},
270+ "repository": c.repoObj(repo, u, f.defaultBranch, run.CreatedAt),
271+ "sender": u.userObj(id, false),
272+ }
273+ repoID := strconv.FormatInt(stableID("repo", u.full), 10)
274+ gh := ordered{
275+ {"job", ""}, {"ref", run.Ref}, {"sha", run.Sha}, {"repository", u.full}, {"repository_owner", c.cfg.Owner},
276+ {"repository_owner_id", strconv.FormatInt(owner.id, 10)}, {"repositoryUrl", "git://" + u.host + "/" + u.full + ".git"},
277+ {"run_id", strconv.FormatInt(run.ID, 10)}, {"run_number", strconv.FormatInt(run.Number, 10)},
278+ {"retention_days", strconv.Itoa(c.retentionDays())}, {"run_attempt", "1"},
279+ {"artifact_cache_size_limit", ghCacheLimit}, {"repository_visibility", string(repo.Visibility)},
280+ {"actor_id", strconv.FormatInt(id.id, 10)}, {"actor", id.login}, {"workflow", run.WorkflowName},
281+ {"head_ref", ""}, {"base_ref", ""}, {"event_name", "push"}, {"server_url", u.server},
282+ {"api_url", u.api}, {"graphql_url", u.api + "/graphql"}, {"ref_name", refName}, {"ref_protected", false},
283+ {"ref_type", refType}, {"secret_source", ghSecretSource}, {"event", event},
284+ {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref}, {"workflow_sha", run.Sha},
285+ {"repository_id", repoID}, {"triggering_actor", id.login},
286+ }
287+ b, err := json.Marshal(gh)
288+ if err != nil {
289+ return "{}"
290+ }
291+ return string(b)
292+}
293+
294+// checkRunID is the number github.com shows in job.check_run_id: derived from
295+// the position of the job instance, as ermite's checks are not numbered. It is
296+// unique and stable per run, job and matrix leg, and fits in a JavaScript
297+// number.
298+func checkRunID(run domain.CIRun, index, combo int) int64 {
299+ return run.ID<<20 | int64(index&0x7ff)<<9 | int64(combo&0x1ff)
300+}
301+
302+// jobKeys are the keys of the "job" context that the server knows, in
303+// GitHub's order (the runner adds "status"). The runner copies the "job" key
304+// of the contexts it is given into the job context.
305+func (c *CI) jobKeys(run domain.CIRun, index, combo int) ordered {
306+ u := c.urls(domain.Repo{Name: run.Repo})
307+ return ordered{
308+ {"check_run_id", checkRunID(run, index, combo)},
309+ {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref},
310+ {"workflow_sha", run.Sha}, {"workflow_repository", u.full}, {"workflow_file_path", run.WorkflowPath},
311+ }
312+}
@@ -0,0 +1,312 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/sha256"
9+ "encoding/base64"
10+ "encoding/binary"
11+ "encoding/json"
12+ "net/url"
13+ "strconv"
14+ "strings"
15+ "time"
16+
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+// This file builds the "github" context of a push and the "job" keys the
21+// server sends, to match what github.com gives (recordings of
22+// ermite-conformance, normalised by its normalize.go).
23+//
24+// Value provenance, used in the comments below:
25+// - real: a value ermite has (repository name, visibility, description,
26+// dates, commits, default branch, configuration);
27+// - derived: computed from real values by a stable rule (ids, node ids,
28+// URLs);
29+// - constant: GitHub's value for a repository or account with no such
30+// feature, or a placeholder where ermite has no concept (no avatars,
31+// stars, forks, issues...).
32+
33+const (
34+ // ghSecretSource is github.secret_source: where the secrets of the job
35+ // come from. Constant: ermite has one source.
36+ ghSecretSource = "Actions"
37+ // ghCacheLimit is github.artifact_cache_size_limit, in GB, as recorded on
38+ // github.com. Constant.
39+ ghCacheLimit = "10"
40+ // apiPath is where the GitHub-shaped API base of the payload lives under
41+ // the public URL. github.api_url is that base, github.graphql_url is
42+ // <base>/graphql and the *_url keys of the repository and the users are
43+ // <base>/repos/... and <base>/users/..., as on github.com. ermite serves
44+ // none of them yet: they are identifiers in the shape of GitHub's.
45+ apiPath = "/-/github/api"
46+)
47+
48+// kv is a key and a value of an ordered JSON object.
49+type kv struct {
50+ k string
51+ v any
52+}
53+
54+// ordered is a JSON object that keeps the order of its keys, as the github
55+// context of GitHub does (toJSON(github) prints that order).
56+type ordered []kv
57+
58+func (o ordered) MarshalJSON() ([]byte, error) {
59+ var b bytes.Buffer
60+ b.WriteByte('{')
61+ for i, p := range o {
62+ if i > 0 {
63+ b.WriteByte(',')
64+ }
65+ k, err := json.Marshal(p.k)
66+ if err != nil {
67+ return nil, err
68+ }
69+ v, err := json.Marshal(p.v)
70+ if err != nil {
71+ return nil, err
72+ }
73+ b.Write(k)
74+ b.WriteByte(':')
75+ b.Write(v)
76+ }
77+ b.WriteByte('}')
78+ return b.Bytes(), nil
79+}
80+
81+// stableID is a positive number that depends only on the kind and the name:
82+// the id of a user or of a repository, which ermite does not number. It fits
83+// in a JavaScript number (GitHub's own ids are well below 2^53).
84+func stableID(kind, name string) int64 {
85+ sum := sha256.Sum256([]byte(kind + "\x00" + name))
86+ return int64(binary.BigEndian.Uint64(sum[:8])>>20) + 1<<20
87+}
88+
89+// nodeID is the legacy form of a GraphQL node id: base64 of "<n>:<Type><id>"
90+// (GitHub: "04:User5212137" is "MDQ6VXNlcjUyMTIxMzc=").
91+func nodeID(prefix, typ string, id int64) string {
92+ return base64.StdEncoding.EncodeToString([]byte(prefix + ":" + typ + strconv.FormatInt(id, 10)))
93+}
94+
95+// ghIdentity is the account a push is attributed to.
96+type ghIdentity struct {
97+ login string
98+ id int64
99+}
100+
101+// identity maps the actor of a run to a login: "owner" and "root" (the
102+// owner's session or the root token) are the owner of the forge, a token
103+// label is itself.
104+func (c *CI) identity(actor string) ghIdentity {
105+ login := actor
106+ if actor == "owner" || actor == "root" || actor == "" {
107+ login = c.cfg.Owner
108+ }
109+ return ghIdentity{login: login, id: stableID("user", login)}
110+}
111+
112+// ghURLs holds the URL bases of the payload of one repository.
113+type ghURLs struct {
114+ server string // github.server_url
115+ api string // github.api_url
116+ host string // host (and port) of the server, for git:// and ssh URLs
117+ owner string
118+ name string
119+ full string // <owner>/<repo>
120+ repo string // <server>/<owner>/<repo>, the page GitHub's html_url points at
121+}
122+
123+func (c *CI) urls(repo domain.Repo) ghURLs {
124+ u := ghURLs{server: c.cfg.PublicURL, owner: c.cfg.Owner, name: repo.Name.String()}
125+ if p, err := url.Parse(c.cfg.PublicURL); err == nil {
126+ u.host = p.Host
127+ }
128+ u.api = u.server + apiPath
129+ u.full = u.owner + "/" + u.name
130+ u.repo = u.server + "/" + u.full
131+ return u
132+}
133+
134+// userObj is the account object of an event (sender, repository owner).
135+// Derived: ids from the login; URLs from the API base. Constant: type, no
136+// avatar (ermite has none: "" is what GitHub gives for gravatar_id too), not
137+// a site admin.
138+func (u ghURLs) userObj(id ghIdentity, owner bool) map[string]any {
139+ api := u.api + "/users/" + id.login
140+ o := map[string]any{
141+ "avatar_url": "", "events_url": api + "/events{/privacy}", "followers_url": api + "/followers",
142+ "following_url": api + "/following{/other_user}", "gists_url": api + "/gists{/gist_id}", "gravatar_id": "",
143+ "html_url": u.server + "/" + id.login, "id": id.id, "login": id.login,
144+ "node_id": nodeID("04", "User", id.id),
145+ "organizations_url": api + "/orgs", "received_events_url": api + "/received_events", "repos_url": api + "/repos",
146+ "site_admin": false, "starred_url": api + "/starred{/owner}{/repo}", "subscriptions_url": api + "/subscriptions",
147+ "type": "User", "url": api, "user_view_type": "public",
148+ }
149+ if owner {
150+ o["email"], o["name"] = "", id.login
151+ }
152+ return o
153+}
154+
155+// repoLinks are the URL templates of the repository object, relative to
156+// <api>/repos/<owner>/<repo>. Derived (constant templates, as GitHub's).
157+var repoLinks = map[string]string{
158+ "archive_url": "/{archive_format}{/ref}", "assignees_url": "/assignees{/user}", "blobs_url": "/git/blobs{/sha}",
159+ "branches_url": "/branches{/branch}", "collaborators_url": "/collaborators{/collaborator}",
160+ "comments_url": "/comments{/number}", "commits_url": "/commits{/sha}", "compare_url": "/compare/{base}...{head}",
161+ "contents_url": "/contents/{+path}", "contributors_url": "/contributors", "deployments_url": "/deployments",
162+ "downloads_url": "/downloads", "events_url": "/events", "forks_url": "/forks", "git_commits_url": "/git/commits{/sha}",
163+ "git_refs_url": "/git/refs{/sha}", "git_tags_url": "/git/tags{/sha}", "hooks_url": "/hooks",
164+ "issue_comment_url": "/issues/comments{/number}", "issue_events_url": "/issues/events{/number}",
165+ "issues_url": "/issues{/number}", "keys_url": "/keys{/key_id}", "labels_url": "/labels{/name}",
166+ "languages_url": "/languages", "merges_url": "/merges", "milestones_url": "/milestones{/number}",
167+ "notifications_url": "/notifications{?since,all,participating}", "pulls_url": "/pulls{/number}",
168+ "releases_url": "/releases{/id}", "stargazers_url": "/stargazers", "statuses_url": "/statuses/{sha}",
169+ "subscribers_url": "/subscribers", "subscription_url": "/subscription", "tags_url": "/tags", "teams_url": "/teams",
170+ "trees_url": "/git/trees{/sha}", "url": "",
171+}
172+
173+// repoObj is event.repository: the 83 keys of GitHub's push payload.
174+//
175+// real: name, full_name, private, visibility, description, default_branch,
176+// master_branch, created_at, pushed_at, updated_at
177+// derived: id, node_id, owner, html_url, svn_url, clone_url, git_url,
178+// ssh_url, url and the other *_url keys
179+// constant: the rest (no forks, stars, issues, pages..., the features of a
180+// new GitHub repository; license, language, homepage and
181+// mirror_url null; size 0)
182+func (c *CI) repoObj(repo domain.Repo, u ghURLs, defaultBranch string, at time.Time) map[string]any {
183+ owner := c.identity("owner")
184+ id := stableID("repo", u.full)
185+ pushed := repo.PushedAt
186+ if pushed.IsZero() {
187+ pushed = at
188+ }
189+ var desc any
190+ if repo.Description != "" {
191+ desc = repo.Description
192+ }
193+ r := map[string]any{
194+ "allow_forking": true, "archived": false, "clone_url": u.repo + ".git",
195+ "created_at": repo.CreatedAt.Unix(), "default_branch": defaultBranch, "description": desc,
196+ "disabled": false, "fork": false, "forks": 0, "forks_count": 0, "full_name": u.full,
197+ "git_url": "git://" + u.host + "/" + u.full + ".git",
198+ "has_discussions": false, "has_downloads": true, "has_issues": true, "has_pages": false, "has_projects": true,
199+ "has_pull_requests": true, "has_wiki": true, "homepage": nil, "html_url": u.repo, "id": id,
200+ "is_template": false, "language": nil, "license": nil, "master_branch": defaultBranch, "mirror_url": nil,
201+ "name": u.name, "node_id": nodeID("010", "Repository", id), "open_issues": 0, "open_issues_count": 0,
202+ "owner": u.userObj(owner, true),
203+ "private": repo.Visibility == domain.VisibilityPrivate, "pull_request_creation_policy": "all",
204+ "pushed_at": pushed.Unix(), "size": 0, "ssh_url": "git@" + u.host + ":" + u.full + ".git",
205+ "stargazers": 0, "stargazers_count": 0, "svn_url": u.repo, "topics": []string{},
206+ "updated_at": pushed.UTC().Format(time.RFC3339), "visibility": string(repo.Visibility),
207+ "watchers": 0, "watchers_count": 0, "web_commit_signoff_required": false,
208+ }
209+ for k, suffix := range repoLinks {
210+ r[k] = u.api + "/repos/" + u.full + suffix
211+ }
212+ return r
213+}
214+
215+// commitJSON is one commit of the push event: GitHub's push payload has
216+// exactly these keys in the recordings (no added/removed/modified, no
217+// username).
218+func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, u ghURLs, cm domain.Commit) map[string]any {
219+ tree := ""
220+ if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil {
221+ tree = id.String()
222+ }
223+ return map[string]any{
224+ "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message,
225+ "timestamp": cm.Committer.Time.UTC().Format(time.RFC3339),
226+ "url": u.repo + "/commit/" + cm.ID.String(),
227+ "author": person(cm.Author.Name, cm.Author.Email), "committer": person(cm.Committer.Name, cm.Committer.Email),
228+ }
229+}
230+
231+func person(name, email string) map[string]any { return map[string]any{"name": name, "email": email} }
232+
233+// githubJSON builds the "github" context of the run as GitHub does for a push
234+// (the job-dependent keys, job and run_attempt, are filled in when a job is
235+// planned). Keys the runner owns (token, workspace, action, event_path, path,
236+// env, output, state, step_summary, artifacts...) are not here.
237+//
238+// The order of the keys is GitHub's, so that toJSON(github) prints the same.
239+// Ids and counters are strings, so is retention_days; "compare" has
240+// 12-character ids: the commit page for a created ref with one commit,
241+// "<first>^...<after>" for a created ref with several, "<before>...<after>" for
242+// an update.
243+func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts) string {
244+ u := c.urls(repo)
245+ id := c.identity(run.Actor)
246+ owner := c.identity("owner")
247+ refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"
248+ if f.isTag {
249+ refName, refType = strings.TrimPrefix(run.Ref, "refs/tags/"), "tag"
250+ }
251+ before := f.update.Old
252+ if f.created {
253+ before = domain.ZeroOID
254+ }
255+ compare := u.repo + "/compare/" + shortID(before) + "..." + shortID(f.update.New)
256+ switch {
257+ case f.created && len(f.commits) > 1:
258+ compare = u.repo + "/compare/" + shortID(f.commits[0].ID.String()) + "^..." + shortID(f.update.New)
259+ case f.created:
260+ compare = u.repo + "/commit/" + shortID(f.update.New)
261+ }
262+ commits := make([]any, 0, len(f.commits))
263+ for _, cm := range f.commits {
264+ commits = append(commits, c.commitJSON(ctx, repo, u, cm))
265+ }
266+ event := map[string]any{
267+ "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced,
268+ "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(ctx, repo, u, f.head),
269+ "pusher": map[string]any{"name": id.login, "email": ""},
270+ "repository": c.repoObj(repo, u, f.defaultBranch, run.CreatedAt),
271+ "sender": u.userObj(id, false),
272+ }
273+ repoID := strconv.FormatInt(stableID("repo", u.full), 10)
274+ gh := ordered{
275+ {"job", ""}, {"ref", run.Ref}, {"sha", run.Sha}, {"repository", u.full}, {"repository_owner", c.cfg.Owner},
276+ {"repository_owner_id", strconv.FormatInt(owner.id, 10)}, {"repositoryUrl", "git://" + u.host + "/" + u.full + ".git"},
277+ {"run_id", strconv.FormatInt(run.ID, 10)}, {"run_number", strconv.FormatInt(run.Number, 10)},
278+ {"retention_days", strconv.Itoa(c.retentionDays())}, {"run_attempt", "1"},
279+ {"artifact_cache_size_limit", ghCacheLimit}, {"repository_visibility", string(repo.Visibility)},
280+ {"actor_id", strconv.FormatInt(id.id, 10)}, {"actor", id.login}, {"workflow", run.WorkflowName},
281+ {"head_ref", ""}, {"base_ref", ""}, {"event_name", "push"}, {"server_url", u.server},
282+ {"api_url", u.api}, {"graphql_url", u.api + "/graphql"}, {"ref_name", refName}, {"ref_protected", false},
283+ {"ref_type", refType}, {"secret_source", ghSecretSource}, {"event", event},
284+ {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref}, {"workflow_sha", run.Sha},
285+ {"repository_id", repoID}, {"triggering_actor", id.login},
286+ }
287+ b, err := json.Marshal(gh)
288+ if err != nil {
289+ return "{}"
290+ }
291+ return string(b)
292+}
293+
294+// checkRunID is the number github.com shows in job.check_run_id: derived from
295+// the position of the job instance, as ermite's checks are not numbered. It is
296+// unique and stable per run, job and matrix leg, and fits in a JavaScript
297+// number.
298+func checkRunID(run domain.CIRun, index, combo int) int64 {
299+ return run.ID<<20 | int64(index&0x7ff)<<9 | int64(combo&0x1ff)
300+}
301+
302+// jobKeys are the keys of the "job" context that the server knows, in
303+// GitHub's order (the runner adds "status"). The runner copies the "job" key
304+// of the contexts it is given into the job context.
305+func (c *CI) jobKeys(run domain.CIRun, index, combo int) ordered {
306+ u := c.urls(domain.Repo{Name: run.Repo})
307+ return ordered{
308+ {"check_run_id", checkRunID(run, index, combo)},
309+ {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref},
310+ {"workflow_sha", run.Sha}, {"workflow_repository", u.full}, {"workflow_file_path", run.WorkflowPath},
311+ }
312+}
M internal/usecase/ci_plan.go +40 -23
@@ -67,34 +67,20 @@ func unsupported(j *workflow.Job) string {
6767 return "environment"
6868 case j.Concurrency != nil:
6969 return "concurrency"
70- case present(j.ContinueOnError):
71- return "continue-on-error"
7270 }
7371 for _, s := range j.Steps {
7472 switch {
7573 case s.Uses != nil:
7674 return "uses: " + s.Uses.Value
77- case present(s.ContinueOnError):
78- return "continue-on-error on a step"
79- case present(s.TimeoutMinutes):
75+ case s.TimeoutMinutes != nil:
76+ // The runner refuses it too (executor.notSupported): it has no
77+ // step timeout yet.
8078 return "timeout-minutes on a step"
8179 }
8280 }
8381 return ""
8482 }
8583
86-// present reports whether an optional key is given and is not a literal
87-// false (continue-on-error: false is the default).
88-func present(t template.Token) bool {
89- if t == nil {
90- return false
91- }
92- if b, ok := t.(*template.Boolean); ok && !b.Value {
93- return false
94- }
95- return true
96-}
97-
9884 // instances returns the indexes of the records of the job key, in order.
9985 func (rs *runState) instances(key string) []int {
10086 var out []int
@@ -120,6 +106,10 @@ func (rs *runState) sortJobs() {
120106 func result(insts []domain.CIJob) string {
121107 var failed, cancelled, ok bool
122108 for _, j := range insts {
109+ if j.ContinueOnError && j.Conclusion.IsFailure() {
110+ ok = true // recorded: needs.<job>.result is success
111+ continue
112+ }
123113 switch j.Conclusion {
124114 case domain.CIFailure, domain.CITimedOut, domain.CIActionRequired:
125115 failed = true
@@ -210,8 +200,8 @@ func (rs *runState) githubValue(key string, attempt int32) (*expr.Dictionary, er
210200 if !ok {
211201 return nil, fmt.Errorf("the stored github context is not an object")
212202 }
213- d.Add("job", expr.String(key))
214- d.Add("run_attempt", expr.String(strconv.Itoa(int(attempt))))
203+ setKey(d, "job", expr.String(key))
204+ setKey(d, "run_attempt", expr.String(strconv.Itoa(int(attempt))))
215205 return d, nil
216206 }
217207
@@ -246,7 +236,7 @@ func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg str
246236 delete(rs.c.heartbeats, jobRef(*j))
247237 rs.c.mu.Unlock()
248238
249- if j.FailFast && conclusion.IsFailure() {
239+ if j.FailFast && !j.ContinueOnError && conclusion.IsFailure() {
250240 for i := range rs.jobs {
251241 s := &rs.jobs[i]
252242 if s.Key != j.Key || s.ID == j.ID || s.Ended() {
@@ -357,7 +347,10 @@ func (rs *runState) planJob(ctx context.Context, wj *workflow.Job, idx int, need
357347 rec := rs.jobs[idx]
358348 fail := func(msg string) { rs.end(&rs.jobs[idx], domain.CIFailure, msg) }
359349
360- gh, err := rs.githubValue(wj.ID, rs.run.Attempt)
350+ // github.job is empty while the job is evaluated (if, strategy, name,
351+ // runs-on...): the runner sets it. Recorded: ctx-github-names, where
352+ // "name: Display name of ${{ github.job }}" shows as "Display name of".
353+ gh, err := rs.githubValue("", rs.run.Attempt)
361354 if err != nil {
362355 fail(err.Error())
363356 return
@@ -431,6 +424,7 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl
431424 if err != nil {
432425 return j, err
433426 }
427+ name = strings.TrimSpace(name) // GitHub shows "Display name of", not "Display name of "
434428 labels, _, err := wj.EvaluateRunsOn(ctx, env)
435429 if err != nil {
436430 return j, err
@@ -451,7 +445,11 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl
451445 return j, err
452446 }
453447 matrix := plan.Combos[i].Values
454- contexts, err := contextsJSON(gh, needs, plan.Context(i), matrix)
448+ var matrixCtx expr.Value = matrix
449+ if !plan.HasMatrix {
450+ matrixCtx = expr.Null{} // GitHub: "matrix": null for a job without a matrix
451+ }
452+ contexts, err := contextsJSON(gh, needs, plan.Context(i), matrixCtx, rs.c.jobKeys(rs.run, base.Index, i))
455453 if err != nil {
456454 return j, err
457455 }
@@ -459,6 +457,15 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl
459457 j.MatrixJSON = compactJSON(expr.ToJSON(matrix))
460458 j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos)
461459 j.FailFast, j.MaxParallel = plan.FailFast, plan.MaxParallel
460+ j.ContinueOnError = false
461+ if wj.ContinueOnError != nil {
462+ // Evaluated with github, needs, strategy, matrix, vars and inputs.
463+ coe, err := template.EvaluateBool(ctx, wj.ContinueOnError, env)
464+ if err != nil {
465+ return j, err
466+ }
467+ j.ContinueOnError = coe
468+ }
462469 j.Planned, j.Phase = true, domain.CIPhaseWaiting
463470 return j, nil
464471 }
@@ -473,7 +480,12 @@ func compactJSON(s string) string {
473480
474481 // contextsJSON is the JSON object of the contexts the server computes for a
475482 // job (Job.contexts in runner.proto).
476-func contextsJSON(github, needs *expr.Dictionary, strategy, matrix *expr.Dictionary) (string, error) {
483+//
484+// "job" holds the keys of the job context the server knows (check_run_id,
485+// workflow_ref, workflow_sha, workflow_repository, workflow_file_path); the
486+// runner adds status. The runner copies the keys of this "job" into its job
487+// context (executor.State.initRest), whatever they are.
488+func contextsJSON(github, needs, strategy *expr.Dictionary, matrix expr.Value, job ordered) (string, error) {
477489 out := map[string]json.RawMessage{}
478490 for k, v := range map[string]expr.Value{
479491 "github": github, "needs": needs, "strategy": strategy, "matrix": matrix,
@@ -481,6 +493,11 @@ func contextsJSON(github, needs *expr.Dictionary, strategy, matrix *expr.Diction
481493 } {
482494 out[k] = json.RawMessage(compactJSON(expr.ToJSON(v)))
483495 }
496+ jb, err := json.Marshal(job)
497+ if err != nil {
498+ return "", err
499+ }
500+ out["job"] = jb
484501 b, err := json.Marshal(out)
485502 if err != nil {
486503 return "", err
@@ -67,34 +67,20 @@ func unsupported(j *workflow.Job) string {
67 return "environment"67 return "environment"
68 case j.Concurrency != nil:68 case j.Concurrency != nil:
69 return "concurrency"69 return "concurrency"
70- case present(j.ContinueOnError):
71- return "continue-on-error"
72 }70 }
73 for _, s := range j.Steps {71 for _, s := range j.Steps {
74 switch {72 switch {
75 case s.Uses != nil:73 case s.Uses != nil:
76 return "uses: " + s.Uses.Value74 return "uses: " + s.Uses.Value
77- case present(s.ContinueOnError):75+ case s.TimeoutMinutes != nil:
78- return "continue-on-error on a step"76+ // The runner refuses it too (executor.notSupported): it has no
79- case present(s.TimeoutMinutes):77+ // step timeout yet.
80 return "timeout-minutes on a step"78 return "timeout-minutes on a step"
81 }79 }
82 }80 }
83 return ""81 return ""
84 }82 }
85 83
86-// present reports whether an optional key is given and is not a literal
87-// false (continue-on-error: false is the default).
88-func present(t template.Token) bool {
89- if t == nil {
90- return false
91- }
92- if b, ok := t.(*template.Boolean); ok && !b.Value {
93- return false
94- }
95- return true
96-}
97-
98 // instances returns the indexes of the records of the job key, in order.84 // instances returns the indexes of the records of the job key, in order.
99 func (rs *runState) instances(key string) []int {85 func (rs *runState) instances(key string) []int {
100 var out []int86 var out []int
@@ -120,6 +106,10 @@ func (rs *runState) sortJobs() {
120 func result(insts []domain.CIJob) string {106 func result(insts []domain.CIJob) string {
121 var failed, cancelled, ok bool107 var failed, cancelled, ok bool
122 for _, j := range insts {108 for _, j := range insts {
109+ if j.ContinueOnError && j.Conclusion.IsFailure() {
110+ ok = true // recorded: needs.<job>.result is success
111+ continue
112+ }
123 switch j.Conclusion {113 switch j.Conclusion {
124 case domain.CIFailure, domain.CITimedOut, domain.CIActionRequired:114 case domain.CIFailure, domain.CITimedOut, domain.CIActionRequired:
125 failed = true115 failed = true
@@ -210,8 +200,8 @@ func (rs *runState) githubValue(key string, attempt int32) (*expr.Dictionary, er
210 if !ok {200 if !ok {
211 return nil, fmt.Errorf("the stored github context is not an object")201 return nil, fmt.Errorf("the stored github context is not an object")
212 }202 }
213- d.Add("job", expr.String(key))203+ setKey(d, "job", expr.String(key))
214- d.Add("run_attempt", expr.String(strconv.Itoa(int(attempt))))204+ setKey(d, "run_attempt", expr.String(strconv.Itoa(int(attempt))))
215 return d, nil205 return d, nil
216 }206 }
217 207
@@ -246,7 +236,7 @@ func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg str
246 delete(rs.c.heartbeats, jobRef(*j))236 delete(rs.c.heartbeats, jobRef(*j))
247 rs.c.mu.Unlock()237 rs.c.mu.Unlock()
248 238
249- if j.FailFast && conclusion.IsFailure() {239+ if j.FailFast && !j.ContinueOnError && conclusion.IsFailure() {
250 for i := range rs.jobs {240 for i := range rs.jobs {
251 s := &rs.jobs[i]241 s := &rs.jobs[i]
252 if s.Key != j.Key || s.ID == j.ID || s.Ended() {242 if s.Key != j.Key || s.ID == j.ID || s.Ended() {
@@ -357,7 +347,10 @@ func (rs *runState) planJob(ctx context.Context, wj *workflow.Job, idx int, need
357 rec := rs.jobs[idx]347 rec := rs.jobs[idx]
358 fail := func(msg string) { rs.end(&rs.jobs[idx], domain.CIFailure, msg) }348 fail := func(msg string) { rs.end(&rs.jobs[idx], domain.CIFailure, msg) }
359 349
360- gh, err := rs.githubValue(wj.ID, rs.run.Attempt)350+ // github.job is empty while the job is evaluated (if, strategy, name,
351+ // runs-on...): the runner sets it. Recorded: ctx-github-names, where
352+ // "name: Display name of ${{ github.job }}" shows as "Display name of".
353+ gh, err := rs.githubValue("", rs.run.Attempt)
361 if err != nil {354 if err != nil {
362 fail(err.Error())355 fail(err.Error())
363 return356 return
@@ -431,6 +424,7 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl
431 if err != nil {424 if err != nil {
432 return j, err425 return j, err
433 }426 }
427+ name = strings.TrimSpace(name) // GitHub shows "Display name of", not "Display name of "
434 labels, _, err := wj.EvaluateRunsOn(ctx, env)428 labels, _, err := wj.EvaluateRunsOn(ctx, env)
435 if err != nil {429 if err != nil {
436 return j, err430 return j, err
@@ -451,7 +445,11 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl
451 return j, err445 return j, err
452 }446 }
453 matrix := plan.Combos[i].Values447 matrix := plan.Combos[i].Values
454- contexts, err := contextsJSON(gh, needs, plan.Context(i), matrix)448+ var matrixCtx expr.Value = matrix
449+ if !plan.HasMatrix {
450+ matrixCtx = expr.Null{} // GitHub: "matrix": null for a job without a matrix
451+ }
452+ contexts, err := contextsJSON(gh, needs, plan.Context(i), matrixCtx, rs.c.jobKeys(rs.run, base.Index, i))
455 if err != nil {453 if err != nil {
456 return j, err454 return j, err
457 }455 }
@@ -459,6 +457,15 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl
459 j.MatrixJSON = compactJSON(expr.ToJSON(matrix))457 j.MatrixJSON = compactJSON(expr.ToJSON(matrix))
460 j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos)458 j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos)
461 j.FailFast, j.MaxParallel = plan.FailFast, plan.MaxParallel459 j.FailFast, j.MaxParallel = plan.FailFast, plan.MaxParallel
460+ j.ContinueOnError = false
461+ if wj.ContinueOnError != nil {
462+ // Evaluated with github, needs, strategy, matrix, vars and inputs.
463+ coe, err := template.EvaluateBool(ctx, wj.ContinueOnError, env)
464+ if err != nil {
465+ return j, err
466+ }
467+ j.ContinueOnError = coe
468+ }
462 j.Planned, j.Phase = true, domain.CIPhaseWaiting469 j.Planned, j.Phase = true, domain.CIPhaseWaiting
463 return j, nil470 return j, nil
464 }471 }
@@ -473,7 +480,12 @@ func compactJSON(s string) string {
473 480
474 // contextsJSON is the JSON object of the contexts the server computes for a481 // contextsJSON is the JSON object of the contexts the server computes for a
475 // job (Job.contexts in runner.proto).482 // job (Job.contexts in runner.proto).
476-func contextsJSON(github, needs *expr.Dictionary, strategy, matrix *expr.Dictionary) (string, error) {483+//
484+// "job" holds the keys of the job context the server knows (check_run_id,
485+// workflow_ref, workflow_sha, workflow_repository, workflow_file_path); the
486+// runner adds status. The runner copies the keys of this "job" into its job
487+// context (executor.State.initRest), whatever they are.
488+func contextsJSON(github, needs, strategy *expr.Dictionary, matrix expr.Value, job ordered) (string, error) {
477 out := map[string]json.RawMessage{}489 out := map[string]json.RawMessage{}
478 for k, v := range map[string]expr.Value{490 for k, v := range map[string]expr.Value{
479 "github": github, "needs": needs, "strategy": strategy, "matrix": matrix,491 "github": github, "needs": needs, "strategy": strategy, "matrix": matrix,
@@ -481,6 +493,11 @@ func contextsJSON(github, needs *expr.Dictionary, strategy, matrix *expr.Diction
481 } {493 } {
482 out[k] = json.RawMessage(compactJSON(expr.ToJSON(v)))494 out[k] = json.RawMessage(compactJSON(expr.ToJSON(v)))
483 }495 }
496+ jb, err := json.Marshal(job)
497+ if err != nil {
498+ return "", err
499+ }
500+ out["job"] = jb
484 b, err := json.Marshal(out)501 b, err := json.Marshal(out)
485 if err != nil {502 if err != nil {
486 return "", err503 return "", err
M internal/usecase/ci_trigger.go +80 -84
@@ -4,7 +4,6 @@ package usecase
44
55 import (
66 "context"
7- "encoding/json"
87 "errors"
98 "fmt"
109 "io"
@@ -17,6 +16,8 @@ import (
1716
1817 "github.com/bnema/zerowrap"
1918
19+ "bnema.dev/ermite-actions/expr"
20+ "bnema.dev/ermite-actions/template"
2021 "bnema.dev/ermite-actions/workflow"
2122 "bnema.dev/ermite/internal/domain"
2223 "bnema.dev/ermite/internal/ports"
@@ -122,7 +123,7 @@ type pushFacts struct {
122123 update domain.RefUpdate
123124 commit domain.ObjectID // the commit the ref points to, tags followed
124125 head domain.Commit
125- commits []domain.Commit // oldest first, at most maxPushCommits (branches only)
126+ commits []domain.Commit // oldest first, at most maxPushCommits
126127 forced bool
127128 created bool
128129 files []string
@@ -168,6 +169,10 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag
168169 Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount,
169170 }):
170171 default:
172+ if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem != "" {
173+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem))
174+ continue
175+ }
171176 errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))
172177 }
173178 }
@@ -190,14 +195,29 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
190195 }
191196 f.commit = res.CommitID
192197 if isTag {
193- cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
198+ // Recorded (push-tag, skip-directive-tag): the event of a tag carries
199+ // the commits that the default branch does not have, like a created
200+ // branch, newest as head_commit. No file list: paths filters are not
201+ // evaluated for tags.
202+ base := c.defaultBase(ctx, repo, "", f.commit)
203+ cs, _, err := c.cfg.Git.ListRange(ctx, repo, base, f.commit, ports.MaxCommitLimit)
194204 if err != nil {
195205 return f, fmt.Errorf("read the commit of the tag: %w", err)
196206 }
197207 if len(cs) == 0 {
198- return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
208+ cs, _, err = c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
209+ if err != nil {
210+ return f, fmt.Errorf("read the commit of the tag: %w", err)
211+ }
212+ if len(cs) == 0 {
213+ return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
214+ }
215+ f.head = cs[0]
216+ return f, nil
199217 }
200218 f.head = cs[0]
219+ f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)])
220+ slices.Reverse(f.commits)
201221 return f, nil
202222 }
203223
@@ -385,8 +405,10 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
385405 Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
386406 Status: domain.CIQueued, CreatedAt: now,
387407 }
408+ githubJSON := c.githubJSON(ctx, repo, run, f)
409+ run.Title = c.runTitle(ctx, wf, githubJSON, run.Title)
388410 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
389- Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: c.githubJSON(ctx, repo, run, f, wf),
411+ Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON,
390412 }}
391413 for i, j := range wf.Jobs {
392414 job := domain.CIJob{
@@ -416,7 +438,59 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
416438 return c.advanceRun(ctx, draft.Run.ID)
417439 }
418440
419-// --- the github context ---
441+// runTitle is the title of a run, GitHub's display_title: the run-name
442+// evaluated with the github, inputs and vars contexts (the contexts the
443+// schema allows there), else the first line of the head commit message. A
444+// run-name that does not evaluate, or that evaluates to nothing, falls back to
445+// the commit message, and the failure is logged.
446+func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
447+ if wf.RunName == nil {
448+ return commitTitle
449+ }
450+ gh, err := expr.FromJSON([]byte(githubJSON))
451+ if err != nil {
452+ return commitTitle
453+ }
454+ env := template.Env{Contexts: map[string]expr.Value{
455+ "github": gh, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
456+ }}
457+ name, err := template.EvaluateString(ctx, wf.RunName, env)
458+ if err != nil {
459+ log := zerowrap.FromCtx(ctx)
460+ log.Warn().Err(err).Msg("ci run-name does not evaluate: the run is titled with the commit message")
461+ return commitTitle
462+ }
463+ if name = firstLine(name); name == "" {
464+ return commitTitle
465+ }
466+ return name
467+}
468+
469+// matrixProblem evaluates the strategy of the jobs that can be evaluated
470+// before the run starts (those that read no needs output) and returns the
471+// error text when a matrix produces more configurations than GitHub allows:
472+// GitHub then fails the run with no job, like an invalid workflow file
473+// (recorded: matrix-over-256). Other strategy errors stay job failures, found
474+// when the job is planned.
475+func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) string {
476+ 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()}
477+ gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f)))
478+ if err != nil {
479+ return ""
480+ }
481+ env := template.Env{Contexts: map[string]expr.Value{
482+ "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
483+ }}
484+ for _, j := range wf.Jobs {
485+ if _, err := j.EvaluateStrategy(ctx, env); err != nil && strings.Contains(err.Error(), "which exceeds the maximum of") {
486+ // ermite-actions' configurationLimitError is not exported.
487+ return wfPath + ": " + err.Error()
488+ }
489+ }
490+ return ""
491+}
492+
493+// --- the github context (see ci_github.go) ---
420494
421495 // shortID is the 12-character form of an object id that GitHub puts in the
422496 // compare URL of a push.
@@ -432,81 +506,3 @@ func shortID(id string) string {
432506 func (c *CI) retentionDays() int {
433507 return max(int(c.cfg.Retention/(24*time.Hour)), 1)
434508 }
435-
436-type obj = map[string]any
437-
438-func person(name, email string) obj { return obj{"name": name, "email": email} }
439-
440-func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, cm domain.Commit) obj {
441- ts := cm.Committer.Time.UTC().Format(time.RFC3339)
442- tree := ""
443- if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil {
444- tree = id.String()
445- }
446- return obj{
447- "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message, "timestamp": ts,
448- "url": c.cfg.PublicURL + "/" + repo.Name.String() + "/commit/" + cm.ID.String(),
449- "author": obj{"name": cm.Author.Name, "email": cm.Author.Email, "username": ""},
450- "committer": obj{"name": cm.Committer.Name, "email": cm.Committer.Email, "username": ""},
451- "added": []string{}, "removed": []string{}, "modified": []string{},
452- }
453-}
454-
455-// githubJSON builds the "github" context of the run as GitHub does for a push
456-// (the job-dependent keys are added when a job is planned). Values that
457-// GitHub's documentation does not settle are marked for the recordings.
458-//
459-// Checked against the recordings of ermite-conformance (push-branch-create,
460-// push-tag, push-force, ctx-github-names): ids and counters are strings, so is
461-// retention_days; "compare" has 12-character ids, the commit page for a created
462-// ref and "<before>...<after>" for an update; repository.default_branch and
463-// master_branch are the default branch.
464-//
465-// TODO(verify with recording): the empty added/removed/modified lists of the
466-// push commits.
467-func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts, wf *workflow.Workflow) string {
468- full := c.cfg.Owner + "/" + repo.Name.String()
469- refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"
470- if f.isTag {
471- refName, refType = strings.TrimPrefix(run.Ref, "refs/tags/"), "tag"
472- }
473- repoURL := c.cfg.PublicURL + "/" + repo.Name.String()
474- before := f.update.Old
475- if f.created {
476- before = domain.ZeroOID
477- }
478- compare := repoURL + "/compare/" + shortID(before) + "..." + shortID(f.update.New)
479- if f.created {
480- compare = repoURL + "/commit/" + shortID(f.update.New)
481- }
482- commits := make([]any, 0, len(f.commits))
483- for _, cm := range f.commits {
484- commits = append(commits, c.commitJSON(ctx, repo, cm))
485- }
486- event := obj{
487- "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced,
488- "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(ctx, repo, f.head),
489- "pusher": person(run.Actor, ""),
490- "repository": obj{
491- "name": repo.Name.String(), "full_name": full, "private": repo.Visibility == domain.VisibilityPrivate,
492- "owner": obj{"login": c.cfg.Owner, "name": c.cfg.Owner}, "html_url": repoURL, "clone_url": repoURL + ".git",
493- "default_branch": f.defaultBranch, "master_branch": f.defaultBranch, "url": repoURL,
494- },
495- "sender": obj{"login": run.Actor, "type": "User"},
496- }
497- wfRef := full + "/" + run.WorkflowPath + "@" + run.Ref
498- gh := obj{
499- "event_name": "push", "event": event, "ref": run.Ref, "ref_name": refName, "ref_type": refType, "ref_protected": false,
500- "sha": run.Sha, "repository": full, "repository_owner": c.cfg.Owner, "repositoryUrl": repoURL + ".git",
501- "actor": run.Actor, "triggering_actor": run.Actor, "workflow": run.WorkflowName, "workflow_ref": wfRef,
502- "workflow_sha": run.Sha, "run_id": strconv.FormatInt(run.ID, 10), "run_number": strconv.FormatInt(run.Number, 10),
503- "server_url": c.cfg.PublicURL, "api_url": c.cfg.PublicURL + "/-/github/api/v3",
504- "graphql_url": c.cfg.PublicURL + "/-/github/api/graphql", "retention_days": strconv.Itoa(c.retentionDays()),
505- "head_ref": "", "base_ref": "",
506- }
507- b, err := json.Marshal(gh)
508- if err != nil {
509- return "{}"
510- }
511- return string(b)
512-}
@@ -4,7 +4,6 @@ package usecase
4 4
5 import (5 import (
6 "context"6 "context"
7- "encoding/json"
8 "errors"7 "errors"
9 "fmt"8 "fmt"
10 "io"9 "io"
@@ -17,6 +16,8 @@ import (
17 16
18 "github.com/bnema/zerowrap"17 "github.com/bnema/zerowrap"
19 18
19+ "bnema.dev/ermite-actions/expr"
20+ "bnema.dev/ermite-actions/template"
20 "bnema.dev/ermite-actions/workflow"21 "bnema.dev/ermite-actions/workflow"
21 "bnema.dev/ermite/internal/domain"22 "bnema.dev/ermite/internal/domain"
22 "bnema.dev/ermite/internal/ports"23 "bnema.dev/ermite/internal/ports"
@@ -122,7 +123,7 @@ type pushFacts struct {
122 update domain.RefUpdate123 update domain.RefUpdate
123 commit domain.ObjectID // the commit the ref points to, tags followed124 commit domain.ObjectID // the commit the ref points to, tags followed
124 head domain.Commit125 head domain.Commit
125- commits []domain.Commit // oldest first, at most maxPushCommits (branches only)126+ commits []domain.Commit // oldest first, at most maxPushCommits
126 forced bool127 forced bool
127 created bool128 created bool
128 files []string129 files []string
@@ -168,6 +169,10 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag
168 Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount,169 Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount,
169 }):170 }):
170 default:171 default:
172+ if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem != "" {
173+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem))
174+ continue
175+ }
171 errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))176 errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))
172 }177 }
173 }178 }
@@ -190,14 +195,29 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
190 }195 }
191 f.commit = res.CommitID196 f.commit = res.CommitID
192 if isTag {197 if isTag {
193- cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)198+ // Recorded (push-tag, skip-directive-tag): the event of a tag carries
199+ // the commits that the default branch does not have, like a created
200+ // branch, newest as head_commit. No file list: paths filters are not
201+ // evaluated for tags.
202+ base := c.defaultBase(ctx, repo, "", f.commit)
203+ cs, _, err := c.cfg.Git.ListRange(ctx, repo, base, f.commit, ports.MaxCommitLimit)
194 if err != nil {204 if err != nil {
195 return f, fmt.Errorf("read the commit of the tag: %w", err)205 return f, fmt.Errorf("read the commit of the tag: %w", err)
196 }206 }
197 if len(cs) == 0 {207 if len(cs) == 0 {
198- return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)208+ cs, _, err = c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
209+ if err != nil {
210+ return f, fmt.Errorf("read the commit of the tag: %w", err)
211+ }
212+ if len(cs) == 0 {
213+ return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
214+ }
215+ f.head = cs[0]
216+ return f, nil
199 }217 }
200 f.head = cs[0]218 f.head = cs[0]
219+ f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)])
220+ slices.Reverse(f.commits)
201 return f, nil221 return f, nil
202 }222 }
203 223
@@ -385,8 +405,10 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
385 Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,405 Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
386 Status: domain.CIQueued, CreatedAt: now,406 Status: domain.CIQueued, CreatedAt: now,
387 }407 }
408+ githubJSON := c.githubJSON(ctx, repo, run, f)
409+ run.Title = c.runTitle(ctx, wf, githubJSON, run.Title)
388 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{410 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
389- Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: c.githubJSON(ctx, repo, run, f, wf),411+ Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON,
390 }}412 }}
391 for i, j := range wf.Jobs {413 for i, j := range wf.Jobs {
392 job := domain.CIJob{414 job := domain.CIJob{
@@ -416,7 +438,59 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
416 return c.advanceRun(ctx, draft.Run.ID)438 return c.advanceRun(ctx, draft.Run.ID)
417 }439 }
418 440
419-// --- the github context ---441+// runTitle is the title of a run, GitHub's display_title: the run-name
442+// evaluated with the github, inputs and vars contexts (the contexts the
443+// schema allows there), else the first line of the head commit message. A
444+// run-name that does not evaluate, or that evaluates to nothing, falls back to
445+// the commit message, and the failure is logged.
446+func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
447+ if wf.RunName == nil {
448+ return commitTitle
449+ }
450+ gh, err := expr.FromJSON([]byte(githubJSON))
451+ if err != nil {
452+ return commitTitle
453+ }
454+ env := template.Env{Contexts: map[string]expr.Value{
455+ "github": gh, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
456+ }}
457+ name, err := template.EvaluateString(ctx, wf.RunName, env)
458+ if err != nil {
459+ log := zerowrap.FromCtx(ctx)
460+ log.Warn().Err(err).Msg("ci run-name does not evaluate: the run is titled with the commit message")
461+ return commitTitle
462+ }
463+ if name = firstLine(name); name == "" {
464+ return commitTitle
465+ }
466+ return name
467+}
468+
469+// matrixProblem evaluates the strategy of the jobs that can be evaluated
470+// before the run starts (those that read no needs output) and returns the
471+// error text when a matrix produces more configurations than GitHub allows:
472+// GitHub then fails the run with no job, like an invalid workflow file
473+// (recorded: matrix-over-256). Other strategy errors stay job failures, found
474+// when the job is planned.
475+func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) string {
476+ 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()}
477+ gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f)))
478+ if err != nil {
479+ return ""
480+ }
481+ env := template.Env{Contexts: map[string]expr.Value{
482+ "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
483+ }}
484+ for _, j := range wf.Jobs {
485+ if _, err := j.EvaluateStrategy(ctx, env); err != nil && strings.Contains(err.Error(), "which exceeds the maximum of") {
486+ // ermite-actions' configurationLimitError is not exported.
487+ return wfPath + ": " + err.Error()
488+ }
489+ }
490+ return ""
491+}
492+
493+// --- the github context (see ci_github.go) ---
420 494
421 // shortID is the 12-character form of an object id that GitHub puts in the495 // shortID is the 12-character form of an object id that GitHub puts in the
422 // compare URL of a push.496 // compare URL of a push.
@@ -432,81 +506,3 @@ func shortID(id string) string {
432 func (c *CI) retentionDays() int {506 func (c *CI) retentionDays() int {
433 return max(int(c.cfg.Retention/(24*time.Hour)), 1)507 return max(int(c.cfg.Retention/(24*time.Hour)), 1)
434 }508 }
435-
436-type obj = map[string]any
437-
438-func person(name, email string) obj { return obj{"name": name, "email": email} }
439-
440-func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, cm domain.Commit) obj {
441- ts := cm.Committer.Time.UTC().Format(time.RFC3339)
442- tree := ""
443- if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil {
444- tree = id.String()
445- }
446- return obj{
447- "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message, "timestamp": ts,
448- "url": c.cfg.PublicURL + "/" + repo.Name.String() + "/commit/" + cm.ID.String(),
449- "author": obj{"name": cm.Author.Name, "email": cm.Author.Email, "username": ""},
450- "committer": obj{"name": cm.Committer.Name, "email": cm.Committer.Email, "username": ""},
451- "added": []string{}, "removed": []string{}, "modified": []string{},
452- }
453-}
454-
455-// githubJSON builds the "github" context of the run as GitHub does for a push
456-// (the job-dependent keys are added when a job is planned). Values that
457-// GitHub's documentation does not settle are marked for the recordings.
458-//
459-// Checked against the recordings of ermite-conformance (push-branch-create,
460-// push-tag, push-force, ctx-github-names): ids and counters are strings, so is
461-// retention_days; "compare" has 12-character ids, the commit page for a created
462-// ref and "<before>...<after>" for an update; repository.default_branch and
463-// master_branch are the default branch.
464-//
465-// TODO(verify with recording): the empty added/removed/modified lists of the
466-// push commits.
467-func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts, wf *workflow.Workflow) string {
468- full := c.cfg.Owner + "/" + repo.Name.String()
469- refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"
470- if f.isTag {
471- refName, refType = strings.TrimPrefix(run.Ref, "refs/tags/"), "tag"
472- }
473- repoURL := c.cfg.PublicURL + "/" + repo.Name.String()
474- before := f.update.Old
475- if f.created {
476- before = domain.ZeroOID
477- }
478- compare := repoURL + "/compare/" + shortID(before) + "..." + shortID(f.update.New)
479- if f.created {
480- compare = repoURL + "/commit/" + shortID(f.update.New)
481- }
482- commits := make([]any, 0, len(f.commits))
483- for _, cm := range f.commits {
484- commits = append(commits, c.commitJSON(ctx, repo, cm))
485- }
486- event := obj{
487- "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced,
488- "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(ctx, repo, f.head),
489- "pusher": person(run.Actor, ""),
490- "repository": obj{
491- "name": repo.Name.String(), "full_name": full, "private": repo.Visibility == domain.VisibilityPrivate,
492- "owner": obj{"login": c.cfg.Owner, "name": c.cfg.Owner}, "html_url": repoURL, "clone_url": repoURL + ".git",
493- "default_branch": f.defaultBranch, "master_branch": f.defaultBranch, "url": repoURL,
494- },
495- "sender": obj{"login": run.Actor, "type": "User"},
496- }
497- wfRef := full + "/" + run.WorkflowPath + "@" + run.Ref
498- gh := obj{
499- "event_name": "push", "event": event, "ref": run.Ref, "ref_name": refName, "ref_type": refType, "ref_protected": false,
500- "sha": run.Sha, "repository": full, "repository_owner": c.cfg.Owner, "repositoryUrl": repoURL + ".git",
501- "actor": run.Actor, "triggering_actor": run.Actor, "workflow": run.WorkflowName, "workflow_ref": wfRef,
502- "workflow_sha": run.Sha, "run_id": strconv.FormatInt(run.ID, 10), "run_number": strconv.FormatInt(run.Number, 10),
503- "server_url": c.cfg.PublicURL, "api_url": c.cfg.PublicURL + "/-/github/api/v3",
504- "graphql_url": c.cfg.PublicURL + "/-/github/api/graphql", "retention_days": strconv.Itoa(c.retentionDays()),
505- "head_ref": "", "base_ref": "",
506- }
507- b, err := json.Marshal(gh)
508- if err != nil {
509- return "{}"
510- }
511- return string(b)
512-}
M proto/ermite/storage/ci.proto +4 -0
@@ -180,6 +180,10 @@ message CIJob {
180180 // The contexts JSON given to the runner, computed when the job is planned
181181 // (needs outputs are final then).
182182 string contexts_json = 39;
183+ // The job's continue-on-error, evaluated when the job is planned: a failure
184+ // of the job does not fail the run, does not cancel the siblings of its
185+ // matrix, and needs.<job>.result is success.
186+ bool continue_on_error = 40;
183187 }
184188
185189 // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or
@@ -180,6 +180,10 @@ message CIJob {
180 // The contexts JSON given to the runner, computed when the job is planned180 // The contexts JSON given to the runner, computed when the job is planned
181 // (needs outputs are final then).181 // (needs outputs are final then).
182 string contexts_json = 39;182 string contexts_json = 39;
183+ // The job's continue-on-error, evaluated when the job is planned: a failure
184+ // of the job does not fail the run, does not cancel the siblings of its
185+ // matrix, and needs.<job>.result is success.
186+ bool continue_on_error = 40;
183 }187 }
184 188
185 // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or189 // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or

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 42

↑↓ moveenter openesc close