feat(ci): match GitHub's github and job contexts, push payload and run titles
Close the differences found by the first conformance compare against the github.com recordings (ermite-conformance). github context: add actor_id, repository_id, repository_owner_id, repository_visibility, secret_source and artifact_cache_size_limit; send the keys in GitHub's order. Ids are stable numbers derived from the login and owner/repo; visibility comes from the repository. event payload (push): repository has GitHub's 83 keys and sender 19 (real, derived or constant per key, documented in ci_github.go); commits and head_commit lose added/removed/modified and author/committer username, as in the recordings. A tag event carries the commits the default branch lacks. The compare URL of several new commits is <first>^...<last>. URLs: commit, compare, html_url and clone_url use <server>/<owner>/<repo>, repositoryUrl is git://<host>/<owner>/<repo>.git, api_url is the API base and graphql_url <base>/graphql. The web redirects GET/HEAD /<owner>/<repo>[...] to /<repo>[...] when the owner is ERMITE_OWNER and the path is no page. job context: the server sends check_run_id, workflow_ref, workflow_sha, workflow_repository and workflow_file_path in contexts.job (the runner adds status and copies the keys). matrix is null for a job without a matrix. github.job is empty while a job is evaluated, as on github.com. run title: Run.title is the evaluated run-name (github, inputs, vars), else the first line of the head commit message. planner: step continue-on-error is no longer refused (the runner runs it). Job-level continue-on-error is evaluated at planning: the run does not fail, fail-fast does not cancel the siblings and needs.<job>.result is success; the job keeps its failure conclusion. An over-256 matrix is an invalid workflow (failed run, no job). step timeout-minutes stays refused: the runner has none.
- commit
- 3690ef935351c1cc6f16d25040955f84ed7867ce
- author
- brice · · 2026-10-12 23:44 UTC
- parent
- 45bfc5b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +1278 -117
M gen/ermite/storage/ci.pb.go +17 -5
| @@ -786,9 +786,13 @@ type CIJob struct { | |||
| 786 | 786 | RunAttempt int32 `protobuf:"varint,38,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"` | |
| 787 | 787 | // The contexts JSON given to the runner, computed when the job is planned | |
| 788 | 788 | // (needs outputs are final then). | |
| 789 | - | ContextsJson string `protobuf:"bytes,39,opt,name=contexts_json,json=contextsJson,proto3" json:"contexts_json,omitempty"` | |
| 790 | - | unknownFields protoimpl.UnknownFields | |
| 791 | - | sizeCache protoimpl.SizeCache | |
| 789 | + | ContextsJson string `protobuf:"bytes,39,opt,name=contexts_json,json=contextsJson,proto3" json:"contexts_json,omitempty"` | |
| 790 | + | // The job's continue-on-error, evaluated when the job is planned: a failure | |
| 791 | + | // of the job does not fail the run, does not cancel the siblings of its | |
| 792 | + | // matrix, and needs.<job>.result is success. | |
| 793 | + | ContinueOnError bool `protobuf:"varint,40,opt,name=continue_on_error,json=continueOnError,proto3" json:"continue_on_error,omitempty"` | |
| 794 | + | unknownFields protoimpl.UnknownFields | |
| 795 | + | sizeCache protoimpl.SizeCache | |
| 792 | 796 | } | |
| 793 | 797 | ||
| 794 | 798 | func (x *CIJob) Reset() { | |
| @@ -1087,6 +1091,13 @@ func (x *CIJob) GetContextsJson() string { | |||
| 1087 | 1091 | return "" | |
| 1088 | 1092 | } | |
| 1089 | 1093 | ||
| 1094 | + | func (x *CIJob) GetContinueOnError() bool { | |
| 1095 | + | if x != nil { | |
| 1096 | + | return x.ContinueOnError | |
| 1097 | + | } | |
| 1098 | + | return false | |
| 1099 | + | } | |
| 1100 | + | ||
| 1090 | 1101 | // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or | |
| 1091 | 1102 | // "run-<run id>" for a run that failed without a job (invalid workflow file). | |
| 1092 | 1103 | type CICheck struct { | |
| @@ -1386,7 +1397,7 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||
| 1386 | 1397 | "\fcompleted_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12\x1b\n" + | |
| 1387 | 1398 | "\tlog_start\x18\a \x01(\x03R\blogStart\x12\x1b\n" + | |
| 1388 | 1399 | "\thas_lines\x18\t \x01(\bR\bhasLinesJ\x04\b\b\x10\tJ\x04\b\n" + | |
| 1389 | - | "\x10\v\"\xba\f\n" + | |
| 1400 | + | "\x10\v\"\xe6\f\n" + | |
| 1390 | 1401 | "\x05CIJob\x12\x0e\n" + | |
| 1391 | 1402 | "\x02id\x18\x01 \x01(\tR\x02id\x12\x15\n" + | |
| 1392 | 1403 | "\x06run_id\x18\x02 \x01(\x03R\x05runId\x12\x18\n" + | |
| @@ -1436,7 +1447,8 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||
| 1436 | 1447 | "\aplanned\x18% \x01(\bR\aplanned\x12\x1f\n" + | |
| 1437 | 1448 | "\vrun_attempt\x18& \x01(\x05R\n" + | |
| 1438 | 1449 | "runAttempt\x12#\n" + | |
| 1439 | - | "\rcontexts_json\x18' \x01(\tR\fcontextsJson\x1a:\n" + | |
| 1450 | + | "\rcontexts_json\x18' \x01(\tR\fcontextsJson\x12*\n" + | |
| 1451 | + | "\x11continue_on_error\x18( \x01(\bR\x0fcontinueOnError\x1a:\n" + | |
| 1440 | 1452 | "\fOutputsEntry\x12\x10\n" + | |
| 1441 | 1453 | "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + | |
| 1442 | 1454 | "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b$\x10%\"\xe5\x03\n" + | |
| @@ -786,9 +786,13 @@ type CIJob struct { | |||||
| 786 | RunAttempt int32 `protobuf:"varint,38,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"` | 786 | RunAttempt int32 `protobuf:"varint,38,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"` | ||
| 787 | // The contexts JSON given to the runner, computed when the job is planned | 787 | // The contexts JSON given to the runner, computed when the job is planned | ||
| 788 | // (needs outputs are final then). | 788 | // (needs outputs are final then). | ||
| 789 | - | ContextsJson string `protobuf:"bytes,39,opt,name=contexts_json,json=contextsJson,proto3" json:"contexts_json,omitempty"` | 789 | + | ContextsJson string `protobuf:"bytes,39,opt,name=contexts_json,json=contextsJson,proto3" json:"contexts_json,omitempty"` |
| 790 | - | unknownFields protoimpl.UnknownFields | 790 | + | // The job's continue-on-error, evaluated when the job is planned: a failure |
| 791 | - | sizeCache protoimpl.SizeCache | 791 | + | // of the job does not fail the run, does not cancel the siblings of its |
| 792 | + | // matrix, and needs.<job>.result is success. | |||
| 793 | + | ContinueOnError bool `protobuf:"varint,40,opt,name=continue_on_error,json=continueOnError,proto3" json:"continue_on_error,omitempty"` | |||
| 794 | + | unknownFields protoimpl.UnknownFields | |||
| 795 | + | sizeCache protoimpl.SizeCache | |||
| 792 | } | 796 | } | ||
| 793 | 797 | ||||
| 794 | func (x *CIJob) Reset() { | 798 | func (x *CIJob) Reset() { | ||
| @@ -1087,6 +1091,13 @@ func (x *CIJob) GetContextsJson() string { | |||||
| 1087 | return "" | 1091 | return "" | ||
| 1088 | } | 1092 | } | ||
| 1089 | 1093 | ||||
| 1094 | + | func (x *CIJob) GetContinueOnError() bool { | |||
| 1095 | + | if x != nil { | |||
| 1096 | + | return x.ContinueOnError | |||
| 1097 | + | } | |||
| 1098 | + | return false | |||
| 1099 | + | } | |||
| 1100 | + | ||||
| 1090 | // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or | 1101 | // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or | ||
| 1091 | // "run-<run id>" for a run that failed without a job (invalid workflow file). | 1102 | // "run-<run id>" for a run that failed without a job (invalid workflow file). | ||
| 1092 | type CICheck struct { | 1103 | type CICheck struct { | ||
| @@ -1386,7 +1397,7 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||||
| 1386 | "\fcompleted_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12\x1b\n" + | 1397 | "\fcompleted_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12\x1b\n" + | ||
| 1387 | "\tlog_start\x18\a \x01(\x03R\blogStart\x12\x1b\n" + | 1398 | "\tlog_start\x18\a \x01(\x03R\blogStart\x12\x1b\n" + | ||
| 1388 | "\thas_lines\x18\t \x01(\bR\bhasLinesJ\x04\b\b\x10\tJ\x04\b\n" + | 1399 | "\thas_lines\x18\t \x01(\bR\bhasLinesJ\x04\b\b\x10\tJ\x04\b\n" + | ||
| 1389 | - | "\x10\v\"\xba\f\n" + | 1400 | + | "\x10\v\"\xe6\f\n" + |
| 1390 | "\x05CIJob\x12\x0e\n" + | 1401 | "\x05CIJob\x12\x0e\n" + | ||
| 1391 | "\x02id\x18\x01 \x01(\tR\x02id\x12\x15\n" + | 1402 | "\x02id\x18\x01 \x01(\tR\x02id\x12\x15\n" + | ||
| 1392 | "\x06run_id\x18\x02 \x01(\x03R\x05runId\x12\x18\n" + | 1403 | "\x06run_id\x18\x02 \x01(\x03R\x05runId\x12\x18\n" + | ||
| @@ -1436,7 +1447,8 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||||
| 1436 | "\aplanned\x18% \x01(\bR\aplanned\x12\x1f\n" + | 1447 | "\aplanned\x18% \x01(\bR\aplanned\x12\x1f\n" + | ||
| 1437 | "\vrun_attempt\x18& \x01(\x05R\n" + | 1448 | "\vrun_attempt\x18& \x01(\x05R\n" + | ||
| 1438 | "runAttempt\x12#\n" + | 1449 | "runAttempt\x12#\n" + | ||
| 1439 | - | "\rcontexts_json\x18' \x01(\tR\fcontextsJson\x1a:\n" + | 1450 | + | "\rcontexts_json\x18' \x01(\tR\fcontextsJson\x12*\n" + |
| 1451 | + | "\x11continue_on_error\x18( \x01(\bR\x0fcontinueOnError\x1a:\n" + | |||
| 1440 | "\fOutputsEntry\x12\x10\n" + | 1452 | "\fOutputsEntry\x12\x10\n" + | ||
| 1441 | "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + | 1453 | "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + | ||
| 1442 | "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b$\x10%\"\xe5\x03\n" + | 1454 | "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b$\x10%\"\xe5\x03\n" + | ||
M 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()), | ||
M internal/adapters/web/web.go +39 -1
| @@ -65,6 +65,10 @@ 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 of such a URL that is not a page | |
| 70 | + | // by itself is redirected to /<repo>/.... Empty: no redirect. | |
| 71 | + | Owner string | |
| 68 | 72 | // PublicURL is the base URL of the instance, without a trailing slash: | |
| 69 | 73 | // the clone URLs are built on it, and its host is the instance name. | |
| 70 | 74 | PublicURL string | |
| @@ -184,7 +188,7 @@ func (s *Server) routes() *http.ServeMux { | |||
| 184 | 188 | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false))) | |
| 185 | 189 | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true))) | |
| 186 | 190 | } | |
| 187 | - | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | |
| 191 | + | mux.HandleFunc(catchAll, s.page(s.notFound)) | |
| 188 | 192 | return mux | |
| 189 | 193 | } | |
| 190 | 194 | ||
| @@ -197,9 +201,43 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 197 | 201 | if !s.gate(w, r) { | |
| 198 | 202 | return | |
| 199 | 203 | } | |
| 204 | + | if to, ok := s.ownerRedirect(r); ok { | |
| 205 | + | http.Redirect(w, r, to, http.StatusPermanentRedirect) | |
| 206 | + | return | |
| 207 | + | } | |
| 200 | 208 | s.mux.ServeHTTP(w, r) | |
| 201 | 209 | } | |
| 202 | 210 | ||
| 211 | + | // catchAll is the pattern of the paths that are no page. | |
| 212 | + | const catchAll = "GET /{repo}/{rest...}" | |
| 213 | + | ||
| 214 | + | // ownerRedirect maps /<owner>/<repo>[.git][/rest] to /<repo>[.git][/rest]: the | |
| 215 | + | // URLs that the github context of CI shows (html_url, commit and compare | |
| 216 | + | // URLs, clone_url) have the owner of the instance in front, like GitHub's. A | |
| 217 | + | // path is only redirected when it would be a 404 otherwise, so that a | |
| 218 | + | // repository named like the owner keeps its own pages. | |
| 219 | + | func (s *Server) ownerRedirect(r *http.Request) (string, bool) { | |
| 220 | + | if s.cfg.Owner == "" || (r.Method != http.MethodGet && r.Method != http.MethodHead) { | |
| 221 | + | return "", false | |
| 222 | + | } | |
| 223 | + | first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/") | |
| 224 | + | if !ok || first != s.cfg.Owner || rest == "" { | |
| 225 | + | return "", false | |
| 226 | + | } | |
| 227 | + | repo, _, _ := strings.Cut(rest, "/") | |
| 228 | + | if _, err := domain.ParseRepoName(strings.TrimSuffix(repo, ".git")); err != nil { | |
| 229 | + | return "", false | |
| 230 | + | } | |
| 231 | + | if _, pattern := s.mux.Handler(r); pattern != catchAll { | |
| 232 | + | return "", false | |
| 233 | + | } | |
| 234 | + | to := "/" + rest | |
| 235 | + | if r.URL.RawQuery != "" { | |
| 236 | + | to += "?" + r.URL.RawQuery | |
| 237 | + | } | |
| 238 | + | return to, true | |
| 239 | + | } | |
| 240 | + | ||
| 203 | 241 | // handler builds a page. It returns the component and its status, or an | |
| 204 | 242 | // error, which becomes the 404 or 500 page. | |
| 205 | 243 | type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error) | |
| @@ -65,6 +65,10 @@ type Config struct { | |||||
| 65 | // Heartbeat is how often an idle event stream gets a comment line; | 65 | // Heartbeat is how often an idle event stream gets a comment line; | ||
| 66 | // DefaultHeartbeat when zero. | 66 | // DefaultHeartbeat when zero. | ||
| 67 | Heartbeat time.Duration | 67 | Heartbeat time.Duration | ||
| 68 | + | // Owner is the owner login the GitHub-shaped URLs of CI show | |||
| 69 | + | // (<server>/<owner>/<repo>/...). A GET of such a URL that is not a page | |||
| 70 | + | // by itself is redirected to /<repo>/.... Empty: no redirect. | |||
| 71 | + | Owner string | |||
| 68 | // PublicURL is the base URL of the instance, without a trailing slash: | 72 | // PublicURL is the base URL of the instance, without a trailing slash: | ||
| 69 | // the clone URLs are built on it, and its host is the instance name. | 73 | // the clone URLs are built on it, and its host is the instance name. | ||
| 70 | PublicURL string | 74 | PublicURL string | ||
| @@ -184,7 +188,7 @@ func (s *Server) routes() *http.ServeMux { | |||||
| 184 | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false))) | 188 | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false))) | ||
| 185 | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true))) | 189 | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true))) | ||
| 186 | } | 190 | } | ||
| 187 | - | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | 191 | + | mux.HandleFunc(catchAll, s.page(s.notFound)) |
| 188 | return mux | 192 | return mux | ||
| 189 | } | 193 | } | ||
| 190 | 194 | ||||
| @@ -197,9 +201,43 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 197 | if !s.gate(w, r) { | 201 | if !s.gate(w, r) { | ||
| 198 | return | 202 | return | ||
| 199 | } | 203 | } | ||
| 204 | + | if to, ok := s.ownerRedirect(r); ok { | |||
| 205 | + | http.Redirect(w, r, to, http.StatusPermanentRedirect) | |||
| 206 | + | return | |||
| 207 | + | } | |||
| 200 | s.mux.ServeHTTP(w, r) | 208 | s.mux.ServeHTTP(w, r) | ||
| 201 | } | 209 | } | ||
| 202 | 210 | ||||
| 211 | + | // catchAll is the pattern of the paths that are no page. | |||
| 212 | + | const catchAll = "GET /{repo}/{rest...}" | |||
| 213 | + | ||||
| 214 | + | // ownerRedirect maps /<owner>/<repo>[.git][/rest] to /<repo>[.git][/rest]: the | |||
| 215 | + | // URLs that the github context of CI shows (html_url, commit and compare | |||
| 216 | + | // URLs, clone_url) have the owner of the instance in front, like GitHub's. A | |||
| 217 | + | // path is only redirected when it would be a 404 otherwise, so that a | |||
| 218 | + | // repository named like the owner keeps its own pages. | |||
| 219 | + | func (s *Server) ownerRedirect(r *http.Request) (string, bool) { | |||
| 220 | + | if s.cfg.Owner == "" || (r.Method != http.MethodGet && r.Method != http.MethodHead) { | |||
| 221 | + | return "", false | |||
| 222 | + | } | |||
| 223 | + | first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/") | |||
| 224 | + | if !ok || first != s.cfg.Owner || rest == "" { | |||
| 225 | + | return "", false | |||
| 226 | + | } | |||
| 227 | + | repo, _, _ := strings.Cut(rest, "/") | |||
| 228 | + | if _, err := domain.ParseRepoName(strings.TrimSuffix(repo, ".git")); err != nil { | |||
| 229 | + | return "", false | |||
| 230 | + | } | |||
| 231 | + | if _, pattern := s.mux.Handler(r); pattern != catchAll { | |||
| 232 | + | return "", false | |||
| 233 | + | } | |||
| 234 | + | to := "/" + rest | |||
| 235 | + | if r.URL.RawQuery != "" { | |||
| 236 | + | to += "?" + r.URL.RawQuery | |||
| 237 | + | } | |||
| 238 | + | return to, true | |||
| 239 | + | } | |||
| 240 | + | ||||
| 203 | // handler builds a page. It returns the component and its status, or an | 241 | // handler builds a page. It returns the component and its status, or an | ||
| 204 | // error, which becomes the 404 or 500 page. | 242 | // error, which becomes the 404 or 500 page. | ||
| 205 | type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error) | 243 | type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error) | ||
M internal/app/ci_flow_test.go +2 -2
| @@ -668,7 +668,7 @@ func TestCIGithubContextOfAPush(t *testing.T) { | |||
| 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 +312 -0
| @@ -0,0 +1,312 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "encoding/base64" | |
| 10 | + | "encoding/binary" | |
| 11 | + | "encoding/json" | |
| 12 | + | "net/url" | |
| 13 | + | "strconv" | |
| 14 | + | "strings" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // This file builds the "github" context of a push and the "job" keys the | |
| 21 | + | // server sends, to match what github.com gives (recordings of | |
| 22 | + | // ermite-conformance, normalised by its normalize.go). | |
| 23 | + | // | |
| 24 | + | // Value provenance, used in the comments below: | |
| 25 | + | // - real: a value ermite has (repository name, visibility, description, | |
| 26 | + | // dates, commits, default branch, configuration); | |
| 27 | + | // - derived: computed from real values by a stable rule (ids, node ids, | |
| 28 | + | // URLs); | |
| 29 | + | // - constant: GitHub's value for a repository or account with no such | |
| 30 | + | // feature, or a placeholder where ermite has no concept (no avatars, | |
| 31 | + | // stars, forks, issues...). | |
| 32 | + | ||
| 33 | + | const ( | |
| 34 | + | // ghSecretSource is github.secret_source: where the secrets of the job | |
| 35 | + | // come from. Constant: ermite has one source. | |
| 36 | + | ghSecretSource = "Actions" | |
| 37 | + | // ghCacheLimit is github.artifact_cache_size_limit, in GB, as recorded on | |
| 38 | + | // github.com. Constant. | |
| 39 | + | ghCacheLimit = "10" | |
| 40 | + | // apiPath is where the GitHub-shaped API base of the payload lives under | |
| 41 | + | // the public URL. github.api_url is that base, github.graphql_url is | |
| 42 | + | // <base>/graphql and the *_url keys of the repository and the users are | |
| 43 | + | // <base>/repos/... and <base>/users/..., as on github.com. ermite serves | |
| 44 | + | // none of them yet: they are identifiers in the shape of GitHub's. | |
| 45 | + | apiPath = "/-/github/api" | |
| 46 | + | ) | |
| 47 | + | ||
| 48 | + | // kv is a key and a value of an ordered JSON object. | |
| 49 | + | type kv struct { | |
| 50 | + | k string | |
| 51 | + | v any | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // ordered is a JSON object that keeps the order of its keys, as the github | |
| 55 | + | // context of GitHub does (toJSON(github) prints that order). | |
| 56 | + | type ordered []kv | |
| 57 | + | ||
| 58 | + | func (o ordered) MarshalJSON() ([]byte, error) { | |
| 59 | + | var b bytes.Buffer | |
| 60 | + | b.WriteByte('{') | |
| 61 | + | for i, p := range o { | |
| 62 | + | if i > 0 { | |
| 63 | + | b.WriteByte(',') | |
| 64 | + | } | |
| 65 | + | k, err := json.Marshal(p.k) | |
| 66 | + | if err != nil { | |
| 67 | + | return nil, err | |
| 68 | + | } | |
| 69 | + | v, err := json.Marshal(p.v) | |
| 70 | + | if err != nil { | |
| 71 | + | return nil, err | |
| 72 | + | } | |
| 73 | + | b.Write(k) | |
| 74 | + | b.WriteByte(':') | |
| 75 | + | b.Write(v) | |
| 76 | + | } | |
| 77 | + | b.WriteByte('}') | |
| 78 | + | return b.Bytes(), nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // stableID is a positive number that depends only on the kind and the name: | |
| 82 | + | // the id of a user or of a repository, which ermite does not number. It fits | |
| 83 | + | // in a JavaScript number (GitHub's own ids are well below 2^53). | |
| 84 | + | func stableID(kind, name string) int64 { | |
| 85 | + | sum := sha256.Sum256([]byte(kind + "\x00" + name)) | |
| 86 | + | return int64(binary.BigEndian.Uint64(sum[:8])>>20) + 1<<20 | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // nodeID is the legacy form of a GraphQL node id: base64 of "<n>:<Type><id>" | |
| 90 | + | // (GitHub: "04:User5212137" is "MDQ6VXNlcjUyMTIxMzc="). | |
| 91 | + | func nodeID(prefix, typ string, id int64) string { | |
| 92 | + | return base64.StdEncoding.EncodeToString([]byte(prefix + ":" + typ + strconv.FormatInt(id, 10))) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // ghIdentity is the account a push is attributed to. | |
| 96 | + | type ghIdentity struct { | |
| 97 | + | login string | |
| 98 | + | id int64 | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // identity maps the actor of a run to a login: "owner" and "root" (the | |
| 102 | + | // owner's session or the root token) are the owner of the forge, a token | |
| 103 | + | // label is itself. | |
| 104 | + | func (c *CI) identity(actor string) ghIdentity { | |
| 105 | + | login := actor | |
| 106 | + | if actor == "owner" || actor == "root" || actor == "" { | |
| 107 | + | login = c.cfg.Owner | |
| 108 | + | } | |
| 109 | + | return ghIdentity{login: login, id: stableID("user", login)} | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // ghURLs holds the URL bases of the payload of one repository. | |
| 113 | + | type ghURLs struct { | |
| 114 | + | server string // github.server_url | |
| 115 | + | api string // github.api_url | |
| 116 | + | host string // host (and port) of the server, for git:// and ssh URLs | |
| 117 | + | owner string | |
| 118 | + | name string | |
| 119 | + | full string // <owner>/<repo> | |
| 120 | + | repo string // <server>/<owner>/<repo>, the page GitHub's html_url points at | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (c *CI) urls(repo domain.Repo) ghURLs { | |
| 124 | + | u := ghURLs{server: c.cfg.PublicURL, owner: c.cfg.Owner, name: repo.Name.String()} | |
| 125 | + | if p, err := url.Parse(c.cfg.PublicURL); err == nil { | |
| 126 | + | u.host = p.Host | |
| 127 | + | } | |
| 128 | + | u.api = u.server + apiPath | |
| 129 | + | u.full = u.owner + "/" + u.name | |
| 130 | + | u.repo = u.server + "/" + u.full | |
| 131 | + | return u | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // userObj is the account object of an event (sender, repository owner). | |
| 135 | + | // Derived: ids from the login; URLs from the API base. Constant: type, no | |
| 136 | + | // avatar (ermite has none: "" is what GitHub gives for gravatar_id too), not | |
| 137 | + | // a site admin. | |
| 138 | + | func (u ghURLs) userObj(id ghIdentity, owner bool) map[string]any { | |
| 139 | + | api := u.api + "/users/" + id.login | |
| 140 | + | o := map[string]any{ | |
| 141 | + | "avatar_url": "", "events_url": api + "/events{/privacy}", "followers_url": api + "/followers", | |
| 142 | + | "following_url": api + "/following{/other_user}", "gists_url": api + "/gists{/gist_id}", "gravatar_id": "", | |
| 143 | + | "html_url": u.server + "/" + id.login, "id": id.id, "login": id.login, | |
| 144 | + | "node_id": nodeID("04", "User", id.id), | |
| 145 | + | "organizations_url": api + "/orgs", "received_events_url": api + "/received_events", "repos_url": api + "/repos", | |
| 146 | + | "site_admin": false, "starred_url": api + "/starred{/owner}{/repo}", "subscriptions_url": api + "/subscriptions", | |
| 147 | + | "type": "User", "url": api, "user_view_type": "public", | |
| 148 | + | } | |
| 149 | + | if owner { | |
| 150 | + | o["email"], o["name"] = "", id.login | |
| 151 | + | } | |
| 152 | + | return o | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // repoLinks are the URL templates of the repository object, relative to | |
| 156 | + | // <api>/repos/<owner>/<repo>. Derived (constant templates, as GitHub's). | |
| 157 | + | var repoLinks = map[string]string{ | |
| 158 | + | "archive_url": "/{archive_format}{/ref}", "assignees_url": "/assignees{/user}", "blobs_url": "/git/blobs{/sha}", | |
| 159 | + | "branches_url": "/branches{/branch}", "collaborators_url": "/collaborators{/collaborator}", | |
| 160 | + | "comments_url": "/comments{/number}", "commits_url": "/commits{/sha}", "compare_url": "/compare/{base}...{head}", | |
| 161 | + | "contents_url": "/contents/{+path}", "contributors_url": "/contributors", "deployments_url": "/deployments", | |
| 162 | + | "downloads_url": "/downloads", "events_url": "/events", "forks_url": "/forks", "git_commits_url": "/git/commits{/sha}", | |
| 163 | + | "git_refs_url": "/git/refs{/sha}", "git_tags_url": "/git/tags{/sha}", "hooks_url": "/hooks", | |
| 164 | + | "issue_comment_url": "/issues/comments{/number}", "issue_events_url": "/issues/events{/number}", | |
| 165 | + | "issues_url": "/issues{/number}", "keys_url": "/keys{/key_id}", "labels_url": "/labels{/name}", | |
| 166 | + | "languages_url": "/languages", "merges_url": "/merges", "milestones_url": "/milestones{/number}", | |
| 167 | + | "notifications_url": "/notifications{?since,all,participating}", "pulls_url": "/pulls{/number}", | |
| 168 | + | "releases_url": "/releases{/id}", "stargazers_url": "/stargazers", "statuses_url": "/statuses/{sha}", | |
| 169 | + | "subscribers_url": "/subscribers", "subscription_url": "/subscription", "tags_url": "/tags", "teams_url": "/teams", | |
| 170 | + | "trees_url": "/git/trees{/sha}", "url": "", | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // repoObj is event.repository: the 83 keys of GitHub's push payload. | |
| 174 | + | // | |
| 175 | + | // real: name, full_name, private, visibility, description, default_branch, | |
| 176 | + | // master_branch, created_at, pushed_at, updated_at | |
| 177 | + | // derived: id, node_id, owner, html_url, svn_url, clone_url, git_url, | |
| 178 | + | // ssh_url, url and the other *_url keys | |
| 179 | + | // constant: the rest (no forks, stars, issues, pages..., the features of a | |
| 180 | + | // new GitHub repository; license, language, homepage and | |
| 181 | + | // mirror_url null; size 0) | |
| 182 | + | func (c *CI) repoObj(repo domain.Repo, u ghURLs, defaultBranch string, at time.Time) map[string]any { | |
| 183 | + | owner := c.identity("owner") | |
| 184 | + | id := stableID("repo", u.full) | |
| 185 | + | pushed := repo.PushedAt | |
| 186 | + | if pushed.IsZero() { | |
| 187 | + | pushed = at | |
| 188 | + | } | |
| 189 | + | var desc any | |
| 190 | + | if repo.Description != "" { | |
| 191 | + | desc = repo.Description | |
| 192 | + | } | |
| 193 | + | r := map[string]any{ | |
| 194 | + | "allow_forking": true, "archived": false, "clone_url": u.repo + ".git", | |
| 195 | + | "created_at": repo.CreatedAt.Unix(), "default_branch": defaultBranch, "description": desc, | |
| 196 | + | "disabled": false, "fork": false, "forks": 0, "forks_count": 0, "full_name": u.full, | |
| 197 | + | "git_url": "git://" + u.host + "/" + u.full + ".git", | |
| 198 | + | "has_discussions": false, "has_downloads": true, "has_issues": true, "has_pages": false, "has_projects": true, | |
| 199 | + | "has_pull_requests": true, "has_wiki": true, "homepage": nil, "html_url": u.repo, "id": id, | |
| 200 | + | "is_template": false, "language": nil, "license": nil, "master_branch": defaultBranch, "mirror_url": nil, | |
| 201 | + | "name": u.name, "node_id": nodeID("010", "Repository", id), "open_issues": 0, "open_issues_count": 0, | |
| 202 | + | "owner": u.userObj(owner, true), | |
| 203 | + | "private": repo.Visibility == domain.VisibilityPrivate, "pull_request_creation_policy": "all", | |
| 204 | + | "pushed_at": pushed.Unix(), "size": 0, "ssh_url": "git@" + u.host + ":" + u.full + ".git", | |
| 205 | + | "stargazers": 0, "stargazers_count": 0, "svn_url": u.repo, "topics": []string{}, | |
| 206 | + | "updated_at": pushed.UTC().Format(time.RFC3339), "visibility": string(repo.Visibility), | |
| 207 | + | "watchers": 0, "watchers_count": 0, "web_commit_signoff_required": false, | |
| 208 | + | } | |
| 209 | + | for k, suffix := range repoLinks { | |
| 210 | + | r[k] = u.api + "/repos/" + u.full + suffix | |
| 211 | + | } | |
| 212 | + | return r | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // commitJSON is one commit of the push event: GitHub's push payload has | |
| 216 | + | // exactly these keys in the recordings (no added/removed/modified, no | |
| 217 | + | // username). | |
| 218 | + | func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, u ghURLs, cm domain.Commit) map[string]any { | |
| 219 | + | tree := "" | |
| 220 | + | if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil { | |
| 221 | + | tree = id.String() | |
| 222 | + | } | |
| 223 | + | return map[string]any{ | |
| 224 | + | "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message, | |
| 225 | + | "timestamp": cm.Committer.Time.UTC().Format(time.RFC3339), | |
| 226 | + | "url": u.repo + "/commit/" + cm.ID.String(), | |
| 227 | + | "author": person(cm.Author.Name, cm.Author.Email), "committer": person(cm.Committer.Name, cm.Committer.Email), | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func person(name, email string) map[string]any { return map[string]any{"name": name, "email": email} } | |
| 232 | + | ||
| 233 | + | // githubJSON builds the "github" context of the run as GitHub does for a push | |
| 234 | + | // (the job-dependent keys, job and run_attempt, are filled in when a job is | |
| 235 | + | // planned). Keys the runner owns (token, workspace, action, event_path, path, | |
| 236 | + | // env, output, state, step_summary, artifacts...) are not here. | |
| 237 | + | // | |
| 238 | + | // The order of the keys is GitHub's, so that toJSON(github) prints the same. | |
| 239 | + | // Ids and counters are strings, so is retention_days; "compare" has | |
| 240 | + | // 12-character ids: the commit page for a created ref with one commit, | |
| 241 | + | // "<first>^...<after>" for a created ref with several, "<before>...<after>" for | |
| 242 | + | // an update. | |
| 243 | + | func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts) string { | |
| 244 | + | u := c.urls(repo) | |
| 245 | + | id := c.identity(run.Actor) | |
| 246 | + | owner := c.identity("owner") | |
| 247 | + | refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch" | |
| 248 | + | if f.isTag { | |
| 249 | + | refName, refType = strings.TrimPrefix(run.Ref, "refs/tags/"), "tag" | |
| 250 | + | } | |
| 251 | + | before := f.update.Old | |
| 252 | + | if f.created { | |
| 253 | + | before = domain.ZeroOID | |
| 254 | + | } | |
| 255 | + | compare := u.repo + "/compare/" + shortID(before) + "..." + shortID(f.update.New) | |
| 256 | + | switch { | |
| 257 | + | case f.created && len(f.commits) > 1: | |
| 258 | + | compare = u.repo + "/compare/" + shortID(f.commits[0].ID.String()) + "^..." + shortID(f.update.New) | |
| 259 | + | case f.created: | |
| 260 | + | compare = u.repo + "/commit/" + shortID(f.update.New) | |
| 261 | + | } | |
| 262 | + | commits := make([]any, 0, len(f.commits)) | |
| 263 | + | for _, cm := range f.commits { | |
| 264 | + | commits = append(commits, c.commitJSON(ctx, repo, u, cm)) | |
| 265 | + | } | |
| 266 | + | event := map[string]any{ | |
| 267 | + | "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced, | |
| 268 | + | "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(ctx, repo, u, f.head), | |
| 269 | + | "pusher": map[string]any{"name": id.login, "email": ""}, | |
| 270 | + | "repository": c.repoObj(repo, u, f.defaultBranch, run.CreatedAt), | |
| 271 | + | "sender": u.userObj(id, false), | |
| 272 | + | } | |
| 273 | + | repoID := strconv.FormatInt(stableID("repo", u.full), 10) | |
| 274 | + | gh := ordered{ | |
| 275 | + | {"job", ""}, {"ref", run.Ref}, {"sha", run.Sha}, {"repository", u.full}, {"repository_owner", c.cfg.Owner}, | |
| 276 | + | {"repository_owner_id", strconv.FormatInt(owner.id, 10)}, {"repositoryUrl", "git://" + u.host + "/" + u.full + ".git"}, | |
| 277 | + | {"run_id", strconv.FormatInt(run.ID, 10)}, {"run_number", strconv.FormatInt(run.Number, 10)}, | |
| 278 | + | {"retention_days", strconv.Itoa(c.retentionDays())}, {"run_attempt", "1"}, | |
| 279 | + | {"artifact_cache_size_limit", ghCacheLimit}, {"repository_visibility", string(repo.Visibility)}, | |
| 280 | + | {"actor_id", strconv.FormatInt(id.id, 10)}, {"actor", id.login}, {"workflow", run.WorkflowName}, | |
| 281 | + | {"head_ref", ""}, {"base_ref", ""}, {"event_name", "push"}, {"server_url", u.server}, | |
| 282 | + | {"api_url", u.api}, {"graphql_url", u.api + "/graphql"}, {"ref_name", refName}, {"ref_protected", false}, | |
| 283 | + | {"ref_type", refType}, {"secret_source", ghSecretSource}, {"event", event}, | |
| 284 | + | {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref}, {"workflow_sha", run.Sha}, | |
| 285 | + | {"repository_id", repoID}, {"triggering_actor", id.login}, | |
| 286 | + | } | |
| 287 | + | b, err := json.Marshal(gh) | |
| 288 | + | if err != nil { | |
| 289 | + | return "{}" | |
| 290 | + | } | |
| 291 | + | return string(b) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // checkRunID is the number github.com shows in job.check_run_id: derived from | |
| 295 | + | // the position of the job instance, as ermite's checks are not numbered. It is | |
| 296 | + | // unique and stable per run, job and matrix leg, and fits in a JavaScript | |
| 297 | + | // number. | |
| 298 | + | func checkRunID(run domain.CIRun, index, combo int) int64 { | |
| 299 | + | return run.ID<<20 | int64(index&0x7ff)<<9 | int64(combo&0x1ff) | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // jobKeys are the keys of the "job" context that the server knows, in | |
| 303 | + | // GitHub's order (the runner adds "status"). The runner copies the "job" key | |
| 304 | + | // of the contexts it is given into the job context. | |
| 305 | + | func (c *CI) jobKeys(run domain.CIRun, index, combo int) ordered { | |
| 306 | + | u := c.urls(domain.Repo{Name: run.Repo}) | |
| 307 | + | return ordered{ | |
| 308 | + | {"check_run_id", checkRunID(run, index, combo)}, | |
| 309 | + | {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref}, | |
| 310 | + | {"workflow_sha", run.Sha}, {"workflow_repository", u.full}, {"workflow_file_path", run.WorkflowPath}, | |
| 311 | + | } | |
| 312 | + | } | |
| @@ -0,0 +1,312 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "crypto/sha256" | |||
| 9 | + | "encoding/base64" | |||
| 10 | + | "encoding/binary" | |||
| 11 | + | "encoding/json" | |||
| 12 | + | "net/url" | |||
| 13 | + | "strconv" | |||
| 14 | + | "strings" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // This file builds the "github" context of a push and the "job" keys the | |||
| 21 | + | // server sends, to match what github.com gives (recordings of | |||
| 22 | + | // ermite-conformance, normalised by its normalize.go). | |||
| 23 | + | // | |||
| 24 | + | // Value provenance, used in the comments below: | |||
| 25 | + | // - real: a value ermite has (repository name, visibility, description, | |||
| 26 | + | // dates, commits, default branch, configuration); | |||
| 27 | + | // - derived: computed from real values by a stable rule (ids, node ids, | |||
| 28 | + | // URLs); | |||
| 29 | + | // - constant: GitHub's value for a repository or account with no such | |||
| 30 | + | // feature, or a placeholder where ermite has no concept (no avatars, | |||
| 31 | + | // stars, forks, issues...). | |||
| 32 | + | ||||
| 33 | + | const ( | |||
| 34 | + | // ghSecretSource is github.secret_source: where the secrets of the job | |||
| 35 | + | // come from. Constant: ermite has one source. | |||
| 36 | + | ghSecretSource = "Actions" | |||
| 37 | + | // ghCacheLimit is github.artifact_cache_size_limit, in GB, as recorded on | |||
| 38 | + | // github.com. Constant. | |||
| 39 | + | ghCacheLimit = "10" | |||
| 40 | + | // apiPath is where the GitHub-shaped API base of the payload lives under | |||
| 41 | + | // the public URL. github.api_url is that base, github.graphql_url is | |||
| 42 | + | // <base>/graphql and the *_url keys of the repository and the users are | |||
| 43 | + | // <base>/repos/... and <base>/users/..., as on github.com. ermite serves | |||
| 44 | + | // none of them yet: they are identifiers in the shape of GitHub's. | |||
| 45 | + | apiPath = "/-/github/api" | |||
| 46 | + | ) | |||
| 47 | + | ||||
| 48 | + | // kv is a key and a value of an ordered JSON object. | |||
| 49 | + | type kv struct { | |||
| 50 | + | k string | |||
| 51 | + | v any | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // ordered is a JSON object that keeps the order of its keys, as the github | |||
| 55 | + | // context of GitHub does (toJSON(github) prints that order). | |||
| 56 | + | type ordered []kv | |||
| 57 | + | ||||
| 58 | + | func (o ordered) MarshalJSON() ([]byte, error) { | |||
| 59 | + | var b bytes.Buffer | |||
| 60 | + | b.WriteByte('{') | |||
| 61 | + | for i, p := range o { | |||
| 62 | + | if i > 0 { | |||
| 63 | + | b.WriteByte(',') | |||
| 64 | + | } | |||
| 65 | + | k, err := json.Marshal(p.k) | |||
| 66 | + | if err != nil { | |||
| 67 | + | return nil, err | |||
| 68 | + | } | |||
| 69 | + | v, err := json.Marshal(p.v) | |||
| 70 | + | if err != nil { | |||
| 71 | + | return nil, err | |||
| 72 | + | } | |||
| 73 | + | b.Write(k) | |||
| 74 | + | b.WriteByte(':') | |||
| 75 | + | b.Write(v) | |||
| 76 | + | } | |||
| 77 | + | b.WriteByte('}') | |||
| 78 | + | return b.Bytes(), nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // stableID is a positive number that depends only on the kind and the name: | |||
| 82 | + | // the id of a user or of a repository, which ermite does not number. It fits | |||
| 83 | + | // in a JavaScript number (GitHub's own ids are well below 2^53). | |||
| 84 | + | func stableID(kind, name string) int64 { | |||
| 85 | + | sum := sha256.Sum256([]byte(kind + "\x00" + name)) | |||
| 86 | + | return int64(binary.BigEndian.Uint64(sum[:8])>>20) + 1<<20 | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // nodeID is the legacy form of a GraphQL node id: base64 of "<n>:<Type><id>" | |||
| 90 | + | // (GitHub: "04:User5212137" is "MDQ6VXNlcjUyMTIxMzc="). | |||
| 91 | + | func nodeID(prefix, typ string, id int64) string { | |||
| 92 | + | return base64.StdEncoding.EncodeToString([]byte(prefix + ":" + typ + strconv.FormatInt(id, 10))) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // ghIdentity is the account a push is attributed to. | |||
| 96 | + | type ghIdentity struct { | |||
| 97 | + | login string | |||
| 98 | + | id int64 | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // identity maps the actor of a run to a login: "owner" and "root" (the | |||
| 102 | + | // owner's session or the root token) are the owner of the forge, a token | |||
| 103 | + | // label is itself. | |||
| 104 | + | func (c *CI) identity(actor string) ghIdentity { | |||
| 105 | + | login := actor | |||
| 106 | + | if actor == "owner" || actor == "root" || actor == "" { | |||
| 107 | + | login = c.cfg.Owner | |||
| 108 | + | } | |||
| 109 | + | return ghIdentity{login: login, id: stableID("user", login)} | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // ghURLs holds the URL bases of the payload of one repository. | |||
| 113 | + | type ghURLs struct { | |||
| 114 | + | server string // github.server_url | |||
| 115 | + | api string // github.api_url | |||
| 116 | + | host string // host (and port) of the server, for git:// and ssh URLs | |||
| 117 | + | owner string | |||
| 118 | + | name string | |||
| 119 | + | full string // <owner>/<repo> | |||
| 120 | + | repo string // <server>/<owner>/<repo>, the page GitHub's html_url points at | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (c *CI) urls(repo domain.Repo) ghURLs { | |||
| 124 | + | u := ghURLs{server: c.cfg.PublicURL, owner: c.cfg.Owner, name: repo.Name.String()} | |||
| 125 | + | if p, err := url.Parse(c.cfg.PublicURL); err == nil { | |||
| 126 | + | u.host = p.Host | |||
| 127 | + | } | |||
| 128 | + | u.api = u.server + apiPath | |||
| 129 | + | u.full = u.owner + "/" + u.name | |||
| 130 | + | u.repo = u.server + "/" + u.full | |||
| 131 | + | return u | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // userObj is the account object of an event (sender, repository owner). | |||
| 135 | + | // Derived: ids from the login; URLs from the API base. Constant: type, no | |||
| 136 | + | // avatar (ermite has none: "" is what GitHub gives for gravatar_id too), not | |||
| 137 | + | // a site admin. | |||
| 138 | + | func (u ghURLs) userObj(id ghIdentity, owner bool) map[string]any { | |||
| 139 | + | api := u.api + "/users/" + id.login | |||
| 140 | + | o := map[string]any{ | |||
| 141 | + | "avatar_url": "", "events_url": api + "/events{/privacy}", "followers_url": api + "/followers", | |||
| 142 | + | "following_url": api + "/following{/other_user}", "gists_url": api + "/gists{/gist_id}", "gravatar_id": "", | |||
| 143 | + | "html_url": u.server + "/" + id.login, "id": id.id, "login": id.login, | |||
| 144 | + | "node_id": nodeID("04", "User", id.id), | |||
| 145 | + | "organizations_url": api + "/orgs", "received_events_url": api + "/received_events", "repos_url": api + "/repos", | |||
| 146 | + | "site_admin": false, "starred_url": api + "/starred{/owner}{/repo}", "subscriptions_url": api + "/subscriptions", | |||
| 147 | + | "type": "User", "url": api, "user_view_type": "public", | |||
| 148 | + | } | |||
| 149 | + | if owner { | |||
| 150 | + | o["email"], o["name"] = "", id.login | |||
| 151 | + | } | |||
| 152 | + | return o | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // repoLinks are the URL templates of the repository object, relative to | |||
| 156 | + | // <api>/repos/<owner>/<repo>. Derived (constant templates, as GitHub's). | |||
| 157 | + | var repoLinks = map[string]string{ | |||
| 158 | + | "archive_url": "/{archive_format}{/ref}", "assignees_url": "/assignees{/user}", "blobs_url": "/git/blobs{/sha}", | |||
| 159 | + | "branches_url": "/branches{/branch}", "collaborators_url": "/collaborators{/collaborator}", | |||
| 160 | + | "comments_url": "/comments{/number}", "commits_url": "/commits{/sha}", "compare_url": "/compare/{base}...{head}", | |||
| 161 | + | "contents_url": "/contents/{+path}", "contributors_url": "/contributors", "deployments_url": "/deployments", | |||
| 162 | + | "downloads_url": "/downloads", "events_url": "/events", "forks_url": "/forks", "git_commits_url": "/git/commits{/sha}", | |||
| 163 | + | "git_refs_url": "/git/refs{/sha}", "git_tags_url": "/git/tags{/sha}", "hooks_url": "/hooks", | |||
| 164 | + | "issue_comment_url": "/issues/comments{/number}", "issue_events_url": "/issues/events{/number}", | |||
| 165 | + | "issues_url": "/issues{/number}", "keys_url": "/keys{/key_id}", "labels_url": "/labels{/name}", | |||
| 166 | + | "languages_url": "/languages", "merges_url": "/merges", "milestones_url": "/milestones{/number}", | |||
| 167 | + | "notifications_url": "/notifications{?since,all,participating}", "pulls_url": "/pulls{/number}", | |||
| 168 | + | "releases_url": "/releases{/id}", "stargazers_url": "/stargazers", "statuses_url": "/statuses/{sha}", | |||
| 169 | + | "subscribers_url": "/subscribers", "subscription_url": "/subscription", "tags_url": "/tags", "teams_url": "/teams", | |||
| 170 | + | "trees_url": "/git/trees{/sha}", "url": "", | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // repoObj is event.repository: the 83 keys of GitHub's push payload. | |||
| 174 | + | // | |||
| 175 | + | // real: name, full_name, private, visibility, description, default_branch, | |||
| 176 | + | // master_branch, created_at, pushed_at, updated_at | |||
| 177 | + | // derived: id, node_id, owner, html_url, svn_url, clone_url, git_url, | |||
| 178 | + | // ssh_url, url and the other *_url keys | |||
| 179 | + | // constant: the rest (no forks, stars, issues, pages..., the features of a | |||
| 180 | + | // new GitHub repository; license, language, homepage and | |||
| 181 | + | // mirror_url null; size 0) | |||
| 182 | + | func (c *CI) repoObj(repo domain.Repo, u ghURLs, defaultBranch string, at time.Time) map[string]any { | |||
| 183 | + | owner := c.identity("owner") | |||
| 184 | + | id := stableID("repo", u.full) | |||
| 185 | + | pushed := repo.PushedAt | |||
| 186 | + | if pushed.IsZero() { | |||
| 187 | + | pushed = at | |||
| 188 | + | } | |||
| 189 | + | var desc any | |||
| 190 | + | if repo.Description != "" { | |||
| 191 | + | desc = repo.Description | |||
| 192 | + | } | |||
| 193 | + | r := map[string]any{ | |||
| 194 | + | "allow_forking": true, "archived": false, "clone_url": u.repo + ".git", | |||
| 195 | + | "created_at": repo.CreatedAt.Unix(), "default_branch": defaultBranch, "description": desc, | |||
| 196 | + | "disabled": false, "fork": false, "forks": 0, "forks_count": 0, "full_name": u.full, | |||
| 197 | + | "git_url": "git://" + u.host + "/" + u.full + ".git", | |||
| 198 | + | "has_discussions": false, "has_downloads": true, "has_issues": true, "has_pages": false, "has_projects": true, | |||
| 199 | + | "has_pull_requests": true, "has_wiki": true, "homepage": nil, "html_url": u.repo, "id": id, | |||
| 200 | + | "is_template": false, "language": nil, "license": nil, "master_branch": defaultBranch, "mirror_url": nil, | |||
| 201 | + | "name": u.name, "node_id": nodeID("010", "Repository", id), "open_issues": 0, "open_issues_count": 0, | |||
| 202 | + | "owner": u.userObj(owner, true), | |||
| 203 | + | "private": repo.Visibility == domain.VisibilityPrivate, "pull_request_creation_policy": "all", | |||
| 204 | + | "pushed_at": pushed.Unix(), "size": 0, "ssh_url": "git@" + u.host + ":" + u.full + ".git", | |||
| 205 | + | "stargazers": 0, "stargazers_count": 0, "svn_url": u.repo, "topics": []string{}, | |||
| 206 | + | "updated_at": pushed.UTC().Format(time.RFC3339), "visibility": string(repo.Visibility), | |||
| 207 | + | "watchers": 0, "watchers_count": 0, "web_commit_signoff_required": false, | |||
| 208 | + | } | |||
| 209 | + | for k, suffix := range repoLinks { | |||
| 210 | + | r[k] = u.api + "/repos/" + u.full + suffix | |||
| 211 | + | } | |||
| 212 | + | return r | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // commitJSON is one commit of the push event: GitHub's push payload has | |||
| 216 | + | // exactly these keys in the recordings (no added/removed/modified, no | |||
| 217 | + | // username). | |||
| 218 | + | func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, u ghURLs, cm domain.Commit) map[string]any { | |||
| 219 | + | tree := "" | |||
| 220 | + | if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil { | |||
| 221 | + | tree = id.String() | |||
| 222 | + | } | |||
| 223 | + | return map[string]any{ | |||
| 224 | + | "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message, | |||
| 225 | + | "timestamp": cm.Committer.Time.UTC().Format(time.RFC3339), | |||
| 226 | + | "url": u.repo + "/commit/" + cm.ID.String(), | |||
| 227 | + | "author": person(cm.Author.Name, cm.Author.Email), "committer": person(cm.Committer.Name, cm.Committer.Email), | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func person(name, email string) map[string]any { return map[string]any{"name": name, "email": email} } | |||
| 232 | + | ||||
| 233 | + | // githubJSON builds the "github" context of the run as GitHub does for a push | |||
| 234 | + | // (the job-dependent keys, job and run_attempt, are filled in when a job is | |||
| 235 | + | // planned). Keys the runner owns (token, workspace, action, event_path, path, | |||
| 236 | + | // env, output, state, step_summary, artifacts...) are not here. | |||
| 237 | + | // | |||
| 238 | + | // The order of the keys is GitHub's, so that toJSON(github) prints the same. | |||
| 239 | + | // Ids and counters are strings, so is retention_days; "compare" has | |||
| 240 | + | // 12-character ids: the commit page for a created ref with one commit, | |||
| 241 | + | // "<first>^...<after>" for a created ref with several, "<before>...<after>" for | |||
| 242 | + | // an update. | |||
| 243 | + | func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts) string { | |||
| 244 | + | u := c.urls(repo) | |||
| 245 | + | id := c.identity(run.Actor) | |||
| 246 | + | owner := c.identity("owner") | |||
| 247 | + | refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch" | |||
| 248 | + | if f.isTag { | |||
| 249 | + | refName, refType = strings.TrimPrefix(run.Ref, "refs/tags/"), "tag" | |||
| 250 | + | } | |||
| 251 | + | before := f.update.Old | |||
| 252 | + | if f.created { | |||
| 253 | + | before = domain.ZeroOID | |||
| 254 | + | } | |||
| 255 | + | compare := u.repo + "/compare/" + shortID(before) + "..." + shortID(f.update.New) | |||
| 256 | + | switch { | |||
| 257 | + | case f.created && len(f.commits) > 1: | |||
| 258 | + | compare = u.repo + "/compare/" + shortID(f.commits[0].ID.String()) + "^..." + shortID(f.update.New) | |||
| 259 | + | case f.created: | |||
| 260 | + | compare = u.repo + "/commit/" + shortID(f.update.New) | |||
| 261 | + | } | |||
| 262 | + | commits := make([]any, 0, len(f.commits)) | |||
| 263 | + | for _, cm := range f.commits { | |||
| 264 | + | commits = append(commits, c.commitJSON(ctx, repo, u, cm)) | |||
| 265 | + | } | |||
| 266 | + | event := map[string]any{ | |||
| 267 | + | "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced, | |||
| 268 | + | "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(ctx, repo, u, f.head), | |||
| 269 | + | "pusher": map[string]any{"name": id.login, "email": ""}, | |||
| 270 | + | "repository": c.repoObj(repo, u, f.defaultBranch, run.CreatedAt), | |||
| 271 | + | "sender": u.userObj(id, false), | |||
| 272 | + | } | |||
| 273 | + | repoID := strconv.FormatInt(stableID("repo", u.full), 10) | |||
| 274 | + | gh := ordered{ | |||
| 275 | + | {"job", ""}, {"ref", run.Ref}, {"sha", run.Sha}, {"repository", u.full}, {"repository_owner", c.cfg.Owner}, | |||
| 276 | + | {"repository_owner_id", strconv.FormatInt(owner.id, 10)}, {"repositoryUrl", "git://" + u.host + "/" + u.full + ".git"}, | |||
| 277 | + | {"run_id", strconv.FormatInt(run.ID, 10)}, {"run_number", strconv.FormatInt(run.Number, 10)}, | |||
| 278 | + | {"retention_days", strconv.Itoa(c.retentionDays())}, {"run_attempt", "1"}, | |||
| 279 | + | {"artifact_cache_size_limit", ghCacheLimit}, {"repository_visibility", string(repo.Visibility)}, | |||
| 280 | + | {"actor_id", strconv.FormatInt(id.id, 10)}, {"actor", id.login}, {"workflow", run.WorkflowName}, | |||
| 281 | + | {"head_ref", ""}, {"base_ref", ""}, {"event_name", "push"}, {"server_url", u.server}, | |||
| 282 | + | {"api_url", u.api}, {"graphql_url", u.api + "/graphql"}, {"ref_name", refName}, {"ref_protected", false}, | |||
| 283 | + | {"ref_type", refType}, {"secret_source", ghSecretSource}, {"event", event}, | |||
| 284 | + | {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref}, {"workflow_sha", run.Sha}, | |||
| 285 | + | {"repository_id", repoID}, {"triggering_actor", id.login}, | |||
| 286 | + | } | |||
| 287 | + | b, err := json.Marshal(gh) | |||
| 288 | + | if err != nil { | |||
| 289 | + | return "{}" | |||
| 290 | + | } | |||
| 291 | + | return string(b) | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | // checkRunID is the number github.com shows in job.check_run_id: derived from | |||
| 295 | + | // the position of the job instance, as ermite's checks are not numbered. It is | |||
| 296 | + | // unique and stable per run, job and matrix leg, and fits in a JavaScript | |||
| 297 | + | // number. | |||
| 298 | + | func checkRunID(run domain.CIRun, index, combo int) int64 { | |||
| 299 | + | return run.ID<<20 | int64(index&0x7ff)<<9 | int64(combo&0x1ff) | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | // jobKeys are the keys of the "job" context that the server knows, in | |||
| 303 | + | // GitHub's order (the runner adds "status"). The runner copies the "job" key | |||
| 304 | + | // of the contexts it is given into the job context. | |||
| 305 | + | func (c *CI) jobKeys(run domain.CIRun, index, combo int) ordered { | |||
| 306 | + | u := c.urls(domain.Repo{Name: run.Repo}) | |||
| 307 | + | return ordered{ | |||
| 308 | + | {"check_run_id", checkRunID(run, index, combo)}, | |||
| 309 | + | {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref}, | |||
| 310 | + | {"workflow_sha", run.Sha}, {"workflow_repository", u.full}, {"workflow_file_path", run.WorkflowPath}, | |||
| 311 | + | } | |||
| 312 | + | } | |||
M internal/usecase/ci_plan.go +40 -23
| @@ -67,34 +67,20 @@ func unsupported(j *workflow.Job) string { | |||
| 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(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(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 +80 -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 | |
| @@ -168,6 +169,10 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag | |||
| 168 | 169 | Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount, | |
| 169 | 170 | }): | |
| 170 | 171 | default: | |
| 172 | + | if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem != "" { | |
| 173 | + | errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem)) | |
| 174 | + | continue | |
| 175 | + | } | |
| 171 | 176 | errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf)) | |
| 172 | 177 | } | |
| 173 | 178 | } | |
| @@ -190,14 +195,29 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda | |||
| 190 | 195 | } | |
| 191 | 196 | f.commit = res.CommitID | |
| 192 | 197 | if isTag { | |
| 193 | - | cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1) | |
| 198 | + | // Recorded (push-tag, skip-directive-tag): the event of a tag carries | |
| 199 | + | // the commits that the default branch does not have, like a created | |
| 200 | + | // branch, newest as head_commit. No file list: paths filters are not | |
| 201 | + | // evaluated for tags. | |
| 202 | + | base := c.defaultBase(ctx, repo, "", f.commit) | |
| 203 | + | cs, _, err := c.cfg.Git.ListRange(ctx, repo, base, f.commit, ports.MaxCommitLimit) | |
| 194 | 204 | if err != nil { | |
| 195 | 205 | return f, fmt.Errorf("read the commit of the tag: %w", err) | |
| 196 | 206 | } | |
| 197 | 207 | if len(cs) == 0 { | |
| 198 | - | return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound) | |
| 208 | + | cs, _, err = c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1) | |
| 209 | + | if err != nil { | |
| 210 | + | return f, fmt.Errorf("read the commit of the tag: %w", err) | |
| 211 | + | } | |
| 212 | + | if len(cs) == 0 { | |
| 213 | + | return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound) | |
| 214 | + | } | |
| 215 | + | f.head = cs[0] | |
| 216 | + | return f, nil | |
| 199 | 217 | } | |
| 200 | 218 | f.head = cs[0] | |
| 219 | + | f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)]) | |
| 220 | + | slices.Reverse(f.commits) | |
| 201 | 221 | return f, nil | |
| 202 | 222 | } | |
| 203 | 223 | ||
| @@ -385,8 +405,10 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push | |||
| 385 | 405 | Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, | |
| 386 | 406 | Status: domain.CIQueued, CreatedAt: now, | |
| 387 | 407 | } | |
| 408 | + | githubJSON := c.githubJSON(ctx, repo, run, f) | |
| 409 | + | run.Title = c.runTitle(ctx, wf, githubJSON, run.Title) | |
| 388 | 410 | d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{ | |
| 389 | - | Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: c.githubJSON(ctx, repo, run, f, wf), | |
| 411 | + | Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON, | |
| 390 | 412 | }} | |
| 391 | 413 | for i, j := range wf.Jobs { | |
| 392 | 414 | job := domain.CIJob{ | |
| @@ -416,7 +438,59 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push | |||
| 416 | 438 | return c.advanceRun(ctx, draft.Run.ID) | |
| 417 | 439 | } | |
| 418 | 440 | ||
| 419 | - | // --- the github context --- | |
| 441 | + | // runTitle is the title of a run, GitHub's display_title: the run-name | |
| 442 | + | // evaluated with the github, inputs and vars contexts (the contexts the | |
| 443 | + | // schema allows there), else the first line of the head commit message. A | |
| 444 | + | // run-name that does not evaluate, or that evaluates to nothing, falls back to | |
| 445 | + | // the commit message, and the failure is logged. | |
| 446 | + | func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string { | |
| 447 | + | if wf.RunName == nil { | |
| 448 | + | return commitTitle | |
| 449 | + | } | |
| 450 | + | gh, err := expr.FromJSON([]byte(githubJSON)) | |
| 451 | + | if err != nil { | |
| 452 | + | return commitTitle | |
| 453 | + | } | |
| 454 | + | env := template.Env{Contexts: map[string]expr.Value{ | |
| 455 | + | "github": gh, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(), | |
| 456 | + | }} | |
| 457 | + | name, err := template.EvaluateString(ctx, wf.RunName, env) | |
| 458 | + | if err != nil { | |
| 459 | + | log := zerowrap.FromCtx(ctx) | |
| 460 | + | log.Warn().Err(err).Msg("ci run-name does not evaluate: the run is titled with the commit message") | |
| 461 | + | return commitTitle | |
| 462 | + | } | |
| 463 | + | if name = firstLine(name); name == "" { | |
| 464 | + | return commitTitle | |
| 465 | + | } | |
| 466 | + | return name | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | // matrixProblem evaluates the strategy of the jobs that can be evaluated | |
| 470 | + | // before the run starts (those that read no needs output) and returns the | |
| 471 | + | // error text when a matrix produces more configurations than GitHub allows: | |
| 472 | + | // GitHub then fails the run with no job, like an invalid workflow file | |
| 473 | + | // (recorded: matrix-over-256). Other strategy errors stay job failures, found | |
| 474 | + | // when the job is planned. | |
| 475 | + | func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) string { | |
| 476 | + | probe := domain.CIRun{Repo: repo.Name, Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, WorkflowPath: wfPath, WorkflowName: wf.Name, CreatedAt: c.now()} | |
| 477 | + | gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f))) | |
| 478 | + | if err != nil { | |
| 479 | + | return "" | |
| 480 | + | } | |
| 481 | + | env := template.Env{Contexts: map[string]expr.Value{ | |
| 482 | + | "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(), | |
| 483 | + | }} | |
| 484 | + | for _, j := range wf.Jobs { | |
| 485 | + | if _, err := j.EvaluateStrategy(ctx, env); err != nil && strings.Contains(err.Error(), "which exceeds the maximum of") { | |
| 486 | + | // ermite-actions' configurationLimitError is not exported. | |
| 487 | + | return wfPath + ": " + err.Error() | |
| 488 | + | } | |
| 489 | + | } | |
| 490 | + | return "" | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | // --- the github context (see ci_github.go) --- | |
| 420 | 494 | ||
| 421 | 495 | // shortID is the 12-character form of an object id that GitHub puts in the | |
| 422 | 496 | // compare URL of a push. | |
| @@ -432,81 +506,3 @@ func shortID(id string) string { | |||
| 432 | 506 | func (c *CI) retentionDays() int { | |
| 433 | 507 | return max(int(c.cfg.Retention/(24*time.Hour)), 1) | |
| 434 | 508 | } | |
| 435 | - | ||
| 436 | - | type obj = map[string]any | |
| 437 | - | ||
| 438 | - | func person(name, email string) obj { return obj{"name": name, "email": email} } | |
| 439 | - | ||
| 440 | - | func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, cm domain.Commit) obj { | |
| 441 | - | ts := cm.Committer.Time.UTC().Format(time.RFC3339) | |
| 442 | - | tree := "" | |
| 443 | - | if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil { | |
| 444 | - | tree = id.String() | |
| 445 | - | } | |
| 446 | - | return obj{ | |
| 447 | - | "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message, "timestamp": ts, | |
| 448 | - | "url": c.cfg.PublicURL + "/" + repo.Name.String() + "/commit/" + cm.ID.String(), | |
| 449 | - | "author": obj{"name": cm.Author.Name, "email": cm.Author.Email, "username": ""}, | |
| 450 | - | "committer": obj{"name": cm.Committer.Name, "email": cm.Committer.Email, "username": ""}, | |
| 451 | - | "added": []string{}, "removed": []string{}, "modified": []string{}, | |
| 452 | - | } | |
| 453 | - | } | |
| 454 | - | ||
| 455 | - | // githubJSON builds the "github" context of the run as GitHub does for a push | |
| 456 | - | // (the job-dependent keys are added when a job is planned). Values that | |
| 457 | - | // GitHub's documentation does not settle are marked for the recordings. | |
| 458 | - | // | |
| 459 | - | // Checked against the recordings of ermite-conformance (push-branch-create, | |
| 460 | - | // push-tag, push-force, ctx-github-names): ids and counters are strings, so is | |
| 461 | - | // retention_days; "compare" has 12-character ids, the commit page for a created | |
| 462 | - | // ref and "<before>...<after>" for an update; repository.default_branch and | |
| 463 | - | // master_branch are the default branch. | |
| 464 | - | // | |
| 465 | - | // TODO(verify with recording): the empty added/removed/modified lists of the | |
| 466 | - | // push commits. | |
| 467 | - | func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts, wf *workflow.Workflow) string { | |
| 468 | - | full := c.cfg.Owner + "/" + repo.Name.String() | |
| 469 | - | refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch" | |
| 470 | - | if f.isTag { | |
| 471 | - | refName, refType = strings.TrimPrefix(run.Ref, "refs/tags/"), "tag" | |
| 472 | - | } | |
| 473 | - | repoURL := c.cfg.PublicURL + "/" + repo.Name.String() | |
| 474 | - | before := f.update.Old | |
| 475 | - | if f.created { | |
| 476 | - | before = domain.ZeroOID | |
| 477 | - | } | |
| 478 | - | compare := repoURL + "/compare/" + shortID(before) + "..." + shortID(f.update.New) | |
| 479 | - | if f.created { | |
| 480 | - | compare = repoURL + "/commit/" + shortID(f.update.New) | |
| 481 | - | } | |
| 482 | - | commits := make([]any, 0, len(f.commits)) | |
| 483 | - | for _, cm := range f.commits { | |
| 484 | - | commits = append(commits, c.commitJSON(ctx, repo, cm)) | |
| 485 | - | } | |
| 486 | - | event := obj{ | |
| 487 | - | "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced, | |
| 488 | - | "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(ctx, repo, f.head), | |
| 489 | - | "pusher": person(run.Actor, ""), | |
| 490 | - | "repository": obj{ | |
| 491 | - | "name": repo.Name.String(), "full_name": full, "private": repo.Visibility == domain.VisibilityPrivate, | |
| 492 | - | "owner": obj{"login": c.cfg.Owner, "name": c.cfg.Owner}, "html_url": repoURL, "clone_url": repoURL + ".git", | |
| 493 | - | "default_branch": f.defaultBranch, "master_branch": f.defaultBranch, "url": repoURL, | |
| 494 | - | }, | |
| 495 | - | "sender": obj{"login": run.Actor, "type": "User"}, | |
| 496 | - | } | |
| 497 | - | wfRef := full + "/" + run.WorkflowPath + "@" + run.Ref | |
| 498 | - | gh := obj{ | |
| 499 | - | "event_name": "push", "event": event, "ref": run.Ref, "ref_name": refName, "ref_type": refType, "ref_protected": false, | |
| 500 | - | "sha": run.Sha, "repository": full, "repository_owner": c.cfg.Owner, "repositoryUrl": repoURL + ".git", | |
| 501 | - | "actor": run.Actor, "triggering_actor": run.Actor, "workflow": run.WorkflowName, "workflow_ref": wfRef, | |
| 502 | - | "workflow_sha": run.Sha, "run_id": strconv.FormatInt(run.ID, 10), "run_number": strconv.FormatInt(run.Number, 10), | |
| 503 | - | "server_url": c.cfg.PublicURL, "api_url": c.cfg.PublicURL + "/-/github/api/v3", | |
| 504 | - | "graphql_url": c.cfg.PublicURL + "/-/github/api/graphql", "retention_days": strconv.Itoa(c.retentionDays()), | |
| 505 | - | "head_ref": "", "base_ref": "", | |
| 506 | - | } | |
| 507 | - | b, err := json.Marshal(gh) | |
| 508 | - | if err != nil { | |
| 509 | - | return "{}" | |
| 510 | - | } | |
| 511 | - | return string(b) | |
| 512 | - | } | |
| @@ -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 | ||
| @@ -168,6 +169,10 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag | |||||
| 168 | Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount, | 169 | Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount, | ||
| 169 | }): | 170 | }): | ||
| 170 | default: | 171 | default: | ||
| 172 | + | if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem != "" { | |||
| 173 | + | errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem)) | |||
| 174 | + | continue | |||
| 175 | + | } | |||
| 171 | errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf)) | 176 | errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf)) | ||
| 172 | } | 177 | } | ||
| 173 | } | 178 | } | ||
| @@ -190,14 +195,29 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda | |||||
| 190 | } | 195 | } | ||
| 191 | f.commit = res.CommitID | 196 | f.commit = res.CommitID | ||
| 192 | if isTag { | 197 | if isTag { | ||
| 193 | - | cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1) | 198 | + | // Recorded (push-tag, skip-directive-tag): the event of a tag carries |
| 199 | + | // the commits that the default branch does not have, like a created | |||
| 200 | + | // branch, newest as head_commit. No file list: paths filters are not | |||
| 201 | + | // evaluated for tags. | |||
| 202 | + | base := c.defaultBase(ctx, repo, "", f.commit) | |||
| 203 | + | cs, _, err := c.cfg.Git.ListRange(ctx, repo, base, f.commit, ports.MaxCommitLimit) | |||
| 194 | if err != nil { | 204 | if err != nil { | ||
| 195 | return f, fmt.Errorf("read the commit of the tag: %w", err) | 205 | return f, fmt.Errorf("read the commit of the tag: %w", err) | ||
| 196 | } | 206 | } | ||
| 197 | if len(cs) == 0 { | 207 | if len(cs) == 0 { | ||
| 198 | - | return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound) | 208 | + | cs, _, err = c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1) |
| 209 | + | if err != nil { | |||
| 210 | + | return f, fmt.Errorf("read the commit of the tag: %w", err) | |||
| 211 | + | } | |||
| 212 | + | if len(cs) == 0 { | |||
| 213 | + | return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound) | |||
| 214 | + | } | |||
| 215 | + | f.head = cs[0] | |||
| 216 | + | return f, nil | |||
| 199 | } | 217 | } | ||
| 200 | f.head = cs[0] | 218 | f.head = cs[0] | ||
| 219 | + | f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)]) | |||
| 220 | + | slices.Reverse(f.commits) | |||
| 201 | return f, nil | 221 | return f, nil | ||
| 202 | } | 222 | } | ||
| 203 | 223 | ||||
| @@ -385,8 +405,10 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push | |||||
| 385 | Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, | 405 | Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, | ||
| 386 | Status: domain.CIQueued, CreatedAt: now, | 406 | Status: domain.CIQueued, CreatedAt: now, | ||
| 387 | } | 407 | } | ||
| 408 | + | githubJSON := c.githubJSON(ctx, repo, run, f) | |||
| 409 | + | run.Title = c.runTitle(ctx, wf, githubJSON, run.Title) | |||
| 388 | d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{ | 410 | d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{ | ||
| 389 | - | Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: c.githubJSON(ctx, repo, run, f, wf), | 411 | + | Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON, |
| 390 | }} | 412 | }} | ||
| 391 | for i, j := range wf.Jobs { | 413 | for i, j := range wf.Jobs { | ||
| 392 | job := domain.CIJob{ | 414 | job := domain.CIJob{ | ||
| @@ -416,7 +438,59 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push | |||||
| 416 | return c.advanceRun(ctx, draft.Run.ID) | 438 | return c.advanceRun(ctx, draft.Run.ID) | ||
| 417 | } | 439 | } | ||
| 418 | 440 | ||||
| 419 | - | // --- the github context --- | 441 | + | // runTitle is the title of a run, GitHub's display_title: the run-name |
| 442 | + | // evaluated with the github, inputs and vars contexts (the contexts the | |||
| 443 | + | // schema allows there), else the first line of the head commit message. A | |||
| 444 | + | // run-name that does not evaluate, or that evaluates to nothing, falls back to | |||
| 445 | + | // the commit message, and the failure is logged. | |||
| 446 | + | func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string { | |||
| 447 | + | if wf.RunName == nil { | |||
| 448 | + | return commitTitle | |||
| 449 | + | } | |||
| 450 | + | gh, err := expr.FromJSON([]byte(githubJSON)) | |||
| 451 | + | if err != nil { | |||
| 452 | + | return commitTitle | |||
| 453 | + | } | |||
| 454 | + | env := template.Env{Contexts: map[string]expr.Value{ | |||
| 455 | + | "github": gh, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(), | |||
| 456 | + | }} | |||
| 457 | + | name, err := template.EvaluateString(ctx, wf.RunName, env) | |||
| 458 | + | if err != nil { | |||
| 459 | + | log := zerowrap.FromCtx(ctx) | |||
| 460 | + | log.Warn().Err(err).Msg("ci run-name does not evaluate: the run is titled with the commit message") | |||
| 461 | + | return commitTitle | |||
| 462 | + | } | |||
| 463 | + | if name = firstLine(name); name == "" { | |||
| 464 | + | return commitTitle | |||
| 465 | + | } | |||
| 466 | + | return name | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | // matrixProblem evaluates the strategy of the jobs that can be evaluated | |||
| 470 | + | // before the run starts (those that read no needs output) and returns the | |||
| 471 | + | // error text when a matrix produces more configurations than GitHub allows: | |||
| 472 | + | // GitHub then fails the run with no job, like an invalid workflow file | |||
| 473 | + | // (recorded: matrix-over-256). Other strategy errors stay job failures, found | |||
| 474 | + | // when the job is planned. | |||
| 475 | + | func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) string { | |||
| 476 | + | probe := domain.CIRun{Repo: repo.Name, Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, WorkflowPath: wfPath, WorkflowName: wf.Name, CreatedAt: c.now()} | |||
| 477 | + | gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f))) | |||
| 478 | + | if err != nil { | |||
| 479 | + | return "" | |||
| 480 | + | } | |||
| 481 | + | env := template.Env{Contexts: map[string]expr.Value{ | |||
| 482 | + | "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(), | |||
| 483 | + | }} | |||
| 484 | + | for _, j := range wf.Jobs { | |||
| 485 | + | if _, err := j.EvaluateStrategy(ctx, env); err != nil && strings.Contains(err.Error(), "which exceeds the maximum of") { | |||
| 486 | + | // ermite-actions' configurationLimitError is not exported. | |||
| 487 | + | return wfPath + ": " + err.Error() | |||
| 488 | + | } | |||
| 489 | + | } | |||
| 490 | + | return "" | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | // --- the github context (see ci_github.go) --- | |||
| 420 | 494 | ||||
| 421 | // shortID is the 12-character form of an object id that GitHub puts in the | 495 | // shortID is the 12-character form of an object id that GitHub puts in the | ||
| 422 | // compare URL of a push. | 496 | // compare URL of a push. | ||
| @@ -432,81 +506,3 @@ func shortID(id string) string { | |||||
| 432 | func (c *CI) retentionDays() int { | 506 | func (c *CI) retentionDays() int { | ||
| 433 | return max(int(c.cfg.Retention/(24*time.Hour)), 1) | 507 | return max(int(c.cfg.Retention/(24*time.Hour)), 1) | ||
| 434 | } | 508 | } | ||
| 435 | - | ||||
| 436 | - | type obj = map[string]any | |||
| 437 | - | ||||
| 438 | - | func person(name, email string) obj { return obj{"name": name, "email": email} } | |||
| 439 | - | ||||
| 440 | - | func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, cm domain.Commit) obj { | |||
| 441 | - | ts := cm.Committer.Time.UTC().Format(time.RFC3339) | |||
| 442 | - | tree := "" | |||
| 443 | - | if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil { | |||
| 444 | - | tree = id.String() | |||
| 445 | - | } | |||
| 446 | - | return obj{ | |||
| 447 | - | "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message, "timestamp": ts, | |||
| 448 | - | "url": c.cfg.PublicURL + "/" + repo.Name.String() + "/commit/" + cm.ID.String(), | |||
| 449 | - | "author": obj{"name": cm.Author.Name, "email": cm.Author.Email, "username": ""}, | |||
| 450 | - | "committer": obj{"name": cm.Committer.Name, "email": cm.Committer.Email, "username": ""}, | |||
| 451 | - | "added": []string{}, "removed": []string{}, "modified": []string{}, | |||
| 452 | - | } | |||
| 453 | - | } | |||
| 454 | - | ||||
| 455 | - | // githubJSON builds the "github" context of the run as GitHub does for a push | |||
| 456 | - | // (the job-dependent keys are added when a job is planned). Values that | |||
| 457 | - | // GitHub's documentation does not settle are marked for the recordings. | |||
| 458 | - | // | |||
| 459 | - | // Checked against the recordings of ermite-conformance (push-branch-create, | |||
| 460 | - | // push-tag, push-force, ctx-github-names): ids and counters are strings, so is | |||
| 461 | - | // retention_days; "compare" has 12-character ids, the commit page for a created | |||
| 462 | - | // ref and "<before>...<after>" for an update; repository.default_branch and | |||
| 463 | - | // master_branch are the default branch. | |||
| 464 | - | // | |||
| 465 | - | // TODO(verify with recording): the empty added/removed/modified lists of the | |||
| 466 | - | // push commits. | |||
| 467 | - | func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts, wf *workflow.Workflow) string { | |||
| 468 | - | full := c.cfg.Owner + "/" + repo.Name.String() | |||
| 469 | - | refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch" | |||
| 470 | - | if f.isTag { | |||
| 471 | - | refName, refType = strings.TrimPrefix(run.Ref, "refs/tags/"), "tag" | |||
| 472 | - | } | |||
| 473 | - | repoURL := c.cfg.PublicURL + "/" + repo.Name.String() | |||
| 474 | - | before := f.update.Old | |||
| 475 | - | if f.created { | |||
| 476 | - | before = domain.ZeroOID | |||
| 477 | - | } | |||
| 478 | - | compare := repoURL + "/compare/" + shortID(before) + "..." + shortID(f.update.New) | |||
| 479 | - | if f.created { | |||
| 480 | - | compare = repoURL + "/commit/" + shortID(f.update.New) | |||
| 481 | - | } | |||
| 482 | - | commits := make([]any, 0, len(f.commits)) | |||
| 483 | - | for _, cm := range f.commits { | |||
| 484 | - | commits = append(commits, c.commitJSON(ctx, repo, cm)) | |||
| 485 | - | } | |||
| 486 | - | event := obj{ | |||
| 487 | - | "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced, | |||
| 488 | - | "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(ctx, repo, f.head), | |||
| 489 | - | "pusher": person(run.Actor, ""), | |||
| 490 | - | "repository": obj{ | |||
| 491 | - | "name": repo.Name.String(), "full_name": full, "private": repo.Visibility == domain.VisibilityPrivate, | |||
| 492 | - | "owner": obj{"login": c.cfg.Owner, "name": c.cfg.Owner}, "html_url": repoURL, "clone_url": repoURL + ".git", | |||
| 493 | - | "default_branch": f.defaultBranch, "master_branch": f.defaultBranch, "url": repoURL, | |||
| 494 | - | }, | |||
| 495 | - | "sender": obj{"login": run.Actor, "type": "User"}, | |||
| 496 | - | } | |||
| 497 | - | wfRef := full + "/" + run.WorkflowPath + "@" + run.Ref | |||
| 498 | - | gh := obj{ | |||
| 499 | - | "event_name": "push", "event": event, "ref": run.Ref, "ref_name": refName, "ref_type": refType, "ref_protected": false, | |||
| 500 | - | "sha": run.Sha, "repository": full, "repository_owner": c.cfg.Owner, "repositoryUrl": repoURL + ".git", | |||
| 501 | - | "actor": run.Actor, "triggering_actor": run.Actor, "workflow": run.WorkflowName, "workflow_ref": wfRef, | |||
| 502 | - | "workflow_sha": run.Sha, "run_id": strconv.FormatInt(run.ID, 10), "run_number": strconv.FormatInt(run.Number, 10), | |||
| 503 | - | "server_url": c.cfg.PublicURL, "api_url": c.cfg.PublicURL + "/-/github/api/v3", | |||
| 504 | - | "graphql_url": c.cfg.PublicURL + "/-/github/api/graphql", "retention_days": strconv.Itoa(c.retentionDays()), | |||
| 505 | - | "head_ref": "", "base_ref": "", | |||
| 506 | - | } | |||
| 507 | - | b, err := json.Marshal(gh) | |||
| 508 | - | if err != nil { | |||
| 509 | - | return "{}" | |||
| 510 | - | } | |||
| 511 | - | return string(b) | |||
| 512 | - | } | |||
M proto/ermite/storage/ci.proto +4 -0
| @@ -180,6 +180,10 @@ message CIJob { | |||
| 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 | ||