detached eaf4295

Merge pull request 'feat(ci): GitHub-shaped contexts, owner URLs, run titles and continue-on-error' (#18) from feat/ci-github-parity into main

commit
eaf429522ea50dc2d9e4532bf0740d8d267f18d2
author
bnema · · 2026-10-13 00:32 UTC
merge of
45bfc5b + c1a72b4 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.8.0
18 files · +1630 -120
M gen/ermite/storage/ci.pb.go +17 -5
@@ -786,9 +786,13 @@ type CIJob struct {
786786 RunAttempt int32 `protobuf:"varint,38,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"`
787787 // The contexts JSON given to the runner, computed when the job is planned
788788 // (needs outputs are final then).
789- ContextsJson string `protobuf:"bytes,39,opt,name=contexts_json,json=contextsJson,proto3" json:"contexts_json,omitempty"`
790- unknownFields protoimpl.UnknownFields
791- sizeCache protoimpl.SizeCache
789+ ContextsJson string `protobuf:"bytes,39,opt,name=contexts_json,json=contextsJson,proto3" json:"contexts_json,omitempty"`
790+ // The job's continue-on-error, evaluated when the job is planned: a failure
791+ // of the job does not fail the run, does not cancel the siblings of its
792+ // matrix, and needs.<job>.result is success.
793+ ContinueOnError bool `protobuf:"varint,40,opt,name=continue_on_error,json=continueOnError,proto3" json:"continue_on_error,omitempty"`
794+ unknownFields protoimpl.UnknownFields
795+ sizeCache protoimpl.SizeCache
792796 }
793797
794798 func (x *CIJob) Reset() {
@@ -1087,6 +1091,13 @@ func (x *CIJob) GetContextsJson() string {
10871091 return ""
10881092 }
10891093
1094+func (x *CIJob) GetContinueOnError() bool {
1095+ if x != nil {
1096+ return x.ContinueOnError
1097+ }
1098+ return false
1099+}
1100+
10901101 // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or
10911102 // "run-<run id>" for a run that failed without a job (invalid workflow file).
10921103 type CICheck struct {
@@ -1386,7 +1397,7 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
13861397 "\fcompleted_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12\x1b\n" +
13871398 "\tlog_start\x18\a \x01(\x03R\blogStart\x12\x1b\n" +
13881399 "\thas_lines\x18\t \x01(\bR\bhasLinesJ\x04\b\b\x10\tJ\x04\b\n" +
1389- "\x10\v\"\xba\f\n" +
1400+ "\x10\v\"\xe6\f\n" +
13901401 "\x05CIJob\x12\x0e\n" +
13911402 "\x02id\x18\x01 \x01(\tR\x02id\x12\x15\n" +
13921403 "\x06run_id\x18\x02 \x01(\x03R\x05runId\x12\x18\n" +
@@ -1436,7 +1447,8 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
14361447 "\aplanned\x18% \x01(\bR\aplanned\x12\x1f\n" +
14371448 "\vrun_attempt\x18& \x01(\x05R\n" +
14381449 "runAttempt\x12#\n" +
1439- "\rcontexts_json\x18' \x01(\tR\fcontextsJson\x1a:\n" +
1450+ "\rcontexts_json\x18' \x01(\tR\fcontextsJson\x12*\n" +
1451+ "\x11continue_on_error\x18( \x01(\bR\x0fcontinueOnError\x1a:\n" +
14401452 "\fOutputsEntry\x12\x10\n" +
14411453 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
14421454 "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b$\x10%\"\xe5\x03\n" +
@@ -786,9 +786,13 @@ type CIJob struct {
786 RunAttempt int32 `protobuf:"varint,38,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"`786 RunAttempt int32 `protobuf:"varint,38,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"`
787 // The contexts JSON given to the runner, computed when the job is planned787 // The contexts JSON given to the runner, computed when the job is planned
788 // (needs outputs are final then).788 // (needs outputs are final then).
789- ContextsJson string `protobuf:"bytes,39,opt,name=contexts_json,json=contextsJson,proto3" json:"contexts_json,omitempty"`789+ ContextsJson string `protobuf:"bytes,39,opt,name=contexts_json,json=contextsJson,proto3" json:"contexts_json,omitempty"`
790- unknownFields protoimpl.UnknownFields790+ // The job's continue-on-error, evaluated when the job is planned: a failure
791- sizeCache protoimpl.SizeCache791+ // of the job does not fail the run, does not cancel the siblings of its
792+ // matrix, and needs.<job>.result is success.
793+ ContinueOnError bool `protobuf:"varint,40,opt,name=continue_on_error,json=continueOnError,proto3" json:"continue_on_error,omitempty"`
794+ unknownFields protoimpl.UnknownFields
795+ sizeCache protoimpl.SizeCache
792 }796 }
793 797
794 func (x *CIJob) Reset() {798 func (x *CIJob) Reset() {
@@ -1087,6 +1091,13 @@ func (x *CIJob) GetContextsJson() string {
1087 return ""1091 return ""
1088 }1092 }
1089 1093
1094+func (x *CIJob) GetContinueOnError() bool {
1095+ if x != nil {
1096+ return x.ContinueOnError
1097+ }
1098+ return false
1099+}
1100+
1090 // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or1101 // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or
1091 // "run-<run id>" for a run that failed without a job (invalid workflow file).1102 // "run-<run id>" for a run that failed without a job (invalid workflow file).
1092 type CICheck struct {1103 type CICheck struct {
@@ -1386,7 +1397,7 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
1386 "\fcompleted_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12\x1b\n" +1397 "\fcompleted_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12\x1b\n" +
1387 "\tlog_start\x18\a \x01(\x03R\blogStart\x12\x1b\n" +1398 "\tlog_start\x18\a \x01(\x03R\blogStart\x12\x1b\n" +
1388 "\thas_lines\x18\t \x01(\bR\bhasLinesJ\x04\b\b\x10\tJ\x04\b\n" +1399 "\thas_lines\x18\t \x01(\bR\bhasLinesJ\x04\b\b\x10\tJ\x04\b\n" +
1389- "\x10\v\"\xba\f\n" +1400+ "\x10\v\"\xe6\f\n" +
1390 "\x05CIJob\x12\x0e\n" +1401 "\x05CIJob\x12\x0e\n" +
1391 "\x02id\x18\x01 \x01(\tR\x02id\x12\x15\n" +1402 "\x02id\x18\x01 \x01(\tR\x02id\x12\x15\n" +
1392 "\x06run_id\x18\x02 \x01(\x03R\x05runId\x12\x18\n" +1403 "\x06run_id\x18\x02 \x01(\x03R\x05runId\x12\x18\n" +
@@ -1436,7 +1447,8 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
1436 "\aplanned\x18% \x01(\bR\aplanned\x12\x1f\n" +1447 "\aplanned\x18% \x01(\bR\aplanned\x12\x1f\n" +
1437 "\vrun_attempt\x18& \x01(\x05R\n" +1448 "\vrun_attempt\x18& \x01(\x05R\n" +
1438 "runAttempt\x12#\n" +1449 "runAttempt\x12#\n" +
1439- "\rcontexts_json\x18' \x01(\tR\fcontextsJson\x1a:\n" +1450+ "\rcontexts_json\x18' \x01(\tR\fcontextsJson\x12*\n" +
1451+ "\x11continue_on_error\x18( \x01(\bR\x0fcontinueOnError\x1a:\n" +
1440 "\fOutputsEntry\x12\x10\n" +1452 "\fOutputsEntry\x12\x10\n" +
1441 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +1453 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
1442 "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b$\x10%\"\xe5\x03\n" +1454 "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b$\x10%\"\xe5\x03\n" +
M go.mod +1 -1
@@ -12,7 +12,7 @@ tool (
1212 )
1313
1414 require (
15- bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4
15+ bnema.dev/ermite-actions v0.0.0-20261013001922-b6a9ba732350
1616 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
@@ -12,7 +12,7 @@ tool (
12 )12 )
13 13
14 require (14 require (
15- bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a415+ bnema.dev/ermite-actions v0.0.0-20261013001922-b6a9ba732350
16 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c16 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c318 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
M go.sum +2 -2 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()),
A internal/adapters/web/ownerredirect_test.go +119 -0
@@ -0,0 +1,119 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "errors"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+const testOwner = "owner"
20+
21+// ownerRedirect sends /<owner>/<rest> to /<rest> whenever no repository named
22+// like the owner is visible, and only for a clean repository segment.
23+func TestOwnerRedirect(t *testing.T) {
24+ t.Parallel()
25+ none := reposServing(t)
26+ tests := []struct {
27+ name string
28+ method string
29+ target string
30+ repos func(t *testing.T) *mocks.RepoService
31+ want string // "" : not redirected
32+ }{
33+ {"a repository page", http.MethodGet, "/owner/demo", nil, "/demo"},
34+ {"a repository page with a slash", http.MethodGet, "/owner/demo/", nil, "/demo/"},
35+ {"a sub-route", http.MethodGet, "/owner/demo/commit/abc123", nil, "/demo/commit/abc123"},
36+ {"head", http.MethodHead, "/owner/demo", nil, "/demo"},
37+ {"post is not redirected", http.MethodPost, "/owner/demo", nil, ""},
38+ {"query is kept", http.MethodGet, "/owner/demo/commits/main?a=1&b=x%20y", nil, "/demo/commits/main?a=1&b=x%20y"},
39+ {"a repository named like a route", http.MethodGet, "/owner/actions", nil, "/actions"},
40+ {"a repository named like a route, with a path", http.MethodGet, "/owner/actions/actions/runs/1", nil, "/actions/actions/runs/1"},
41+ {"git smart HTTP", http.MethodGet, "/owner/repo.git/info/refs?service=git-upload-pack", nil, "/repo.git/info/refs?service=git-upload-pack"},
42+ {"the owner alone", http.MethodGet, "/owner", nil, ""},
43+ {"the owner with a slash", http.MethodGet, "/owner/", nil, ""},
44+ {"another first segment", http.MethodGet, "/other/demo", nil, ""},
45+ {"a prefix of the owner", http.MethodGet, "/owners/demo", nil, ""},
46+ {"a double slash host", http.MethodGet, "//evil.com", nil, ""},
47+ {"a double slash host after the owner", http.MethodGet, "/owner//evil.com", nil, ""},
48+ {"an encoded slash", http.MethodGet, "/owner/%2Fevil.com", nil, ""},
49+ {"an encoded slash after the repository", http.MethodGet, "/owner/demo%2F/x", nil, ""},
50+ {"an encoded backslash", http.MethodGet, "/owner/%5Cevil.com", nil, ""},
51+ {"a backslash", http.MethodGet, `/owner/\evil.com`, nil, ""},
52+ {"dot dot", http.MethodGet, "/owner/..", nil, ""},
53+ {"dot dot after the repository", http.MethodGet, "/owner/../etc", nil, ""},
54+ {"encoded dot dot", http.MethodGet, "/owner/%2e%2e/etc", nil, ""},
55+ {"a dot", http.MethodGet, "/owner/.", nil, ""},
56+ {"an uppercase name", http.MethodGet, "/owner/Demo", nil, ""},
57+ {"the /-/ routes", http.MethodGet, "/owner/-/events", nil, ""},
58+ {"a bare .git", http.MethodGet, "/owner/.git/info/refs", nil, ""},
59+ {"a repository really named like the owner", http.MethodGet, "/owner/demo", func(t *testing.T) *mocks.RepoService {
60+ return reposServing(t, publicRepo(t, testOwner))
61+ }, ""},
62+ {"a route of the repository named like the owner", http.MethodGet, "/owner/actions", func(t *testing.T) *mocks.RepoService {
63+ return reposServing(t, publicRepo(t, testOwner), publicRepo(t, "actions"))
64+ }, ""},
65+ {"a private repository named like the owner is a missing one", http.MethodGet, "/owner/demo", func(t *testing.T) *mocks.RepoService {
66+ m := mocks.NewRepoService(t)
67+ m.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
68+ return m
69+ }, "/demo"},
70+ {"a failing lookup serves the request as it is", http.MethodGet, "/owner/demo", func(t *testing.T) *mocks.RepoService {
71+ return reposFailing(t, errors.New("store down"))
72+ }, ""},
73+ }
74+ for _, tt := range tests {
75+ t.Run(tt.name, func(t *testing.T) {
76+ t.Parallel()
77+ repos := none
78+ if tt.repos != nil {
79+ repos = tt.repos(t)
80+ }
81+ srv := newTestServer(t, Config{Repos: repos, Browse: mocks.NewBrowseService(t), Owner: testOwner})
82+ req := httptest.NewRequest(tt.method, tt.target, nil)
83+ to, ok := srv.ownerRedirect(req)
84+ assert.Equal(t, tt.want != "", ok)
85+ assert.Equal(t, tt.want, to)
86+ if !ok {
87+ return
88+ }
89+ // Whatever the target is, it stays on this origin.
90+ require.True(t, to[0] == '/' && (len(to) == 1 || (to[1] != '/' && to[1] != '\\')), "target %q", to)
91+ })
92+ }
93+}
94+
95+// Through ServeHTTP the redirect is a 308 with the Location, and the paths
96+// that are not redirected reach the router.
97+func TestOwnerRedirectServed(t *testing.T) {
98+ t.Parallel()
99+ srv := newTestServer(t, Config{Repos: reposServing(t), Browse: mocks.NewBrowseService(t), Owner: testOwner})
100+ rec := httptest.NewRecorder()
101+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/owner/actions?x=1", nil))
102+ assert.Equal(t, http.StatusPermanentRedirect, rec.Code)
103+ assert.Equal(t, "/actions?x=1", rec.Header().Get("Location"))
104+
105+ rec = httptest.NewRecorder()
106+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "//evil.com", nil))
107+ // The mux cleans the path itself and answers with a local redirect: the
108+ // owner redirect adds none, and nothing leads off this origin.
109+ assert.NotEqual(t, http.StatusPermanentRedirect, rec.Code)
110+ assert.Equal(t, "/evil.com", rec.Header().Get("Location"))
111+}
112+
113+// Without an owner there is no redirect.
114+func TestOwnerRedirectOff(t *testing.T) {
115+ t.Parallel()
116+ srv := newTestServer(t, Config{Repos: reposServing(t), Browse: mocks.NewBrowseService(t)})
117+ _, ok := srv.ownerRedirect(httptest.NewRequest(http.MethodGet, "/owner/demo", nil))
118+ assert.False(t, ok)
119+}
@@ -0,0 +1,119 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "errors"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+const testOwner = "owner"
20+
21+// ownerRedirect sends /<owner>/<rest> to /<rest> whenever no repository named
22+// like the owner is visible, and only for a clean repository segment.
23+func TestOwnerRedirect(t *testing.T) {
24+ t.Parallel()
25+ none := reposServing(t)
26+ tests := []struct {
27+ name string
28+ method string
29+ target string
30+ repos func(t *testing.T) *mocks.RepoService
31+ want string // "" : not redirected
32+ }{
33+ {"a repository page", http.MethodGet, "/owner/demo", nil, "/demo"},
34+ {"a repository page with a slash", http.MethodGet, "/owner/demo/", nil, "/demo/"},
35+ {"a sub-route", http.MethodGet, "/owner/demo/commit/abc123", nil, "/demo/commit/abc123"},
36+ {"head", http.MethodHead, "/owner/demo", nil, "/demo"},
37+ {"post is not redirected", http.MethodPost, "/owner/demo", nil, ""},
38+ {"query is kept", http.MethodGet, "/owner/demo/commits/main?a=1&b=x%20y", nil, "/demo/commits/main?a=1&b=x%20y"},
39+ {"a repository named like a route", http.MethodGet, "/owner/actions", nil, "/actions"},
40+ {"a repository named like a route, with a path", http.MethodGet, "/owner/actions/actions/runs/1", nil, "/actions/actions/runs/1"},
41+ {"git smart HTTP", http.MethodGet, "/owner/repo.git/info/refs?service=git-upload-pack", nil, "/repo.git/info/refs?service=git-upload-pack"},
42+ {"the owner alone", http.MethodGet, "/owner", nil, ""},
43+ {"the owner with a slash", http.MethodGet, "/owner/", nil, ""},
44+ {"another first segment", http.MethodGet, "/other/demo", nil, ""},
45+ {"a prefix of the owner", http.MethodGet, "/owners/demo", nil, ""},
46+ {"a double slash host", http.MethodGet, "//evil.com", nil, ""},
47+ {"a double slash host after the owner", http.MethodGet, "/owner//evil.com", nil, ""},
48+ {"an encoded slash", http.MethodGet, "/owner/%2Fevil.com", nil, ""},
49+ {"an encoded slash after the repository", http.MethodGet, "/owner/demo%2F/x", nil, ""},
50+ {"an encoded backslash", http.MethodGet, "/owner/%5Cevil.com", nil, ""},
51+ {"a backslash", http.MethodGet, `/owner/\evil.com`, nil, ""},
52+ {"dot dot", http.MethodGet, "/owner/..", nil, ""},
53+ {"dot dot after the repository", http.MethodGet, "/owner/../etc", nil, ""},
54+ {"encoded dot dot", http.MethodGet, "/owner/%2e%2e/etc", nil, ""},
55+ {"a dot", http.MethodGet, "/owner/.", nil, ""},
56+ {"an uppercase name", http.MethodGet, "/owner/Demo", nil, ""},
57+ {"the /-/ routes", http.MethodGet, "/owner/-/events", nil, ""},
58+ {"a bare .git", http.MethodGet, "/owner/.git/info/refs", nil, ""},
59+ {"a repository really named like the owner", http.MethodGet, "/owner/demo", func(t *testing.T) *mocks.RepoService {
60+ return reposServing(t, publicRepo(t, testOwner))
61+ }, ""},
62+ {"a route of the repository named like the owner", http.MethodGet, "/owner/actions", func(t *testing.T) *mocks.RepoService {
63+ return reposServing(t, publicRepo(t, testOwner), publicRepo(t, "actions"))
64+ }, ""},
65+ {"a private repository named like the owner is a missing one", http.MethodGet, "/owner/demo", func(t *testing.T) *mocks.RepoService {
66+ m := mocks.NewRepoService(t)
67+ m.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
68+ return m
69+ }, "/demo"},
70+ {"a failing lookup serves the request as it is", http.MethodGet, "/owner/demo", func(t *testing.T) *mocks.RepoService {
71+ return reposFailing(t, errors.New("store down"))
72+ }, ""},
73+ }
74+ for _, tt := range tests {
75+ t.Run(tt.name, func(t *testing.T) {
76+ t.Parallel()
77+ repos := none
78+ if tt.repos != nil {
79+ repos = tt.repos(t)
80+ }
81+ srv := newTestServer(t, Config{Repos: repos, Browse: mocks.NewBrowseService(t), Owner: testOwner})
82+ req := httptest.NewRequest(tt.method, tt.target, nil)
83+ to, ok := srv.ownerRedirect(req)
84+ assert.Equal(t, tt.want != "", ok)
85+ assert.Equal(t, tt.want, to)
86+ if !ok {
87+ return
88+ }
89+ // Whatever the target is, it stays on this origin.
90+ require.True(t, to[0] == '/' && (len(to) == 1 || (to[1] != '/' && to[1] != '\\')), "target %q", to)
91+ })
92+ }
93+}
94+
95+// Through ServeHTTP the redirect is a 308 with the Location, and the paths
96+// that are not redirected reach the router.
97+func TestOwnerRedirectServed(t *testing.T) {
98+ t.Parallel()
99+ srv := newTestServer(t, Config{Repos: reposServing(t), Browse: mocks.NewBrowseService(t), Owner: testOwner})
100+ rec := httptest.NewRecorder()
101+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/owner/actions?x=1", nil))
102+ assert.Equal(t, http.StatusPermanentRedirect, rec.Code)
103+ assert.Equal(t, "/actions?x=1", rec.Header().Get("Location"))
104+
105+ rec = httptest.NewRecorder()
106+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "//evil.com", nil))
107+ // The mux cleans the path itself and answers with a local redirect: the
108+ // owner redirect adds none, and nothing leads off this origin.
109+ assert.NotEqual(t, http.StatusPermanentRedirect, rec.Code)
110+ assert.Equal(t, "/evil.com", rec.Header().Get("Location"))
111+}
112+
113+// Without an owner there is no redirect.
114+func TestOwnerRedirectOff(t *testing.T) {
115+ t.Parallel()
116+ srv := newTestServer(t, Config{Repos: reposServing(t), Browse: mocks.NewBrowseService(t)})
117+ _, ok := srv.ownerRedirect(httptest.NewRequest(http.MethodGet, "/owner/demo", nil))
118+ assert.False(t, ok)
119+}
M internal/adapters/web/web.go +73 -1
@@ -65,6 +65,11 @@ 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 or HEAD of /<owner>/<rest> is
70+ // redirected to /<rest> unless a repository named like the owner is
71+ // visible to the caller. Empty: no redirect.
72+ Owner string
6873 // PublicURL is the base URL of the instance, without a trailing slash:
6974 // the clone URLs are built on it, and its host is the instance name.
7075 PublicURL string
@@ -100,6 +105,9 @@ type Server struct {
100105 // pathCache holds the path lists the file finder fetches.
101106 pathCache *pathCache
102107 mux *http.ServeMux
108+ // ownerRepo is the owner login as a repository name, zero when it is not
109+ // a valid one (then no repository can be named like the owner).
110+ ownerRepo domain.RepoName
103111 // refsFan shares the fragments of the refs page between the watchers of
104112 // a push; entries are few and short-lived.
105113 refsFan fanout
@@ -129,6 +137,7 @@ func New(cfg Config) (*Server, error) {
129137 pathCache: newPathCache(pathCacheBytes, pathBuilds),
130138 origin: originOf(u),
131139 }
140+ s.ownerRepo, _ = domain.ParseRepoName(cfg.Owner)
132141 s.mux = s.routes()
133142 return s, nil
134143 }
@@ -184,7 +193,7 @@ func (s *Server) routes() *http.ServeMux {
184193 mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false)))
185194 mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true)))
186195 }
187- mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
196+ mux.HandleFunc(catchAll, s.page(s.notFound))
188197 return mux
189198 }
190199
@@ -197,9 +206,72 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
197206 if !s.gate(w, r) {
198207 return
199208 }
209+ if to, ok := s.ownerRedirect(r); ok {
210+ http.Redirect(w, r, to, http.StatusPermanentRedirect)
211+ return
212+ }
200213 s.mux.ServeHTTP(w, r)
201214 }
202215
216+// catchAll is the pattern of the paths that are no page.
217+const catchAll = "GET /{repo}/{rest...}"
218+
219+// ownerRedirect maps /<owner>/<repo>[.git][/rest] to /<repo>[.git][/rest]: the
220+// URLs that the github context of CI shows (html_url, commit and compare
221+// URLs, clone_url) have the owner of the instance in front, like GitHub's.
222+//
223+// The rule is by repository, not by route: when no repository named like the
224+// owner is visible to the caller, every GET or HEAD of /<owner>/<rest> is
225+// redirected, so /<owner>/actions reaches the repository named "actions". A
226+// repository named like the owner keeps its own pages. A private one is seen
227+// as missing by an anonymous caller (the use case answers ErrNotFound), so the
228+// redirect is the same as for a name nobody has and tells nothing.
229+//
230+// The target is built from a segment that parses as a repository name, which
231+// has no slash, backslash, percent sign or dot-dot: it can only be a path on
232+// this origin, never a "//host" one.
233+func (s *Server) ownerRedirect(r *http.Request) (string, bool) {
234+ if s.cfg.Owner == "" || (r.Method != http.MethodGet && r.Method != http.MethodHead) {
235+ return "", false
236+ }
237+ first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
238+ if !ok || first != s.cfg.Owner || rest == "" {
239+ return "", false
240+ }
241+ repo, _, _ := strings.Cut(rest, "/")
242+ if _, err := domain.ParseRepoPath(repo); err != nil {
243+ return "", false
244+ }
245+ if s.ownerNamesARepo(r) {
246+ return "", false
247+ }
248+ to := "/" + rest
249+ if r.URL.RawQuery != "" {
250+ to += "?" + r.URL.RawQuery
251+ }
252+ return to, true
253+}
254+
255+// ownerNamesARepo tells whether the caller can see a repository named like
256+// the owner. A lookup that fails otherwise counts as yes: the request is then
257+// served as it was before, without a redirect.
258+func (s *Server) ownerNamesARepo(r *http.Request) bool {
259+ if s.ownerRepo.IsZero() {
260+ return false // the owner login is not a repository name
261+ }
262+ ctx := r.Context()
263+ _, err := s.cfg.Repos.Get(ctx, principal(ctx), s.ownerRepo)
264+ switch {
265+ case err == nil:
266+ return true
267+ case errors.Is(err, domain.ErrNotFound):
268+ return false
269+ }
270+ log := zerowrap.FromCtx(ctx)
271+ log.Debug().Err(err).Msg("owner redirect: the lookup of the repository named like the owner failed")
272+ return true
273+}
274+
203275 // handler builds a page. It returns the component and its status, or an
204276 // error, which becomes the 404 or 500 page.
205277 type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)
@@ -65,6 +65,11 @@ 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 or HEAD of /<owner>/<rest> is
70+ // redirected to /<rest> unless a repository named like the owner is
71+ // visible to the caller. Empty: no redirect.
72+ Owner string
68 // PublicURL is the base URL of the instance, without a trailing slash:73 // 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.74 // the clone URLs are built on it, and its host is the instance name.
70 PublicURL string75 PublicURL string
@@ -100,6 +105,9 @@ type Server struct {
100 // pathCache holds the path lists the file finder fetches.105 // pathCache holds the path lists the file finder fetches.
101 pathCache *pathCache106 pathCache *pathCache
102 mux *http.ServeMux107 mux *http.ServeMux
108+ // ownerRepo is the owner login as a repository name, zero when it is not
109+ // a valid one (then no repository can be named like the owner).
110+ ownerRepo domain.RepoName
103 // refsFan shares the fragments of the refs page between the watchers of111 // refsFan shares the fragments of the refs page between the watchers of
104 // a push; entries are few and short-lived.112 // a push; entries are few and short-lived.
105 refsFan fanout113 refsFan fanout
@@ -129,6 +137,7 @@ func New(cfg Config) (*Server, error) {
129 pathCache: newPathCache(pathCacheBytes, pathBuilds),137 pathCache: newPathCache(pathCacheBytes, pathBuilds),
130 origin: originOf(u),138 origin: originOf(u),
131 }139 }
140+ s.ownerRepo, _ = domain.ParseRepoName(cfg.Owner)
132 s.mux = s.routes()141 s.mux = s.routes()
133 return s, nil142 return s, nil
134 }143 }
@@ -184,7 +193,7 @@ func (s *Server) routes() *http.ServeMux {
184 mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false)))193 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)))194 mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true)))
186 }195 }
187- mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))196+ mux.HandleFunc(catchAll, s.page(s.notFound))
188 return mux197 return mux
189 }198 }
190 199
@@ -197,9 +206,72 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
197 if !s.gate(w, r) {206 if !s.gate(w, r) {
198 return207 return
199 }208 }
209+ if to, ok := s.ownerRedirect(r); ok {
210+ http.Redirect(w, r, to, http.StatusPermanentRedirect)
211+ return
212+ }
200 s.mux.ServeHTTP(w, r)213 s.mux.ServeHTTP(w, r)
201 }214 }
202 215
216+// catchAll is the pattern of the paths that are no page.
217+const catchAll = "GET /{repo}/{rest...}"
218+
219+// ownerRedirect maps /<owner>/<repo>[.git][/rest] to /<repo>[.git][/rest]: the
220+// URLs that the github context of CI shows (html_url, commit and compare
221+// URLs, clone_url) have the owner of the instance in front, like GitHub's.
222+//
223+// The rule is by repository, not by route: when no repository named like the
224+// owner is visible to the caller, every GET or HEAD of /<owner>/<rest> is
225+// redirected, so /<owner>/actions reaches the repository named "actions". A
226+// repository named like the owner keeps its own pages. A private one is seen
227+// as missing by an anonymous caller (the use case answers ErrNotFound), so the
228+// redirect is the same as for a name nobody has and tells nothing.
229+//
230+// The target is built from a segment that parses as a repository name, which
231+// has no slash, backslash, percent sign or dot-dot: it can only be a path on
232+// this origin, never a "//host" one.
233+func (s *Server) ownerRedirect(r *http.Request) (string, bool) {
234+ if s.cfg.Owner == "" || (r.Method != http.MethodGet && r.Method != http.MethodHead) {
235+ return "", false
236+ }
237+ first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
238+ if !ok || first != s.cfg.Owner || rest == "" {
239+ return "", false
240+ }
241+ repo, _, _ := strings.Cut(rest, "/")
242+ if _, err := domain.ParseRepoPath(repo); err != nil {
243+ return "", false
244+ }
245+ if s.ownerNamesARepo(r) {
246+ return "", false
247+ }
248+ to := "/" + rest
249+ if r.URL.RawQuery != "" {
250+ to += "?" + r.URL.RawQuery
251+ }
252+ return to, true
253+}
254+
255+// ownerNamesARepo tells whether the caller can see a repository named like
256+// the owner. A lookup that fails otherwise counts as yes: the request is then
257+// served as it was before, without a redirect.
258+func (s *Server) ownerNamesARepo(r *http.Request) bool {
259+ if s.ownerRepo.IsZero() {
260+ return false // the owner login is not a repository name
261+ }
262+ ctx := r.Context()
263+ _, err := s.cfg.Repos.Get(ctx, principal(ctx), s.ownerRepo)
264+ switch {
265+ case err == nil:
266+ return true
267+ case errors.Is(err, domain.ErrNotFound):
268+ return false
269+ }
270+ log := zerowrap.FromCtx(ctx)
271+ log.Debug().Err(err).Msg("owner redirect: the lookup of the repository named like the owner failed")
272+ return true
273+}
274+
203 // handler builds a page. It returns the component and its status, or an275 // handler builds a page. It returns the component and its status, or an
204 // error, which becomes the 404 or 500 page.276 // error, which becomes the 404 or 500 page.
205 type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)277 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 +374 -0
@@ -0,0 +1,374 @@
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+ "github.com/bnema/zerowrap"
18+
19+ "bnema.dev/ermite/internal/domain"
20+)
21+
22+// This file builds the "github" context of a push and the "job" keys the
23+// server sends, to match what github.com gives (recordings of
24+// ermite-conformance, normalised by its normalize.go).
25+//
26+// Value provenance, used in the comments below:
27+// - real: a value ermite has (repository name, visibility, description,
28+// dates, commits, default branch, configuration);
29+// - derived: computed from real values by a stable rule (ids, node ids,
30+// URLs);
31+// - constant: GitHub's value for a repository or account with no such
32+// feature, or a placeholder where ermite has no concept (no avatars,
33+// stars, forks, issues...).
34+
35+const (
36+ // ghSecretSource is github.secret_source: where the secrets of the job
37+ // come from. Constant: ermite has one source.
38+ ghSecretSource = "Actions"
39+ // ghCacheLimit is github.artifact_cache_size_limit, in GB, as recorded on
40+ // github.com. Constant.
41+ ghCacheLimit = "10"
42+ // apiPath is where the GitHub-shaped API base of the payload lives under
43+ // the public URL. github.api_url is that base, github.graphql_url is
44+ // <base>/graphql and the *_url keys of the repository and the users are
45+ // <base>/repos/... and <base>/users/..., as on github.com. ermite serves
46+ // none of them yet: they are identifiers in the shape of GitHub's.
47+ apiPath = "/-/github/api"
48+)
49+
50+// pair is a key and a value of an ordered JSON object.
51+type pair struct {
52+ k string
53+ v any
54+}
55+
56+// ordered is a JSON object that keeps the order of its keys, as the github
57+// context of GitHub does (toJSON(github) prints that order).
58+type ordered []pair
59+
60+func (o ordered) MarshalJSON() ([]byte, error) {
61+ var b bytes.Buffer
62+ b.WriteByte('{')
63+ for i, p := range o {
64+ if i > 0 {
65+ b.WriteByte(',')
66+ }
67+ k, err := json.Marshal(p.k)
68+ if err != nil {
69+ return nil, err
70+ }
71+ v, err := json.Marshal(p.v)
72+ if err != nil {
73+ return nil, err
74+ }
75+ b.Write(k)
76+ b.WriteByte(':')
77+ b.Write(v)
78+ }
79+ b.WriteByte('}')
80+ return b.Bytes(), nil
81+}
82+
83+// stableID is a positive number that depends only on the kind and the name:
84+// the id of a user or of a repository, which ermite does not number. It fits
85+// in a JavaScript number (GitHub's own ids are well below 2^53).
86+func stableID(kind, name string) int64 {
87+ sum := sha256.Sum256([]byte(kind + "\x00" + name))
88+ return int64(binary.BigEndian.Uint64(sum[:8])>>20) + 1<<20
89+}
90+
91+// nodeID is the legacy form of a GraphQL node id: base64 of "<n>:<Type><id>"
92+// (GitHub: "04:User5212137" is "MDQ6VXNlcjUyMTIxMzc=").
93+func nodeID(prefix, typ string, id int64) string {
94+ return base64.StdEncoding.EncodeToString([]byte(prefix + ":" + typ + strconv.FormatInt(id, 10)))
95+}
96+
97+// ghIdentity is the account a push is attributed to.
98+type ghIdentity struct {
99+ login string
100+ id int64
101+}
102+
103+// identity maps the actor of a run to a login: "owner" and "root" (the
104+// owner's session or the root token) are the owner of the forge, a token
105+// label is itself.
106+func (c *CI) identity(actor string) ghIdentity {
107+ login := actor
108+ if actor == "owner" || actor == "root" || actor == "" {
109+ login = c.cfg.Owner
110+ }
111+ return ghIdentity{login: login, id: stableID("user", login)}
112+}
113+
114+// ghURLs holds the URL bases of the payload of one repository.
115+type ghURLs struct {
116+ server string // github.server_url
117+ api string // github.api_url
118+ host string // host (and port) of the server, for git:// and ssh URLs
119+ owner string
120+ name string
121+ full string // <owner>/<repo>
122+ repo string // <server>/<owner>/<repo>, the page GitHub's html_url points at
123+}
124+
125+func (c *CI) urls(repo domain.Repo) ghURLs {
126+ u := ghURLs{server: c.cfg.PublicURL, owner: c.cfg.Owner, name: repo.Name.String()}
127+ if p, err := url.Parse(c.cfg.PublicURL); err == nil {
128+ u.host = p.Host
129+ }
130+ u.api = u.server + apiPath
131+ u.full = u.owner + "/" + u.name
132+ u.repo = u.server + "/" + u.full
133+ return u
134+}
135+
136+// userObj is the account object of an event (sender, repository owner).
137+// Derived: ids from the login; URLs from the API base. Constant: type, no
138+// avatar (ermite has none: "" is what GitHub gives for gravatar_id too), not
139+// a site admin.
140+func (u ghURLs) userObj(id ghIdentity, owner bool) map[string]any {
141+ api := u.api + "/users/" + id.login
142+ o := map[string]any{
143+ "avatar_url": "", "events_url": api + "/events{/privacy}", "followers_url": api + "/followers",
144+ "following_url": api + "/following{/other_user}", "gists_url": api + "/gists{/gist_id}", "gravatar_id": "",
145+ "html_url": u.server + "/" + id.login, "id": id.id, "login": id.login,
146+ "node_id": nodeID("04", "User", id.id),
147+ "organizations_url": api + "/orgs", "received_events_url": api + "/received_events", "repos_url": api + "/repos",
148+ "site_admin": false, "starred_url": api + "/starred{/owner}{/repo}", "subscriptions_url": api + "/subscriptions",
149+ "type": "User", "url": api, "user_view_type": "public",
150+ }
151+ if owner {
152+ o["email"], o["name"] = "", id.login
153+ }
154+ return o
155+}
156+
157+// repoLinks are the URL templates of the repository object, relative to
158+// <api>/repos/<owner>/<repo>. Derived (constant templates, as GitHub's).
159+var repoLinks = map[string]string{
160+ "archive_url": "/{archive_format}{/ref}", "assignees_url": "/assignees{/user}", "blobs_url": "/git/blobs{/sha}",
161+ "branches_url": "/branches{/branch}", "collaborators_url": "/collaborators{/collaborator}",
162+ "comments_url": "/comments{/number}", "commits_url": "/commits{/sha}", "compare_url": "/compare/{base}...{head}",
163+ "contents_url": "/contents/{+path}", "contributors_url": "/contributors", "deployments_url": "/deployments",
164+ "downloads_url": "/downloads", "events_url": "/events", "forks_url": "/forks", "git_commits_url": "/git/commits{/sha}",
165+ "git_refs_url": "/git/refs{/sha}", "git_tags_url": "/git/tags{/sha}", "hooks_url": "/hooks",
166+ "issue_comment_url": "/issues/comments{/number}", "issue_events_url": "/issues/events{/number}",
167+ "issues_url": "/issues{/number}", "keys_url": "/keys{/key_id}", "labels_url": "/labels{/name}",
168+ "languages_url": "/languages", "merges_url": "/merges", "milestones_url": "/milestones{/number}",
169+ "notifications_url": "/notifications{?since,all,participating}", "pulls_url": "/pulls{/number}",
170+ "releases_url": "/releases{/id}", "stargazers_url": "/stargazers", "statuses_url": "/statuses/{sha}",
171+ "subscribers_url": "/subscribers", "subscription_url": "/subscription", "tags_url": "/tags", "teams_url": "/teams",
172+ "trees_url": "/git/trees{/sha}", "url": "",
173+}
174+
175+// repoObj is event.repository: the 83 keys of GitHub's push payload.
176+//
177+// real: name, full_name, private, visibility, description, default_branch,
178+// master_branch, created_at, pushed_at, updated_at
179+// derived: id, node_id, owner, html_url, svn_url, clone_url, git_url,
180+// ssh_url, url and the other *_url keys
181+// constant: the rest (no forks, stars, issues, pages..., the features of a
182+// new GitHub repository; license, language, homepage and
183+// mirror_url null; size 0)
184+func (c *CI) repoObj(repo domain.Repo, u ghURLs, defaultBranch string, at time.Time) map[string]any {
185+ owner := c.identity("owner")
186+ id := stableID("repo", u.full)
187+ pushed := repo.PushedAt
188+ if pushed.IsZero() {
189+ pushed = at
190+ }
191+ var desc any
192+ if repo.Description != "" {
193+ desc = repo.Description
194+ }
195+ r := map[string]any{
196+ "allow_forking": true, "archived": false, "clone_url": u.repo + ".git",
197+ "created_at": repo.CreatedAt.Unix(), "default_branch": defaultBranch, "description": desc,
198+ "disabled": false, "fork": false, "forks": 0, "forks_count": 0, "full_name": u.full,
199+ "git_url": "git://" + u.host + "/" + u.full + ".git",
200+ "has_discussions": false, "has_downloads": true, "has_issues": true, "has_pages": false, "has_projects": true,
201+ "has_pull_requests": true, "has_wiki": true, "homepage": nil, "html_url": u.repo, "id": id,
202+ "is_template": false, "language": nil, "license": nil, "master_branch": defaultBranch, "mirror_url": nil,
203+ "name": u.name, "node_id": nodeID("010", "Repository", id), "open_issues": 0, "open_issues_count": 0,
204+ "owner": u.userObj(owner, true),
205+ "private": repo.Visibility == domain.VisibilityPrivate, "pull_request_creation_policy": "all",
206+ "pushed_at": pushed.Unix(), "size": 0, "ssh_url": "git@" + u.host + ":" + u.full + ".git",
207+ "stargazers": 0, "stargazers_count": 0, "svn_url": u.repo, "topics": []string{},
208+ "updated_at": pushed.UTC().Format(time.RFC3339), "visibility": string(repo.Visibility),
209+ "watchers": 0, "watchers_count": 0, "web_commit_signoff_required": false,
210+ }
211+ for k, suffix := range repoLinks {
212+ r[k] = u.api + "/repos/" + u.full + suffix
213+ }
214+ return r
215+}
216+
217+// commitJSON is one commit of the push event: GitHub's push payload has
218+// exactly these keys in the recordings (no added/removed/modified, no
219+// username).
220+func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, u ghURLs, cm domain.Commit) map[string]any {
221+ tree := ""
222+ if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil {
223+ tree = id.String()
224+ }
225+ return map[string]any{
226+ "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message,
227+ "timestamp": cm.Committer.Time.UTC().Format(time.RFC3339),
228+ "url": u.repo + "/commit/" + cm.ID.String(),
229+ "author": person(cm.Author.Name, cm.Author.Email), "committer": person(cm.Committer.Name, cm.Committer.Email),
230+ }
231+}
232+
233+// pushPayload is the part of the push event that is the same for every
234+// workflow of one ref: the commits, each with its tree id (one Git call per
235+// commit). It is built once per ref and shared by the probes and the runs.
236+type pushPayload struct {
237+ commits []any
238+ head map[string]any
239+}
240+
241+// pushPayload builds the commit part of the push event of f.
242+func (c *CI) pushPayload(ctx context.Context, repo domain.Repo, f pushFacts) *pushPayload {
243+ u := c.urls(repo)
244+ p := &pushPayload{commits: make([]any, 0, len(f.commits)), head: c.commitJSON(ctx, repo, u, f.head)}
245+ for _, cm := range f.commits {
246+ p.commits = append(p.commits, c.commitJSON(ctx, repo, u, cm))
247+ }
248+ return p
249+}
250+
251+func person(name, email string) map[string]any { return map[string]any{"name": name, "email": email} }
252+
253+// githubJSON builds the "github" context of the run as GitHub does for a push
254+// (the job-dependent keys, job and run_attempt, are filled in when a job is
255+// planned). Keys the runner owns (token, workspace, action, event_path, path,
256+// env, output, state, step_summary, artifacts...) are not here.
257+//
258+// The order of the keys is GitHub's, so that toJSON(github) prints the same.
259+// Ids and counters are strings, so is retention_days; "compare" has
260+// 12-character ids: the commit page for a created ref with one commit,
261+// "<first>^...<after>" for a created ref with several, "<before>...<after>" for
262+// an update.
263+//
264+// The commits of the event come from f.payload, built once per ref; without
265+// one they are read from Git here.
266+func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts) string {
267+ u := c.urls(repo)
268+ id := c.identity(run.Actor)
269+ owner := c.identity("owner")
270+ refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"
271+ if f.isTag {
272+ refName, refType = strings.TrimPrefix(run.Ref, "refs/tags/"), "tag"
273+ }
274+ before := f.update.Old
275+ if f.created {
276+ before = domain.ZeroOID
277+ }
278+ compare := u.repo + "/compare/" + shortID(before) + "..." + shortID(f.update.New)
279+ switch {
280+ case f.created && len(f.commits) > 1:
281+ compare = u.repo + "/compare/" + shortID(f.commits[0].ID.String()) + "^..." + shortID(f.update.New)
282+ case f.created:
283+ compare = u.repo + "/commit/" + shortID(f.update.New)
284+ }
285+ payload := f.payload
286+ if payload == nil {
287+ payload = c.pushPayload(ctx, repo, f)
288+ }
289+ event := map[string]any{
290+ "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced,
291+ "base_ref": nil, "compare": compare, "commits": payload.commits, "head_commit": payload.head,
292+ "pusher": map[string]any{"name": id.login, "email": ""},
293+ "repository": c.repoObj(repo, u, f.defaultBranch, run.CreatedAt),
294+ "sender": u.userObj(id, false),
295+ }
296+ repoID := strconv.FormatInt(stableID("repo", u.full), 10)
297+ gh := ordered{
298+ {"job", ""}, {"ref", run.Ref}, {"sha", run.Sha}, {"repository", u.full}, {"repository_owner", c.cfg.Owner},
299+ {"repository_owner_id", strconv.FormatInt(owner.id, 10)}, {"repositoryUrl", "git://" + u.host + "/" + u.full + ".git"},
300+ {"run_id", strconv.FormatInt(run.ID, 10)}, {"run_number", strconv.FormatInt(run.Number, 10)},
301+ {"retention_days", strconv.Itoa(c.retentionDays())}, {"run_attempt", "1"},
302+ {"artifact_cache_size_limit", ghCacheLimit}, {"repository_visibility", string(repo.Visibility)},
303+ {"actor_id", strconv.FormatInt(id.id, 10)}, {"actor", id.login}, {"workflow", run.WorkflowName},
304+ {"head_ref", ""}, {"base_ref", ""}, {"event_name", "push"}, {"server_url", u.server},
305+ {"api_url", u.api}, {"graphql_url", u.api + "/graphql"}, {"ref_name", refName}, {"ref_protected", false},
306+ {"ref_type", refType}, {"secret_source", ghSecretSource}, {"event", event},
307+ {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref}, {"workflow_sha", run.Sha},
308+ {"repository_id", repoID}, {"triggering_actor", id.login},
309+ }
310+ b, err := json.Marshal(gh)
311+ if err != nil {
312+ return "{}"
313+ }
314+ return string(b)
315+}
316+
317+// Layout of job.check_run_id, 53 bits so that it is exact as a JavaScript
318+// number (2^53 is the first integer that is not):
319+//
320+// bits 0-8 combo: matrix leg, 9 bits (0-511); a matrix has at most 256
321+// bits 9-18 index: position of the job in the workflow file, 10 bits
322+// (0-1023); ermite has no limit of its own on the number
323+// of jobs
324+// bits 19-25 attempt: run attempt, 7 bits (0-127), so that every re-run
325+// gets new ids, as on GitHub
326+// bits 26-52 run: run id, 27 bits (below 134 million)
327+//
328+// A field that overflows would wrap into another instance's id, so the whole
329+// id is 0 then (see checkRunID).
330+const (
331+ checkComboBits = 9
332+ checkIndexBits = 10
333+ checkAttemptBits = 7
334+ checkRunBits = 53 - checkComboBits - checkIndexBits - checkAttemptBits
335+)
336+
337+// checkRunID is the number github.com shows in job.check_run_id: derived from
338+// the run, its attempt and the position of the job instance, as ermite's
339+// checks are not numbered. It is unique and stable per attempt, job and matrix
340+// leg, and fits in a JavaScript number.
341+//
342+// The key is informational, so it never fails a job: a value outside its field
343+// (which would wrap and give two instances the same id) is logged at warn with
344+// the run, attempt and job, and the id falls back to 0.
345+func checkRunID(ctx context.Context, run domain.CIRun, index, combo int) int64 {
346+ attempt := int64(run.Attempt)
347+ switch {
348+ case run.ID < 0 || run.ID >= 1<<checkRunBits,
349+ attempt < 0 || attempt >= 1<<checkAttemptBits,
350+ index < 0 || index >= 1<<checkIndexBits,
351+ combo < 0 || combo >= 1<<checkComboBits:
352+ log := zerowrap.FromCtx(ctx)
353+ log.Warn().Int64("run_id", run.ID).Int32("attempt", run.Attempt).Int("job_index", index).Int("leg", combo).
354+ Msg("ci check_run_id is out of range: the job context shows 0")
355+ return 0
356+ }
357+ return run.ID<<(checkAttemptBits+checkIndexBits+checkComboBits) |
358+ attempt<<(checkIndexBits+checkComboBits) |
359+ int64(index)<<checkComboBits |
360+ int64(combo)
361+}
362+
363+// jobKeys are the keys of the "job" context that the server knows, in
364+// GitHub's order (the runner adds "status"). The runner copies the "job" key
365+// of the contexts it is given into the job context.
366+func (c *CI) jobKeys(ctx context.Context, run domain.CIRun, index, combo int) ordered {
367+ id := checkRunID(ctx, run, index, combo)
368+ u := c.urls(domain.Repo{Name: run.Repo})
369+ return ordered{
370+ {"check_run_id", id},
371+ {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref},
372+ {"workflow_sha", run.Sha}, {"workflow_repository", u.full}, {"workflow_file_path", run.WorkflowPath},
373+ }
374+}
@@ -0,0 +1,374 @@
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+ "github.com/bnema/zerowrap"
18+
19+ "bnema.dev/ermite/internal/domain"
20+)
21+
22+// This file builds the "github" context of a push and the "job" keys the
23+// server sends, to match what github.com gives (recordings of
24+// ermite-conformance, normalised by its normalize.go).
25+//
26+// Value provenance, used in the comments below:
27+// - real: a value ermite has (repository name, visibility, description,
28+// dates, commits, default branch, configuration);
29+// - derived: computed from real values by a stable rule (ids, node ids,
30+// URLs);
31+// - constant: GitHub's value for a repository or account with no such
32+// feature, or a placeholder where ermite has no concept (no avatars,
33+// stars, forks, issues...).
34+
35+const (
36+ // ghSecretSource is github.secret_source: where the secrets of the job
37+ // come from. Constant: ermite has one source.
38+ ghSecretSource = "Actions"
39+ // ghCacheLimit is github.artifact_cache_size_limit, in GB, as recorded on
40+ // github.com. Constant.
41+ ghCacheLimit = "10"
42+ // apiPath is where the GitHub-shaped API base of the payload lives under
43+ // the public URL. github.api_url is that base, github.graphql_url is
44+ // <base>/graphql and the *_url keys of the repository and the users are
45+ // <base>/repos/... and <base>/users/..., as on github.com. ermite serves
46+ // none of them yet: they are identifiers in the shape of GitHub's.
47+ apiPath = "/-/github/api"
48+)
49+
50+// pair is a key and a value of an ordered JSON object.
51+type pair struct {
52+ k string
53+ v any
54+}
55+
56+// ordered is a JSON object that keeps the order of its keys, as the github
57+// context of GitHub does (toJSON(github) prints that order).
58+type ordered []pair
59+
60+func (o ordered) MarshalJSON() ([]byte, error) {
61+ var b bytes.Buffer
62+ b.WriteByte('{')
63+ for i, p := range o {
64+ if i > 0 {
65+ b.WriteByte(',')
66+ }
67+ k, err := json.Marshal(p.k)
68+ if err != nil {
69+ return nil, err
70+ }
71+ v, err := json.Marshal(p.v)
72+ if err != nil {
73+ return nil, err
74+ }
75+ b.Write(k)
76+ b.WriteByte(':')
77+ b.Write(v)
78+ }
79+ b.WriteByte('}')
80+ return b.Bytes(), nil
81+}
82+
83+// stableID is a positive number that depends only on the kind and the name:
84+// the id of a user or of a repository, which ermite does not number. It fits
85+// in a JavaScript number (GitHub's own ids are well below 2^53).
86+func stableID(kind, name string) int64 {
87+ sum := sha256.Sum256([]byte(kind + "\x00" + name))
88+ return int64(binary.BigEndian.Uint64(sum[:8])>>20) + 1<<20
89+}
90+
91+// nodeID is the legacy form of a GraphQL node id: base64 of "<n>:<Type><id>"
92+// (GitHub: "04:User5212137" is "MDQ6VXNlcjUyMTIxMzc=").
93+func nodeID(prefix, typ string, id int64) string {
94+ return base64.StdEncoding.EncodeToString([]byte(prefix + ":" + typ + strconv.FormatInt(id, 10)))
95+}
96+
97+// ghIdentity is the account a push is attributed to.
98+type ghIdentity struct {
99+ login string
100+ id int64
101+}
102+
103+// identity maps the actor of a run to a login: "owner" and "root" (the
104+// owner's session or the root token) are the owner of the forge, a token
105+// label is itself.
106+func (c *CI) identity(actor string) ghIdentity {
107+ login := actor
108+ if actor == "owner" || actor == "root" || actor == "" {
109+ login = c.cfg.Owner
110+ }
111+ return ghIdentity{login: login, id: stableID("user", login)}
112+}
113+
114+// ghURLs holds the URL bases of the payload of one repository.
115+type ghURLs struct {
116+ server string // github.server_url
117+ api string // github.api_url
118+ host string // host (and port) of the server, for git:// and ssh URLs
119+ owner string
120+ name string
121+ full string // <owner>/<repo>
122+ repo string // <server>/<owner>/<repo>, the page GitHub's html_url points at
123+}
124+
125+func (c *CI) urls(repo domain.Repo) ghURLs {
126+ u := ghURLs{server: c.cfg.PublicURL, owner: c.cfg.Owner, name: repo.Name.String()}
127+ if p, err := url.Parse(c.cfg.PublicURL); err == nil {
128+ u.host = p.Host
129+ }
130+ u.api = u.server + apiPath
131+ u.full = u.owner + "/" + u.name
132+ u.repo = u.server + "/" + u.full
133+ return u
134+}
135+
136+// userObj is the account object of an event (sender, repository owner).
137+// Derived: ids from the login; URLs from the API base. Constant: type, no
138+// avatar (ermite has none: "" is what GitHub gives for gravatar_id too), not
139+// a site admin.
140+func (u ghURLs) userObj(id ghIdentity, owner bool) map[string]any {
141+ api := u.api + "/users/" + id.login
142+ o := map[string]any{
143+ "avatar_url": "", "events_url": api + "/events{/privacy}", "followers_url": api + "/followers",
144+ "following_url": api + "/following{/other_user}", "gists_url": api + "/gists{/gist_id}", "gravatar_id": "",
145+ "html_url": u.server + "/" + id.login, "id": id.id, "login": id.login,
146+ "node_id": nodeID("04", "User", id.id),
147+ "organizations_url": api + "/orgs", "received_events_url": api + "/received_events", "repos_url": api + "/repos",
148+ "site_admin": false, "starred_url": api + "/starred{/owner}{/repo}", "subscriptions_url": api + "/subscriptions",
149+ "type": "User", "url": api, "user_view_type": "public",
150+ }
151+ if owner {
152+ o["email"], o["name"] = "", id.login
153+ }
154+ return o
155+}
156+
157+// repoLinks are the URL templates of the repository object, relative to
158+// <api>/repos/<owner>/<repo>. Derived (constant templates, as GitHub's).
159+var repoLinks = map[string]string{
160+ "archive_url": "/{archive_format}{/ref}", "assignees_url": "/assignees{/user}", "blobs_url": "/git/blobs{/sha}",
161+ "branches_url": "/branches{/branch}", "collaborators_url": "/collaborators{/collaborator}",
162+ "comments_url": "/comments{/number}", "commits_url": "/commits{/sha}", "compare_url": "/compare/{base}...{head}",
163+ "contents_url": "/contents/{+path}", "contributors_url": "/contributors", "deployments_url": "/deployments",
164+ "downloads_url": "/downloads", "events_url": "/events", "forks_url": "/forks", "git_commits_url": "/git/commits{/sha}",
165+ "git_refs_url": "/git/refs{/sha}", "git_tags_url": "/git/tags{/sha}", "hooks_url": "/hooks",
166+ "issue_comment_url": "/issues/comments{/number}", "issue_events_url": "/issues/events{/number}",
167+ "issues_url": "/issues{/number}", "keys_url": "/keys{/key_id}", "labels_url": "/labels{/name}",
168+ "languages_url": "/languages", "merges_url": "/merges", "milestones_url": "/milestones{/number}",
169+ "notifications_url": "/notifications{?since,all,participating}", "pulls_url": "/pulls{/number}",
170+ "releases_url": "/releases{/id}", "stargazers_url": "/stargazers", "statuses_url": "/statuses/{sha}",
171+ "subscribers_url": "/subscribers", "subscription_url": "/subscription", "tags_url": "/tags", "teams_url": "/teams",
172+ "trees_url": "/git/trees{/sha}", "url": "",
173+}
174+
175+// repoObj is event.repository: the 83 keys of GitHub's push payload.
176+//
177+// real: name, full_name, private, visibility, description, default_branch,
178+// master_branch, created_at, pushed_at, updated_at
179+// derived: id, node_id, owner, html_url, svn_url, clone_url, git_url,
180+// ssh_url, url and the other *_url keys
181+// constant: the rest (no forks, stars, issues, pages..., the features of a
182+// new GitHub repository; license, language, homepage and
183+// mirror_url null; size 0)
184+func (c *CI) repoObj(repo domain.Repo, u ghURLs, defaultBranch string, at time.Time) map[string]any {
185+ owner := c.identity("owner")
186+ id := stableID("repo", u.full)
187+ pushed := repo.PushedAt
188+ if pushed.IsZero() {
189+ pushed = at
190+ }
191+ var desc any
192+ if repo.Description != "" {
193+ desc = repo.Description
194+ }
195+ r := map[string]any{
196+ "allow_forking": true, "archived": false, "clone_url": u.repo + ".git",
197+ "created_at": repo.CreatedAt.Unix(), "default_branch": defaultBranch, "description": desc,
198+ "disabled": false, "fork": false, "forks": 0, "forks_count": 0, "full_name": u.full,
199+ "git_url": "git://" + u.host + "/" + u.full + ".git",
200+ "has_discussions": false, "has_downloads": true, "has_issues": true, "has_pages": false, "has_projects": true,
201+ "has_pull_requests": true, "has_wiki": true, "homepage": nil, "html_url": u.repo, "id": id,
202+ "is_template": false, "language": nil, "license": nil, "master_branch": defaultBranch, "mirror_url": nil,
203+ "name": u.name, "node_id": nodeID("010", "Repository", id), "open_issues": 0, "open_issues_count": 0,
204+ "owner": u.userObj(owner, true),
205+ "private": repo.Visibility == domain.VisibilityPrivate, "pull_request_creation_policy": "all",
206+ "pushed_at": pushed.Unix(), "size": 0, "ssh_url": "git@" + u.host + ":" + u.full + ".git",
207+ "stargazers": 0, "stargazers_count": 0, "svn_url": u.repo, "topics": []string{},
208+ "updated_at": pushed.UTC().Format(time.RFC3339), "visibility": string(repo.Visibility),
209+ "watchers": 0, "watchers_count": 0, "web_commit_signoff_required": false,
210+ }
211+ for k, suffix := range repoLinks {
212+ r[k] = u.api + "/repos/" + u.full + suffix
213+ }
214+ return r
215+}
216+
217+// commitJSON is one commit of the push event: GitHub's push payload has
218+// exactly these keys in the recordings (no added/removed/modified, no
219+// username).
220+func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, u ghURLs, cm domain.Commit) map[string]any {
221+ tree := ""
222+ if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil {
223+ tree = id.String()
224+ }
225+ return map[string]any{
226+ "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message,
227+ "timestamp": cm.Committer.Time.UTC().Format(time.RFC3339),
228+ "url": u.repo + "/commit/" + cm.ID.String(),
229+ "author": person(cm.Author.Name, cm.Author.Email), "committer": person(cm.Committer.Name, cm.Committer.Email),
230+ }
231+}
232+
233+// pushPayload is the part of the push event that is the same for every
234+// workflow of one ref: the commits, each with its tree id (one Git call per
235+// commit). It is built once per ref and shared by the probes and the runs.
236+type pushPayload struct {
237+ commits []any
238+ head map[string]any
239+}
240+
241+// pushPayload builds the commit part of the push event of f.
242+func (c *CI) pushPayload(ctx context.Context, repo domain.Repo, f pushFacts) *pushPayload {
243+ u := c.urls(repo)
244+ p := &pushPayload{commits: make([]any, 0, len(f.commits)), head: c.commitJSON(ctx, repo, u, f.head)}
245+ for _, cm := range f.commits {
246+ p.commits = append(p.commits, c.commitJSON(ctx, repo, u, cm))
247+ }
248+ return p
249+}
250+
251+func person(name, email string) map[string]any { return map[string]any{"name": name, "email": email} }
252+
253+// githubJSON builds the "github" context of the run as GitHub does for a push
254+// (the job-dependent keys, job and run_attempt, are filled in when a job is
255+// planned). Keys the runner owns (token, workspace, action, event_path, path,
256+// env, output, state, step_summary, artifacts...) are not here.
257+//
258+// The order of the keys is GitHub's, so that toJSON(github) prints the same.
259+// Ids and counters are strings, so is retention_days; "compare" has
260+// 12-character ids: the commit page for a created ref with one commit,
261+// "<first>^...<after>" for a created ref with several, "<before>...<after>" for
262+// an update.
263+//
264+// The commits of the event come from f.payload, built once per ref; without
265+// one they are read from Git here.
266+func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts) string {
267+ u := c.urls(repo)
268+ id := c.identity(run.Actor)
269+ owner := c.identity("owner")
270+ refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"
271+ if f.isTag {
272+ refName, refType = strings.TrimPrefix(run.Ref, "refs/tags/"), "tag"
273+ }
274+ before := f.update.Old
275+ if f.created {
276+ before = domain.ZeroOID
277+ }
278+ compare := u.repo + "/compare/" + shortID(before) + "..." + shortID(f.update.New)
279+ switch {
280+ case f.created && len(f.commits) > 1:
281+ compare = u.repo + "/compare/" + shortID(f.commits[0].ID.String()) + "^..." + shortID(f.update.New)
282+ case f.created:
283+ compare = u.repo + "/commit/" + shortID(f.update.New)
284+ }
285+ payload := f.payload
286+ if payload == nil {
287+ payload = c.pushPayload(ctx, repo, f)
288+ }
289+ event := map[string]any{
290+ "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced,
291+ "base_ref": nil, "compare": compare, "commits": payload.commits, "head_commit": payload.head,
292+ "pusher": map[string]any{"name": id.login, "email": ""},
293+ "repository": c.repoObj(repo, u, f.defaultBranch, run.CreatedAt),
294+ "sender": u.userObj(id, false),
295+ }
296+ repoID := strconv.FormatInt(stableID("repo", u.full), 10)
297+ gh := ordered{
298+ {"job", ""}, {"ref", run.Ref}, {"sha", run.Sha}, {"repository", u.full}, {"repository_owner", c.cfg.Owner},
299+ {"repository_owner_id", strconv.FormatInt(owner.id, 10)}, {"repositoryUrl", "git://" + u.host + "/" + u.full + ".git"},
300+ {"run_id", strconv.FormatInt(run.ID, 10)}, {"run_number", strconv.FormatInt(run.Number, 10)},
301+ {"retention_days", strconv.Itoa(c.retentionDays())}, {"run_attempt", "1"},
302+ {"artifact_cache_size_limit", ghCacheLimit}, {"repository_visibility", string(repo.Visibility)},
303+ {"actor_id", strconv.FormatInt(id.id, 10)}, {"actor", id.login}, {"workflow", run.WorkflowName},
304+ {"head_ref", ""}, {"base_ref", ""}, {"event_name", "push"}, {"server_url", u.server},
305+ {"api_url", u.api}, {"graphql_url", u.api + "/graphql"}, {"ref_name", refName}, {"ref_protected", false},
306+ {"ref_type", refType}, {"secret_source", ghSecretSource}, {"event", event},
307+ {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref}, {"workflow_sha", run.Sha},
308+ {"repository_id", repoID}, {"triggering_actor", id.login},
309+ }
310+ b, err := json.Marshal(gh)
311+ if err != nil {
312+ return "{}"
313+ }
314+ return string(b)
315+}
316+
317+// Layout of job.check_run_id, 53 bits so that it is exact as a JavaScript
318+// number (2^53 is the first integer that is not):
319+//
320+// bits 0-8 combo: matrix leg, 9 bits (0-511); a matrix has at most 256
321+// bits 9-18 index: position of the job in the workflow file, 10 bits
322+// (0-1023); ermite has no limit of its own on the number
323+// of jobs
324+// bits 19-25 attempt: run attempt, 7 bits (0-127), so that every re-run
325+// gets new ids, as on GitHub
326+// bits 26-52 run: run id, 27 bits (below 134 million)
327+//
328+// A field that overflows would wrap into another instance's id, so the whole
329+// id is 0 then (see checkRunID).
330+const (
331+ checkComboBits = 9
332+ checkIndexBits = 10
333+ checkAttemptBits = 7
334+ checkRunBits = 53 - checkComboBits - checkIndexBits - checkAttemptBits
335+)
336+
337+// checkRunID is the number github.com shows in job.check_run_id: derived from
338+// the run, its attempt and the position of the job instance, as ermite's
339+// checks are not numbered. It is unique and stable per attempt, job and matrix
340+// leg, and fits in a JavaScript number.
341+//
342+// The key is informational, so it never fails a job: a value outside its field
343+// (which would wrap and give two instances the same id) is logged at warn with
344+// the run, attempt and job, and the id falls back to 0.
345+func checkRunID(ctx context.Context, run domain.CIRun, index, combo int) int64 {
346+ attempt := int64(run.Attempt)
347+ switch {
348+ case run.ID < 0 || run.ID >= 1<<checkRunBits,
349+ attempt < 0 || attempt >= 1<<checkAttemptBits,
350+ index < 0 || index >= 1<<checkIndexBits,
351+ combo < 0 || combo >= 1<<checkComboBits:
352+ log := zerowrap.FromCtx(ctx)
353+ log.Warn().Int64("run_id", run.ID).Int32("attempt", run.Attempt).Int("job_index", index).Int("leg", combo).
354+ Msg("ci check_run_id is out of range: the job context shows 0")
355+ return 0
356+ }
357+ return run.ID<<(checkAttemptBits+checkIndexBits+checkComboBits) |
358+ attempt<<(checkIndexBits+checkComboBits) |
359+ int64(index)<<checkComboBits |
360+ int64(combo)
361+}
362+
363+// jobKeys are the keys of the "job" context that the server knows, in
364+// GitHub's order (the runner adds "status"). The runner copies the "job" key
365+// of the contexts it is given into the job context.
366+func (c *CI) jobKeys(ctx context.Context, run domain.CIRun, index, combo int) ordered {
367+ id := checkRunID(ctx, run, index, combo)
368+ u := c.urls(domain.Repo{Name: run.Repo})
369+ return ordered{
370+ {"check_run_id", id},
371+ {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref},
372+ {"workflow_sha", run.Sha}, {"workflow_repository", u.full}, {"workflow_file_path", run.WorkflowPath},
373+ }
374+}
A internal/usecase/ci_github_test.go +111 -0
@@ -0,0 +1,111 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "fmt"
9+ "math"
10+ "strconv"
11+ "strings"
12+ "testing"
13+
14+ "github.com/bnema/zerowrap"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite-actions/workflow"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+// The title of a run is the run-name when it evaluates to something, else the
24+// commit title; a run-name is cut to its first line and 256 characters.
25+func TestRunTitle(t *testing.T) {
26+ t.Parallel()
27+ const commit = "fix: the commit title"
28+ const githubJSON = `{"ref_name":"main","event":{"head_commit":{"message":"one\ntwo"}}}`
29+ tests := []struct {
30+ name string
31+ runName string // the run-name expression, "" : the workflow has none
32+ want string
33+ }{
34+ {"no run-name", "", commit},
35+ {"a run-name", "deploy ${{ github.ref_name }}", "deploy main"},
36+ {"an evaluation error", "${{ fromJSON('{') }}", commit},
37+ {"an empty result", "${{ '' }}", commit},
38+ {"a blank result", "${{ ' ' }}", commit},
39+ {"a first line only", "${{ github.event.head_commit.message }}", "one"},
40+ {"cut at 256 characters", strings.Repeat("é", 300), strings.Repeat("é", 256)},
41+ {"exactly 256 characters", strings.Repeat("a", 256), strings.Repeat("a", 256)},
42+ }
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ t.Parallel()
46+ u := newCIUnit(t, nil)
47+ yaml := "on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: 'true'\n"
48+ if tt.runName != "" {
49+ yaml = "run-name: \"" + strings.ReplaceAll(strings.ReplaceAll(tt.runName, `\`, `\\`), `"`, `\"`) + "\"\n" + yaml
50+ }
51+ wf, errs := workflow.Parse(".github/workflows/a.yml", []byte(yaml))
52+ require.Empty(t, errs)
53+ require.NotNil(t, wf)
54+ assert.Equal(t, tt.want, u.ci.RunTitle(context.Background(), wf, githubJSON, commit))
55+ })
56+ }
57+}
58+
59+// The check run id keeps the run, its attempt, the job and the leg apart, stays
60+// below 2^53, and refuses what does not fit instead of wrapping.
61+func TestCheckRunID(t *testing.T) {
62+ t.Parallel()
63+ ctx := context.Background()
64+ run := func(id int64, attempt int32) domain.CIRun { return domain.CIRun{ID: id, Attempt: attempt} }
65+ seen := map[int64]string{}
66+ for _, id := range []int64{1, 2, 1<<27 - 1} {
67+ for _, attempt := range []int32{1, 2, 127} {
68+ for _, index := range []int{0, 1, 1023} {
69+ for _, combo := range []int{0, 1, 255, 511} {
70+ got := usecase.CheckRunID(ctx, run(id, attempt), index, combo)
71+ require.NotZero(t, got)
72+ assert.Less(t, got, int64(1)<<53)
73+ key := fmt.Sprintf("run %d attempt %d job %d leg %d", id, attempt, index, combo)
74+ assert.NotContains(t, seen, got, "%s collides", key)
75+ seen[got] = key
76+ }
77+ }
78+ }
79+ }
80+ a := usecase.CheckRunID(ctx, run(7, 1), 0, 0)
81+ b := usecase.CheckRunID(ctx, run(7, 2), 0, 0)
82+ assert.NotEqual(t, a, b, "a re-run gets new ids")
83+ again := usecase.CheckRunID(ctx, run(7, 1), 0, 0)
84+ assert.Equal(t, a, again, "stable per attempt")
85+
86+ // In range, the layout is fixed: leg in the low bits, then job, attempt, run.
87+ assert.Equal(t, int64(5)<<26|int64(3)<<19|int64(2)<<9|1, usecase.CheckRunID(ctx, run(5, 3), 2, 1))
88+
89+ // Out of range, the id is 0 and the overflow is logged at warn, never an
90+ // error: the key is informational.
91+ for name, c := range map[string]struct {
92+ run domain.CIRun
93+ index, combo int
94+ }{
95+ "job index": {run(1, 1), 1024, 0},
96+ "negative job": {run(1, 1), -1, 0},
97+ "leg": {run(1, 1), 0, 512},
98+ "attempt": {run(1, 128), 0, 0},
99+ "run id": {run(1<<27, 1), 0, 0},
100+ "huge run id": {run(math.MaxInt64, 1), 0, 0},
101+ "negative run": {run(-1, 1), 0, 0},
102+ } {
103+ var logs bytes.Buffer
104+ lctx := zerowrap.WithCtx(ctx, zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
105+ assert.Zero(t, usecase.CheckRunID(lctx, c.run, c.index, c.combo), name)
106+ assert.Contains(t, logs.String(), `"level":"warn"`, name)
107+ assert.Contains(t, logs.String(), `"run_id":`+strconv.FormatInt(c.run.ID, 10), name)
108+ assert.Contains(t, logs.String(), `"attempt":`+strconv.Itoa(int(c.run.Attempt)), name)
109+ assert.Contains(t, logs.String(), `"job_index":`+strconv.Itoa(c.index), name)
110+ }
111+}
@@ -0,0 +1,111 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "fmt"
9+ "math"
10+ "strconv"
11+ "strings"
12+ "testing"
13+
14+ "github.com/bnema/zerowrap"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite-actions/workflow"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+// The title of a run is the run-name when it evaluates to something, else the
24+// commit title; a run-name is cut to its first line and 256 characters.
25+func TestRunTitle(t *testing.T) {
26+ t.Parallel()
27+ const commit = "fix: the commit title"
28+ const githubJSON = `{"ref_name":"main","event":{"head_commit":{"message":"one\ntwo"}}}`
29+ tests := []struct {
30+ name string
31+ runName string // the run-name expression, "" : the workflow has none
32+ want string
33+ }{
34+ {"no run-name", "", commit},
35+ {"a run-name", "deploy ${{ github.ref_name }}", "deploy main"},
36+ {"an evaluation error", "${{ fromJSON('{') }}", commit},
37+ {"an empty result", "${{ '' }}", commit},
38+ {"a blank result", "${{ ' ' }}", commit},
39+ {"a first line only", "${{ github.event.head_commit.message }}", "one"},
40+ {"cut at 256 characters", strings.Repeat("é", 300), strings.Repeat("é", 256)},
41+ {"exactly 256 characters", strings.Repeat("a", 256), strings.Repeat("a", 256)},
42+ }
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ t.Parallel()
46+ u := newCIUnit(t, nil)
47+ yaml := "on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: 'true'\n"
48+ if tt.runName != "" {
49+ yaml = "run-name: \"" + strings.ReplaceAll(strings.ReplaceAll(tt.runName, `\`, `\\`), `"`, `\"`) + "\"\n" + yaml
50+ }
51+ wf, errs := workflow.Parse(".github/workflows/a.yml", []byte(yaml))
52+ require.Empty(t, errs)
53+ require.NotNil(t, wf)
54+ assert.Equal(t, tt.want, u.ci.RunTitle(context.Background(), wf, githubJSON, commit))
55+ })
56+ }
57+}
58+
59+// The check run id keeps the run, its attempt, the job and the leg apart, stays
60+// below 2^53, and refuses what does not fit instead of wrapping.
61+func TestCheckRunID(t *testing.T) {
62+ t.Parallel()
63+ ctx := context.Background()
64+ run := func(id int64, attempt int32) domain.CIRun { return domain.CIRun{ID: id, Attempt: attempt} }
65+ seen := map[int64]string{}
66+ for _, id := range []int64{1, 2, 1<<27 - 1} {
67+ for _, attempt := range []int32{1, 2, 127} {
68+ for _, index := range []int{0, 1, 1023} {
69+ for _, combo := range []int{0, 1, 255, 511} {
70+ got := usecase.CheckRunID(ctx, run(id, attempt), index, combo)
71+ require.NotZero(t, got)
72+ assert.Less(t, got, int64(1)<<53)
73+ key := fmt.Sprintf("run %d attempt %d job %d leg %d", id, attempt, index, combo)
74+ assert.NotContains(t, seen, got, "%s collides", key)
75+ seen[got] = key
76+ }
77+ }
78+ }
79+ }
80+ a := usecase.CheckRunID(ctx, run(7, 1), 0, 0)
81+ b := usecase.CheckRunID(ctx, run(7, 2), 0, 0)
82+ assert.NotEqual(t, a, b, "a re-run gets new ids")
83+ again := usecase.CheckRunID(ctx, run(7, 1), 0, 0)
84+ assert.Equal(t, a, again, "stable per attempt")
85+
86+ // In range, the layout is fixed: leg in the low bits, then job, attempt, run.
87+ assert.Equal(t, int64(5)<<26|int64(3)<<19|int64(2)<<9|1, usecase.CheckRunID(ctx, run(5, 3), 2, 1))
88+
89+ // Out of range, the id is 0 and the overflow is logged at warn, never an
90+ // error: the key is informational.
91+ for name, c := range map[string]struct {
92+ run domain.CIRun
93+ index, combo int
94+ }{
95+ "job index": {run(1, 1), 1024, 0},
96+ "negative job": {run(1, 1), -1, 0},
97+ "leg": {run(1, 1), 0, 512},
98+ "attempt": {run(1, 128), 0, 0},
99+ "run id": {run(1<<27, 1), 0, 0},
100+ "huge run id": {run(math.MaxInt64, 1), 0, 0},
101+ "negative run": {run(-1, 1), 0, 0},
102+ } {
103+ var logs bytes.Buffer
104+ lctx := zerowrap.WithCtx(ctx, zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
105+ assert.Zero(t, usecase.CheckRunID(lctx, c.run, c.index, c.combo), name)
106+ assert.Contains(t, logs.String(), `"level":"warn"`, name)
107+ assert.Contains(t, logs.String(), `"run_id":`+strconv.FormatInt(c.run.ID, 10), name)
108+ assert.Contains(t, logs.String(), `"attempt":`+strconv.Itoa(int(c.run.Attempt)), name)
109+ assert.Contains(t, logs.String(), `"job_index":`+strconv.Itoa(c.index), name)
110+ }
111+}
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(ctx, 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(ctx, 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 +92 -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
@@ -132,6 +133,9 @@ type pushFacts struct {
132133 isTag bool
133134 // defaultBranch is the default branch of the repository, "" when unknown.
134135 defaultBranch string
136+ // payload is the commit part of the push event, shared by the workflows
137+ // of the ref; handleRef builds it once, before the first one that matches.
138+ payload *pushPayload
135139 }
136140
137141 func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tagCount int) error {
@@ -168,6 +172,13 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag
168172 Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount,
169173 }):
170174 default:
175+ if facts.payload == nil {
176+ facts.payload = c.pushPayload(ctx, repo, facts)
177+ }
178+ if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem != "" {
179+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem))
180+ continue
181+ }
171182 errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))
172183 }
173184 }
@@ -190,14 +201,29 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
190201 }
191202 f.commit = res.CommitID
192203 if isTag {
193- cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
204+ // Recorded (push-tag, skip-directive-tag): the event of a tag carries
205+ // the commits that the default branch does not have, like a created
206+ // branch, newest as head_commit. No file list: paths filters are not
207+ // evaluated for tags.
208+ base := c.defaultBase(ctx, repo, "", f.commit)
209+ cs, _, err := c.cfg.Git.ListRange(ctx, repo, base, f.commit, ports.MaxCommitLimit)
194210 if err != nil {
195211 return f, fmt.Errorf("read the commit of the tag: %w", err)
196212 }
197213 if len(cs) == 0 {
198- return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
214+ cs, _, err = c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
215+ if err != nil {
216+ return f, fmt.Errorf("read the commit of the tag: %w", err)
217+ }
218+ if len(cs) == 0 {
219+ return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
220+ }
221+ f.head = cs[0]
222+ return f, nil
199223 }
200224 f.head = cs[0]
225+ f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)])
226+ slices.Reverse(f.commits)
201227 return f, nil
202228 }
203229
@@ -385,8 +411,10 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
385411 Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
386412 Status: domain.CIQueued, CreatedAt: now,
387413 }
414+ githubJSON := c.githubJSON(ctx, repo, run, f)
415+ run.Title = c.runTitle(ctx, wf, githubJSON, run.Title)
388416 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
389- Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: c.githubJSON(ctx, repo, run, f, wf),
417+ Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON,
390418 }}
391419 for i, j := range wf.Jobs {
392420 job := domain.CIJob{
@@ -416,7 +444,65 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
416444 return c.advanceRun(ctx, draft.Run.ID)
417445 }
418446
419-// --- the github context ---
447+// runTitle is the title of a run, GitHub's display_title: the run-name
448+// evaluated with the github, inputs and vars contexts (the contexts the
449+// schema allows there), else the first line of the head commit message. A
450+// run-name that does not evaluate, or that evaluates to nothing, falls back to
451+// the commit message, and the failure is logged.
452+func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
453+ if wf.RunName == nil {
454+ return commitTitle
455+ }
456+ gh, err := expr.FromJSON([]byte(githubJSON))
457+ if err != nil {
458+ return commitTitle
459+ }
460+ env := template.Env{Contexts: map[string]expr.Value{
461+ "github": gh, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
462+ }}
463+ name, err := template.EvaluateString(ctx, wf.RunName, env)
464+ if err != nil {
465+ log := zerowrap.FromCtx(ctx)
466+ log.Warn().Err(err).Msg("ci run-name does not evaluate: the run is titled with the commit message")
467+ return commitTitle
468+ }
469+ if name = firstLine(name); name == "" {
470+ return commitTitle
471+ }
472+ return name
473+}
474+
475+// matrixProblem expands the strategy of every job of the file, whatever its
476+// "if:" says, with an empty needs context (a matrix built from a needs output does not
477+// evaluate here, and that error is ignored),
478+// and returns the error text of the first strategy that produces more
479+// configurations than GitHub allows: GitHub then fails the run with no job,
480+// like an invalid workflow file (recorded: matrix-over-256). Any other error
481+// of a strategy is ignored here and stays a job failure, found when the job is
482+// planned. Whether GitHub counts the matrix of a job that "if:" skips is not
483+// recorded yet (needs an "if: false" scenario over 256). The github context
484+// is that of a run with no id yet, on the commits shared by the ref.
485+func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) string {
486+ if !slices.ContainsFunc(wf.Jobs, func(j *workflow.Job) bool { return j.Strategy != nil }) {
487+ return "" // nothing to expand: no context to build
488+ }
489+ 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()}
490+ gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f)))
491+ if err != nil {
492+ return ""
493+ }
494+ env := template.Env{Contexts: map[string]expr.Value{
495+ "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
496+ }}
497+ for _, j := range wf.Jobs {
498+ if _, err := j.EvaluateStrategy(ctx, env); errors.Is(err, workflow.ErrTooManyConfigurations) {
499+ return wfPath + ": " + err.Error()
500+ }
501+ }
502+ return ""
503+}
504+
505+// --- the github context (see ci_github.go) ---
420506
421507 // shortID is the 12-character form of an object id that GitHub puts in the
422508 // compare URL of a push.
@@ -432,81 +518,3 @@ func shortID(id string) string {
432518 func (c *CI) retentionDays() int {
433519 return max(int(c.cfg.Retention/(24*time.Hour)), 1)
434520 }
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
@@ -132,6 +133,9 @@ type pushFacts struct {
132 isTag bool133 isTag bool
133 // defaultBranch is the default branch of the repository, "" when unknown.134 // defaultBranch is the default branch of the repository, "" when unknown.
134 defaultBranch string135 defaultBranch string
136+ // payload is the commit part of the push event, shared by the workflows
137+ // of the ref; handleRef builds it once, before the first one that matches.
138+ payload *pushPayload
135 }139 }
136 140
137 func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tagCount int) error {141 func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tagCount int) error {
@@ -168,6 +172,13 @@ 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,172 Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount,
169 }):173 }):
170 default:174 default:
175+ if facts.payload == nil {
176+ facts.payload = c.pushPayload(ctx, repo, facts)
177+ }
178+ if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem != "" {
179+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem))
180+ continue
181+ }
171 errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))182 errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))
172 }183 }
173 }184 }
@@ -190,14 +201,29 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
190 }201 }
191 f.commit = res.CommitID202 f.commit = res.CommitID
192 if isTag {203 if isTag {
193- cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)204+ // Recorded (push-tag, skip-directive-tag): the event of a tag carries
205+ // the commits that the default branch does not have, like a created
206+ // branch, newest as head_commit. No file list: paths filters are not
207+ // evaluated for tags.
208+ base := c.defaultBase(ctx, repo, "", f.commit)
209+ cs, _, err := c.cfg.Git.ListRange(ctx, repo, base, f.commit, ports.MaxCommitLimit)
194 if err != nil {210 if err != nil {
195 return f, fmt.Errorf("read the commit of the tag: %w", err)211 return f, fmt.Errorf("read the commit of the tag: %w", err)
196 }212 }
197 if len(cs) == 0 {213 if len(cs) == 0 {
198- return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)214+ cs, _, err = c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
215+ if err != nil {
216+ return f, fmt.Errorf("read the commit of the tag: %w", err)
217+ }
218+ if len(cs) == 0 {
219+ return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
220+ }
221+ f.head = cs[0]
222+ return f, nil
199 }223 }
200 f.head = cs[0]224 f.head = cs[0]
225+ f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)])
226+ slices.Reverse(f.commits)
201 return f, nil227 return f, nil
202 }228 }
203 229
@@ -385,8 +411,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,411 Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
386 Status: domain.CIQueued, CreatedAt: now,412 Status: domain.CIQueued, CreatedAt: now,
387 }413 }
414+ githubJSON := c.githubJSON(ctx, repo, run, f)
415+ run.Title = c.runTitle(ctx, wf, githubJSON, run.Title)
388 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{416 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
389- Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: c.githubJSON(ctx, repo, run, f, wf),417+ Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON,
390 }}418 }}
391 for i, j := range wf.Jobs {419 for i, j := range wf.Jobs {
392 job := domain.CIJob{420 job := domain.CIJob{
@@ -416,7 +444,65 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
416 return c.advanceRun(ctx, draft.Run.ID)444 return c.advanceRun(ctx, draft.Run.ID)
417 }445 }
418 446
419-// --- the github context ---447+// runTitle is the title of a run, GitHub's display_title: the run-name
448+// evaluated with the github, inputs and vars contexts (the contexts the
449+// schema allows there), else the first line of the head commit message. A
450+// run-name that does not evaluate, or that evaluates to nothing, falls back to
451+// the commit message, and the failure is logged.
452+func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
453+ if wf.RunName == nil {
454+ return commitTitle
455+ }
456+ gh, err := expr.FromJSON([]byte(githubJSON))
457+ if err != nil {
458+ return commitTitle
459+ }
460+ env := template.Env{Contexts: map[string]expr.Value{
461+ "github": gh, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
462+ }}
463+ name, err := template.EvaluateString(ctx, wf.RunName, env)
464+ if err != nil {
465+ log := zerowrap.FromCtx(ctx)
466+ log.Warn().Err(err).Msg("ci run-name does not evaluate: the run is titled with the commit message")
467+ return commitTitle
468+ }
469+ if name = firstLine(name); name == "" {
470+ return commitTitle
471+ }
472+ return name
473+}
474+
475+// matrixProblem expands the strategy of every job of the file, whatever its
476+// "if:" says, with an empty needs context (a matrix built from a needs output does not
477+// evaluate here, and that error is ignored),
478+// and returns the error text of the first strategy that produces more
479+// configurations than GitHub allows: GitHub then fails the run with no job,
480+// like an invalid workflow file (recorded: matrix-over-256). Any other error
481+// of a strategy is ignored here and stays a job failure, found when the job is
482+// planned. Whether GitHub counts the matrix of a job that "if:" skips is not
483+// recorded yet (needs an "if: false" scenario over 256). The github context
484+// is that of a run with no id yet, on the commits shared by the ref.
485+func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) string {
486+ if !slices.ContainsFunc(wf.Jobs, func(j *workflow.Job) bool { return j.Strategy != nil }) {
487+ return "" // nothing to expand: no context to build
488+ }
489+ 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()}
490+ gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f)))
491+ if err != nil {
492+ return ""
493+ }
494+ env := template.Env{Contexts: map[string]expr.Value{
495+ "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
496+ }}
497+ for _, j := range wf.Jobs {
498+ if _, err := j.EvaluateStrategy(ctx, env); errors.Is(err, workflow.ErrTooManyConfigurations) {
499+ return wfPath + ": " + err.Error()
500+ }
501+ }
502+ return ""
503+}
504+
505+// --- the github context (see ci_github.go) ---
420 506
421 // shortID is the 12-character form of an object id that GitHub puts in the507 // shortID is the 12-character form of an object id that GitHub puts in the
422 // compare URL of a push.508 // compare URL of a push.
@@ -432,81 +518,3 @@ func shortID(id string) string {
432 func (c *CI) retentionDays() int {518 func (c *CI) retentionDays() int {
433 return max(int(c.cfg.Retention/(24*time.Hour)), 1)519 return max(int(c.cfg.Retention/(24*time.Hour)), 1)
434 }520 }
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 internal/usecase/export_test.go +11 -0
@@ -5,6 +5,7 @@ package usecase
55 import (
66 "context"
77
8+ "bnema.dev/ermite-actions/workflow"
89 "bnema.dev/ermite/internal/domain"
910 )
1011
@@ -13,3 +14,13 @@ func AuthorOf(p domain.Principal) string { return authorOf(p) }
1314
1415 // Sweep exposes the daily sweep to the tests.
1516 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
17+
18+// RunTitle exposes runTitle to the tests.
19+func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
20+ return c.runTitle(ctx, wf, githubJSON, commitTitle)
21+}
22+
23+// CheckRunID exposes checkRunID to the tests.
24+func CheckRunID(ctx context.Context, run domain.CIRun, index, combo int) int64 {
25+ return checkRunID(ctx, run, index, combo)
26+}
@@ -5,6 +5,7 @@ package usecase
5 import (5 import (
6 "context"6 "context"
7 7
8+ "bnema.dev/ermite-actions/workflow"
8 "bnema.dev/ermite/internal/domain"9 "bnema.dev/ermite/internal/domain"
9 )10 )
10 11
@@ -13,3 +14,13 @@ func AuthorOf(p domain.Principal) string { return authorOf(p) }
13 14
14 // Sweep exposes the daily sweep to the tests.15 // Sweep exposes the daily sweep to the tests.
15 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }16 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
17+
18+// RunTitle exposes runTitle to the tests.
19+func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
20+ return c.runTitle(ctx, wf, githubJSON, commitTitle)
21+}
22+
23+// CheckRunID exposes checkRunID to the tests.
24+func CheckRunID(ctx context.Context, run domain.CIRun, index, combo int) int64 {
25+ return checkRunID(ctx, run, index, combo)
26+}
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