detached 6ef9232

Merge pull request 'fix(ci): server-side GitHub Actions parity for tags, run names, invalid runs and needs chains' (#20) from fix/ci-server-parity into main

commit
6ef923292f65b3222f1889f2171c5f30f3cda9c2
author
bnema · · 2026-10-13 04:01 UTC
merge of
76fdffc + 38090f1 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
19 files · +764 -75
M gen/ermite/storage/ci.pb.go +16 -4 generated file: collapsed
M go.mod +2 -2
@@ -12,8 +12,8 @@ tool (
1212 )
1313
1414 require (
15- bnema.dev/ermite-actions v0.0.0-20261013001922-b6a9ba732350
16- bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c
15+ bnema.dev/ermite-actions v0.0.0-20261013033114-44b8f3fa08d1
16+ bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf6
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
1919 connectrpc.com/connect v1.21.0
@@ -12,8 +12,8 @@ tool (
12 )12 )
13 13
14 require (14 require (
15- bnema.dev/ermite-actions v0.0.0-20261013001922-b6a9ba73235015+ bnema.dev/ermite-actions v0.0.0-20261013033114-44b8f3fa08d1
16- bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c16+ bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf6
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c318 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +4 -4 generated file: collapsed
M internal/adapters/gitcli/changed.go +15 -0
@@ -58,3 +58,18 @@ func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head
5858 }
5959 return paths, truncated, nil
6060 }
61+
62+// CommitsOffBranches lists the commits reachable from head and from no branch
63+// (refs/heads/*), newest first, at most limit. It runs
64+// `git log --not --branches --not <head>`: tags do not count, so the commit of
65+// another tag that no branch has is listed.
66+//
67+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
68+func (g *Git) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
69+ if head.IsZero() {
70+ return nil, false, fmt.Errorf("commits off branches: %w: no head", domain.ErrInvalid)
71+ }
72+ // "--not --branches" excludes every branch, the second "--not" turns the
73+ // negation off again for the head that follows.
74+ return g.logCommits(ctx, repo, head, limit, []string{"--not", "--branches", "--not"}, nil)
75+}
@@ -58,3 +58,18 @@ func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head
58 }58 }
59 return paths, truncated, nil59 return paths, truncated, nil
60 }60 }
61+
62+// CommitsOffBranches lists the commits reachable from head and from no branch
63+// (refs/heads/*), newest first, at most limit. It runs
64+// `git log --not --branches --not <head>`: tags do not count, so the commit of
65+// another tag that no branch has is listed.
66+//
67+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
68+func (g *Git) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
69+ if head.IsZero() {
70+ return nil, false, fmt.Errorf("commits off branches: %w: no head", domain.ErrInvalid)
71+ }
72+ // "--not --branches" excludes every branch, the second "--not" turns the
73+ // negation off again for the head that follows.
74+ return g.logCommits(ctx, repo, head, limit, []string{"--not", "--branches", "--not"}, nil)
75+}
M internal/adapters/gitcli/changed_test.go +58 -0
@@ -67,3 +67,61 @@ func TestChangedPathsKeepsAWholeRenameAndOddNames(t *testing.T) {
6767 require.NoError(t, err)
6868 assert.ElementsMatch(t, []string{"c.txt", "dir with space/é.txt"}, got, "both sides of a rename, verbatim")
6969 }
70+
71+func TestCommitsOffBranches(t *testing.T) {
72+ g, data := newGit(t)
73+ ctx := context.Background()
74+ m := newMergeFixture(t, data)
75+ t.Cleanup(func() { _ = g.Close(ctx) })
76+
77+ msgs := func(cs []domain.Commit) []string {
78+ var out []string
79+ for _, c := range cs {
80+ out = append(out, c.Summary)
81+ }
82+ return out
83+ }
84+
85+ // Every commit of the fixture is on main or feat.
86+ got, more, err := g.CommitsOffBranches(ctx, m.f.Name, m.d, 10)
87+ require.NoError(t, err)
88+ assert.Empty(t, got, "feat has its tip")
89+ assert.False(t, more)
90+
91+ // A commit no branch has, on top of feat: listed alone, then with its
92+ // parent when that has no branch either (a branch deleted, a detached tip).
93+ m.f.Git("checkout", "--quiet", "--detach", m.d.String())
94+ m.f.Write("t1.txt", "one\n")
95+ t1 := oidOf(t, m.f.Commit("tagged one"))
96+ m.f.Write("t2.txt", "two\n")
97+ t2 := oidOf(t, m.f.Commit("tagged two"))
98+ m.f.Git("update-ref", "refs/tags/v1", t2.String())
99+
100+ got, more, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 10)
101+ require.NoError(t, err)
102+ assert.Equal(t, []string{"tagged two", "tagged one"}, msgs(got), "newest first, down to the commits a branch has")
103+ assert.False(t, more)
104+ got, _, err = g.CommitsOffBranches(ctx, m.f.Name, t1, 10)
105+ require.NoError(t, err)
106+ assert.Equal(t, []string{"tagged one"}, msgs(got))
107+
108+ // A bound cuts the list and says so.
109+ got, more, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 1)
110+ require.NoError(t, err)
111+ assert.Equal(t, []string{"tagged two"}, msgs(got))
112+ assert.True(t, more)
113+
114+ // Another tag is no branch: t2 stays listed although v1 points to it, and
115+ // moving a branch onto t1 takes t1 out of the list.
116+ m.f.Git("update-ref", "refs/heads/rel", t1.String())
117+ got, _, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 10)
118+ require.NoError(t, err)
119+ assert.Equal(t, []string{"tagged two"}, msgs(got))
120+
121+ _, _, err = g.CommitsOffBranches(ctx, m.f.Name, domain.ObjectID{}, 10)
122+ assert.ErrorIs(t, err, domain.ErrInvalid)
123+ _, _, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 0)
124+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
125+ _, _, err = g.CommitsOffBranches(ctx, name(t, "missing"), t2, 5)
126+ assert.ErrorIs(t, err, domain.ErrNotFound)
127+}
@@ -67,3 +67,61 @@ func TestChangedPathsKeepsAWholeRenameAndOddNames(t *testing.T) {
67 require.NoError(t, err)67 require.NoError(t, err)
68 assert.ElementsMatch(t, []string{"c.txt", "dir with space/é.txt"}, got, "both sides of a rename, verbatim")68 assert.ElementsMatch(t, []string{"c.txt", "dir with space/é.txt"}, got, "both sides of a rename, verbatim")
69 }69 }
70+
71+func TestCommitsOffBranches(t *testing.T) {
72+ g, data := newGit(t)
73+ ctx := context.Background()
74+ m := newMergeFixture(t, data)
75+ t.Cleanup(func() { _ = g.Close(ctx) })
76+
77+ msgs := func(cs []domain.Commit) []string {
78+ var out []string
79+ for _, c := range cs {
80+ out = append(out, c.Summary)
81+ }
82+ return out
83+ }
84+
85+ // Every commit of the fixture is on main or feat.
86+ got, more, err := g.CommitsOffBranches(ctx, m.f.Name, m.d, 10)
87+ require.NoError(t, err)
88+ assert.Empty(t, got, "feat has its tip")
89+ assert.False(t, more)
90+
91+ // A commit no branch has, on top of feat: listed alone, then with its
92+ // parent when that has no branch either (a branch deleted, a detached tip).
93+ m.f.Git("checkout", "--quiet", "--detach", m.d.String())
94+ m.f.Write("t1.txt", "one\n")
95+ t1 := oidOf(t, m.f.Commit("tagged one"))
96+ m.f.Write("t2.txt", "two\n")
97+ t2 := oidOf(t, m.f.Commit("tagged two"))
98+ m.f.Git("update-ref", "refs/tags/v1", t2.String())
99+
100+ got, more, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 10)
101+ require.NoError(t, err)
102+ assert.Equal(t, []string{"tagged two", "tagged one"}, msgs(got), "newest first, down to the commits a branch has")
103+ assert.False(t, more)
104+ got, _, err = g.CommitsOffBranches(ctx, m.f.Name, t1, 10)
105+ require.NoError(t, err)
106+ assert.Equal(t, []string{"tagged one"}, msgs(got))
107+
108+ // A bound cuts the list and says so.
109+ got, more, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 1)
110+ require.NoError(t, err)
111+ assert.Equal(t, []string{"tagged two"}, msgs(got))
112+ assert.True(t, more)
113+
114+ // Another tag is no branch: t2 stays listed although v1 points to it, and
115+ // moving a branch onto t1 takes t1 out of the list.
116+ m.f.Git("update-ref", "refs/heads/rel", t1.String())
117+ got, _, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 10)
118+ require.NoError(t, err)
119+ assert.Equal(t, []string{"tagged two"}, msgs(got))
120+
121+ _, _, err = g.CommitsOffBranches(ctx, m.f.Name, domain.ObjectID{}, 10)
122+ assert.ErrorIs(t, err, domain.ErrInvalid)
123+ _, _, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 0)
124+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
125+ _, _, err = g.CommitsOffBranches(ctx, name(t, "missing"), t2, 5)
126+ assert.ErrorIs(t, err, domain.ErrNotFound)
127+}
M internal/adapters/gitcli/merge.go +17 -4
@@ -132,6 +132,19 @@ func (g *Git) IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, de
132132 //
133133 //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
134134 func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
135+ var excluded []string
136+ if !exclude.IsZero() {
137+ excluded = []string{"^" + exclude.String()}
138+ }
139+ return g.logCommits(ctx, repo, head, limit, nil, excluded)
140+}
141+
142+// logCommits runs `git log` for the commits reachable from head: revArgs go
143+// before the head (options such as --not), excluded after it. Newest first,
144+// at most limit; more says there were others.
145+//
146+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
147+func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) {
135148 if limit < 1 || limit > ports.MaxCommitLimit {
136149 return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit)
137150 }
@@ -139,10 +152,10 @@ func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head
139152 if err != nil {
140153 return nil, false, err
141154 }
142- args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat, "--end-of-options", head.String())
143- if !exclude.IsZero() {
144- args = append(args, "^"+exclude.String())
145- }
155+ args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat)
156+ args = append(args, revArgs...)
157+ args = append(args, "--end-of-options", head.String())
158+ args = append(args, excluded...)
146159 var (
147160 commits []domain.Commit
148161 more bool
@@ -132,6 +132,19 @@ func (g *Git) IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, de
132 //132 //
133 //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port133 //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
134 func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {134 func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
135+ var excluded []string
136+ if !exclude.IsZero() {
137+ excluded = []string{"^" + exclude.String()}
138+ }
139+ return g.logCommits(ctx, repo, head, limit, nil, excluded)
140+}
141+
142+// logCommits runs `git log` for the commits reachable from head: revArgs go
143+// before the head (options such as --not), excluded after it. Newest first,
144+// at most limit; more says there were others.
145+//
146+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
147+func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) {
135 if limit < 1 || limit > ports.MaxCommitLimit {148 if limit < 1 || limit > ports.MaxCommitLimit {
136 return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit)149 return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit)
137 }150 }
@@ -139,10 +152,10 @@ func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head
139 if err != nil {152 if err != nil {
140 return nil, false, err153 return nil, false, err
141 }154 }
142- args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat, "--end-of-options", head.String())155+ args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat)
143- if !exclude.IsZero() {156+ args = append(args, revArgs...)
144- args = append(args, "^"+exclude.String())157+ args = append(args, "--end-of-options", head.String())
145- }158+ args = append(args, excluded...)
146 var (159 var (
147 commits []domain.Commit160 commits []domain.Commit
148 more bool161 more bool
M internal/adapters/kvstore/ci.go +6 -2
@@ -268,7 +268,7 @@ func encodeRun(r domain.CIRun) ([]byte, error) {
268268 }
269269 return mustMarshal(&storagepb.CIRun{
270270 Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,
271- WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Title: r.Title,
271+ WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Name: r.Name, Title: r.Title,
272272 Event: r.Event, Ref: r.Ref, Sha: r.Sha, Actor: r.Actor,
273273 Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error,
274274 CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt),
@@ -285,9 +285,13 @@ func decodeRun(item kv.Item) (domain.CIRun, error) {
285285 if err != nil {
286286 return domain.CIRun{}, fmt.Errorf("decode run: %w", err)
287287 }
288+ name := pb.GetName()
289+ if name == "" {
290+ name = pb.GetWorkflowName() // stored before the field existed
291+ }
288292 return domain.CIRun{
289293 Repo: repo, ID: pb.GetId(), Number: pb.GetNumber(), Attempt: pb.GetAttempt(),
290- WorkflowPath: pb.GetWorkflowPath(), WorkflowName: pb.GetWorkflowName(), Title: pb.GetTitle(),
294+ WorkflowPath: pb.GetWorkflowPath(), WorkflowName: pb.GetWorkflowName(), Name: name, Title: pb.GetTitle(),
291295 Event: pb.GetEvent(), Ref: pb.GetRef(), Sha: pb.GetSha(), Actor: pb.GetActor(),
292296 Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(),
293297 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
@@ -268,7 +268,7 @@ func encodeRun(r domain.CIRun) ([]byte, error) {
268 }268 }
269 return mustMarshal(&storagepb.CIRun{269 return mustMarshal(&storagepb.CIRun{
270 Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,270 Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,
271- WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Title: r.Title,271+ WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Name: r.Name, Title: r.Title,
272 Event: r.Event, Ref: r.Ref, Sha: r.Sha, Actor: r.Actor,272 Event: r.Event, Ref: r.Ref, Sha: r.Sha, Actor: r.Actor,
273 Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error,273 Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error,
274 CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt),274 CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt),
@@ -285,9 +285,13 @@ func decodeRun(item kv.Item) (domain.CIRun, error) {
285 if err != nil {285 if err != nil {
286 return domain.CIRun{}, fmt.Errorf("decode run: %w", err)286 return domain.CIRun{}, fmt.Errorf("decode run: %w", err)
287 }287 }
288+ name := pb.GetName()
289+ if name == "" {
290+ name = pb.GetWorkflowName() // stored before the field existed
291+ }
288 return domain.CIRun{292 return domain.CIRun{
289 Repo: repo, ID: pb.GetId(), Number: pb.GetNumber(), Attempt: pb.GetAttempt(),293 Repo: repo, ID: pb.GetId(), Number: pb.GetNumber(), Attempt: pb.GetAttempt(),
290- WorkflowPath: pb.GetWorkflowPath(), WorkflowName: pb.GetWorkflowName(), Title: pb.GetTitle(),294+ WorkflowPath: pb.GetWorkflowPath(), WorkflowName: pb.GetWorkflowName(), Name: name, Title: pb.GetTitle(),
291 Event: pb.GetEvent(), Ref: pb.GetRef(), Sha: pb.GetSha(), Actor: pb.GetActor(),295 Event: pb.GetEvent(), Ref: pb.GetRef(), Sha: pb.GetSha(), Actor: pb.GetActor(),
292 Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(),296 Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(),
293 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),297 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
M internal/adapters/kvstore/ci_test.go +25 -0
@@ -373,3 +373,28 @@ func TestCICancelReasonsRoundTrip(t *testing.T) {
373373 assert.Equal(t, reason, got.CancelReason)
374374 }
375375 }
376+
377+// Run.Name round-trips, and a run stored without one (before the field existed)
378+// reads as its workflow name.
379+func TestCIRunNameRoundTripsAndDefaultsToTheWorkflowName(t *testing.T) {
380+ ctx := context.Background()
381+ _, ci, n := ciRepo(t)
382+
383+ old, err := ci.CreateRun(ctx, n, ".github/workflows/ci.yml", draftOf(n, ".github/workflows/ci.yml", 0))
384+ require.NoError(t, err)
385+ got, err := ci.GetRun(ctx, n, old.Run.ID)
386+ require.NoError(t, err)
387+ assert.Equal(t, "ci", got.WorkflowName)
388+ assert.Equal(t, "ci", got.Name, "no stored name: the workflow name")
389+
390+ named, err := ci.CreateRun(ctx, n, ".github/workflows/ci.yml", func(id, number int64) ports.CIRunDraft {
391+ d := draftOf(n, ".github/workflows/ci.yml", 0)(id, number)
392+ d.Run.Name = "run push on branch by someone"
393+ return d
394+ })
395+ require.NoError(t, err)
396+ got, err = ci.GetRun(ctx, n, named.Run.ID)
397+ require.NoError(t, err)
398+ assert.Equal(t, "run push on branch by someone", got.Name)
399+ assert.Equal(t, "ci", got.WorkflowName, "the workflow name is unchanged")
400+}
@@ -373,3 +373,28 @@ func TestCICancelReasonsRoundTrip(t *testing.T) {
373 assert.Equal(t, reason, got.CancelReason)373 assert.Equal(t, reason, got.CancelReason)
374 }374 }
375 }375 }
376+
377+// Run.Name round-trips, and a run stored without one (before the field existed)
378+// reads as its workflow name.
379+func TestCIRunNameRoundTripsAndDefaultsToTheWorkflowName(t *testing.T) {
380+ ctx := context.Background()
381+ _, ci, n := ciRepo(t)
382+
383+ old, err := ci.CreateRun(ctx, n, ".github/workflows/ci.yml", draftOf(n, ".github/workflows/ci.yml", 0))
384+ require.NoError(t, err)
385+ got, err := ci.GetRun(ctx, n, old.Run.ID)
386+ require.NoError(t, err)
387+ assert.Equal(t, "ci", got.WorkflowName)
388+ assert.Equal(t, "ci", got.Name, "no stored name: the workflow name")
389+
390+ named, err := ci.CreateRun(ctx, n, ".github/workflows/ci.yml", func(id, number int64) ports.CIRunDraft {
391+ d := draftOf(n, ".github/workflows/ci.yml", 0)(id, number)
392+ d.Run.Name = "run push on branch by someone"
393+ return d
394+ })
395+ require.NoError(t, err)
396+ got, err = ci.GetRun(ctx, n, named.Run.ID)
397+ require.NoError(t, err)
398+ assert.Equal(t, "run push on branch by someone", got.Name)
399+ assert.Equal(t, "ci", got.WorkflowName, "the workflow name is unchanged")
400+}
M internal/adapters/rpc/ci_convert.go +10 -1
@@ -130,10 +130,19 @@ func cancelReasonToPB(r domain.CICancelReason) pb.CancelReason {
130130 return pb.CancelReason_CANCEL_REASON_UNSPECIFIED
131131 }
132132
133+// runName is Run.name: the name of the run, the workflow name for a run that
134+// has none of its own.
135+func runName(r domain.CIRun) string {
136+ if r.Name == "" {
137+ return r.WorkflowName
138+ }
139+ return r.Name
140+}
141+
133142 func runToPB(r domain.CIRun) *pb.Run {
134143 return &pb.Run{
135144 Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,
136- WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Title: r.Title, Event: r.Event,
145+ WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Name: runName(r), Title: r.Title, Event: r.Event,
137146 Ref: r.Ref, Sha: r.Sha, Actor: r.Actor, Status: statusToPB(r.Status), Conclusion: conclusionToPB(r.Conclusion),
138147 Error: r.Error, CreatedAt: timestamp(r.CreatedAt), StartedAt: timestamp(r.StartedAt), CompletedAt: timestamp(r.CompletedAt),
139148 Version: int64(r.Version), //nolint:gosec // a store sequence, far below 2^63
@@ -130,10 +130,19 @@ func cancelReasonToPB(r domain.CICancelReason) pb.CancelReason {
130 return pb.CancelReason_CANCEL_REASON_UNSPECIFIED130 return pb.CancelReason_CANCEL_REASON_UNSPECIFIED
131 }131 }
132 132
133+// runName is Run.name: the name of the run, the workflow name for a run that
134+// has none of its own.
135+func runName(r domain.CIRun) string {
136+ if r.Name == "" {
137+ return r.WorkflowName
138+ }
139+ return r.Name
140+}
141+
133 func runToPB(r domain.CIRun) *pb.Run {142 func runToPB(r domain.CIRun) *pb.Run {
134 return &pb.Run{143 return &pb.Run{
135 Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,144 Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,
136- WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Title: r.Title, Event: r.Event,145+ WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Name: runName(r), Title: r.Title, Event: r.Event,
137 Ref: r.Ref, Sha: r.Sha, Actor: r.Actor, Status: statusToPB(r.Status), Conclusion: conclusionToPB(r.Conclusion),146 Ref: r.Ref, Sha: r.Sha, Actor: r.Actor, Status: statusToPB(r.Status), Conclusion: conclusionToPB(r.Conclusion),
138 Error: r.Error, CreatedAt: timestamp(r.CreatedAt), StartedAt: timestamp(r.StartedAt), CompletedAt: timestamp(r.CompletedAt),147 Error: r.Error, CreatedAt: timestamp(r.CreatedAt), StartedAt: timestamp(r.StartedAt), CompletedAt: timestamp(r.CompletedAt),
139 Version: int64(r.Version), //nolint:gosec // a store sequence, far below 2^63148 Version: int64(r.Version), //nolint:gosec // a store sequence, far below 2^63
M internal/app/ci_parity_test.go +330 -5
@@ -15,6 +15,7 @@ import (
1515 "github.com/stretchr/testify/require"
1616
1717 pb "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/domain"
1819 )
1920
2021 // conformance is testdata/conformance/ctx-job-runner.contexts.json: the
@@ -219,7 +220,10 @@ func jsonNumber(v any) string {
219220 return string(b)
220221 }
221222
222-// The run title is the evaluated run-name, else the head commit message.
223+// The run title is the evaluated run-name, else the head commit message. The
224+// name of the run is the evaluated run-name too (recorded: ctx-github-names,
225+// name = title = the run-name), else the workflow name; github.workflow stays
226+// the workflow name.
223227 func TestCIRunTitle(t *testing.T) {
224228 c := newCIHarness(t, nil)
225229 c.registerRunner("alpha", "ubuntu-latest")
@@ -233,14 +237,24 @@ jobs:
233237 - run: echo hi
234238 `, ".github/workflows/plain.yml": workflowWith("branches: [main]")}, "the head commit\n\nbody")
235239 c.waitFor("two runs", func() bool { return len(c.runs()) == 2 })
236- titles := map[string]string{}
240+ titles, names, workflows := map[string]string{}, map[string]string{}, map[string]string{}
237241 for _, r := range c.runs() {
238242 titles[r.GetWorkflowPath()] = r.GetTitle()
243+ names[r.GetWorkflowPath()] = r.GetName()
244+ workflows[r.GetWorkflowPath()] = r.GetWorkflowName()
239245 }
240246 assert.Equal(t, map[string]string{
241247 ".github/workflows/named.yml": "run push on branch by someone",
242248 ".github/workflows/plain.yml": "the head commit",
243249 }, titles)
250+ assert.Equal(t, map[string]string{
251+ ".github/workflows/named.yml": "run push on branch by someone",
252+ ".github/workflows/plain.yml": ".github/workflows/plain.yml",
253+ }, names, "the run-name, else the workflow name")
254+ assert.Equal(t, map[string]string{
255+ ".github/workflows/named.yml": "Named",
256+ ".github/workflows/plain.yml": ".github/workflows/plain.yml",
257+ }, workflows, "workflow_name is unchanged by a run-name")
244258 }
245259
246260 // An over-256 matrix is an invalid workflow, like on github.com
@@ -270,6 +284,7 @@ jobs:
270284 run := c.getRun(c.runs()[0].GetId())
271285 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run.GetRun().GetConclusion())
272286 assert.Empty(t, run.GetJobs(), "no job")
287+ assert.Equal(t, ".github/workflows/m.yml", run.GetRun().GetWorkflowName(), "no name: in this file: the path")
273288 assert.Contains(t, run.GetRun().GetError(), "Strategy for job 'too-many' produced 272 configurations which exceeds the maximum of 256 configurations")
274289 }
275290
@@ -435,9 +450,9 @@ jobs:
435450 assert.Equal(t, "Display name of", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetName())
436451 }
437452
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).
453+// A tag push carries the commits that no branch has, newest as head_commit, and
454+// the compare URL of several commits is "<first>^...<last>" (recorded:
455+// skip-directive-tag, where no branch exists).
441456 func TestCITagEventCarriesTheNewCommits(t *testing.T) {
442457 c := newCIHarness(t, nil)
443458 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -465,3 +480,313 @@ func TestCITagEventCarriesTheNewCommits(t *testing.T) {
465480 assert.Equal(t, "tag", ctxs["github"]["ref_type"])
466481 assert.Equal(t, true, ev["created"])
467482 }
483+
484+// tagEvent pushes the tag and returns the push event and the title of the run
485+// it starts, after `want` runs exist.
486+func (c *ciHarness) tagEvent(runner *fakeRunner, tag string, want int) (map[string]any, string) {
487+ c.t.Helper()
488+ c.mustGit(c.wt, "tag", tag)
489+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), tag)
490+ job := runner.await()
491+ var ctxs map[string]map[string]any
492+ require.NoError(c.t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
493+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
494+ c.waitFor("the run exists", func() bool { return len(c.runs()) == want })
495+ var title string
496+ for _, r := range c.runs() {
497+ if r.GetRef() == "refs/tags/"+tag {
498+ title = r.GetTitle()
499+ }
500+ }
501+ return ctxs["github"]["event"].(map[string]any), title
502+}
503+
504+func eventMessages(ev map[string]any) []string {
505+ var msgs []string
506+ for _, cm := range ev["commits"].([]any) {
507+ msgs = append(msgs, cm.(map[string]any)["message"].(string))
508+ }
509+ return msgs
510+}
511+
512+// A tag on a commit that a branch has carries that commit alone, and the
513+// compare URL is its page, not a range from the previous push (recorded:
514+// filter-tags, filter-paths-tag, filter-branches-ignore). The tag run is
515+// titled with the workflow name (filter-*).
516+func TestCITagEventOfACommitABranchHas(t *testing.T) {
517+ c := newCIHarness(t, nil)
518+ runner := c.registerRunner("alpha", "ubuntu-latest")
519+ // Two commits on main, pushed with the workflow; both are on a branch.
520+ c.push("main", map[string]string{".github/workflows/rel.yml": "name: Release\non:\n push:\n tags: ['v*']\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
521+ older := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
522+ c.writeFile(c.wt, "x.txt", []byte("x\n"))
523+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main")
524+ newer := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
525+
526+ ev, title := c.tagEvent(runner, "v1", 1)
527+ assert.Equal(t, []string{"add x.txt"}, eventMessages(ev), "the tagged commit alone, not the push range")
528+ assert.Equal(t, newer, ev["head_commit"].(map[string]any)["id"])
529+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+newer[:12], ev["compare"])
530+ assert.Equal(t, "Release", title, "a tag run is titled with the workflow name, not the commit message")
531+
532+ // A tag on an older commit of the branch: that commit alone.
533+ c.mustGit(c.wt, "tag", "v0", older)
534+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v0")
535+ job := runner.await()
536+ var ctxs map[string]map[string]any
537+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
538+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
539+ ev = ctxs["github"]["event"].(map[string]any)
540+ assert.Equal(t, older, ev["after"])
541+ assert.Equal(t, older, ev["head_commit"].(map[string]any)["id"])
542+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+older[:12], ev["compare"])
543+}
544+
545+// A tag on a new commit on top of the branch (recorded: push-tag) carries that
546+// commit alone: the commit of the branch under it is not part of the event.
547+func TestCITagEventOfANewCommitOnTopOfABranch(t *testing.T) {
548+ c := newCIHarness(t, nil)
549+ runner := c.registerRunner("alpha", "ubuntu-latest")
550+ c.push("main", map[string]string{".github/workflows/ci.yml": "on:\n push:\n branches: [main]\n tags: ['v*']\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "branch commit")
551+ first := runner.await()
552+ runner.runJob(first, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
553+ c.waitFor("the branch run ends", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
554+ assert.Equal(t, "branch commit", c.runs()[0].GetTitle(), "a branch run keeps the commit message as title")
555+
556+ c.writeFile(c.wt, "t.txt", []byte("t\n")) // "add t.txt", only the tag is pushed
557+ tagged := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
558+ ev, title := c.tagEvent(runner, "v1", 2)
559+ assert.Equal(t, []string{"add t.txt"}, eventMessages(ev))
560+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+tagged[:12], ev["compare"])
561+ assert.Equal(t, ".github/workflows/ci.yml", title, "no name:, so the workflow name is the path of the file (assumed, not recorded)")
562+ assert.Equal(t, true, ev["created"])
563+ assert.Equal(t, domain.ZeroOID, ev["before"])
564+}
565+
566+// Several tags in one push start one run each, with the same event: the
567+// commit alone (recorded: push-tags-limit), titled with the workflow name.
568+func TestCIThreeTagsInOnePush(t *testing.T) {
569+ c := newCIHarness(t, nil)
570+ runner := c.registerRunner("alpha", "ubuntu-latest")
571+ c.push("main", map[string]string{".github/workflows/t.yml": "name: Tags\non:\n push:\n tags: ['v*']\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
572+ c.writeFile(c.wt, "t.txt", []byte("t\n"))
573+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
574+ for _, tag := range []string{"v1", "v2", "v3"} {
575+ c.mustGit(c.wt, "tag", tag)
576+ }
577+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1", "v2", "v3")
578+ for range 3 {
579+ job := runner.await()
580+ var ctxs map[string]map[string]any
581+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
582+ ev := ctxs["github"]["event"].(map[string]any)
583+ assert.Equal(t, []string{"add t.txt"}, eventMessages(ev))
584+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+sha[:12], ev["compare"])
585+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
586+ }
587+ c.waitFor("three runs", func() bool { return len(c.runs()) == 3 })
588+ for _, r := range c.runs() {
589+ assert.Equal(t, "Tags", r.GetTitle())
590+ }
591+}
592+
593+// A tag run without run-name is titled with the name of the workflow registered
594+// for its path: the "name:" of the file on the default branch, which can differ
595+// from the file at the tag (recorded: push-tag, push-tags-limit, push-delete-tag
596+// and skip-directive-tag, whose ci.yml is "push-branch-create" on the default
597+// branch of the recording repository and has the scenario's own name at the
598+// tag; and the files with a unique name, where both agree). Its name stays the
599+// workflow name of the tagged file.
600+func TestCITagTitleIsTheNameRegisteredOnTheDefaultBranch(t *testing.T) {
601+ c := newCIHarness(t, nil)
602+ runner := c.registerRunner("alpha", "ubuntu-latest")
603+ wf := func(name string) map[string]string {
604+ return map[string]string{".github/workflows/ci.yml": "name: " + name + "\non:\n push:\n tags: ['v*']\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}
605+ }
606+ c.push("main", wf("registered"), "")
607+ // The tagged commit is off every branch and renames the workflow.
608+ c.mustGit(c.wt, "checkout", "-q", "--detach")
609+ c.writeFile(c.wt, ".github/workflows/ci.yml", []byte(wf("renamed")[".github/workflows/ci.yml"]))
610+ _, title := c.tagEvent(runner, "v1", 1)
611+ assert.Equal(t, "registered", title, "the name of ci.yml on the default branch")
612+ r := c.runs()[0]
613+ assert.Equal(t, "renamed", r.GetWorkflowName(), "the workflow of the run is the file at the tag (github.workflow)")
614+ assert.Equal(t, "renamed", r.GetName(), "and so is the name of the run, which has no run-name")
615+
616+ // A file the default branch does not have: the run's own workflow name.
617+ c.writeFile(c.wt, ".github/workflows/other.yml", []byte("name: other\non:\n push:\n tags: ['w*']\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"))
618+ _, title = c.tagEvent(runner, "w1", 2)
619+ assert.Equal(t, "other", title)
620+}
621+
622+// A tag run with a run-name keeps the evaluated run-name as its title (the
623+// run-name of a tag push is not recorded; branch pushes are, ctx-github-names).
624+func TestCITagRunKeepsItsRunName(t *testing.T) {
625+ c := newCIHarness(t, nil)
626+ runner := c.registerRunner("alpha", "ubuntu-latest")
627+ c.push("main", map[string]string{".github/workflows/t.yml": "name: Tags\nrun-name: \"release ${{ github.ref_name }}\"\non:\n push:\n tags: ['v*']\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
628+ _, title := c.tagEvent(runner, "v1", 1)
629+ assert.Equal(t, "release v1", title)
630+}
631+
632+// An exclude entry with a key that is no key of the matrix makes the workflow
633+// invalid, and the run is named after the file (recorded: matrix-edge-values,
634+// a-exclude-unknown-key.yml: name = path, no job), while an over-256 matrix
635+// keeps the name of the workflow (matrix-over-256).
636+func TestCIInvalidStrategyRunNames(t *testing.T) {
637+ c := newCIHarness(t, nil)
638+ c.registerRunner("alpha", "ubuntu-latest")
639+ var a, b []string
640+ for i := 1; i <= 17; i++ {
641+ a = append(a, itoa(i))
642+ }
643+ for i := 1; i <= 16; i++ {
644+ b = append(b, itoa(i))
645+ }
646+ c.push("main", map[string]string{
647+ ".github/workflows/over.yml": `name: matrix-over-256
648+on: push
649+jobs:
650+ too-many:
651+ runs-on: ubuntu-latest
652+ strategy:
653+ matrix:
654+ a: [` + strings.Join(a, ", ") + `]
655+ b: [` + strings.Join(b, ", ") + `]
656+ steps:
657+ - run: echo
658+`,
659+ ".github/workflows/exclude.yml": `name: a-exclude-unknown-key
660+on: push
661+jobs:
662+ main:
663+ runs-on: ubuntu-latest
664+ strategy:
665+ matrix:
666+ os: [one, two]
667+ exclude:
668+ - nokey: x
669+ - os: one
670+ nokey: y
671+ steps:
672+ - run: echo "os=${{ matrix.os }}"
673+`,
674+ ".github/workflows/noname.yml": `on: push
675+jobs:
676+ main:
677+ runs-on: ubuntu-latest
678+ strategy:
679+ matrix:
680+ os: [one]
681+ exclude:
682+ - nokey: x
683+ steps:
684+ - run: echo
685+`}, "")
686+ c.waitFor("three failed runs", func() bool {
687+ rs := c.runs()
688+ if len(rs) != 3 {
689+ return false
690+ }
691+ for _, r := range rs {
692+ if r.GetStatus() != pb.CiStatus_CI_STATUS_COMPLETED {
693+ return false
694+ }
695+ }
696+ return true
697+ })
698+ names := map[string]string{}
699+ for _, r := range c.runs() {
700+ names[r.GetWorkflowPath()] = r.GetWorkflowName()
701+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, r.GetConclusion(), r.GetWorkflowPath())
702+ assert.Empty(t, c.getRun(r.GetId()).GetJobs(), "no job: %s", r.GetWorkflowPath())
703+ if r.GetWorkflowPath() == ".github/workflows/exclude.yml" {
704+ one := ".github/workflows/exclude.yml: Matrix exclude key 'nokey' does not match any key within the matrix"
705+ assert.Equal(t, one+"\n"+one, r.GetError(), "once per offending entry")
706+ }
707+ }
708+ assert.Equal(t, map[string]string{
709+ ".github/workflows/over.yml": "matrix-over-256",
710+ ".github/workflows/exclude.yml": ".github/workflows/exclude.yml",
711+ ".github/workflows/noname.yml": ".github/workflows/noname.yml",
712+ }, names)
713+ // The check of a failed run carries the same workflow name.
714+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
715+ checkNames := map[string]string{}
716+ for _, ck := range c.checks(sha) {
717+ checkNames[ck.GetName()] = ck.GetWorkflowName()
718+ }
719+ assert.Equal(t, "matrix-over-256", checkNames[".github/workflows/over.yml"])
720+ assert.Equal(t, ".github/workflows/exclude.yml", checkNames[".github/workflows/exclude.yml"])
721+}
722+
723+// The default success() of a job looks at the whole chain of its needs, not
724+// only at the direct ones (recorded: needs-failed-dependency): chain-after-always
725+// needs with-always, which ran through always() after broken failed, and is
726+// skipped. with-always and with-failure still run.
727+func TestCIDefaultSuccessLooksAtTheWholeChain(t *testing.T) {
728+ c := newCIHarness(t, nil)
729+ runner := c.registerRunner("alpha", "ubuntu-latest")
730+ c.push("main", map[string]string{".github/workflows/n.yml": `name: needs-failed-dependency
731+on: push
732+jobs:
733+ broken:
734+ runs-on: ubuntu-latest
735+ steps:
736+ - run: exit 1
737+ plain:
738+ needs: broken
739+ runs-on: ubuntu-latest
740+ steps:
741+ - run: echo SHOULD-NOT-RUN
742+ with-always:
743+ needs: broken
744+ if: always()
745+ runs-on: ubuntu-latest
746+ steps:
747+ - run: echo "result=${{ needs.broken.result }}"
748+ with-failure:
749+ needs: broken
750+ if: failure()
751+ runs-on: ubuntu-latest
752+ steps:
753+ - run: echo "result=${{ needs.broken.result }}"
754+ chain-after-always:
755+ needs: with-always
756+ runs-on: ubuntu-latest
757+ steps:
758+ - run: echo chain-after-always-runs-because-with-always-succeeded
759+ chain-with-failure:
760+ needs: with-always
761+ if: failure()
762+ runs-on: ubuntu-latest
763+ steps:
764+ - run: echo "failure() is true when an ancestor failed"
765+ chain-always:
766+ needs: with-always
767+ if: always()
768+ runs-on: ubuntu-latest
769+ steps:
770+ - run: echo always
771+`}, "")
772+ broken := runner.await()
773+ assert.Equal(t, "broken", broken.GetJobKey())
774+ runner.runJob(broken, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x")
775+ ran := map[string]bool{}
776+ for range 4 {
777+ j := runner.await()
778+ ran[j.GetJobKey()] = true
779+ runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
780+ }
781+ assert.Equal(t, map[string]bool{"with-always": true, "with-failure": true, "chain-with-failure": true, "chain-always": true}, ran)
782+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
783+ got := map[string]pb.CiConclusion{}
784+ for _, j := range c.getRun(c.runs()[0].GetId()).GetJobs() {
785+ got[j.GetName()] = j.GetConclusion()
786+ }
787+ skipped, ok := pb.CiConclusion_CI_CONCLUSION_SKIPPED, pb.CiConclusion_CI_CONCLUSION_SUCCESS
788+ assert.Equal(t, map[string]pb.CiConclusion{
789+ "broken": pb.CiConclusion_CI_CONCLUSION_FAILURE, "plain": skipped, "with-always": ok, "with-failure": ok,
790+ "chain-after-always": skipped, "chain-with-failure": ok, "chain-always": ok,
791+ }, got)
792+}
@@ -15,6 +15,7 @@ import (
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16 16
17 pb "bnema.dev/ermite-api/gen/ermite"17 pb "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/domain"
18 )19 )
19 20
20 // conformance is testdata/conformance/ctx-job-runner.contexts.json: the21 // conformance is testdata/conformance/ctx-job-runner.contexts.json: the
@@ -219,7 +220,10 @@ func jsonNumber(v any) string {
219 return string(b)220 return string(b)
220 }221 }
221 222
222-// The run title is the evaluated run-name, else the head commit message.223+// The run title is the evaluated run-name, else the head commit message. The
224+// name of the run is the evaluated run-name too (recorded: ctx-github-names,
225+// name = title = the run-name), else the workflow name; github.workflow stays
226+// the workflow name.
223 func TestCIRunTitle(t *testing.T) {227 func TestCIRunTitle(t *testing.T) {
224 c := newCIHarness(t, nil)228 c := newCIHarness(t, nil)
225 c.registerRunner("alpha", "ubuntu-latest")229 c.registerRunner("alpha", "ubuntu-latest")
@@ -233,14 +237,24 @@ jobs:
233 - run: echo hi237 - run: echo hi
234 `, ".github/workflows/plain.yml": workflowWith("branches: [main]")}, "the head commit\n\nbody")238 `, ".github/workflows/plain.yml": workflowWith("branches: [main]")}, "the head commit\n\nbody")
235 c.waitFor("two runs", func() bool { return len(c.runs()) == 2 })239 c.waitFor("two runs", func() bool { return len(c.runs()) == 2 })
236- titles := map[string]string{}240+ titles, names, workflows := map[string]string{}, map[string]string{}, map[string]string{}
237 for _, r := range c.runs() {241 for _, r := range c.runs() {
238 titles[r.GetWorkflowPath()] = r.GetTitle()242 titles[r.GetWorkflowPath()] = r.GetTitle()
243+ names[r.GetWorkflowPath()] = r.GetName()
244+ workflows[r.GetWorkflowPath()] = r.GetWorkflowName()
239 }245 }
240 assert.Equal(t, map[string]string{246 assert.Equal(t, map[string]string{
241 ".github/workflows/named.yml": "run push on branch by someone",247 ".github/workflows/named.yml": "run push on branch by someone",
242 ".github/workflows/plain.yml": "the head commit",248 ".github/workflows/plain.yml": "the head commit",
243 }, titles)249 }, titles)
250+ assert.Equal(t, map[string]string{
251+ ".github/workflows/named.yml": "run push on branch by someone",
252+ ".github/workflows/plain.yml": ".github/workflows/plain.yml",
253+ }, names, "the run-name, else the workflow name")
254+ assert.Equal(t, map[string]string{
255+ ".github/workflows/named.yml": "Named",
256+ ".github/workflows/plain.yml": ".github/workflows/plain.yml",
257+ }, workflows, "workflow_name is unchanged by a run-name")
244 }258 }
245 259
246 // An over-256 matrix is an invalid workflow, like on github.com260 // An over-256 matrix is an invalid workflow, like on github.com
@@ -270,6 +284,7 @@ jobs:
270 run := c.getRun(c.runs()[0].GetId())284 run := c.getRun(c.runs()[0].GetId())
271 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run.GetRun().GetConclusion())285 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run.GetRun().GetConclusion())
272 assert.Empty(t, run.GetJobs(), "no job")286 assert.Empty(t, run.GetJobs(), "no job")
287+ assert.Equal(t, ".github/workflows/m.yml", run.GetRun().GetWorkflowName(), "no name: in this file: the path")
273 assert.Contains(t, run.GetRun().GetError(), "Strategy for job 'too-many' produced 272 configurations which exceeds the maximum of 256 configurations")288 assert.Contains(t, run.GetRun().GetError(), "Strategy for job 'too-many' produced 272 configurations which exceeds the maximum of 256 configurations")
274 }289 }
275 290
@@ -435,9 +450,9 @@ jobs:
435 assert.Equal(t, "Display name of", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetName())450 assert.Equal(t, "Display name of", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetName())
436 }451 }
437 452
438-// A tag push carries, like a created branch, the commits the default branch453+// A tag push carries the commits that no branch has, newest as head_commit, and
439-// lacks, newest as head_commit, and the compare URL of several commits is454+// the compare URL of several commits is "<first>^...<last>" (recorded:
440-// "<first>^...<last>" (recorded: push-tag, skip-directive-tag).455+// skip-directive-tag, where no branch exists).
441 func TestCITagEventCarriesTheNewCommits(t *testing.T) {456 func TestCITagEventCarriesTheNewCommits(t *testing.T) {
442 c := newCIHarness(t, nil)457 c := newCIHarness(t, nil)
443 runner := c.registerRunner("alpha", "ubuntu-latest")458 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -465,3 +480,313 @@ func TestCITagEventCarriesTheNewCommits(t *testing.T) {
465 assert.Equal(t, "tag", ctxs["github"]["ref_type"])480 assert.Equal(t, "tag", ctxs["github"]["ref_type"])
466 assert.Equal(t, true, ev["created"])481 assert.Equal(t, true, ev["created"])
467 }482 }
483+
484+// tagEvent pushes the tag and returns the push event and the title of the run
485+// it starts, after `want` runs exist.
486+func (c *ciHarness) tagEvent(runner *fakeRunner, tag string, want int) (map[string]any, string) {
487+ c.t.Helper()
488+ c.mustGit(c.wt, "tag", tag)
489+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), tag)
490+ job := runner.await()
491+ var ctxs map[string]map[string]any
492+ require.NoError(c.t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
493+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
494+ c.waitFor("the run exists", func() bool { return len(c.runs()) == want })
495+ var title string
496+ for _, r := range c.runs() {
497+ if r.GetRef() == "refs/tags/"+tag {
498+ title = r.GetTitle()
499+ }
500+ }
501+ return ctxs["github"]["event"].(map[string]any), title
502+}
503+
504+func eventMessages(ev map[string]any) []string {
505+ var msgs []string
506+ for _, cm := range ev["commits"].([]any) {
507+ msgs = append(msgs, cm.(map[string]any)["message"].(string))
508+ }
509+ return msgs
510+}
511+
512+// A tag on a commit that a branch has carries that commit alone, and the
513+// compare URL is its page, not a range from the previous push (recorded:
514+// filter-tags, filter-paths-tag, filter-branches-ignore). The tag run is
515+// titled with the workflow name (filter-*).
516+func TestCITagEventOfACommitABranchHas(t *testing.T) {
517+ c := newCIHarness(t, nil)
518+ runner := c.registerRunner("alpha", "ubuntu-latest")
519+ // Two commits on main, pushed with the workflow; both are on a branch.
520+ c.push("main", map[string]string{".github/workflows/rel.yml": "name: Release\non:\n push:\n tags: ['v*']\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
521+ older := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
522+ c.writeFile(c.wt, "x.txt", []byte("x\n"))
523+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main")
524+ newer := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
525+
526+ ev, title := c.tagEvent(runner, "v1", 1)
527+ assert.Equal(t, []string{"add x.txt"}, eventMessages(ev), "the tagged commit alone, not the push range")
528+ assert.Equal(t, newer, ev["head_commit"].(map[string]any)["id"])
529+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+newer[:12], ev["compare"])
530+ assert.Equal(t, "Release", title, "a tag run is titled with the workflow name, not the commit message")
531+
532+ // A tag on an older commit of the branch: that commit alone.
533+ c.mustGit(c.wt, "tag", "v0", older)
534+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v0")
535+ job := runner.await()
536+ var ctxs map[string]map[string]any
537+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
538+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
539+ ev = ctxs["github"]["event"].(map[string]any)
540+ assert.Equal(t, older, ev["after"])
541+ assert.Equal(t, older, ev["head_commit"].(map[string]any)["id"])
542+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+older[:12], ev["compare"])
543+}
544+
545+// A tag on a new commit on top of the branch (recorded: push-tag) carries that
546+// commit alone: the commit of the branch under it is not part of the event.
547+func TestCITagEventOfANewCommitOnTopOfABranch(t *testing.T) {
548+ c := newCIHarness(t, nil)
549+ runner := c.registerRunner("alpha", "ubuntu-latest")
550+ c.push("main", map[string]string{".github/workflows/ci.yml": "on:\n push:\n branches: [main]\n tags: ['v*']\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "branch commit")
551+ first := runner.await()
552+ runner.runJob(first, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
553+ c.waitFor("the branch run ends", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
554+ assert.Equal(t, "branch commit", c.runs()[0].GetTitle(), "a branch run keeps the commit message as title")
555+
556+ c.writeFile(c.wt, "t.txt", []byte("t\n")) // "add t.txt", only the tag is pushed
557+ tagged := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
558+ ev, title := c.tagEvent(runner, "v1", 2)
559+ assert.Equal(t, []string{"add t.txt"}, eventMessages(ev))
560+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+tagged[:12], ev["compare"])
561+ assert.Equal(t, ".github/workflows/ci.yml", title, "no name:, so the workflow name is the path of the file (assumed, not recorded)")
562+ assert.Equal(t, true, ev["created"])
563+ assert.Equal(t, domain.ZeroOID, ev["before"])
564+}
565+
566+// Several tags in one push start one run each, with the same event: the
567+// commit alone (recorded: push-tags-limit), titled with the workflow name.
568+func TestCIThreeTagsInOnePush(t *testing.T) {
569+ c := newCIHarness(t, nil)
570+ runner := c.registerRunner("alpha", "ubuntu-latest")
571+ c.push("main", map[string]string{".github/workflows/t.yml": "name: Tags\non:\n push:\n tags: ['v*']\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
572+ c.writeFile(c.wt, "t.txt", []byte("t\n"))
573+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
574+ for _, tag := range []string{"v1", "v2", "v3"} {
575+ c.mustGit(c.wt, "tag", tag)
576+ }
577+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1", "v2", "v3")
578+ for range 3 {
579+ job := runner.await()
580+ var ctxs map[string]map[string]any
581+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
582+ ev := ctxs["github"]["event"].(map[string]any)
583+ assert.Equal(t, []string{"add t.txt"}, eventMessages(ev))
584+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+sha[:12], ev["compare"])
585+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
586+ }
587+ c.waitFor("three runs", func() bool { return len(c.runs()) == 3 })
588+ for _, r := range c.runs() {
589+ assert.Equal(t, "Tags", r.GetTitle())
590+ }
591+}
592+
593+// A tag run without run-name is titled with the name of the workflow registered
594+// for its path: the "name:" of the file on the default branch, which can differ
595+// from the file at the tag (recorded: push-tag, push-tags-limit, push-delete-tag
596+// and skip-directive-tag, whose ci.yml is "push-branch-create" on the default
597+// branch of the recording repository and has the scenario's own name at the
598+// tag; and the files with a unique name, where both agree). Its name stays the
599+// workflow name of the tagged file.
600+func TestCITagTitleIsTheNameRegisteredOnTheDefaultBranch(t *testing.T) {
601+ c := newCIHarness(t, nil)
602+ runner := c.registerRunner("alpha", "ubuntu-latest")
603+ wf := func(name string) map[string]string {
604+ return map[string]string{".github/workflows/ci.yml": "name: " + name + "\non:\n push:\n tags: ['v*']\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}
605+ }
606+ c.push("main", wf("registered"), "")
607+ // The tagged commit is off every branch and renames the workflow.
608+ c.mustGit(c.wt, "checkout", "-q", "--detach")
609+ c.writeFile(c.wt, ".github/workflows/ci.yml", []byte(wf("renamed")[".github/workflows/ci.yml"]))
610+ _, title := c.tagEvent(runner, "v1", 1)
611+ assert.Equal(t, "registered", title, "the name of ci.yml on the default branch")
612+ r := c.runs()[0]
613+ assert.Equal(t, "renamed", r.GetWorkflowName(), "the workflow of the run is the file at the tag (github.workflow)")
614+ assert.Equal(t, "renamed", r.GetName(), "and so is the name of the run, which has no run-name")
615+
616+ // A file the default branch does not have: the run's own workflow name.
617+ c.writeFile(c.wt, ".github/workflows/other.yml", []byte("name: other\non:\n push:\n tags: ['w*']\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"))
618+ _, title = c.tagEvent(runner, "w1", 2)
619+ assert.Equal(t, "other", title)
620+}
621+
622+// A tag run with a run-name keeps the evaluated run-name as its title (the
623+// run-name of a tag push is not recorded; branch pushes are, ctx-github-names).
624+func TestCITagRunKeepsItsRunName(t *testing.T) {
625+ c := newCIHarness(t, nil)
626+ runner := c.registerRunner("alpha", "ubuntu-latest")
627+ c.push("main", map[string]string{".github/workflows/t.yml": "name: Tags\nrun-name: \"release ${{ github.ref_name }}\"\non:\n push:\n tags: ['v*']\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
628+ _, title := c.tagEvent(runner, "v1", 1)
629+ assert.Equal(t, "release v1", title)
630+}
631+
632+// An exclude entry with a key that is no key of the matrix makes the workflow
633+// invalid, and the run is named after the file (recorded: matrix-edge-values,
634+// a-exclude-unknown-key.yml: name = path, no job), while an over-256 matrix
635+// keeps the name of the workflow (matrix-over-256).
636+func TestCIInvalidStrategyRunNames(t *testing.T) {
637+ c := newCIHarness(t, nil)
638+ c.registerRunner("alpha", "ubuntu-latest")
639+ var a, b []string
640+ for i := 1; i <= 17; i++ {
641+ a = append(a, itoa(i))
642+ }
643+ for i := 1; i <= 16; i++ {
644+ b = append(b, itoa(i))
645+ }
646+ c.push("main", map[string]string{
647+ ".github/workflows/over.yml": `name: matrix-over-256
648+on: push
649+jobs:
650+ too-many:
651+ runs-on: ubuntu-latest
652+ strategy:
653+ matrix:
654+ a: [` + strings.Join(a, ", ") + `]
655+ b: [` + strings.Join(b, ", ") + `]
656+ steps:
657+ - run: echo
658+`,
659+ ".github/workflows/exclude.yml": `name: a-exclude-unknown-key
660+on: push
661+jobs:
662+ main:
663+ runs-on: ubuntu-latest
664+ strategy:
665+ matrix:
666+ os: [one, two]
667+ exclude:
668+ - nokey: x
669+ - os: one
670+ nokey: y
671+ steps:
672+ - run: echo "os=${{ matrix.os }}"
673+`,
674+ ".github/workflows/noname.yml": `on: push
675+jobs:
676+ main:
677+ runs-on: ubuntu-latest
678+ strategy:
679+ matrix:
680+ os: [one]
681+ exclude:
682+ - nokey: x
683+ steps:
684+ - run: echo
685+`}, "")
686+ c.waitFor("three failed runs", func() bool {
687+ rs := c.runs()
688+ if len(rs) != 3 {
689+ return false
690+ }
691+ for _, r := range rs {
692+ if r.GetStatus() != pb.CiStatus_CI_STATUS_COMPLETED {
693+ return false
694+ }
695+ }
696+ return true
697+ })
698+ names := map[string]string{}
699+ for _, r := range c.runs() {
700+ names[r.GetWorkflowPath()] = r.GetWorkflowName()
701+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, r.GetConclusion(), r.GetWorkflowPath())
702+ assert.Empty(t, c.getRun(r.GetId()).GetJobs(), "no job: %s", r.GetWorkflowPath())
703+ if r.GetWorkflowPath() == ".github/workflows/exclude.yml" {
704+ one := ".github/workflows/exclude.yml: Matrix exclude key 'nokey' does not match any key within the matrix"
705+ assert.Equal(t, one+"\n"+one, r.GetError(), "once per offending entry")
706+ }
707+ }
708+ assert.Equal(t, map[string]string{
709+ ".github/workflows/over.yml": "matrix-over-256",
710+ ".github/workflows/exclude.yml": ".github/workflows/exclude.yml",
711+ ".github/workflows/noname.yml": ".github/workflows/noname.yml",
712+ }, names)
713+ // The check of a failed run carries the same workflow name.
714+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
715+ checkNames := map[string]string{}
716+ for _, ck := range c.checks(sha) {
717+ checkNames[ck.GetName()] = ck.GetWorkflowName()
718+ }
719+ assert.Equal(t, "matrix-over-256", checkNames[".github/workflows/over.yml"])
720+ assert.Equal(t, ".github/workflows/exclude.yml", checkNames[".github/workflows/exclude.yml"])
721+}
722+
723+// The default success() of a job looks at the whole chain of its needs, not
724+// only at the direct ones (recorded: needs-failed-dependency): chain-after-always
725+// needs with-always, which ran through always() after broken failed, and is
726+// skipped. with-always and with-failure still run.
727+func TestCIDefaultSuccessLooksAtTheWholeChain(t *testing.T) {
728+ c := newCIHarness(t, nil)
729+ runner := c.registerRunner("alpha", "ubuntu-latest")
730+ c.push("main", map[string]string{".github/workflows/n.yml": `name: needs-failed-dependency
731+on: push
732+jobs:
733+ broken:
734+ runs-on: ubuntu-latest
735+ steps:
736+ - run: exit 1
737+ plain:
738+ needs: broken
739+ runs-on: ubuntu-latest
740+ steps:
741+ - run: echo SHOULD-NOT-RUN
742+ with-always:
743+ needs: broken
744+ if: always()
745+ runs-on: ubuntu-latest
746+ steps:
747+ - run: echo "result=${{ needs.broken.result }}"
748+ with-failure:
749+ needs: broken
750+ if: failure()
751+ runs-on: ubuntu-latest
752+ steps:
753+ - run: echo "result=${{ needs.broken.result }}"
754+ chain-after-always:
755+ needs: with-always
756+ runs-on: ubuntu-latest
757+ steps:
758+ - run: echo chain-after-always-runs-because-with-always-succeeded
759+ chain-with-failure:
760+ needs: with-always
761+ if: failure()
762+ runs-on: ubuntu-latest
763+ steps:
764+ - run: echo "failure() is true when an ancestor failed"
765+ chain-always:
766+ needs: with-always
767+ if: always()
768+ runs-on: ubuntu-latest
769+ steps:
770+ - run: echo always
771+`}, "")
772+ broken := runner.await()
773+ assert.Equal(t, "broken", broken.GetJobKey())
774+ runner.runJob(broken, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x")
775+ ran := map[string]bool{}
776+ for range 4 {
777+ j := runner.await()
778+ ran[j.GetJobKey()] = true
779+ runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
780+ }
781+ assert.Equal(t, map[string]bool{"with-always": true, "with-failure": true, "chain-with-failure": true, "chain-always": true}, ran)
782+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
783+ got := map[string]pb.CiConclusion{}
784+ for _, j := range c.getRun(c.runs()[0].GetId()).GetJobs() {
785+ got[j.GetName()] = j.GetConclusion()
786+ }
787+ skipped, ok := pb.CiConclusion_CI_CONCLUSION_SKIPPED, pb.CiConclusion_CI_CONCLUSION_SUCCESS
788+ assert.Equal(t, map[string]pb.CiConclusion{
789+ "broken": pb.CiConclusion_CI_CONCLUSION_FAILURE, "plain": skipped, "with-always": ok, "with-failure": ok,
790+ "chain-after-always": skipped, "chain-with-failure": ok, "chain-always": ok,
791+ }, got)
792+}
M internal/app/cli_run.go +3 -2
@@ -24,6 +24,7 @@ type jsonRun struct {
2424 Number int64 `json:"number"`
2525 Attempt int32 `json:"attempt"`
2626 Workflow string `json:"workflow"`
27+ Name string `json:"name"`
2728 WorkflowPath string `json:"workflow_path"`
2829 Title string `json:"title"`
2930 Event string `json:"event"`
@@ -73,7 +74,7 @@ type jsonRunDetail struct {
7374 }
7475
7576 const jsonRunHelp = `
76-JSON fields of a run (--json): id, repo, number, attempt, workflow, workflow_path, title, event,
77+JSON fields of a run (--json): id, repo, number, attempt, workflow, name (the name of the run: its run-name, else the workflow), workflow_path, title, event,
7778 ref, sha, actor, status (queued, in_progress, completed), conclusion (success, failure, cancelled,
7879 skipped, timed_out, neutral, action_required; empty until completed), error, created_at,
7980 started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and
@@ -113,7 +114,7 @@ func runResult(status ermite.CiStatus, c ermite.CiConclusion) string {
113114 func toJSONRun(r *ermite.Run) jsonRun {
114115 return jsonRun{
115116 ID: r.GetId(), Repo: r.GetRepo(), Number: r.GetNumber(), Attempt: r.GetAttempt(), Workflow: r.GetWorkflowName(),
116- WorkflowPath: r.GetWorkflowPath(), Title: r.GetTitle(), Event: r.GetEvent(), Ref: r.GetRef(), Sha: r.GetSha(),
117+ Name: r.GetName(), WorkflowPath: r.GetWorkflowPath(), Title: r.GetTitle(), Event: r.GetEvent(), Ref: r.GetRef(), Sha: r.GetSha(),
117118 Actor: r.GetActor(), Status: statusText(r.GetStatus()), Conclusion: conclusionText(r.GetConclusion()), Error: r.GetError(),
118119 CreatedAt: jsonTime(r.GetCreatedAt()), StartedAt: jsonTime(r.GetStartedAt()), CompletedAt: jsonTime(r.GetCompletedAt()),
119120 }
@@ -24,6 +24,7 @@ type jsonRun struct {
24 Number int64 `json:"number"`24 Number int64 `json:"number"`
25 Attempt int32 `json:"attempt"`25 Attempt int32 `json:"attempt"`
26 Workflow string `json:"workflow"`26 Workflow string `json:"workflow"`
27+ Name string `json:"name"`
27 WorkflowPath string `json:"workflow_path"`28 WorkflowPath string `json:"workflow_path"`
28 Title string `json:"title"`29 Title string `json:"title"`
29 Event string `json:"event"`30 Event string `json:"event"`
@@ -73,7 +74,7 @@ type jsonRunDetail struct {
73 }74 }
74 75
75 const jsonRunHelp = `76 const jsonRunHelp = `
76-JSON fields of a run (--json): id, repo, number, attempt, workflow, workflow_path, title, event,77+JSON fields of a run (--json): id, repo, number, attempt, workflow, name (the name of the run: its run-name, else the workflow), workflow_path, title, event,
77 ref, sha, actor, status (queued, in_progress, completed), conclusion (success, failure, cancelled,78 ref, sha, actor, status (queued, in_progress, completed), conclusion (success, failure, cancelled,
78 skipped, timed_out, neutral, action_required; empty until completed), error, created_at,79 skipped, timed_out, neutral, action_required; empty until completed), error, created_at,
79 started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and80 started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and
@@ -113,7 +114,7 @@ func runResult(status ermite.CiStatus, c ermite.CiConclusion) string {
113 func toJSONRun(r *ermite.Run) jsonRun {114 func toJSONRun(r *ermite.Run) jsonRun {
114 return jsonRun{115 return jsonRun{
115 ID: r.GetId(), Repo: r.GetRepo(), Number: r.GetNumber(), Attempt: r.GetAttempt(), Workflow: r.GetWorkflowName(),116 ID: r.GetId(), Repo: r.GetRepo(), Number: r.GetNumber(), Attempt: r.GetAttempt(), Workflow: r.GetWorkflowName(),
116- WorkflowPath: r.GetWorkflowPath(), Title: r.GetTitle(), Event: r.GetEvent(), Ref: r.GetRef(), Sha: r.GetSha(),117+ Name: r.GetName(), WorkflowPath: r.GetWorkflowPath(), Title: r.GetTitle(), Event: r.GetEvent(), Ref: r.GetRef(), Sha: r.GetSha(),
117 Actor: r.GetActor(), Status: statusText(r.GetStatus()), Conclusion: conclusionText(r.GetConclusion()), Error: r.GetError(),118 Actor: r.GetActor(), Status: statusText(r.GetStatus()), Conclusion: conclusionText(r.GetConclusion()), Error: r.GetError(),
118 CreatedAt: jsonTime(r.GetCreatedAt()), StartedAt: jsonTime(r.GetStartedAt()), CompletedAt: jsonTime(r.GetCompletedAt()),119 CreatedAt: jsonTime(r.GetCreatedAt()), StartedAt: jsonTime(r.GetStartedAt()), CompletedAt: jsonTime(r.GetCompletedAt()),
119 }120 }
M internal/app/cli_run_test.go +1 -1
@@ -44,7 +44,7 @@ func TestCLIRunsAndRunners(t *testing.T) {
4444 assert.Equal(t, "failure", runs[0].Conclusion)
4545 assert.Equal(t, "completed", runs[0].Status)
4646 assert.Equal(t, "refs/heads/main", runs[0].Ref)
47- assert.Equal(t, []string{"actor", "attempt", "completed_at", "conclusion", "created_at", "error", "event", "id", "number", "ref", "repo", "sha", "started_at", "status", "title", "workflow", "workflow_path"}, keys(t, strings.TrimSpace(strings.Trim(strings.TrimSpace(js.stdout), "[]"))))
47+ assert.Equal(t, []string{"actor", "attempt", "completed_at", "conclusion", "created_at", "error", "event", "id", "name", "number", "ref", "repo", "sha", "started_at", "status", "title", "workflow", "workflow_path"}, keys(t, strings.TrimSpace(strings.Trim(strings.TrimSpace(js.stdout), "[]"))))
4848
4949 none := h.cli(t, adminToken, "run", "list", "proj", "--status", "queued")
5050 require.NoError(t, none.err)
@@ -44,7 +44,7 @@ func TestCLIRunsAndRunners(t *testing.T) {
44 assert.Equal(t, "failure", runs[0].Conclusion)44 assert.Equal(t, "failure", runs[0].Conclusion)
45 assert.Equal(t, "completed", runs[0].Status)45 assert.Equal(t, "completed", runs[0].Status)
46 assert.Equal(t, "refs/heads/main", runs[0].Ref)46 assert.Equal(t, "refs/heads/main", runs[0].Ref)
47- assert.Equal(t, []string{"actor", "attempt", "completed_at", "conclusion", "created_at", "error", "event", "id", "number", "ref", "repo", "sha", "started_at", "status", "title", "workflow", "workflow_path"}, keys(t, strings.TrimSpace(strings.Trim(strings.TrimSpace(js.stdout), "[]"))))47+ assert.Equal(t, []string{"actor", "attempt", "completed_at", "conclusion", "created_at", "error", "event", "id", "name", "number", "ref", "repo", "sha", "started_at", "status", "title", "workflow", "workflow_path"}, keys(t, strings.TrimSpace(strings.Trim(strings.TrimSpace(js.stdout), "[]"))))
48 48
49 none := h.cli(t, adminToken, "run", "list", "proj", "--status", "queued")49 none := h.cli(t, adminToken, "run", "list", "proj", "--status", "queued")
50 require.NoError(t, none.err)50 require.NoError(t, none.err)
M internal/domain/ci.go +10 -6
@@ -155,12 +155,16 @@ func CILostMessage(runner string) string {
155155
156156 // CIRun is one execution of one workflow file for one event.
157157 type CIRun struct {
158- Repo RepoName
159- ID int64
160- Number int64
161- Attempt int32
162- WorkflowPath string
163- WorkflowName string
158+ Repo RepoName
159+ ID int64
160+ Number int64
161+ Attempt int32
162+ WorkflowPath string
163+ WorkflowName string
164+ // Name is GitHub's name of the run: the evaluated run-name when the
165+ // workflow has one, else WorkflowName. WorkflowName stays the name of the
166+ // workflow (github.workflow, the checks).
167+ Name string
164168 Title string
165169 Event string
166170 Ref string
@@ -155,12 +155,16 @@ func CILostMessage(runner string) string {
155 155
156 // CIRun is one execution of one workflow file for one event.156 // CIRun is one execution of one workflow file for one event.
157 type CIRun struct {157 type CIRun struct {
158- Repo RepoName158+ Repo RepoName
159- ID int64159+ ID int64
160- Number int64160+ Number int64
161- Attempt int32161+ Attempt int32
162- WorkflowPath string162+ WorkflowPath string
163- WorkflowName string163+ WorkflowName string
164+ // Name is GitHub's name of the run: the evaluated run-name when the
165+ // workflow has one, else WorkflowName. WorkflowName stays the name of the
166+ // workflow (github.workflow, the checks).
167+ Name string
164 Title string168 Title string
165 Event string169 Event string
166 Ref string170 Ref string
M internal/ports/ci.go +5 -0
@@ -173,6 +173,11 @@ type CIGit interface {
173173 // means the empty tree), in the order of the diff and without renames
174174 // detection, at most max; truncated is true when there were more.
175175 ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, max int) (paths []string, truncated bool, err error)
176+ // CommitsOffBranches returns the commits reachable from head and from no
177+ // branch (refs/heads/*; tags do not count), newest first, at most limit
178+ // (1 to MaxCommitLimit); more is true when there are others. It is empty
179+ // for a commit that a branch already has.
180+ CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) (commits []domain.Commit, more bool, err error)
176181 }
177182
178183 // --- inbound ---
@@ -173,6 +173,11 @@ type CIGit interface {
173 // means the empty tree), in the order of the diff and without renames173 // means the empty tree), in the order of the diff and without renames
174 // detection, at most max; truncated is true when there were more.174 // detection, at most max; truncated is true when there were more.
175 ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, max int) (paths []string, truncated bool, err error)175 ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, max int) (paths []string, truncated bool, err error)
176+ // CommitsOffBranches returns the commits reachable from head and from no
177+ // branch (refs/heads/*; tags do not count), newest first, at most limit
178+ // (1 to MaxCommitLimit); more is true when there are others. It is empty
179+ // for a commit that a branch already has.
180+ CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) (commits []domain.Commit, more bool, err error)
176 }181 }
177 182
178 // --- inbound ---183 // --- inbound ---
M internal/ports/mocks/CIGit.go +86 -0
@@ -138,3 +138,89 @@ func (_c *CIGit_ChangedPaths_Call) RunAndReturn(run func(ctx context.Context, re
138138 _c.Call.Return(run)
139139 return _c
140140 }
141+
142+// CommitsOffBranches provides a mock function for the type CIGit
143+func (_mock *CIGit) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
144+ ret := _mock.Called(ctx, repo, head, limit)
145+
146+ if len(ret) == 0 {
147+ panic("no return value specified for CommitsOffBranches")
148+ }
149+
150+ var r0 []domain.Commit
151+ var r1 bool
152+ var r2 error
153+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, int) ([]domain.Commit, bool, error)); ok {
154+ return returnFunc(ctx, repo, head, limit)
155+ }
156+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, int) []domain.Commit); ok {
157+ r0 = returnFunc(ctx, repo, head, limit)
158+ } else {
159+ if ret.Get(0) != nil {
160+ r0 = ret.Get(0).([]domain.Commit)
161+ }
162+ }
163+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, int) bool); ok {
164+ r1 = returnFunc(ctx, repo, head, limit)
165+ } else {
166+ r1 = ret.Get(1).(bool)
167+ }
168+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, domain.ObjectID, int) error); ok {
169+ r2 = returnFunc(ctx, repo, head, limit)
170+ } else {
171+ r2 = ret.Error(2)
172+ }
173+ return r0, r1, r2
174+}
175+
176+// CIGit_CommitsOffBranches_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommitsOffBranches'
177+type CIGit_CommitsOffBranches_Call struct {
178+ *mock.Call
179+}
180+
181+// CommitsOffBranches is a helper method to define mock.On call
182+// - ctx context.Context
183+// - repo domain.RepoName
184+// - head domain.ObjectID
185+// - limit int
186+func (_e *CIGit_Expecter) CommitsOffBranches(ctx any, repo any, head any, limit any) *CIGit_CommitsOffBranches_Call {
187+ return &CIGit_CommitsOffBranches_Call{Call: _e.mock.On("CommitsOffBranches", ctx, repo, head, limit)}
188+}
189+
190+func (_c *CIGit_CommitsOffBranches_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int)) *CIGit_CommitsOffBranches_Call {
191+ _c.Call.Run(func(args mock.Arguments) {
192+ var arg0 context.Context
193+ if args[0] != nil {
194+ arg0 = args[0].(context.Context)
195+ }
196+ var arg1 domain.RepoName
197+ if args[1] != nil {
198+ arg1 = args[1].(domain.RepoName)
199+ }
200+ var arg2 domain.ObjectID
201+ if args[2] != nil {
202+ arg2 = args[2].(domain.ObjectID)
203+ }
204+ var arg3 int
205+ if args[3] != nil {
206+ arg3 = args[3].(int)
207+ }
208+ run(
209+ arg0,
210+ arg1,
211+ arg2,
212+ arg3,
213+ )
214+ })
215+ return _c
216+}
217+
218+func (_c *CIGit_CommitsOffBranches_Call) Return(commits []domain.Commit, more bool, err error) *CIGit_CommitsOffBranches_Call {
219+ _c.Call.Return(commits, more, err)
220+ return _c
221+}
222+
223+func (_c *CIGit_CommitsOffBranches_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error)) *CIGit_CommitsOffBranches_Call {
224+ _c.Call.Return(run)
225+ return _c
226+}
@@ -138,3 +138,89 @@ func (_c *CIGit_ChangedPaths_Call) RunAndReturn(run func(ctx context.Context, re
138 _c.Call.Return(run)138 _c.Call.Return(run)
139 return _c139 return _c
140 }140 }
141+
142+// CommitsOffBranches provides a mock function for the type CIGit
143+func (_mock *CIGit) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
144+ ret := _mock.Called(ctx, repo, head, limit)
145+
146+ if len(ret) == 0 {
147+ panic("no return value specified for CommitsOffBranches")
148+ }
149+
150+ var r0 []domain.Commit
151+ var r1 bool
152+ var r2 error
153+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, int) ([]domain.Commit, bool, error)); ok {
154+ return returnFunc(ctx, repo, head, limit)
155+ }
156+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, int) []domain.Commit); ok {
157+ r0 = returnFunc(ctx, repo, head, limit)
158+ } else {
159+ if ret.Get(0) != nil {
160+ r0 = ret.Get(0).([]domain.Commit)
161+ }
162+ }
163+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, int) bool); ok {
164+ r1 = returnFunc(ctx, repo, head, limit)
165+ } else {
166+ r1 = ret.Get(1).(bool)
167+ }
168+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, domain.ObjectID, int) error); ok {
169+ r2 = returnFunc(ctx, repo, head, limit)
170+ } else {
171+ r2 = ret.Error(2)
172+ }
173+ return r0, r1, r2
174+}
175+
176+// CIGit_CommitsOffBranches_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommitsOffBranches'
177+type CIGit_CommitsOffBranches_Call struct {
178+ *mock.Call
179+}
180+
181+// CommitsOffBranches is a helper method to define mock.On call
182+// - ctx context.Context
183+// - repo domain.RepoName
184+// - head domain.ObjectID
185+// - limit int
186+func (_e *CIGit_Expecter) CommitsOffBranches(ctx any, repo any, head any, limit any) *CIGit_CommitsOffBranches_Call {
187+ return &CIGit_CommitsOffBranches_Call{Call: _e.mock.On("CommitsOffBranches", ctx, repo, head, limit)}
188+}
189+
190+func (_c *CIGit_CommitsOffBranches_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int)) *CIGit_CommitsOffBranches_Call {
191+ _c.Call.Run(func(args mock.Arguments) {
192+ var arg0 context.Context
193+ if args[0] != nil {
194+ arg0 = args[0].(context.Context)
195+ }
196+ var arg1 domain.RepoName
197+ if args[1] != nil {
198+ arg1 = args[1].(domain.RepoName)
199+ }
200+ var arg2 domain.ObjectID
201+ if args[2] != nil {
202+ arg2 = args[2].(domain.ObjectID)
203+ }
204+ var arg3 int
205+ if args[3] != nil {
206+ arg3 = args[3].(int)
207+ }
208+ run(
209+ arg0,
210+ arg1,
211+ arg2,
212+ arg3,
213+ )
214+ })
215+ return _c
216+}
217+
218+func (_c *CIGit_CommitsOffBranches_Call) Return(commits []domain.Commit, more bool, err error) *CIGit_CommitsOffBranches_Call {
219+ _c.Call.Return(commits, more, err)
220+ return _c
221+}
222+
223+func (_c *CIGit_CommitsOffBranches_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error)) *CIGit_CommitsOffBranches_Call {
224+ _c.Call.Return(run)
225+ return _c
226+}
M internal/usecase/ci_plan.go +24 -2
@@ -133,9 +133,31 @@ func result(insts []domain.CIJob) string {
133133
134134 // needsOf computes the needs context and the status functions of a job from
135135 // the jobs it needs, and whether they all have ended.
136-func (rs *runState) needsOf(j *workflow.Job) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) {
136+//
137+// The needs context holds the direct needs only, but the status functions look
138+// at the whole chain (wf.Ancestors): a job whose need ran through always()
139+// after a failure further up is skipped by its default success() (recorded:
140+// needs-failed-dependency, chain-after-always), and failure() is true when an
141+// ancestor failed (documented, not recorded). An ancestor beyond the direct
142+// needs only changes success() and failure(): cancelled() is not recorded for
143+// them, and a skipped ancestor behind a need that did run is not either.
144+func (rs *runState) needsOf(wf *workflow.Workflow, j *workflow.Job) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) {
137145 needs = expr.NewDictionary()
138146 st = workflow.StatusFuncs{Success: true, Cancelled: rs.run.CancelRequested}
147+ for _, n := range wf.Ancestors(j.ID) {
148+ var insts []domain.CIJob
149+ for _, i := range rs.instances(n) {
150+ if rs.jobs[i].Ended() {
151+ insts = append(insts, rs.jobs[i])
152+ }
153+ }
154+ switch result(insts) {
155+ case "failure":
156+ st.Failure, st.Success = true, false
157+ case "cancelled":
158+ st.Success = false
159+ }
160+ }
139161 for _, n := range j.Needs {
140162 var insts []domain.CIJob
141163 for _, i := range rs.instances(n) {
@@ -305,7 +327,7 @@ func (rs *runState) advance(ctx context.Context) error {
305327 if rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting {
306328 continue
307329 }
308- needs, st, ready := rs.needsOf(wj)
330+ needs, st, ready := rs.needsOf(wf, wj)
309331 if !ready {
310332 continue
311333 }
@@ -133,9 +133,31 @@ func result(insts []domain.CIJob) string {
133 133
134 // needsOf computes the needs context and the status functions of a job from134 // needsOf computes the needs context and the status functions of a job from
135 // the jobs it needs, and whether they all have ended.135 // the jobs it needs, and whether they all have ended.
136-func (rs *runState) needsOf(j *workflow.Job) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) {136+//
137+// The needs context holds the direct needs only, but the status functions look
138+// at the whole chain (wf.Ancestors): a job whose need ran through always()
139+// after a failure further up is skipped by its default success() (recorded:
140+// needs-failed-dependency, chain-after-always), and failure() is true when an
141+// ancestor failed (documented, not recorded). An ancestor beyond the direct
142+// needs only changes success() and failure(): cancelled() is not recorded for
143+// them, and a skipped ancestor behind a need that did run is not either.
144+func (rs *runState) needsOf(wf *workflow.Workflow, j *workflow.Job) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) {
137 needs = expr.NewDictionary()145 needs = expr.NewDictionary()
138 st = workflow.StatusFuncs{Success: true, Cancelled: rs.run.CancelRequested}146 st = workflow.StatusFuncs{Success: true, Cancelled: rs.run.CancelRequested}
147+ for _, n := range wf.Ancestors(j.ID) {
148+ var insts []domain.CIJob
149+ for _, i := range rs.instances(n) {
150+ if rs.jobs[i].Ended() {
151+ insts = append(insts, rs.jobs[i])
152+ }
153+ }
154+ switch result(insts) {
155+ case "failure":
156+ st.Failure, st.Success = true, false
157+ case "cancelled":
158+ st.Success = false
159+ }
160+ }
139 for _, n := range j.Needs {161 for _, n := range j.Needs {
140 var insts []domain.CIJob162 var insts []domain.CIJob
141 for _, i := range rs.instances(n) {163 for _, i := range rs.instances(n) {
@@ -305,7 +327,7 @@ func (rs *runState) advance(ctx context.Context) error {
305 if rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting {327 if rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting {
306 continue328 continue
307 }329 }
308- needs, st, ready := rs.needsOf(wj)330+ needs, st, ready := rs.needsOf(wf, wj)
309 if !ready {331 if !ready {
310 continue332 continue
311 }333 }
M internal/usecase/ci_trigger.go +142 -40
@@ -161,13 +161,13 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag
161161 wf, perrs := workflow.Parse(f.path, f.content)
162162 switch {
163163 case f.problem != "":
164- errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.problem))
164+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.path, f.problem))
165165 case len(perrs) > 0:
166166 var lines []string
167167 for _, e := range perrs {
168168 lines = append(lines, e.Error())
169169 }
170- errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, strings.Join(lines, "\n")))
170+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.path, strings.Join(lines, "\n")))
171171 case skip || !wf.MatchPush(workflow.PushInfo{
172172 Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount,
173173 }):
@@ -175,8 +175,12 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag
175175 if facts.payload == nil {
176176 facts.payload = c.pushPayload(ctx, repo, facts)
177177 }
178- if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem != "" {
179- errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem))
178+ if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem.text != "" {
179+ name := f.path
180+ if problem.named {
181+ name = workflowName(wf, f.path)
182+ }
183+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, name, problem.text))
180184 continue
181185 }
182186 errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))
@@ -201,14 +205,18 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
201205 }
202206 f.commit = res.CommitID
203207 if isTag {
204- // Recorded (push-tag, skip-directive-tag): the event of a tag carries
205- // the commits that the default branch does not have, like a created
206- // branch, newest as head_commit. No file list: paths filters are not
208+ // Recorded (push-tag, skip-directive-tag, push-tags-limit): the event of
209+ // a tag carries the commits that no branch has, newest as head_commit
210+ // ("compare" is then that commit's page, or "<first>^...<head>" for
211+ // several). Not the range from the previous push, and not the commits
212+ // the default branch lacks: in push-tag the branch commit under the
213+ // tagged one is not listed, in skip-directive-tag, which has no branch,
214+ // both commits are. A tag on a commit that a branch has lists that
215+ // commit alone (not recorded). No file list: paths filters are not
207216 // evaluated for tags.
208- base := c.defaultBase(ctx, repo, "", f.commit)
209- cs, _, err := c.cfg.Git.ListRange(ctx, repo, base, f.commit, ports.MaxCommitLimit)
217+ cs, _, err := c.cfg.Diff.CommitsOffBranches(ctx, repo, f.commit, ports.MaxCommitLimit)
210218 if err != nil {
211- return f, fmt.Errorf("read the commit of the tag: %w", err)
219+ return f, fmt.Errorf("read the commits of the tag: %w", err)
212220 }
213221 if len(cs) == 0 {
214222 cs, _, err = c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
@@ -218,8 +226,6 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
218226 if len(cs) == 0 {
219227 return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
220228 }
221- f.head = cs[0]
222- return f, nil
223229 }
224230 f.head = cs[0]
225231 f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)])
@@ -364,11 +370,15 @@ func firstLine(s string) string {
364370
365371 // startInvalid records the failed run of an invalid workflow file: no job, the
366372 // errors as Run.error, like GitHub's "Invalid workflow file".
367-func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, problem string) error {
373+//
374+// name is what the run is called: the path of the file when the file is not a
375+// workflow GitHub accepts, or the workflow's own name for a workflow that
376+// parses but whose strategy GitHub refuses (recorded: matrix-over-256).
377+func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, name, problem string) error {
368378 now := c.now()
369379 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
370380 run := domain.CIRun{
371- Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: wfPath,
381+ Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
372382 Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
373383 Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError),
374384 CreatedAt: now, StartedAt: now, CompletedAt: now,
@@ -377,7 +387,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
377387 Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}"},
378388 Checks: []domain.CICheck{{
379389 ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted,
380- Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: wfPath, CreatedAt: now, StartedAt: now, CompletedAt: now,
390+ Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: name, CreatedAt: now, StartedAt: now, CompletedAt: now,
381391 }},
382392 }
383393 })
@@ -401,18 +411,30 @@ func (c *CI) publishRun(ctx context.Context, repo domain.Repo, run domain.CIRun)
401411 // job of the file, then plans the jobs that can start.
402412 func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error {
403413 now := c.now()
404- name := wf.Name
405- if name == "" {
406- name = wfPath
414+ name := workflowName(wf, wfPath)
415+ // The title of a run without run-name is the commit message for a branch.
416+ // For a tag GitHub shows the name of the workflow registered for the path,
417+ // not the commit message (recorded: filter-tags, filter-paths-tag,
418+ // filter-branches-ignore, and push-tag, push-tags-limit, push-delete-tag,
419+ // skip-directive-tag, whose ci.yml is called "push-branch-create" on the
420+ // default branch of the recording repository although each run's own
421+ // workflow has another name: 14 of 14 tag runs). Registered is read as the
422+ // "name:" of the file on the default branch; the first version GitHub saw
423+ // is the same file in the recording and cannot be told apart.
424+ title := firstLine(f.head.Message)
425+ if f.isTag {
426+ title = c.registeredName(ctx, repo.Name, wfPath, name)
407427 }
408428 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
409429 run := domain.CIRun{
410- Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name,
411- Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
430+ Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
431+ Title: title, Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
412432 Status: domain.CIQueued, CreatedAt: now,
413433 }
414434 githubJSON := c.githubJSON(ctx, repo, run, f)
415- run.Title = c.runTitle(ctx, wf, githubJSON, run.Title)
435+ if runName, ok := c.evalRunName(ctx, wf, githubJSON); ok {
436+ run.Name, run.Title = runName, runName
437+ }
416438 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
417439 Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON,
418440 }}
@@ -444,18 +466,83 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
444466 return c.advanceRun(ctx, draft.Run.ID)
445467 }
446468
447-// runTitle is the title of a run, GitHub's display_title: the run-name
448-// evaluated with the github, inputs and vars contexts (the contexts the
449-// schema allows there), else the first line of the head commit message. A
450-// run-name that does not evaluate, or that evaluates to nothing, falls back to
451-// the commit message, and the failure is logged.
452-func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
469+// workflowName is the name of a workflow, the path of its file when it has no
470+// "name:".
471+func workflowName(wf *workflow.Workflow, path string) string {
472+ if wf.Name == "" {
473+ return path
474+ }
475+ return wf.Name
476+}
477+
478+// registeredName is the name GitHub shows for a tag run without run-name: the
479+// name of the workflow registered for the path, here the "name:" of the file on
480+// the default branch (the path when that file has none). It falls back to own,
481+// the name of the workflow of the run, when the default branch has no such
482+// file, the file does not parse, or anything cannot be read; a failure other
483+// than a missing file is logged at debug.
484+func (c *CI) registeredName(ctx context.Context, repo domain.RepoName, wfPath, own string) string {
485+ name, err := c.registeredNameOf(ctx, repo, wfPath)
486+ if err != nil {
487+ if !errors.Is(err, domain.ErrNotFound) {
488+ log := zerowrap.FromCtx(ctx)
489+ log.Debug().Err(err).Str("workflow", wfPath).Msg("ci tag run title: the registered workflow name is not readable, using the run's own")
490+ }
491+ return own
492+ }
493+ return name
494+}
495+
496+// registeredNameOf is registeredName without the fallback; a missing default
497+// branch or file is domain.ErrNotFound.
498+func (c *CI) registeredNameOf(ctx context.Context, repo domain.RepoName, wfPath string) (string, error) {
499+ def, exists, err := c.cfg.Repos.DefaultBranch(ctx, repo)
500+ if err != nil {
501+ return "", err
502+ }
503+ if !exists {
504+ return "", domain.ErrNotFound
505+ }
506+ tip, err := c.cfg.Git.BranchHead(ctx, repo, def)
507+ if err != nil {
508+ return "", err
509+ }
510+ tp, err := domain.ParseTreePath(wfPath)
511+ if err != nil {
512+ return "", err
513+ }
514+ _, rc, err := c.cfg.Reader.OpenBlob(ctx, repo, domain.RevisionOf(tip), tp)
515+ if err != nil {
516+ return "", err
517+ }
518+ content, err := io.ReadAll(io.LimitReader(rc, maxCIWorkflowBytes+1))
519+ _ = rc.Close()
520+ if err != nil {
521+ return "", err
522+ }
523+ if len(content) > maxCIWorkflowBytes {
524+ return "", fmt.Errorf("workflow file over %d bytes", maxCIWorkflowBytes)
525+ }
526+ wf, errs := workflow.Parse(wfPath, content)
527+ if len(errs) > 0 {
528+ return "", fmt.Errorf("workflow does not parse: %v", errs[0])
529+ }
530+ return workflowName(wf, wfPath), nil
531+}
532+
533+// evalRunName evaluates the run-name of a workflow with the github, inputs and
534+// vars contexts (the contexts the schema allows there). ok is false when the
535+// workflow has none, when it does not evaluate (logged), or when it evaluates
536+// to nothing: the run then has no name of its own, GitHub's display title falls
537+// back to the commit message (a branch) or the registered name (a tag), and
538+// the name of the run to the name of the workflow.
539+func (c *CI) evalRunName(ctx context.Context, wf *workflow.Workflow, githubJSON string) (string, bool) {
453540 if wf.RunName == nil {
454- return commitTitle
541+ return "", false
455542 }
456543 gh, err := expr.FromJSON([]byte(githubJSON))
457544 if err != nil {
458- return commitTitle
545+ return "", false
459546 }
460547 env := template.Env{Contexts: map[string]expr.Value{
461548 "github": gh, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
@@ -463,13 +550,13 @@ func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, co
463550 name, err := template.EvaluateString(ctx, wf.RunName, env)
464551 if err != nil {
465552 log := zerowrap.FromCtx(ctx)
466- log.Warn().Err(err).Msg("ci run-name does not evaluate: the run is titled with the commit message")
467- return commitTitle
553+ log.Warn().Err(err).Msg("ci run-name does not evaluate: the run keeps the workflow name and the default title")
554+ return "", false
468555 }
469556 if name = firstLine(name); name == "" {
470- return commitTitle
557+ return "", false
471558 }
472- return name
559+ return name, true
473560 }
474561
475562 // matrixProblem expands the strategy of every job of the file, whatever its
@@ -477,29 +564,44 @@ func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, co
477564 // evaluate here, and that error is ignored),
478565 // and returns the error text of the first strategy that produces more
479566 // 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
567+// like an invalid workflow file (recorded: matrix-over-256; the run keeps the
568+// name of the workflow). An "exclude" entry with a key that is no vector of the
569+// matrix is refused too, and there GitHub names the run after the file path
570+// (recorded: matrix-edge-values, a-exclude-unknown-key.yml). Any other error
481571 // of a strategy is ignored here and stays a job failure, found when the job is
482572 // planned. Whether GitHub counts the matrix of a job that "if:" skips is not
483573 // recorded yet (needs an "if: false" scenario over 256). The github context
484574 // is that of a run with no id yet, on the commits shared by the ref.
485-func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) string {
575+func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) matrixIssue {
486576 if !slices.ContainsFunc(wf.Jobs, func(j *workflow.Job) bool { return j.Strategy != nil }) {
487- return "" // nothing to expand: no context to build
577+ return matrixIssue{} // nothing to expand: no context to build
488578 }
489579 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()}
490580 gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f)))
491581 if err != nil {
492- return ""
582+ return matrixIssue{}
493583 }
494584 env := template.Env{Contexts: map[string]expr.Value{
495585 "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
496586 }}
497587 for _, j := range wf.Jobs {
498- if _, err := j.EvaluateStrategy(ctx, env); errors.Is(err, workflow.ErrTooManyConfigurations) {
499- return wfPath + ": " + err.Error()
588+ _, err := j.EvaluateStrategy(ctx, env)
589+ switch {
590+ case errors.Is(err, workflow.ErrTooManyConfigurations):
591+ return matrixIssue{text: wfPath + ": " + err.Error(), named: true}
592+ case errors.Is(err, workflow.ErrExcludeUnknownKey):
593+ // One line per offending entry, each with the file.
594+ return matrixIssue{text: wfPath + ": " + strings.ReplaceAll(err.Error(), "\n", "\n"+wfPath+": ")}
500595 }
501596 }
502- return ""
597+ return matrixIssue{}
598+}
599+
600+// matrixIssue is a strategy GitHub refuses: the error text, and whether the run
601+// keeps the name of the workflow (else it is named after the file).
602+type matrixIssue struct {
603+ text string
604+ named bool
503605 }
504606
505607 // --- the github context (see ci_github.go) ---
@@ -161,13 +161,13 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag
161 wf, perrs := workflow.Parse(f.path, f.content)161 wf, perrs := workflow.Parse(f.path, f.content)
162 switch {162 switch {
163 case f.problem != "":163 case f.problem != "":
164- errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.problem))164+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.path, f.problem))
165 case len(perrs) > 0:165 case len(perrs) > 0:
166 var lines []string166 var lines []string
167 for _, e := range perrs {167 for _, e := range perrs {
168 lines = append(lines, e.Error())168 lines = append(lines, e.Error())
169 }169 }
170- errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, strings.Join(lines, "\n")))170+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.path, strings.Join(lines, "\n")))
171 case skip || !wf.MatchPush(workflow.PushInfo{171 case skip || !wf.MatchPush(workflow.PushInfo{
172 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,
173 }):173 }):
@@ -175,8 +175,12 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag
175 if facts.payload == nil {175 if facts.payload == nil {
176 facts.payload = c.pushPayload(ctx, repo, facts)176 facts.payload = c.pushPayload(ctx, repo, facts)
177 }177 }
178- 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.text != "" {
179- errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem))179+ name := f.path
180+ if problem.named {
181+ name = workflowName(wf, f.path)
182+ }
183+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, name, problem.text))
180 continue184 continue
181 }185 }
182 errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))186 errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))
@@ -201,14 +205,18 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
201 }205 }
202 f.commit = res.CommitID206 f.commit = res.CommitID
203 if isTag {207 if isTag {
204- // Recorded (push-tag, skip-directive-tag): the event of a tag carries208+ // Recorded (push-tag, skip-directive-tag, push-tags-limit): the event of
205- // the commits that the default branch does not have, like a created209+ // a tag carries the commits that no branch has, newest as head_commit
206- // branch, newest as head_commit. No file list: paths filters are not210+ // ("compare" is then that commit's page, or "<first>^...<head>" for
211+ // several). Not the range from the previous push, and not the commits
212+ // the default branch lacks: in push-tag the branch commit under the
213+ // tagged one is not listed, in skip-directive-tag, which has no branch,
214+ // both commits are. A tag on a commit that a branch has lists that
215+ // commit alone (not recorded). No file list: paths filters are not
207 // evaluated for tags.216 // evaluated for tags.
208- base := c.defaultBase(ctx, repo, "", f.commit)217+ cs, _, err := c.cfg.Diff.CommitsOffBranches(ctx, repo, f.commit, ports.MaxCommitLimit)
209- cs, _, err := c.cfg.Git.ListRange(ctx, repo, base, f.commit, ports.MaxCommitLimit)
210 if err != nil {218 if err != nil {
211- return f, fmt.Errorf("read the commit of the tag: %w", err)219+ return f, fmt.Errorf("read the commits of the tag: %w", err)
212 }220 }
213 if len(cs) == 0 {221 if len(cs) == 0 {
214 cs, _, err = c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)222 cs, _, err = c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
@@ -218,8 +226,6 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
218 if len(cs) == 0 {226 if len(cs) == 0 {
219 return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)227 return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
220 }228 }
221- f.head = cs[0]
222- return f, nil
223 }229 }
224 f.head = cs[0]230 f.head = cs[0]
225 f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)])231 f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)])
@@ -364,11 +370,15 @@ func firstLine(s string) string {
364 370
365 // startInvalid records the failed run of an invalid workflow file: no job, the371 // startInvalid records the failed run of an invalid workflow file: no job, the
366 // errors as Run.error, like GitHub's "Invalid workflow file".372 // errors as Run.error, like GitHub's "Invalid workflow file".
367-func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, problem string) error {373+//
374+// name is what the run is called: the path of the file when the file is not a
375+// workflow GitHub accepts, or the workflow's own name for a workflow that
376+// parses but whose strategy GitHub refuses (recorded: matrix-over-256).
377+func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, name, problem string) error {
368 now := c.now()378 now := c.now()
369 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {379 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
370 run := domain.CIRun{380 run := domain.CIRun{
371- Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: wfPath,381+ Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
372 Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,382 Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
373 Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError),383 Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError),
374 CreatedAt: now, StartedAt: now, CompletedAt: now,384 CreatedAt: now, StartedAt: now, CompletedAt: now,
@@ -377,7 +387,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
377 Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}"},387 Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}"},
378 Checks: []domain.CICheck{{388 Checks: []domain.CICheck{{
379 ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted,389 ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted,
380- Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: wfPath, CreatedAt: now, StartedAt: now, CompletedAt: now,390+ Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: name, CreatedAt: now, StartedAt: now, CompletedAt: now,
381 }},391 }},
382 }392 }
383 })393 })
@@ -401,18 +411,30 @@ func (c *CI) publishRun(ctx context.Context, repo domain.Repo, run domain.CIRun)
401 // job of the file, then plans the jobs that can start.411 // job of the file, then plans the jobs that can start.
402 func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error {412 func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error {
403 now := c.now()413 now := c.now()
404- name := wf.Name414+ name := workflowName(wf, wfPath)
405- if name == "" {415+ // The title of a run without run-name is the commit message for a branch.
406- name = wfPath416+ // For a tag GitHub shows the name of the workflow registered for the path,
417+ // not the commit message (recorded: filter-tags, filter-paths-tag,
418+ // filter-branches-ignore, and push-tag, push-tags-limit, push-delete-tag,
419+ // skip-directive-tag, whose ci.yml is called "push-branch-create" on the
420+ // default branch of the recording repository although each run's own
421+ // workflow has another name: 14 of 14 tag runs). Registered is read as the
422+ // "name:" of the file on the default branch; the first version GitHub saw
423+ // is the same file in the recording and cannot be told apart.
424+ title := firstLine(f.head.Message)
425+ if f.isTag {
426+ title = c.registeredName(ctx, repo.Name, wfPath, name)
407 }427 }
408 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {428 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
409 run := domain.CIRun{429 run := domain.CIRun{
410- Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name,430+ Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
411- Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,431+ Title: title, Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
412 Status: domain.CIQueued, CreatedAt: now,432 Status: domain.CIQueued, CreatedAt: now,
413 }433 }
414 githubJSON := c.githubJSON(ctx, repo, run, f)434 githubJSON := c.githubJSON(ctx, repo, run, f)
415- run.Title = c.runTitle(ctx, wf, githubJSON, run.Title)435+ if runName, ok := c.evalRunName(ctx, wf, githubJSON); ok {
436+ run.Name, run.Title = runName, runName
437+ }
416 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{438 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
417 Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON,439 Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON,
418 }}440 }}
@@ -444,18 +466,83 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
444 return c.advanceRun(ctx, draft.Run.ID)466 return c.advanceRun(ctx, draft.Run.ID)
445 }467 }
446 468
447-// runTitle is the title of a run, GitHub's display_title: the run-name469+// workflowName is the name of a workflow, the path of its file when it has no
448-// evaluated with the github, inputs and vars contexts (the contexts the470+// "name:".
449-// schema allows there), else the first line of the head commit message. A471+func workflowName(wf *workflow.Workflow, path string) string {
450-// run-name that does not evaluate, or that evaluates to nothing, falls back to472+ if wf.Name == "" {
451-// the commit message, and the failure is logged.473+ return path
452-func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {474+ }
475+ return wf.Name
476+}
477+
478+// registeredName is the name GitHub shows for a tag run without run-name: the
479+// name of the workflow registered for the path, here the "name:" of the file on
480+// the default branch (the path when that file has none). It falls back to own,
481+// the name of the workflow of the run, when the default branch has no such
482+// file, the file does not parse, or anything cannot be read; a failure other
483+// than a missing file is logged at debug.
484+func (c *CI) registeredName(ctx context.Context, repo domain.RepoName, wfPath, own string) string {
485+ name, err := c.registeredNameOf(ctx, repo, wfPath)
486+ if err != nil {
487+ if !errors.Is(err, domain.ErrNotFound) {
488+ log := zerowrap.FromCtx(ctx)
489+ log.Debug().Err(err).Str("workflow", wfPath).Msg("ci tag run title: the registered workflow name is not readable, using the run's own")
490+ }
491+ return own
492+ }
493+ return name
494+}
495+
496+// registeredNameOf is registeredName without the fallback; a missing default
497+// branch or file is domain.ErrNotFound.
498+func (c *CI) registeredNameOf(ctx context.Context, repo domain.RepoName, wfPath string) (string, error) {
499+ def, exists, err := c.cfg.Repos.DefaultBranch(ctx, repo)
500+ if err != nil {
501+ return "", err
502+ }
503+ if !exists {
504+ return "", domain.ErrNotFound
505+ }
506+ tip, err := c.cfg.Git.BranchHead(ctx, repo, def)
507+ if err != nil {
508+ return "", err
509+ }
510+ tp, err := domain.ParseTreePath(wfPath)
511+ if err != nil {
512+ return "", err
513+ }
514+ _, rc, err := c.cfg.Reader.OpenBlob(ctx, repo, domain.RevisionOf(tip), tp)
515+ if err != nil {
516+ return "", err
517+ }
518+ content, err := io.ReadAll(io.LimitReader(rc, maxCIWorkflowBytes+1))
519+ _ = rc.Close()
520+ if err != nil {
521+ return "", err
522+ }
523+ if len(content) > maxCIWorkflowBytes {
524+ return "", fmt.Errorf("workflow file over %d bytes", maxCIWorkflowBytes)
525+ }
526+ wf, errs := workflow.Parse(wfPath, content)
527+ if len(errs) > 0 {
528+ return "", fmt.Errorf("workflow does not parse: %v", errs[0])
529+ }
530+ return workflowName(wf, wfPath), nil
531+}
532+
533+// evalRunName evaluates the run-name of a workflow with the github, inputs and
534+// vars contexts (the contexts the schema allows there). ok is false when the
535+// workflow has none, when it does not evaluate (logged), or when it evaluates
536+// to nothing: the run then has no name of its own, GitHub's display title falls
537+// back to the commit message (a branch) or the registered name (a tag), and
538+// the name of the run to the name of the workflow.
539+func (c *CI) evalRunName(ctx context.Context, wf *workflow.Workflow, githubJSON string) (string, bool) {
453 if wf.RunName == nil {540 if wf.RunName == nil {
454- return commitTitle541+ return "", false
455 }542 }
456 gh, err := expr.FromJSON([]byte(githubJSON))543 gh, err := expr.FromJSON([]byte(githubJSON))
457 if err != nil {544 if err != nil {
458- return commitTitle545+ return "", false
459 }546 }
460 env := template.Env{Contexts: map[string]expr.Value{547 env := template.Env{Contexts: map[string]expr.Value{
461 "github": gh, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),548 "github": gh, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
@@ -463,13 +550,13 @@ func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, co
463 name, err := template.EvaluateString(ctx, wf.RunName, env)550 name, err := template.EvaluateString(ctx, wf.RunName, env)
464 if err != nil {551 if err != nil {
465 log := zerowrap.FromCtx(ctx)552 log := zerowrap.FromCtx(ctx)
466- log.Warn().Err(err).Msg("ci run-name does not evaluate: the run is titled with the commit message")553+ log.Warn().Err(err).Msg("ci run-name does not evaluate: the run keeps the workflow name and the default title")
467- return commitTitle554+ return "", false
468 }555 }
469 if name = firstLine(name); name == "" {556 if name = firstLine(name); name == "" {
470- return commitTitle557+ return "", false
471 }558 }
472- return name559+ return name, true
473 }560 }
474 561
475 // matrixProblem expands the strategy of every job of the file, whatever its562 // matrixProblem expands the strategy of every job of the file, whatever its
@@ -477,29 +564,44 @@ func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, co
477 // evaluate here, and that error is ignored),564 // evaluate here, and that error is ignored),
478 // and returns the error text of the first strategy that produces more565 // 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,566 // configurations than GitHub allows: GitHub then fails the run with no job,
480-// like an invalid workflow file (recorded: matrix-over-256). Any other error567+// like an invalid workflow file (recorded: matrix-over-256; the run keeps the
568+// name of the workflow). An "exclude" entry with a key that is no vector of the
569+// matrix is refused too, and there GitHub names the run after the file path
570+// (recorded: matrix-edge-values, a-exclude-unknown-key.yml). Any other error
481 // of a strategy is ignored here and stays a job failure, found when the job is571 // 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 not572 // 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 context573 // 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.574 // is that of a run with no id yet, on the commits shared by the ref.
485-func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) string {575+func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) matrixIssue {
486 if !slices.ContainsFunc(wf.Jobs, func(j *workflow.Job) bool { return j.Strategy != nil }) {576 if !slices.ContainsFunc(wf.Jobs, func(j *workflow.Job) bool { return j.Strategy != nil }) {
487- return "" // nothing to expand: no context to build577+ return matrixIssue{} // nothing to expand: no context to build
488 }578 }
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()}579 probe := domain.CIRun{Repo: repo.Name, Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, WorkflowPath: wfPath, WorkflowName: wf.Name, CreatedAt: c.now()}
490 gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f)))580 gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f)))
491 if err != nil {581 if err != nil {
492- return ""582+ return matrixIssue{}
493 }583 }
494 env := template.Env{Contexts: map[string]expr.Value{584 env := template.Env{Contexts: map[string]expr.Value{
495 "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),585 "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
496 }}586 }}
497 for _, j := range wf.Jobs {587 for _, j := range wf.Jobs {
498- if _, err := j.EvaluateStrategy(ctx, env); errors.Is(err, workflow.ErrTooManyConfigurations) {588+ _, err := j.EvaluateStrategy(ctx, env)
499- return wfPath + ": " + err.Error()589+ switch {
590+ case errors.Is(err, workflow.ErrTooManyConfigurations):
591+ return matrixIssue{text: wfPath + ": " + err.Error(), named: true}
592+ case errors.Is(err, workflow.ErrExcludeUnknownKey):
593+ // One line per offending entry, each with the file.
594+ return matrixIssue{text: wfPath + ": " + strings.ReplaceAll(err.Error(), "\n", "\n"+wfPath+": ")}
500 }595 }
501 }596 }
502- return ""597+ return matrixIssue{}
598+}
599+
600+// matrixIssue is a strategy GitHub refuses: the error text, and whether the run
601+// keeps the name of the workflow (else it is named after the file).
602+type matrixIssue struct {
603+ text string
604+ named bool
503 }605 }
504 606
505 // --- the github context (see ci_github.go) ---607 // --- the github context (see ci_github.go) ---
M internal/usecase/export_test.go +6 -2
@@ -15,9 +15,13 @@ func AuthorOf(p domain.Principal) string { return authorOf(p) }
1515 // Sweep exposes the daily sweep to the tests.
1616 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
1717
18-// RunTitle exposes runTitle to the tests.
18+// RunTitle gives the title of a run as startRun sets it: the evaluated
19+// run-name, else commitTitle.
1920 func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
20- return c.runTitle(ctx, wf, githubJSON, commitTitle)
21+ if name, ok := c.evalRunName(ctx, wf, githubJSON); ok {
22+ return name
23+ }
24+ return commitTitle
2125 }
2226
2327 // CheckRunID exposes checkRunID to the tests.
@@ -15,9 +15,13 @@ func AuthorOf(p domain.Principal) string { return authorOf(p) }
15 // Sweep exposes the daily sweep to the tests.15 // Sweep exposes the daily sweep to the tests.
16 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }16 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
17 17
18-// RunTitle exposes runTitle to the tests.18+// RunTitle gives the title of a run as startRun sets it: the evaluated
19+// run-name, else commitTitle.
19 func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {20 func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
20- return c.runTitle(ctx, wf, githubJSON, commitTitle)21+ if name, ok := c.evalRunName(ctx, wf, githubJSON); ok {
22+ return name
23+ }
24+ return commitTitle
21 }25 }
22 26
23 // CheckRunID exposes checkRunID to the tests.27 // CheckRunID exposes checkRunID to the tests.
M proto/ermite/storage/ci.proto +4 -0
@@ -95,6 +95,10 @@ message CIRun {
9595 google.protobuf.Timestamp completed_at = 17;
9696 bool cancel_requested = 20;
9797 google.protobuf.Timestamp cancel_requested_at = 21;
98+ // name is GitHub's name of the run: the evaluated run-name, else the
99+ // workflow name. Empty in runs stored before it existed (read as
100+ // workflow_name).
101+ string name = 22;
98102 }
99103
100104 // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned
@@ -95,6 +95,10 @@ message CIRun {
95 google.protobuf.Timestamp completed_at = 17;95 google.protobuf.Timestamp completed_at = 17;
96 bool cancel_requested = 20;96 bool cancel_requested = 20;
97 google.protobuf.Timestamp cancel_requested_at = 21;97 google.protobuf.Timestamp cancel_requested_at = 21;
98+ // name is GitHub's name of the run: the evaluated run-name, else the
99+ // workflow name. Empty in runs stored before it existed (read as
100+ // workflow_name).
101+ string name = 22;
98 }102 }
99 103
100 // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned104 // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned

Keyboard shortcuts

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

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

Branches 2

Tags 42

↑↓ moveenter openesc close