detached a67d46a

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +209 -23
M internal/usecase/ci_github.go +76 -15
@@ -9,6 +9,8 @@ import (
99 "encoding/base64"
1010 "encoding/binary"
1111 "encoding/json"
12+ "errors"
13+ "fmt"
1214 "net/url"
1315 "strconv"
1416 "strings"
@@ -45,15 +47,15 @@ const (
4547 apiPath = "/-/github/api"
4648 )
4749
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 {
5052 k string
5153 v any
5254 }
5355
5456 // ordered is a JSON object that keeps the order of its keys, as the github
5557 // context of GitHub does (toJSON(github) prints that order).
56-type ordered []kv
58+type ordered []pair
5759
5860 func (o ordered) MarshalJSON() ([]byte, error) {
5961 var b bytes.Buffer
@@ -228,6 +230,24 @@ func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, u ghURLs, cm doma
228230 }
229231 }
230232
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+
231251 func person(name, email string) map[string]any { return map[string]any{"name": name, "email": email} }
232252
233253 // 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
240260 // 12-character ids: the commit page for a created ref with one commit,
241261 // "<first>^...<after>" for a created ref with several, "<before>...<after>" for
242262 // 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.
243266 func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts) string {
244267 u := c.urls(repo)
245268 id := c.identity(run.Actor)
@@ -259,13 +282,13 @@ func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun,
259282 case f.created:
260283 compare = u.repo + "/commit/" + shortID(f.update.New)
261284 }
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)
265288 }
266289 event := map[string]any{
267290 "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,
269292 "pusher": map[string]any{"name": id.login, "email": ""},
270293 "repository": c.repoObj(repo, u, f.defaultBranch, run.CreatedAt),
271294 "sender": u.userObj(id, false),
@@ -291,22 +314,60 @@ func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun,
291314 return string(b)
292315 }
293316
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+
294338 // 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
300357 }
301358
302359 // jobKeys are the keys of the "job" context that the server knows, in
303360 // GitHub's order (the runner adds "status"). The runner copies the "job" key
304361 // 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+ }
306367 u := c.urls(domain.Repo{Name: run.Repo})
307368 return ordered{
308- {"check_run_id", checkRunID(run, index, combo)},
369+ {"check_run_id", id},
309370 {"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref},
310371 {"workflow_sha", run.Sha}, {"workflow_repository", u.full}, {"workflow_file_path", run.WorkflowPath},
311- }
372+ }, nil
312373 }
@@ -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 string52 k string
51 v any53 v any
52 }54 }
53 55
54 // ordered is a JSON object that keeps the order of its keys, as the github56 // 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 []kv58+type ordered []pair
57 59
58 func (o ordered) MarshalJSON() ([]byte, error) {60 func (o ordered) MarshalJSON() ([]byte, error) {
59 var b bytes.Buffer61 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 push253 // 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>" for261 // "<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 from338 // 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 is339+// 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 JavaScript340+// 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, in359 // 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" key360 // 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
449449 if !plan.HasMatrix {
450450 matrixCtx = expr.Null{} // GitHub: "matrix": null for a job without a matrix
451451 }
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)
453457 if err != nil {
454458 return j, err
455459 }
@@ -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 matrix450 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, err458 return j, err
455 }459 }
M internal/usecase/ci_trigger.go +20 -7
@@ -133,6 +133,9 @@ type pushFacts struct {
133133 isTag bool
134134 // defaultBranch is the default branch of the repository, "" when unknown.
135135 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
136139 }
137140
138141 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
169172 Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount,
170173 }):
171174 default:
175+ if facts.payload == nil {
176+ facts.payload = c.pushPayload(ctx, repo, facts)
177+ }
172178 if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem != "" {
173179 errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem))
174180 continue
@@ -466,13 +472,20 @@ func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, co
466472 return name
467473 }
468474
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.
475485 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+ }
476489 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()}
477490 gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f)))
478491 if err != nil {
@@ -483,7 +496,7 @@ func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f
483496 }}
484497 for _, j := range wf.Jobs {
485498 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.
487500 return wfPath + ": " + err.Error()
488501 }
489502 }
@@ -133,6 +133,9 @@ type pushFacts struct {
133 isTag bool133 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 string135 defaultBranch string
136+ // payload is the commit part of the push event, shared by the workflows
137+ // of the ref; handleRef builds it once, before the first one that matches.
138+ payload *pushPayload
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 continue180 continue
@@ -466,13 +472,20 @@ func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, co
466 return name472 return name
467 }473 }
468 474
469-// matrixProblem evaluates the strategy of the jobs that can be evaluated475+// 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 the476+// "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 file478+// and returns the error text of the first strategy that produces more
473-// (recorded: matrix-over-256). Other strategy errors stay job failures, found479+// 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
55 import (
66 "context"
77
8+ "bnema.dev/ermite-actions/workflow"
89 "bnema.dev/ermite/internal/domain"
910 )
1011
@@ -13,3 +14,13 @@ func AuthorOf(p domain.Principal) string { return authorOf(p) }
1314
1415 // Sweep exposes the daily sweep to the tests.
1516 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
17+
18+// RunTitle exposes runTitle to the tests.
19+func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
20+ return c.runTitle(ctx, wf, githubJSON, commitTitle)
21+}
22+
23+// CheckRunID exposes checkRunID to the tests.
24+func CheckRunID(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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close