refactor(ci): build the push commits once per ref and number check runs per attempt
- GO-002: the commit part of the push event (one CommitTree call per commit) is built once per ref and shared by the strategy probe and the runs; the probe is skipped for a workflow with no strategy. - GO-004: check_run_id mixes in the run attempt, with a documented 53-bit layout; a value that does not fit fails the job instead of wrapping. - GO-005: the doc comment of matrixProblem says what the loop evaluates. - GO-007: table test of runTitle. - GO-010: kv -> pair.
- commit
- a67d46a676e65232f981c945b7309c4c0e0a31e6
- author
- brice · · 2026-10-13 00:13 UTC
- parent
- 43dcef4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +209 -23
M internal/usecase/ci_github.go +76 -15
| @@ -9,6 +9,8 @@ import ( | |||
| 9 | 9 | "encoding/base64" | |
| 10 | 10 | "encoding/binary" | |
| 11 | 11 | "encoding/json" | |
| 12 | + | "errors" | |
| 13 | + | "fmt" | |
| 12 | 14 | "net/url" | |
| 13 | 15 | "strconv" | |
| 14 | 16 | "strings" | |
| @@ -45,15 +47,15 @@ const ( | |||
| 45 | 47 | apiPath = "/-/github/api" | |
| 46 | 48 | ) | |
| 47 | 49 | ||
| 48 | - | // kv is a key and a value of an ordered JSON object. | |
| 49 | - | type kv struct { | |
| 50 | + | // pair is a key and a value of an ordered JSON object. | |
| 51 | + | type pair struct { | |
| 50 | 52 | k string | |
| 51 | 53 | v any | |
| 52 | 54 | } | |
| 53 | 55 | ||
| 54 | 56 | // ordered is a JSON object that keeps the order of its keys, as the github | |
| 55 | 57 | // context of GitHub does (toJSON(github) prints that order). | |
| 56 | - | type ordered []kv | |
| 58 | + | type ordered []pair | |
| 57 | 59 | ||
| 58 | 60 | func (o ordered) MarshalJSON() ([]byte, error) { | |
| 59 | 61 | var b bytes.Buffer | |
| @@ -228,6 +230,24 @@ func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, u ghURLs, cm doma | |||
| 228 | 230 | } | |
| 229 | 231 | } | |
| 230 | 232 | ||
| 233 | + | // pushPayload is the part of the push event that is the same for every | |
| 234 | + | // workflow of one ref: the commits, each with its tree id (one Git call per | |
| 235 | + | // commit). It is built once per ref and shared by the probes and the runs. | |
| 236 | + | type pushPayload struct { | |
| 237 | + | commits []any | |
| 238 | + | head map[string]any | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // pushPayload builds the commit part of the push event of f. | |
| 242 | + | func (c *CI) pushPayload(ctx context.Context, repo domain.Repo, f pushFacts) *pushPayload { | |
| 243 | + | u := c.urls(repo) | |
| 244 | + | p := &pushPayload{commits: make([]any, 0, len(f.commits)), head: c.commitJSON(ctx, repo, u, f.head)} | |
| 245 | + | for _, cm := range f.commits { | |
| 246 | + | p.commits = append(p.commits, c.commitJSON(ctx, repo, u, cm)) | |
| 247 | + | } | |
| 248 | + | return p | |
| 249 | + | } | |
| 250 | + | ||
| 231 | 251 | func person(name, email string) map[string]any { return map[string]any{"name": name, "email": email} } | |
| 232 | 252 | ||
| 233 | 253 | // githubJSON builds the "github" context of the run as GitHub does for a push | |
| @@ -240,6 +260,9 @@ func person(name, email string) map[string]any { return map[string]any{"name": n | |||
| 240 | 260 | // 12-character ids: the commit page for a created ref with one commit, | |
| 241 | 261 | // "<first>^...<after>" for a created ref with several, "<before>...<after>" for | |
| 242 | 262 | // an update. | |
| 263 | + | // | |
| 264 | + | // The commits of the event come from f.payload, built once per ref; without | |
| 265 | + | // one they are read from Git here. | |
| 243 | 266 | func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts) string { | |
| 244 | 267 | u := c.urls(repo) | |
| 245 | 268 | id := c.identity(run.Actor) | |
| @@ -259,13 +282,13 @@ func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, | |||
| 259 | 282 | case f.created: | |
| 260 | 283 | compare = u.repo + "/commit/" + shortID(f.update.New) | |
| 261 | 284 | } | |
| 262 | - | commits := make([]any, 0, len(f.commits)) | |
| 263 | - | for _, cm := range f.commits { | |
| 264 | - | commits = append(commits, c.commitJSON(ctx, repo, u, cm)) | |
| 285 | + | payload := f.payload | |
| 286 | + | if payload == nil { | |
| 287 | + | payload = c.pushPayload(ctx, repo, f) | |
| 265 | 288 | } | |
| 266 | 289 | event := map[string]any{ | |
| 267 | 290 | "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), | |
| 291 | + | "base_ref": nil, "compare": compare, "commits": payload.commits, "head_commit": payload.head, | |
| 269 | 292 | "pusher": map[string]any{"name": id.login, "email": ""}, | |
| 270 | 293 | "repository": c.repoObj(repo, u, f.defaultBranch, run.CreatedAt), | |
| 271 | 294 | "sender": u.userObj(id, false), | |
| @@ -291,22 +314,60 @@ func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, | |||
| 291 | 314 | return string(b) | |
| 292 | 315 | } | |
| 293 | 316 | ||
| 317 | + | // Layout of job.check_run_id, 53 bits so that it is exact as a JavaScript | |
| 318 | + | // number (2^53 is the first integer that is not): | |
| 319 | + | // | |
| 320 | + | // bits 0-8 combo: matrix leg, 9 bits (0-511); a matrix has at most 256 | |
| 321 | + | // bits 9-18 index: position of the job in the workflow file, 10 bits | |
| 322 | + | // (0-1023): a workflow of more jobs than that is refused | |
| 323 | + | // here, ermite has no limit of its own on them | |
| 324 | + | // bits 19-25 attempt: run attempt, 7 bits (0-127), so that every re-run | |
| 325 | + | // gets new ids, as on GitHub | |
| 326 | + | // bits 26-52 run: run id, 27 bits (below 134 million) | |
| 327 | + | const ( | |
| 328 | + | checkComboBits = 9 | |
| 329 | + | checkIndexBits = 10 | |
| 330 | + | checkAttemptBits = 7 | |
| 331 | + | checkRunBits = 53 - checkComboBits - checkIndexBits - checkAttemptBits | |
| 332 | + | ) | |
| 333 | + | ||
| 334 | + | // errCheckRunID is returned when a job instance does not fit the layout of | |
| 335 | + | // check_run_id: the id is not wrapped or truncated, the job fails with it. | |
| 336 | + | var errCheckRunID = errors.New("the job instance is out of the range of a check run id") | |
| 337 | + | ||
| 294 | 338 | // 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) | |
| 339 | + | // the run, its attempt and the position of the job instance, as ermite's | |
| 340 | + | // checks are not numbered. It is unique and stable per attempt, job and matrix | |
| 341 | + | // leg, and fits in a JavaScript number. A value outside its field is an error, | |
| 342 | + | // never a silent wrap that would give two instances the same id. | |
| 343 | + | func checkRunID(run domain.CIRun, index, combo int) (int64, error) { | |
| 344 | + | attempt := int64(run.Attempt) | |
| 345 | + | switch { | |
| 346 | + | case run.ID < 0 || run.ID >= 1<<checkRunBits, | |
| 347 | + | attempt < 0 || attempt >= 1<<checkAttemptBits, | |
| 348 | + | index < 0 || index >= 1<<checkIndexBits, | |
| 349 | + | combo < 0 || combo >= 1<<checkComboBits: | |
| 350 | + | return 0, fmt.Errorf("%w: run %d, attempt %d, job %d, leg %d", errCheckRunID, run.ID, run.Attempt, index, combo) | |
| 351 | + | } | |
| 352 | + | id := run.ID<<(checkAttemptBits+checkIndexBits+checkComboBits) | | |
| 353 | + | attempt<<(checkIndexBits+checkComboBits) | | |
| 354 | + | int64(index)<<checkComboBits | | |
| 355 | + | int64(combo) | |
| 356 | + | return id, nil | |
| 300 | 357 | } | |
| 301 | 358 | ||
| 302 | 359 | // jobKeys are the keys of the "job" context that the server knows, in | |
| 303 | 360 | // GitHub's order (the runner adds "status"). The runner copies the "job" key | |
| 304 | 361 | // of the contexts it is given into the job context. | |
| 305 | - | func (c *CI) jobKeys(run domain.CIRun, index, combo int) ordered { | |
| 362 | + | func (c *CI) jobKeys(run domain.CIRun, index, combo int) (ordered, error) { | |
| 363 | + | id, err := checkRunID(run, index, combo) | |
| 364 | + | if err != nil { | |
| 365 | + | return nil, err | |
| 366 | + | } | |
| 306 | 367 | u := c.urls(domain.Repo{Name: run.Repo}) | |
| 307 | 368 | return ordered{ | |
| 308 | - | {"check_run_id", checkRunID(run, index, combo)}, | |
| 369 | + | {"check_run_id", id}, | |
| 309 | 370 | {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref}, | |
| 310 | 371 | {"workflow_sha", run.Sha}, {"workflow_repository", u.full}, {"workflow_file_path", run.WorkflowPath}, | |
| 311 | - | } | |
| 372 | + | }, nil | |
| 312 | 373 | } | |
| @@ -9,6 +9,8 @@ import ( | |||||
| 9 | "encoding/base64" | 9 | "encoding/base64" | ||
| 10 | "encoding/binary" | 10 | "encoding/binary" | ||
| 11 | "encoding/json" | 11 | "encoding/json" | ||
| 12 | + | "errors" | |||
| 13 | + | "fmt" | |||
| 12 | "net/url" | 14 | "net/url" | ||
| 13 | "strconv" | 15 | "strconv" | ||
| 14 | "strings" | 16 | "strings" | ||
| @@ -45,15 +47,15 @@ const ( | |||||
| 45 | apiPath = "/-/github/api" | 47 | apiPath = "/-/github/api" | ||
| 46 | ) | 48 | ) | ||
| 47 | 49 | ||||
| 48 | - | // kv is a key and a value of an ordered JSON object. | 50 | + | // pair is a key and a value of an ordered JSON object. |
| 49 | - | type kv struct { | 51 | + | type pair struct { |
| 50 | k string | 52 | k string | ||
| 51 | v any | 53 | v any | ||
| 52 | } | 54 | } | ||
| 53 | 55 | ||||
| 54 | // ordered is a JSON object that keeps the order of its keys, as the github | 56 | // 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). | 57 | // context of GitHub does (toJSON(github) prints that order). | ||
| 56 | - | type ordered []kv | 58 | + | type ordered []pair |
| 57 | 59 | ||||
| 58 | func (o ordered) MarshalJSON() ([]byte, error) { | 60 | func (o ordered) MarshalJSON() ([]byte, error) { | ||
| 59 | var b bytes.Buffer | 61 | var b bytes.Buffer | ||
| @@ -228,6 +230,24 @@ func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, u ghURLs, cm doma | |||||
| 228 | } | 230 | } | ||
| 229 | } | 231 | } | ||
| 230 | 232 | ||||
| 233 | + | // pushPayload is the part of the push event that is the same for every | |||
| 234 | + | // workflow of one ref: the commits, each with its tree id (one Git call per | |||
| 235 | + | // commit). It is built once per ref and shared by the probes and the runs. | |||
| 236 | + | type pushPayload struct { | |||
| 237 | + | commits []any | |||
| 238 | + | head map[string]any | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // pushPayload builds the commit part of the push event of f. | |||
| 242 | + | func (c *CI) pushPayload(ctx context.Context, repo domain.Repo, f pushFacts) *pushPayload { | |||
| 243 | + | u := c.urls(repo) | |||
| 244 | + | p := &pushPayload{commits: make([]any, 0, len(f.commits)), head: c.commitJSON(ctx, repo, u, f.head)} | |||
| 245 | + | for _, cm := range f.commits { | |||
| 246 | + | p.commits = append(p.commits, c.commitJSON(ctx, repo, u, cm)) | |||
| 247 | + | } | |||
| 248 | + | return p | |||
| 249 | + | } | |||
| 250 | + | ||||
| 231 | func person(name, email string) map[string]any { return map[string]any{"name": name, "email": email} } | 251 | func person(name, email string) map[string]any { return map[string]any{"name": name, "email": email} } | ||
| 232 | 252 | ||||
| 233 | // githubJSON builds the "github" context of the run as GitHub does for a push | 253 | // githubJSON builds the "github" context of the run as GitHub does for a push | ||
| @@ -240,6 +260,9 @@ func person(name, email string) map[string]any { return map[string]any{"name": n | |||||
| 240 | // 12-character ids: the commit page for a created ref with one commit, | 260 | // 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 | 261 | // "<first>^...<after>" for a created ref with several, "<before>...<after>" for | ||
| 242 | // an update. | 262 | // an update. | ||
| 263 | + | // | |||
| 264 | + | // The commits of the event come from f.payload, built once per ref; without | |||
| 265 | + | // one they are read from Git here. | |||
| 243 | func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts) string { | 266 | func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts) string { | ||
| 244 | u := c.urls(repo) | 267 | u := c.urls(repo) | ||
| 245 | id := c.identity(run.Actor) | 268 | id := c.identity(run.Actor) | ||
| @@ -259,13 +282,13 @@ func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, | |||||
| 259 | case f.created: | 282 | case f.created: | ||
| 260 | compare = u.repo + "/commit/" + shortID(f.update.New) | 283 | compare = u.repo + "/commit/" + shortID(f.update.New) | ||
| 261 | } | 284 | } | ||
| 262 | - | commits := make([]any, 0, len(f.commits)) | 285 | + | payload := f.payload |
| 263 | - | for _, cm := range f.commits { | 286 | + | if payload == nil { |
| 264 | - | commits = append(commits, c.commitJSON(ctx, repo, u, cm)) | 287 | + | payload = c.pushPayload(ctx, repo, f) |
| 265 | } | 288 | } | ||
| 266 | event := map[string]any{ | 289 | event := map[string]any{ | ||
| 267 | "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced, | 290 | "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), | 291 | + | "base_ref": nil, "compare": compare, "commits": payload.commits, "head_commit": payload.head, |
| 269 | "pusher": map[string]any{"name": id.login, "email": ""}, | 292 | "pusher": map[string]any{"name": id.login, "email": ""}, | ||
| 270 | "repository": c.repoObj(repo, u, f.defaultBranch, run.CreatedAt), | 293 | "repository": c.repoObj(repo, u, f.defaultBranch, run.CreatedAt), | ||
| 271 | "sender": u.userObj(id, false), | 294 | "sender": u.userObj(id, false), | ||
| @@ -291,22 +314,60 @@ func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, | |||||
| 291 | return string(b) | 314 | return string(b) | ||
| 292 | } | 315 | } | ||
| 293 | 316 | ||||
| 317 | + | // Layout of job.check_run_id, 53 bits so that it is exact as a JavaScript | |||
| 318 | + | // number (2^53 is the first integer that is not): | |||
| 319 | + | // | |||
| 320 | + | // bits 0-8 combo: matrix leg, 9 bits (0-511); a matrix has at most 256 | |||
| 321 | + | // bits 9-18 index: position of the job in the workflow file, 10 bits | |||
| 322 | + | // (0-1023): a workflow of more jobs than that is refused | |||
| 323 | + | // here, ermite has no limit of its own on them | |||
| 324 | + | // bits 19-25 attempt: run attempt, 7 bits (0-127), so that every re-run | |||
| 325 | + | // gets new ids, as on GitHub | |||
| 326 | + | // bits 26-52 run: run id, 27 bits (below 134 million) | |||
| 327 | + | const ( | |||
| 328 | + | checkComboBits = 9 | |||
| 329 | + | checkIndexBits = 10 | |||
| 330 | + | checkAttemptBits = 7 | |||
| 331 | + | checkRunBits = 53 - checkComboBits - checkIndexBits - checkAttemptBits | |||
| 332 | + | ) | |||
| 333 | + | ||||
| 334 | + | // errCheckRunID is returned when a job instance does not fit the layout of | |||
| 335 | + | // check_run_id: the id is not wrapped or truncated, the job fails with it. | |||
| 336 | + | var errCheckRunID = errors.New("the job instance is out of the range of a check run id") | |||
| 337 | + | ||||
| 294 | // checkRunID is the number github.com shows in job.check_run_id: derived from | 338 | // 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 | 339 | + | // the run, its attempt and the position of the job instance, as ermite's |
| 296 | - | // unique and stable per run, job and matrix leg, and fits in a JavaScript | 340 | + | // checks are not numbered. It is unique and stable per attempt, job and matrix |
| 297 | - | // number. | 341 | + | // leg, and fits in a JavaScript number. A value outside its field is an error, |
| 298 | - | func checkRunID(run domain.CIRun, index, combo int) int64 { | 342 | + | // never a silent wrap that would give two instances the same id. |
| 299 | - | return run.ID<<20 | int64(index&0x7ff)<<9 | int64(combo&0x1ff) | 343 | + | func checkRunID(run domain.CIRun, index, combo int) (int64, error) { |
| 344 | + | attempt := int64(run.Attempt) | |||
| 345 | + | switch { | |||
| 346 | + | case run.ID < 0 || run.ID >= 1<<checkRunBits, | |||
| 347 | + | attempt < 0 || attempt >= 1<<checkAttemptBits, | |||
| 348 | + | index < 0 || index >= 1<<checkIndexBits, | |||
| 349 | + | combo < 0 || combo >= 1<<checkComboBits: | |||
| 350 | + | return 0, fmt.Errorf("%w: run %d, attempt %d, job %d, leg %d", errCheckRunID, run.ID, run.Attempt, index, combo) | |||
| 351 | + | } | |||
| 352 | + | id := run.ID<<(checkAttemptBits+checkIndexBits+checkComboBits) | | |||
| 353 | + | attempt<<(checkIndexBits+checkComboBits) | | |||
| 354 | + | int64(index)<<checkComboBits | | |||
| 355 | + | int64(combo) | |||
| 356 | + | return id, nil | |||
| 300 | } | 357 | } | ||
| 301 | 358 | ||||
| 302 | // jobKeys are the keys of the "job" context that the server knows, in | 359 | // 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 | 360 | // GitHub's order (the runner adds "status"). The runner copies the "job" key | ||
| 304 | // of the contexts it is given into the job context. | 361 | // of the contexts it is given into the job context. | ||
| 305 | - | func (c *CI) jobKeys(run domain.CIRun, index, combo int) ordered { | 362 | + | func (c *CI) jobKeys(run domain.CIRun, index, combo int) (ordered, error) { |
| 363 | + | id, err := checkRunID(run, index, combo) | |||
| 364 | + | if err != nil { | |||
| 365 | + | return nil, err | |||
| 366 | + | } | |||
| 306 | u := c.urls(domain.Repo{Name: run.Repo}) | 367 | u := c.urls(domain.Repo{Name: run.Repo}) | ||
| 307 | return ordered{ | 368 | return ordered{ | ||
| 308 | - | {"check_run_id", checkRunID(run, index, combo)}, | 369 | + | {"check_run_id", id}, |
| 309 | {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref}, | 370 | {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref}, | ||
| 310 | {"workflow_sha", run.Sha}, {"workflow_repository", u.full}, {"workflow_file_path", run.WorkflowPath}, | 371 | {"workflow_sha", run.Sha}, {"workflow_repository", u.full}, {"workflow_file_path", run.WorkflowPath}, | ||
| 311 | - | } | 372 | + | }, nil |
| 312 | } | 373 | } | ||
A internal/usecase/ci_github_test.go +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "math" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite-actions/workflow" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/usecase" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // The title of a run is the run-name when it evaluates to something, else the | |
| 21 | + | // commit title; a run-name is cut to its first line and 256 characters. | |
| 22 | + | func TestRunTitle(t *testing.T) { | |
| 23 | + | t.Parallel() | |
| 24 | + | const commit = "fix: the commit title" | |
| 25 | + | const githubJSON = `{"ref_name":"main","event":{"head_commit":{"message":"one\ntwo"}}}` | |
| 26 | + | tests := []struct { | |
| 27 | + | name string | |
| 28 | + | runName string // the run-name expression, "" : the workflow has none | |
| 29 | + | want string | |
| 30 | + | }{ | |
| 31 | + | {"no run-name", "", commit}, | |
| 32 | + | {"a run-name", "deploy ${{ github.ref_name }}", "deploy main"}, | |
| 33 | + | {"an evaluation error", "${{ fromJSON('{') }}", commit}, | |
| 34 | + | {"an empty result", "${{ '' }}", commit}, | |
| 35 | + | {"a blank result", "${{ ' ' }}", commit}, | |
| 36 | + | {"a first line only", "${{ github.event.head_commit.message }}", "one"}, | |
| 37 | + | {"cut at 256 characters", strings.Repeat("é", 300), strings.Repeat("é", 256)}, | |
| 38 | + | {"exactly 256 characters", strings.Repeat("a", 256), strings.Repeat("a", 256)}, | |
| 39 | + | } | |
| 40 | + | for _, tt := range tests { | |
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |
| 42 | + | t.Parallel() | |
| 43 | + | u := newCIUnit(t, nil) | |
| 44 | + | yaml := "on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: 'true'\n" | |
| 45 | + | if tt.runName != "" { | |
| 46 | + | yaml = "run-name: \"" + strings.ReplaceAll(strings.ReplaceAll(tt.runName, `\`, `\\`), `"`, `\"`) + "\"\n" + yaml | |
| 47 | + | } | |
| 48 | + | wf, errs := workflow.Parse(".github/workflows/a.yml", []byte(yaml)) | |
| 49 | + | require.Empty(t, errs) | |
| 50 | + | require.NotNil(t, wf) | |
| 51 | + | assert.Equal(t, tt.want, u.ci.RunTitle(context.Background(), wf, githubJSON, commit)) | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // The check run id keeps the run, its attempt, the job and the leg apart, stays | |
| 57 | + | // below 2^53, and refuses what does not fit instead of wrapping. | |
| 58 | + | func TestCheckRunID(t *testing.T) { | |
| 59 | + | t.Parallel() | |
| 60 | + | run := func(id int64, attempt int32) domain.CIRun { return domain.CIRun{ID: id, Attempt: attempt} } | |
| 61 | + | seen := map[int64]string{} | |
| 62 | + | for _, id := range []int64{1, 2, 1<<27 - 1} { | |
| 63 | + | for _, attempt := range []int32{1, 2, 127} { | |
| 64 | + | for _, index := range []int{0, 1, 1023} { | |
| 65 | + | for _, combo := range []int{0, 1, 255, 511} { | |
| 66 | + | got, err := usecase.CheckRunID(run(id, attempt), index, combo) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | assert.Less(t, got, int64(1)<<53) | |
| 69 | + | key := fmt.Sprintf("run %d attempt %d job %d leg %d", id, attempt, index, combo) | |
| 70 | + | assert.NotContains(t, seen, got, "%s collides", key) | |
| 71 | + | seen[got] = key | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | a, _ := usecase.CheckRunID(run(7, 1), 0, 0) | |
| 77 | + | b, _ := usecase.CheckRunID(run(7, 2), 0, 0) | |
| 78 | + | assert.NotEqual(t, a, b, "a re-run gets new ids") | |
| 79 | + | again, _ := usecase.CheckRunID(run(7, 1), 0, 0) | |
| 80 | + | assert.Equal(t, a, again, "stable per attempt") | |
| 81 | + | ||
| 82 | + | for name, c := range map[string]struct { | |
| 83 | + | run domain.CIRun | |
| 84 | + | index, combo int | |
| 85 | + | }{ | |
| 86 | + | "job index": {run(1, 1), 1024, 0}, | |
| 87 | + | "negative job": {run(1, 1), -1, 0}, | |
| 88 | + | "leg": {run(1, 1), 0, 512}, | |
| 89 | + | "attempt": {run(1, 128), 0, 0}, | |
| 90 | + | "run id": {run(1<<27, 1), 0, 0}, | |
| 91 | + | "huge run id": {run(math.MaxInt64, 1), 0, 0}, | |
| 92 | + | "negative run": {run(-1, 1), 0, 0}, | |
| 93 | + | } { | |
| 94 | + | _, err := usecase.CheckRunID(c.run, c.index, c.combo) | |
| 95 | + | assert.Error(t, err, name) | |
| 96 | + | } | |
| 97 | + | } | |
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "math" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite-actions/workflow" | |||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | "bnema.dev/ermite/internal/usecase" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // The title of a run is the run-name when it evaluates to something, else the | |||
| 21 | + | // commit title; a run-name is cut to its first line and 256 characters. | |||
| 22 | + | func TestRunTitle(t *testing.T) { | |||
| 23 | + | t.Parallel() | |||
| 24 | + | const commit = "fix: the commit title" | |||
| 25 | + | const githubJSON = `{"ref_name":"main","event":{"head_commit":{"message":"one\ntwo"}}}` | |||
| 26 | + | tests := []struct { | |||
| 27 | + | name string | |||
| 28 | + | runName string // the run-name expression, "" : the workflow has none | |||
| 29 | + | want string | |||
| 30 | + | }{ | |||
| 31 | + | {"no run-name", "", commit}, | |||
| 32 | + | {"a run-name", "deploy ${{ github.ref_name }}", "deploy main"}, | |||
| 33 | + | {"an evaluation error", "${{ fromJSON('{') }}", commit}, | |||
| 34 | + | {"an empty result", "${{ '' }}", commit}, | |||
| 35 | + | {"a blank result", "${{ ' ' }}", commit}, | |||
| 36 | + | {"a first line only", "${{ github.event.head_commit.message }}", "one"}, | |||
| 37 | + | {"cut at 256 characters", strings.Repeat("é", 300), strings.Repeat("é", 256)}, | |||
| 38 | + | {"exactly 256 characters", strings.Repeat("a", 256), strings.Repeat("a", 256)}, | |||
| 39 | + | } | |||
| 40 | + | for _, tt := range tests { | |||
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 42 | + | t.Parallel() | |||
| 43 | + | u := newCIUnit(t, nil) | |||
| 44 | + | yaml := "on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: 'true'\n" | |||
| 45 | + | if tt.runName != "" { | |||
| 46 | + | yaml = "run-name: \"" + strings.ReplaceAll(strings.ReplaceAll(tt.runName, `\`, `\\`), `"`, `\"`) + "\"\n" + yaml | |||
| 47 | + | } | |||
| 48 | + | wf, errs := workflow.Parse(".github/workflows/a.yml", []byte(yaml)) | |||
| 49 | + | require.Empty(t, errs) | |||
| 50 | + | require.NotNil(t, wf) | |||
| 51 | + | assert.Equal(t, tt.want, u.ci.RunTitle(context.Background(), wf, githubJSON, commit)) | |||
| 52 | + | }) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // The check run id keeps the run, its attempt, the job and the leg apart, stays | |||
| 57 | + | // below 2^53, and refuses what does not fit instead of wrapping. | |||
| 58 | + | func TestCheckRunID(t *testing.T) { | |||
| 59 | + | t.Parallel() | |||
| 60 | + | run := func(id int64, attempt int32) domain.CIRun { return domain.CIRun{ID: id, Attempt: attempt} } | |||
| 61 | + | seen := map[int64]string{} | |||
| 62 | + | for _, id := range []int64{1, 2, 1<<27 - 1} { | |||
| 63 | + | for _, attempt := range []int32{1, 2, 127} { | |||
| 64 | + | for _, index := range []int{0, 1, 1023} { | |||
| 65 | + | for _, combo := range []int{0, 1, 255, 511} { | |||
| 66 | + | got, err := usecase.CheckRunID(run(id, attempt), index, combo) | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | assert.Less(t, got, int64(1)<<53) | |||
| 69 | + | key := fmt.Sprintf("run %d attempt %d job %d leg %d", id, attempt, index, combo) | |||
| 70 | + | assert.NotContains(t, seen, got, "%s collides", key) | |||
| 71 | + | seen[got] = key | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | a, _ := usecase.CheckRunID(run(7, 1), 0, 0) | |||
| 77 | + | b, _ := usecase.CheckRunID(run(7, 2), 0, 0) | |||
| 78 | + | assert.NotEqual(t, a, b, "a re-run gets new ids") | |||
| 79 | + | again, _ := usecase.CheckRunID(run(7, 1), 0, 0) | |||
| 80 | + | assert.Equal(t, a, again, "stable per attempt") | |||
| 81 | + | ||||
| 82 | + | for name, c := range map[string]struct { | |||
| 83 | + | run domain.CIRun | |||
| 84 | + | index, combo int | |||
| 85 | + | }{ | |||
| 86 | + | "job index": {run(1, 1), 1024, 0}, | |||
| 87 | + | "negative job": {run(1, 1), -1, 0}, | |||
| 88 | + | "leg": {run(1, 1), 0, 512}, | |||
| 89 | + | "attempt": {run(1, 128), 0, 0}, | |||
| 90 | + | "run id": {run(1<<27, 1), 0, 0}, | |||
| 91 | + | "huge run id": {run(math.MaxInt64, 1), 0, 0}, | |||
| 92 | + | "negative run": {run(-1, 1), 0, 0}, | |||
| 93 | + | } { | |||
| 94 | + | _, err := usecase.CheckRunID(c.run, c.index, c.combo) | |||
| 95 | + | assert.Error(t, err, name) | |||
| 96 | + | } | |||
| 97 | + | } | |||
M internal/usecase/ci_plan.go +5 -1
| @@ -449,7 +449,11 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl | |||
| 449 | 449 | if !plan.HasMatrix { | |
| 450 | 450 | matrixCtx = expr.Null{} // GitHub: "matrix": null for a job without a matrix | |
| 451 | 451 | } | |
| 452 | - | contexts, err := contextsJSON(gh, needs, plan.Context(i), matrixCtx, rs.c.jobKeys(rs.run, base.Index, i)) | |
| 452 | + | jobKeys, err := rs.c.jobKeys(rs.run, base.Index, i) | |
| 453 | + | if err != nil { | |
| 454 | + | return j, err | |
| 455 | + | } | |
| 456 | + | contexts, err := contextsJSON(gh, needs, plan.Context(i), matrixCtx, jobKeys) | |
| 453 | 457 | if err != nil { | |
| 454 | 458 | return j, err | |
| 455 | 459 | } | |
| @@ -449,7 +449,11 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl | |||||
| 449 | if !plan.HasMatrix { | 449 | if !plan.HasMatrix { | ||
| 450 | matrixCtx = expr.Null{} // GitHub: "matrix": null for a job without a matrix | 450 | matrixCtx = expr.Null{} // GitHub: "matrix": null for a job without a matrix | ||
| 451 | } | 451 | } | ||
| 452 | - | contexts, err := contextsJSON(gh, needs, plan.Context(i), matrixCtx, rs.c.jobKeys(rs.run, base.Index, i)) | 452 | + | jobKeys, err := rs.c.jobKeys(rs.run, base.Index, i) |
| 453 | + | if err != nil { | |||
| 454 | + | return j, err | |||
| 455 | + | } | |||
| 456 | + | contexts, err := contextsJSON(gh, needs, plan.Context(i), matrixCtx, jobKeys) | |||
| 453 | if err != nil { | 457 | if err != nil { | ||
| 454 | return j, err | 458 | return j, err | ||
| 455 | } | 459 | } | ||
M internal/usecase/ci_trigger.go +20 -7
| @@ -133,6 +133,9 @@ type pushFacts struct { | |||
| 133 | 133 | isTag bool | |
| 134 | 134 | // defaultBranch is the default branch of the repository, "" when unknown. | |
| 135 | 135 | defaultBranch string | |
| 136 | + | // payload is the commit part of the push event, shared by the workflows | |
| 137 | + | // of the ref; handleRef builds it once, before the first one that matches. | |
| 138 | + | payload *pushPayload | |
| 136 | 139 | } | |
| 137 | 140 | ||
| 138 | 141 | func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tagCount int) error { | |
| @@ -169,6 +172,9 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag | |||
| 169 | 172 | Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount, | |
| 170 | 173 | }): | |
| 171 | 174 | default: | |
| 175 | + | if facts.payload == nil { | |
| 176 | + | facts.payload = c.pushPayload(ctx, repo, facts) | |
| 177 | + | } | |
| 172 | 178 | if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem != "" { | |
| 173 | 179 | errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem)) | |
| 174 | 180 | continue | |
| @@ -466,13 +472,20 @@ func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, co | |||
| 466 | 472 | return name | |
| 467 | 473 | } | |
| 468 | 474 | ||
| 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 | + | // matrixProblem expands the strategy of every job of the file, whatever its | |
| 476 | + | // "if:" says, with an empty needs context (a matrix built from a needs output does not | |
| 477 | + | // evaluate here, and that error is ignored), | |
| 478 | + | // and returns the error text of the first strategy that produces more | |
| 479 | + | // configurations than GitHub allows: GitHub then fails the run with no job, | |
| 480 | + | // like an invalid workflow file (recorded: matrix-over-256). Any other error | |
| 481 | + | // of a strategy is ignored here and stays a job failure, found when the job is | |
| 482 | + | // planned. Whether GitHub counts the matrix of a job that "if:" skips is not | |
| 483 | + | // recorded yet (needs an "if: false" scenario over 256). The github context | |
| 484 | + | // is that of a run with no id yet, on the commits shared by the ref. | |
| 475 | 485 | func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) string { | |
| 486 | + | if !slices.ContainsFunc(wf.Jobs, func(j *workflow.Job) bool { return j.Strategy != nil }) { | |
| 487 | + | return "" // nothing to expand: no context to build | |
| 488 | + | } | |
| 476 | 489 | probe := domain.CIRun{Repo: repo.Name, Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, WorkflowPath: wfPath, WorkflowName: wf.Name, CreatedAt: c.now()} | |
| 477 | 490 | gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f))) | |
| 478 | 491 | if err != nil { | |
| @@ -483,7 +496,7 @@ func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f | |||
| 483 | 496 | }} | |
| 484 | 497 | for _, j := range wf.Jobs { | |
| 485 | 498 | if _, err := j.EvaluateStrategy(ctx, env); err != nil && strings.Contains(err.Error(), "which exceeds the maximum of") { | |
| 486 | - | // ermite-actions' configurationLimitError is not exported. | |
| 499 | + | // ermite-actions' configurationLimitError is not exported yet. | |
| 487 | 500 | return wfPath + ": " + err.Error() | |
| 488 | 501 | } | |
| 489 | 502 | } | |
| @@ -133,6 +133,9 @@ type pushFacts struct { | |||||
| 133 | isTag bool | 133 | isTag bool | ||
| 134 | // defaultBranch is the default branch of the repository, "" when unknown. | 134 | // defaultBranch is the default branch of the repository, "" when unknown. | ||
| 135 | defaultBranch string | 135 | defaultBranch string | ||
| 136 | + | // payload is the commit part of the push event, shared by the workflows | |||
| 137 | + | // of the ref; handleRef builds it once, before the first one that matches. | |||
| 138 | + | payload *pushPayload | |||
| 136 | } | 139 | } | ||
| 137 | 140 | ||||
| 138 | func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tagCount int) error { | 141 | func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tagCount int) error { | ||
| @@ -169,6 +172,9 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag | |||||
| 169 | Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount, | 172 | Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount, | ||
| 170 | }): | 173 | }): | ||
| 171 | default: | 174 | default: | ||
| 175 | + | if facts.payload == nil { | |||
| 176 | + | facts.payload = c.pushPayload(ctx, repo, facts) | |||
| 177 | + | } | |||
| 172 | if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem != "" { | 178 | 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)) | 179 | errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem)) | ||
| 174 | continue | 180 | continue | ||
| @@ -466,13 +472,20 @@ func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, co | |||||
| 466 | return name | 472 | return name | ||
| 467 | } | 473 | } | ||
| 468 | 474 | ||||
| 469 | - | // matrixProblem evaluates the strategy of the jobs that can be evaluated | 475 | + | // matrixProblem expands the strategy of every job of the file, whatever its |
| 470 | - | // before the run starts (those that read no needs output) and returns the | 476 | + | // "if:" says, with an empty needs context (a matrix built from a needs output does not |
| 471 | - | // error text when a matrix produces more configurations than GitHub allows: | 477 | + | // evaluate here, and that error is ignored), |
| 472 | - | // GitHub then fails the run with no job, like an invalid workflow file | 478 | + | // and returns the error text of the first strategy that produces more |
| 473 | - | // (recorded: matrix-over-256). Other strategy errors stay job failures, found | 479 | + | // configurations than GitHub allows: GitHub then fails the run with no job, |
| 474 | - | // when the job is planned. | 480 | + | // like an invalid workflow file (recorded: matrix-over-256). Any other error |
| 481 | + | // of a strategy is ignored here and stays a job failure, found when the job is | |||
| 482 | + | // planned. Whether GitHub counts the matrix of a job that "if:" skips is not | |||
| 483 | + | // recorded yet (needs an "if: false" scenario over 256). The github context | |||
| 484 | + | // is that of a run with no id yet, on the commits shared by the ref. | |||
| 475 | func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) string { | 485 | func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) string { | ||
| 486 | + | if !slices.ContainsFunc(wf.Jobs, func(j *workflow.Job) bool { return j.Strategy != nil }) { | |||
| 487 | + | return "" // nothing to expand: no context to build | |||
| 488 | + | } | |||
| 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()} | 489 | probe := domain.CIRun{Repo: repo.Name, Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, WorkflowPath: wfPath, WorkflowName: wf.Name, CreatedAt: c.now()} | ||
| 477 | gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f))) | 490 | gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f))) | ||
| 478 | if err != nil { | 491 | if err != nil { | ||
| @@ -483,7 +496,7 @@ func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f | |||||
| 483 | }} | 496 | }} | ||
| 484 | for _, j := range wf.Jobs { | 497 | for _, j := range wf.Jobs { | ||
| 485 | if _, err := j.EvaluateStrategy(ctx, env); err != nil && strings.Contains(err.Error(), "which exceeds the maximum of") { | 498 | if _, err := j.EvaluateStrategy(ctx, env); err != nil && strings.Contains(err.Error(), "which exceeds the maximum of") { | ||
| 486 | - | // ermite-actions' configurationLimitError is not exported. | 499 | + | // ermite-actions' configurationLimitError is not exported yet. |
| 487 | return wfPath + ": " + err.Error() | 500 | return wfPath + ": " + err.Error() | ||
| 488 | } | 501 | } | ||
| 489 | } | 502 | } | ||
M internal/usecase/export_test.go +11 -0
| @@ -5,6 +5,7 @@ package usecase | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | ||
| 8 | + | "bnema.dev/ermite-actions/workflow" | |
| 8 | 9 | "bnema.dev/ermite/internal/domain" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -13,3 +14,13 @@ func AuthorOf(p domain.Principal) string { return authorOf(p) } | |||
| 13 | 14 | ||
| 14 | 15 | // Sweep exposes the daily sweep to the tests. | |
| 15 | 16 | func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) } | |
| 17 | + | ||
| 18 | + | // RunTitle exposes runTitle to the tests. | |
| 19 | + | func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string { | |
| 20 | + | return c.runTitle(ctx, wf, githubJSON, commitTitle) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // CheckRunID exposes checkRunID to the tests. | |
| 24 | + | func CheckRunID(run domain.CIRun, index, combo int) (int64, error) { | |
| 25 | + | return checkRunID(run, index, combo) | |
| 26 | + | } | |
| @@ -5,6 +5,7 @@ package usecase | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | 7 | ||||
| 8 | + | "bnema.dev/ermite-actions/workflow" | |||
| 8 | "bnema.dev/ermite/internal/domain" | 9 | "bnema.dev/ermite/internal/domain" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| @@ -13,3 +14,13 @@ func AuthorOf(p domain.Principal) string { return authorOf(p) } | |||||
| 13 | 14 | ||||
| 14 | // Sweep exposes the daily sweep to the tests. | 15 | // Sweep exposes the daily sweep to the tests. | ||
| 15 | func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) } | 16 | func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) } | ||
| 17 | + | ||||
| 18 | + | // RunTitle exposes runTitle to the tests. | |||
| 19 | + | func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string { | |||
| 20 | + | return c.runTitle(ctx, wf, githubJSON, commitTitle) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // CheckRunID exposes checkRunID to the tests. | |||
| 24 | + | func CheckRunID(run domain.CIRun, index, combo int) (int64, error) { | |||
| 25 | + | return checkRunID(run, index, combo) | |||
| 26 | + | } | |||