Merge pull request 'feat(ci): GitHub-shaped contexts, owner URLs, run titles and continue-on-error' (#18) from feat/ci-github-parity into main
18 files · +1630 -120
M gen/ermite/storage/ci.pb.go +17 -5 generated file: collapsed
M go.mod +1 -1
| @@ -12,7 +12,7 @@ tool ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | - | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 | |
| 15 | + | bnema.dev/ermite-actions v0.0.0-20261013001922-b6a9ba732350 | |
| 16 | 16 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c | |
| 17 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 18 | 18 | 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-fcc31accb5a4 | 15 | + | bnema.dev/ermite-actions v0.0.0-20261013001922-b6a9ba732350 |
| 16 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c | 16 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c | ||
| 17 | bnema.dev/ermite-git v0.1.0 | 17 | bnema.dev/ermite-git v0.1.0 | ||
| 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | ||
M go.sum +2 -2
| @@ -1,5 +1,5 @@ | |||
| 1 | - | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 h1:3TBeF1oKAan41P2AiU1KLGYc4ZTG5KweCe5klMMIkxk= | |
| 2 | - | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | |
| 1 | + | bnema.dev/ermite-actions v0.0.0-20261013001922-b6a9ba732350 h1:f1DTC/j0kJMfpdN0RBZ586VcG+06m2MSZOZBPDcw0rY= | |
| 2 | + | bnema.dev/ermite-actions v0.0.0-20261013001922-b6a9ba732350/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | |
| 3 | 3 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c h1:XFaUL8e/4reQ/0M3JUwi/4dmTWspxgwXXg3UQKmFoXQ= | |
| 4 | 4 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |
| 5 | 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | |
| @@ -1,5 +1,5 @@ | |||||
| 1 | - | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 h1:3TBeF1oKAan41P2AiU1KLGYc4ZTG5KweCe5klMMIkxk= | 1 | + | bnema.dev/ermite-actions v0.0.0-20261013001922-b6a9ba732350 h1:f1DTC/j0kJMfpdN0RBZ586VcG+06m2MSZOZBPDcw0rY= |
| 2 | - | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | 2 | + | bnema.dev/ermite-actions v0.0.0-20261013001922-b6a9ba732350/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= |
| 3 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c h1:XFaUL8e/4reQ/0M3JUwi/4dmTWspxgwXXg3UQKmFoXQ= | 3 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c h1:XFaUL8e/4reQ/0M3JUwi/4dmTWspxgwXXg3UQKmFoXQ= | ||
| 4 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | 4 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | ||
| 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | ||
M internal/adapters/kvstore/ci.go +2 -2
| @@ -319,7 +319,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) { | |||
| 319 | 319 | Isolation: encodeIsolation(j.Isolation), Steps: steps, | |
| 320 | 320 | CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt), | |
| 321 | 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 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 | |
| 323 | 323 | Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt), | |
| 324 | 324 | AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3 | |
| 325 | 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 | 348 | Isolation: decodeIsolation(pb.GetIsolation()), | |
| 349 | 349 | CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()), | |
| 350 | 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 | 352 | MaxParallel: int(pb.GetMaxParallel()), Outputs: pb.GetOutputs(), | |
| 353 | 353 | TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()), | |
| 354 | 354 | 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 limit | 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 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 |
| 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 3 | 324 | 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 { | |||
| 65 | 65 | // Heartbeat is how often an idle event stream gets a comment line; | |
| 66 | 66 | // DefaultHeartbeat when zero. | |
| 67 | 67 | 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 | 73 | // PublicURL is the base URL of the instance, without a trailing slash: | |
| 69 | 74 | // the clone URLs are built on it, and its host is the instance name. | |
| 70 | 75 | PublicURL string | |
| @@ -100,6 +105,9 @@ type Server struct { | |||
| 100 | 105 | // pathCache holds the path lists the file finder fetches. | |
| 101 | 106 | pathCache *pathCache | |
| 102 | 107 | 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 | 111 | // refsFan shares the fragments of the refs page between the watchers of | |
| 104 | 112 | // a push; entries are few and short-lived. | |
| 105 | 113 | refsFan fanout | |
| @@ -129,6 +137,7 @@ func New(cfg Config) (*Server, error) { | |||
| 129 | 137 | pathCache: newPathCache(pathCacheBytes, pathBuilds), | |
| 130 | 138 | origin: originOf(u), | |
| 131 | 139 | } | |
| 140 | + | s.ownerRepo, _ = domain.ParseRepoName(cfg.Owner) | |
| 132 | 141 | s.mux = s.routes() | |
| 133 | 142 | return s, nil | |
| 134 | 143 | } | |
| @@ -184,7 +193,7 @@ func (s *Server) routes() *http.ServeMux { | |||
| 184 | 193 | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false))) | |
| 185 | 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 | 197 | return mux | |
| 189 | 198 | } | |
| 190 | 199 | ||
| @@ -197,9 +206,72 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 197 | 206 | if !s.gate(w, r) { | |
| 198 | 207 | return | |
| 199 | 208 | } | |
| 209 | + | if to, ok := s.ownerRedirect(r); ok { | |
| 210 | + | http.Redirect(w, r, to, http.StatusPermanentRedirect) | |
| 211 | + | return | |
| 212 | + | } | |
| 200 | 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 | 275 | // handler builds a page. It returns the component and its status, or an | |
| 204 | 276 | // error, which becomes the 404 or 500 page. | |
| 205 | 277 | 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.Duration | 67 | 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 string | 75 | 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 *pathCache | 106 | pathCache *pathCache | ||
| 102 | mux *http.ServeMux | 107 | 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 of | 111 | // 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 fanout | 113 | 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, nil | 142 | 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 mux | 197 | 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 | return | 207 | 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 an | 275 | // 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) { | |||
| 668 | 668 | } | |
| 669 | 669 | ||
| 670 | 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 | 672 | assert.Equal(t, true, event["created"]) | |
| 673 | 673 | assert.Equal(t, domain.ZeroOID, event["before"]) | |
| 674 | 674 | repo := event["repository"].(map[string]any) | |
| @@ -681,6 +681,6 @@ func TestCIGithubContextOfAPush(t *testing.T) { | |||
| 681 | 681 | ||
| 682 | 682 | second := c.push("main", map[string]string{"a.txt": "a\n"}, "") | |
| 683 | 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 | 685 | assert.Equal(t, false, event["created"]) | |
| 686 | 686 | } | |
| @@ -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 | |||
| 41 | 41 | MergeRequests: f.MergeRequests, | |
| 42 | 42 | Runs: f.CI, | |
| 43 | 43 | Events: f.EventLog, | |
| 44 | + | Owner: cfg.Owner, | |
| 44 | 45 | PublicURL: cfg.PublicURL, | |
| 45 | 46 | SourceURL: cfg.SourceURL, | |
| 46 | 47 | 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 { | |||
| 245 | 245 | // TimeoutMinutes is the timeout of the job, at least 1. | |
| 246 | 246 | TimeoutMinutes int32 | |
| 247 | 247 | 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 | 253 | // MaxParallel is the max-parallel of the strategy, 0 for no limit. | |
| 249 | 254 | MaxParallel int | |
| 250 | 255 | 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 int32 | 246 | TimeoutMinutes int32 | ||
| 247 | FailFast bool | 247 | 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 int | 254 | MaxParallel int | ||
| 250 | Outputs map[string]string | 255 | Outputs map[string]string | ||
M internal/usecase/ci.go +2 -0
| @@ -398,6 +398,8 @@ func (rs *runState) recompute(now time.Time) domain.CIRun { | |||
| 398 | 398 | started = j.StartedAt | |
| 399 | 399 | } | |
| 400 | 400 | switch { | |
| 401 | + | case j.Conclusion.IsFailure() && j.ContinueOnError: | |
| 402 | + | // continue-on-error: the job failed, the run does not. | |
| 401 | 403 | case j.Conclusion.IsFailure(): | |
| 402 | 404 | failed = true | |
| 403 | 405 | case j.Conclusion == domain.CICancelled: | |
| @@ -398,6 +398,8 @@ func (rs *runState) recompute(now time.Time) domain.CIRun { | |||||
| 398 | started = j.StartedAt | 398 | 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 = true | 404 | 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 { | |||
| 67 | 67 | return "environment" | |
| 68 | 68 | case j.Concurrency != nil: | |
| 69 | 69 | return "concurrency" | |
| 70 | - | case present(j.ContinueOnError): | |
| 71 | - | return "continue-on-error" | |
| 72 | 70 | } | |
| 73 | 71 | for _, s := range j.Steps { | |
| 74 | 72 | switch { | |
| 75 | 73 | case s.Uses != nil: | |
| 76 | 74 | 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. | |
| 80 | 78 | return "timeout-minutes on a step" | |
| 81 | 79 | } | |
| 82 | 80 | } | |
| 83 | 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 | 84 | // instances returns the indexes of the records of the job key, in order. | |
| 99 | 85 | func (rs *runState) instances(key string) []int { | |
| 100 | 86 | var out []int | |
| @@ -120,6 +106,10 @@ func (rs *runState) sortJobs() { | |||
| 120 | 106 | func result(insts []domain.CIJob) string { | |
| 121 | 107 | var failed, cancelled, ok bool | |
| 122 | 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 | 113 | switch j.Conclusion { | |
| 124 | 114 | case domain.CIFailure, domain.CITimedOut, domain.CIActionRequired: | |
| 125 | 115 | failed = true | |
| @@ -210,8 +200,8 @@ func (rs *runState) githubValue(key string, attempt int32) (*expr.Dictionary, er | |||
| 210 | 200 | if !ok { | |
| 211 | 201 | return nil, fmt.Errorf("the stored github context is not an object") | |
| 212 | 202 | } | |
| 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)))) | |
| 215 | 205 | return d, nil | |
| 216 | 206 | } | |
| 217 | 207 | ||
| @@ -246,7 +236,7 @@ func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg str | |||
| 246 | 236 | delete(rs.c.heartbeats, jobRef(*j)) | |
| 247 | 237 | rs.c.mu.Unlock() | |
| 248 | 238 | ||
| 249 | - | if j.FailFast && conclusion.IsFailure() { | |
| 239 | + | if j.FailFast && !j.ContinueOnError && conclusion.IsFailure() { | |
| 250 | 240 | for i := range rs.jobs { | |
| 251 | 241 | s := &rs.jobs[i] | |
| 252 | 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 | 347 | rec := rs.jobs[idx] | |
| 358 | 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 | 354 | if err != nil { | |
| 362 | 355 | fail(err.Error()) | |
| 363 | 356 | return | |
| @@ -431,6 +424,7 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl | |||
| 431 | 424 | if err != nil { | |
| 432 | 425 | return j, err | |
| 433 | 426 | } | |
| 427 | + | name = strings.TrimSpace(name) // GitHub shows "Display name of", not "Display name of " | |
| 434 | 428 | labels, _, err := wj.EvaluateRunsOn(ctx, env) | |
| 435 | 429 | if err != nil { | |
| 436 | 430 | return j, err | |
| @@ -451,7 +445,11 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl | |||
| 451 | 445 | return j, err | |
| 452 | 446 | } | |
| 453 | 447 | 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 | 453 | if err != nil { | |
| 456 | 454 | return j, err | |
| 457 | 455 | } | |
| @@ -459,6 +457,15 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl | |||
| 459 | 457 | j.MatrixJSON = compactJSON(expr.ToJSON(matrix)) | |
| 460 | 458 | j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos) | |
| 461 | 459 | 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 | 469 | j.Planned, j.Phase = true, domain.CIPhaseWaiting | |
| 463 | 470 | return j, nil | |
| 464 | 471 | } | |
| @@ -473,7 +480,12 @@ func compactJSON(s string) string { | |||
| 473 | 480 | ||
| 474 | 481 | // contextsJSON is the JSON object of the contexts the server computes for a | |
| 475 | 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 | 489 | out := map[string]json.RawMessage{} | |
| 478 | 490 | for k, v := range map[string]expr.Value{ | |
| 479 | 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 | 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 | 501 | b, err := json.Marshal(out) | |
| 485 | 502 | if err != nil { | |
| 486 | 503 | 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.Value | 74 | 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 []int | 86 | 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 bool | 107 | 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 = true | 115 | 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, nil | 205 | 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 | return | 356 | 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, err | 425 | 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, err | 430 | return j, err | ||
| @@ -451,7 +445,11 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl | |||||
| 451 | return j, err | 445 | return j, err | ||
| 452 | } | 446 | } | ||
| 453 | matrix := plan.Combos[i].Values | 447 | 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, err | 454 | 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.MaxParallel | 459 | 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.CIPhaseWaiting | 469 | j.Planned, j.Phase = true, domain.CIPhaseWaiting | ||
| 463 | return j, nil | 470 | 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 a | 481 | // 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 "", err | 503 | return "", err | ||
M internal/usecase/ci_trigger.go +92 -84
| @@ -4,7 +4,6 @@ package usecase | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | - | "encoding/json" | |
| 8 | 7 | "errors" | |
| 9 | 8 | "fmt" | |
| 10 | 9 | "io" | |
| @@ -17,6 +16,8 @@ import ( | |||
| 17 | 16 | ||
| 18 | 17 | "github.com/bnema/zerowrap" | |
| 19 | 18 | ||
| 19 | + | "bnema.dev/ermite-actions/expr" | |
| 20 | + | "bnema.dev/ermite-actions/template" | |
| 20 | 21 | "bnema.dev/ermite-actions/workflow" | |
| 21 | 22 | "bnema.dev/ermite/internal/domain" | |
| 22 | 23 | "bnema.dev/ermite/internal/ports" | |
| @@ -122,7 +123,7 @@ type pushFacts struct { | |||
| 122 | 123 | update domain.RefUpdate | |
| 123 | 124 | commit domain.ObjectID // the commit the ref points to, tags followed | |
| 124 | 125 | head domain.Commit | |
| 125 | - | commits []domain.Commit // oldest first, at most maxPushCommits (branches only) | |
| 126 | + | commits []domain.Commit // oldest first, at most maxPushCommits | |
| 126 | 127 | forced bool | |
| 127 | 128 | created bool | |
| 128 | 129 | files []string | |
| @@ -132,6 +133,9 @@ type pushFacts struct { | |||
| 132 | 133 | isTag bool | |
| 133 | 134 | // defaultBranch is the default branch of the repository, "" when unknown. | |
| 134 | 135 | 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 | 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 | 172 | Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount, | |
| 169 | 173 | }): | |
| 170 | 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 | 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 | 202 | f.commit = res.CommitID | |
| 192 | 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 | 210 | if err != nil { | |
| 195 | 211 | return f, fmt.Errorf("read the commit of the tag: %w", err) | |
| 196 | 212 | } | |
| 197 | 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 | 224 | f.head = cs[0] | |
| 225 | + | f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)]) | |
| 226 | + | slices.Reverse(f.commits) | |
| 201 | 227 | 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 | 411 | Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, | |
| 386 | 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 | 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 | 419 | for i, j := range wf.Jobs { | |
| 392 | 420 | job := domain.CIJob{ | |
| @@ -416,7 +444,65 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push | |||
| 416 | 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 | 507 | // shortID is the 12-character form of an object id that GitHub puts in the | |
| 422 | 508 | // compare URL of a push. | |
| @@ -432,81 +518,3 @@ func shortID(id string) string { | |||
| 432 | 518 | func (c *CI) retentionDays() int { | |
| 433 | 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 | - | } | |
| @@ -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.RefUpdate | 123 | update domain.RefUpdate | ||
| 123 | commit domain.ObjectID // the commit the ref points to, tags followed | 124 | commit domain.ObjectID // the commit the ref points to, tags followed | ||
| 124 | head domain.Commit | 125 | 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 bool | 127 | forced bool | ||
| 127 | created bool | 128 | created bool | ||
| 128 | files []string | 129 | files []string | ||
| @@ -132,6 +133,9 @@ type pushFacts struct { | |||||
| 132 | isTag bool | 133 | 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 string | 135 | 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.CommitID | 202 | 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, nil | 227 | 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 the | 507 | // 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 | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | ||
| 8 | + | "bnema.dev/ermite-actions/workflow" | |
| 8 | 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 | 15 | // Sweep exposes the daily sweep to the tests. | |
| 15 | 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 | + | } | |
| @@ -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 { | |||
| 180 | 180 | // The contexts JSON given to the runner, computed when the job is planned | |
| 181 | 181 | // (needs outputs are final then). | |
| 182 | 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 | 189 | // 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 planned | 180 | // 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, or | 189 | // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or | ||