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
19 files · +764 -75
M gen/ermite/storage/ci.pb.go +16 -4
| @@ -391,8 +391,12 @@ type CIRun struct { | |||
| 391 | 391 | CompletedAt *timestamppb.Timestamp `protobuf:"bytes,17,opt,name=completed_at,json=completedAt,proto3" json:"completed_at,omitempty"` | |
| 392 | 392 | CancelRequested bool `protobuf:"varint,20,opt,name=cancel_requested,json=cancelRequested,proto3" json:"cancel_requested,omitempty"` | |
| 393 | 393 | CancelRequestedAt *timestamppb.Timestamp `protobuf:"bytes,21,opt,name=cancel_requested_at,json=cancelRequestedAt,proto3" json:"cancel_requested_at,omitempty"` | |
| 394 | - | unknownFields protoimpl.UnknownFields | |
| 395 | - | sizeCache protoimpl.SizeCache | |
| 394 | + | // name is GitHub's name of the run: the evaluated run-name, else the | |
| 395 | + | // workflow name. Empty in runs stored before it existed (read as | |
| 396 | + | // workflow_name). | |
| 397 | + | Name string `protobuf:"bytes,22,opt,name=name,proto3" json:"name,omitempty"` | |
| 398 | + | unknownFields protoimpl.UnknownFields | |
| 399 | + | sizeCache protoimpl.SizeCache | |
| 396 | 400 | } | |
| 397 | 401 | ||
| 398 | 402 | func (x *CIRun) Reset() { | |
| @@ -558,6 +562,13 @@ func (x *CIRun) GetCancelRequestedAt() *timestamppb.Timestamp { | |||
| 558 | 562 | return nil | |
| 559 | 563 | } | |
| 560 | 564 | ||
| 565 | + | func (x *CIRun) GetName() string { | |
| 566 | + | if x != nil { | |
| 567 | + | return x.Name | |
| 568 | + | } | |
| 569 | + | return "" | |
| 570 | + | } | |
| 571 | + | ||
| 561 | 572 | // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned | |
| 562 | 573 | // from, kept apart so that listing runs does not decode it. | |
| 563 | 574 | type CIRunSource struct { | |
| @@ -1353,7 +1364,7 @@ var File_ermite_storage_ci_proto protoreflect.FileDescriptor | |||
| 1353 | 1364 | ||
| 1354 | 1365 | const file_ermite_storage_ci_proto_rawDesc = "" + | |
| 1355 | 1366 | "\n" + | |
| 1356 | - | "\x17ermite/storage/ci.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xcb\x05\n" + | |
| 1367 | + | "\x17ermite/storage/ci.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xdf\x05\n" + | |
| 1357 | 1368 | "\x05CIRun\x12\x12\n" + | |
| 1358 | 1369 | "\x04repo\x18\x01 \x01(\tR\x04repo\x12\x0e\n" + | |
| 1359 | 1370 | "\x02id\x18\x02 \x01(\x03R\x02id\x12\x16\n" + | |
| @@ -1378,7 +1389,8 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||
| 1378 | 1389 | "started_at\x18\x10 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12=\n" + | |
| 1379 | 1390 | "\fcompleted_at\x18\x11 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12)\n" + | |
| 1380 | 1391 | "\x10cancel_requested\x18\x14 \x01(\bR\x0fcancelRequested\x12J\n" + | |
| 1381 | - | "\x13cancel_requested_at\x18\x15 \x01(\v2\x1a.google.protobuf.TimestampR\x11cancelRequestedAtJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"~\n" + | |
| 1392 | + | "\x13cancel_requested_at\x18\x15 \x01(\v2\x1a.google.protobuf.TimestampR\x11cancelRequestedAt\x12\x12\n" + | |
| 1393 | + | "\x04name\x18\x16 \x01(\tR\x04nameJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"~\n" + | |
| 1382 | 1394 | "\vCIRunSource\x12\x15\n" + | |
| 1383 | 1395 | "\x06run_id\x18\x04 \x01(\x03R\x05runId\x12#\n" + | |
| 1384 | 1396 | "\rworkflow_yaml\x18\x01 \x01(\tR\fworkflowYaml\x12\x1f\n" + | |
| @@ -391,8 +391,12 @@ type CIRun struct { | |||||
| 391 | CompletedAt *timestamppb.Timestamp `protobuf:"bytes,17,opt,name=completed_at,json=completedAt,proto3" json:"completed_at,omitempty"` | 391 | CompletedAt *timestamppb.Timestamp `protobuf:"bytes,17,opt,name=completed_at,json=completedAt,proto3" json:"completed_at,omitempty"` | ||
| 392 | CancelRequested bool `protobuf:"varint,20,opt,name=cancel_requested,json=cancelRequested,proto3" json:"cancel_requested,omitempty"` | 392 | CancelRequested bool `protobuf:"varint,20,opt,name=cancel_requested,json=cancelRequested,proto3" json:"cancel_requested,omitempty"` | ||
| 393 | CancelRequestedAt *timestamppb.Timestamp `protobuf:"bytes,21,opt,name=cancel_requested_at,json=cancelRequestedAt,proto3" json:"cancel_requested_at,omitempty"` | 393 | CancelRequestedAt *timestamppb.Timestamp `protobuf:"bytes,21,opt,name=cancel_requested_at,json=cancelRequestedAt,proto3" json:"cancel_requested_at,omitempty"` | ||
| 394 | - | unknownFields protoimpl.UnknownFields | 394 | + | // name is GitHub's name of the run: the evaluated run-name, else the |
| 395 | - | sizeCache protoimpl.SizeCache | 395 | + | // workflow name. Empty in runs stored before it existed (read as |
| 396 | + | // workflow_name). | |||
| 397 | + | Name string `protobuf:"bytes,22,opt,name=name,proto3" json:"name,omitempty"` | |||
| 398 | + | unknownFields protoimpl.UnknownFields | |||
| 399 | + | sizeCache protoimpl.SizeCache | |||
| 396 | } | 400 | } | ||
| 397 | 401 | ||||
| 398 | func (x *CIRun) Reset() { | 402 | func (x *CIRun) Reset() { | ||
| @@ -558,6 +562,13 @@ func (x *CIRun) GetCancelRequestedAt() *timestamppb.Timestamp { | |||||
| 558 | return nil | 562 | return nil | ||
| 559 | } | 563 | } | ||
| 560 | 564 | ||||
| 565 | + | func (x *CIRun) GetName() string { | |||
| 566 | + | if x != nil { | |||
| 567 | + | return x.Name | |||
| 568 | + | } | |||
| 569 | + | return "" | |||
| 570 | + | } | |||
| 571 | + | ||||
| 561 | // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned | 572 | // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned | ||
| 562 | // from, kept apart so that listing runs does not decode it. | 573 | // from, kept apart so that listing runs does not decode it. | ||
| 563 | type CIRunSource struct { | 574 | type CIRunSource struct { | ||
| @@ -1353,7 +1364,7 @@ var File_ermite_storage_ci_proto protoreflect.FileDescriptor | |||||
| 1353 | 1364 | ||||
| 1354 | const file_ermite_storage_ci_proto_rawDesc = "" + | 1365 | const file_ermite_storage_ci_proto_rawDesc = "" + | ||
| 1355 | "\n" + | 1366 | "\n" + | ||
| 1356 | - | "\x17ermite/storage/ci.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xcb\x05\n" + | 1367 | + | "\x17ermite/storage/ci.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xdf\x05\n" + |
| 1357 | "\x05CIRun\x12\x12\n" + | 1368 | "\x05CIRun\x12\x12\n" + | ||
| 1358 | "\x04repo\x18\x01 \x01(\tR\x04repo\x12\x0e\n" + | 1369 | "\x04repo\x18\x01 \x01(\tR\x04repo\x12\x0e\n" + | ||
| 1359 | "\x02id\x18\x02 \x01(\x03R\x02id\x12\x16\n" + | 1370 | "\x02id\x18\x02 \x01(\x03R\x02id\x12\x16\n" + | ||
| @@ -1378,7 +1389,8 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||||
| 1378 | "started_at\x18\x10 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12=\n" + | 1389 | "started_at\x18\x10 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12=\n" + | ||
| 1379 | "\fcompleted_at\x18\x11 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12)\n" + | 1390 | "\fcompleted_at\x18\x11 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12)\n" + | ||
| 1380 | "\x10cancel_requested\x18\x14 \x01(\bR\x0fcancelRequested\x12J\n" + | 1391 | "\x10cancel_requested\x18\x14 \x01(\bR\x0fcancelRequested\x12J\n" + | ||
| 1381 | - | "\x13cancel_requested_at\x18\x15 \x01(\v2\x1a.google.protobuf.TimestampR\x11cancelRequestedAtJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"~\n" + | 1392 | + | "\x13cancel_requested_at\x18\x15 \x01(\v2\x1a.google.protobuf.TimestampR\x11cancelRequestedAt\x12\x12\n" + |
| 1393 | + | "\x04name\x18\x16 \x01(\tR\x04nameJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"~\n" + | |||
| 1382 | "\vCIRunSource\x12\x15\n" + | 1394 | "\vCIRunSource\x12\x15\n" + | ||
| 1383 | "\x06run_id\x18\x04 \x01(\x03R\x05runId\x12#\n" + | 1395 | "\x06run_id\x18\x04 \x01(\x03R\x05runId\x12#\n" + | ||
| 1384 | "\rworkflow_yaml\x18\x01 \x01(\tR\fworkflowYaml\x12\x1f\n" + | 1396 | "\rworkflow_yaml\x18\x01 \x01(\tR\fworkflowYaml\x12\x1f\n" + | ||
M go.mod +2 -2
| @@ -12,8 +12,8 @@ tool ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | 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 | |
| 17 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 18 | 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | |
| 19 | 19 | 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-b6a9ba732350 | 15 | + | bnema.dev/ermite-actions v0.0.0-20261013033114-44b8f3fa08d1 |
| 16 | - | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c | 16 | + | bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf6 |
| 17 | bnema.dev/ermite-git v0.1.0 | 17 | bnema.dev/ermite-git v0.1.0 | ||
| 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | ||
| 19 | connectrpc.com/connect v1.21.0 | 19 | 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 | |||
| 58 | 58 | } | |
| 59 | 59 | 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 | + | } | |
| @@ -58,3 +58,18 @@ func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head | |||||
| 58 | } | 58 | } | ||
| 59 | return paths, truncated, nil | 59 | 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) { | |||
| 67 | 67 | require.NoError(t, err) | |
| 68 | 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 | + | } | |
| @@ -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 | |||
| 132 | 132 | // | |
| 133 | 133 | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port | |
| 134 | 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 | 148 | if limit < 1 || limit > ports.MaxCommitLimit { | |
| 136 | 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 | 152 | if err != nil { | |
| 140 | 153 | return nil, false, err | |
| 141 | 154 | } | |
| 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...) | |
| 146 | 159 | var ( | |
| 147 | 160 | commits []domain.Commit | |
| 148 | 161 | 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 port | 133 | //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, err | 153 | 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.Commit | 160 | commits []domain.Commit | ||
| 148 | more bool | 161 | more bool | ||
M internal/adapters/kvstore/ci.go +6 -2
| @@ -268,7 +268,7 @@ func encodeRun(r domain.CIRun) ([]byte, error) { | |||
| 268 | 268 | } | |
| 269 | 269 | return mustMarshal(&storagepb.CIRun{ | |
| 270 | 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 | 272 | Event: r.Event, Ref: r.Ref, Sha: r.Sha, Actor: r.Actor, | |
| 273 | 273 | Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error, | |
| 274 | 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 | 285 | if err != nil { | |
| 286 | 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 | 292 | return domain.CIRun{ | |
| 289 | 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 | 295 | Event: pb.GetEvent(), Ref: pb.GetRef(), Sha: pb.GetSha(), Actor: pb.GetActor(), | |
| 292 | 296 | Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(), | |
| 293 | 297 | 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) { | |||
| 373 | 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 | + | } | |
| @@ -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 { | |||
| 130 | 130 | 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 | 142 | func runToPB(r domain.CIRun) *pb.Run { | |
| 134 | 143 | return &pb.Run{ | |
| 135 | 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 | 146 | Ref: r.Ref, Sha: r.Sha, Actor: r.Actor, Status: statusToPB(r.Status), Conclusion: conclusionToPB(r.Conclusion), | |
| 138 | 147 | Error: r.Error, CreatedAt: timestamp(r.CreatedAt), StartedAt: timestamp(r.StartedAt), CompletedAt: timestamp(r.CompletedAt), | |
| 139 | 148 | 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_UNSPECIFIED | 130 | 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^63 | 148 | 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 ( | |||
| 15 | 15 | "github.com/stretchr/testify/require" | |
| 16 | 16 | ||
| 17 | 17 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | // conformance is testdata/conformance/ctx-job-runner.contexts.json: the | |
| @@ -219,7 +220,10 @@ func jsonNumber(v any) string { | |||
| 219 | 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 | 227 | func TestCIRunTitle(t *testing.T) { | |
| 224 | 228 | c := newCIHarness(t, nil) | |
| 225 | 229 | c.registerRunner("alpha", "ubuntu-latest") | |
| @@ -233,14 +237,24 @@ jobs: | |||
| 233 | 237 | - run: echo hi | |
| 234 | 238 | `, ".github/workflows/plain.yml": workflowWith("branches: [main]")}, "the head commit\n\nbody") | |
| 235 | 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 | 241 | for _, r := range c.runs() { | |
| 238 | 242 | titles[r.GetWorkflowPath()] = r.GetTitle() | |
| 243 | + | names[r.GetWorkflowPath()] = r.GetName() | |
| 244 | + | workflows[r.GetWorkflowPath()] = r.GetWorkflowName() | |
| 239 | 245 | } | |
| 240 | 246 | assert.Equal(t, map[string]string{ | |
| 241 | 247 | ".github/workflows/named.yml": "run push on branch by someone", | |
| 242 | 248 | ".github/workflows/plain.yml": "the head commit", | |
| 243 | 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 | 260 | // An over-256 matrix is an invalid workflow, like on github.com | |
| @@ -270,6 +284,7 @@ jobs: | |||
| 270 | 284 | run := c.getRun(c.runs()[0].GetId()) | |
| 271 | 285 | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run.GetRun().GetConclusion()) | |
| 272 | 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 | 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 | 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 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). | |
| 441 | 456 | func TestCITagEventCarriesTheNewCommits(t *testing.T) { | |
| 442 | 457 | c := newCIHarness(t, nil) | |
| 443 | 458 | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| @@ -465,3 +480,313 @@ func TestCITagEventCarriesTheNewCommits(t *testing.T) { | |||
| 465 | 480 | assert.Equal(t, "tag", ctxs["github"]["ref_type"]) | |
| 466 | 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 | + | } | |
| @@ -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: the | 21 | // 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 hi | 237 | - 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.com | 260 | // 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 branch | 453 | + | // 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 is | 454 | + | // 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 { | |||
| 24 | 24 | Number int64 `json:"number"` | |
| 25 | 25 | Attempt int32 `json:"attempt"` | |
| 26 | 26 | Workflow string `json:"workflow"` | |
| 27 | + | Name string `json:"name"` | |
| 27 | 28 | WorkflowPath string `json:"workflow_path"` | |
| 28 | 29 | Title string `json:"title"` | |
| 29 | 30 | Event string `json:"event"` | |
| @@ -73,7 +74,7 @@ type jsonRunDetail struct { | |||
| 73 | 74 | } | |
| 74 | 75 | ||
| 75 | 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 | 78 | ref, sha, actor, status (queued, in_progress, completed), conclusion (success, failure, cancelled, | |
| 78 | 79 | skipped, timed_out, neutral, action_required; empty until completed), error, created_at, | |
| 79 | 80 | 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 | 114 | func toJSONRun(r *ermite.Run) jsonRun { | |
| 114 | 115 | return jsonRun{ | |
| 115 | 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 | 118 | Actor: r.GetActor(), Status: statusText(r.GetStatus()), Conclusion: conclusionText(r.GetConclusion()), Error: r.GetError(), | |
| 118 | 119 | CreatedAt: jsonTime(r.GetCreatedAt()), StartedAt: jsonTime(r.GetStartedAt()), CompletedAt: jsonTime(r.GetCompletedAt()), | |
| 119 | 120 | } | |
| @@ -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 and | 80 | 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) { | |||
| 44 | 44 | assert.Equal(t, "failure", runs[0].Conclusion) | |
| 45 | 45 | assert.Equal(t, "completed", runs[0].Status) | |
| 46 | 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 | 49 | none := h.cli(t, adminToken, "run", "list", "proj", "--status", "queued") | |
| 50 | 50 | 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 { | |||
| 155 | 155 | ||
| 156 | 156 | // CIRun is one execution of one workflow file for one event. | |
| 157 | 157 | 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 | |
| 164 | 168 | Title string | |
| 165 | 169 | Event string | |
| 166 | 170 | 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 RepoName | 158 | + | Repo RepoName |
| 159 | - | ID int64 | 159 | + | ID int64 |
| 160 | - | Number int64 | 160 | + | Number int64 |
| 161 | - | Attempt int32 | 161 | + | Attempt int32 |
| 162 | - | WorkflowPath string | 162 | + | WorkflowPath string |
| 163 | - | WorkflowName 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 | |||
| 164 | Title string | 168 | Title string | ||
| 165 | Event string | 169 | Event string | ||
| 166 | Ref string | 170 | Ref string | ||
M internal/ports/ci.go +5 -0
| @@ -173,6 +173,11 @@ type CIGit interface { | |||
| 173 | 173 | // means the empty tree), in the order of the diff and without renames | |
| 174 | 174 | // detection, at most max; truncated is true when there were more. | |
| 175 | 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 | 183 | // --- inbound --- | |
| @@ -173,6 +173,11 @@ type CIGit interface { | |||||
| 173 | // means the empty tree), in the order of the diff and without renames | 173 | // 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 | |||
| 138 | 138 | _c.Call.Return(run) | |
| 139 | 139 | 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 | + | } | |
| @@ -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 _c | 139 | 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 { | |||
| 133 | 133 | ||
| 134 | 134 | // needsOf computes the needs context and the status functions of a job from | |
| 135 | 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 | 145 | needs = expr.NewDictionary() | |
| 138 | 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 | 161 | for _, n := range j.Needs { | |
| 140 | 162 | var insts []domain.CIJob | |
| 141 | 163 | for _, i := range rs.instances(n) { | |
| @@ -305,7 +327,7 @@ func (rs *runState) advance(ctx context.Context) error { | |||
| 305 | 327 | if rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting { | |
| 306 | 328 | continue | |
| 307 | 329 | } | |
| 308 | - | needs, st, ready := rs.needsOf(wj) | |
| 330 | + | needs, st, ready := rs.needsOf(wf, wj) | |
| 309 | 331 | if !ready { | |
| 310 | 332 | continue | |
| 311 | 333 | } | |
| @@ -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 from | 134 | // 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.CIJob | 162 | 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 | continue | 328 | 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 | continue | 332 | 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 | |||
| 161 | 161 | wf, perrs := workflow.Parse(f.path, f.content) | |
| 162 | 162 | switch { | |
| 163 | 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 | 165 | case len(perrs) > 0: | |
| 166 | 166 | var lines []string | |
| 167 | 167 | for _, e := range perrs { | |
| 168 | 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 | 171 | case skip || !wf.MatchPush(workflow.PushInfo{ | |
| 172 | 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 | 175 | if facts.payload == nil { | |
| 176 | 176 | facts.payload = c.pushPayload(ctx, repo, facts) | |
| 177 | 177 | } | |
| 178 | - | if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem != "" { | |
| 179 | - | errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem)) | |
| 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)) | |
| 180 | 184 | continue | |
| 181 | 185 | } | |
| 182 | 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 | 206 | f.commit = res.CommitID | |
| 203 | 207 | 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 | |
| 207 | 216 | // 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) | |
| 210 | 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 | 221 | if len(cs) == 0 { | |
| 214 | 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 | 226 | if len(cs) == 0 { | |
| 219 | 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 | 230 | f.head = cs[0] | |
| 225 | 231 | f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)]) | |
| @@ -364,11 +370,15 @@ func firstLine(s string) string { | |||
| 364 | 370 | ||
| 365 | 371 | // startInvalid records the failed run of an invalid workflow file: no job, the | |
| 366 | 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 | 378 | now := c.now() | |
| 369 | 379 | draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft { | |
| 370 | 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 | 382 | Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, | |
| 373 | 383 | Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError), | |
| 374 | 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 | 387 | Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}"}, | |
| 378 | 388 | Checks: []domain.CICheck{{ | |
| 379 | 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 | 411 | // job of the file, then plans the jobs that can start. | |
| 402 | 412 | func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error { | |
| 403 | 413 | 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) | |
| 407 | 427 | } | |
| 408 | 428 | draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft { | |
| 409 | 429 | 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, | |
| 412 | 432 | Status: domain.CIQueued, CreatedAt: now, | |
| 413 | 433 | } | |
| 414 | 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 | 438 | d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{ | |
| 417 | 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 | 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-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) { | |
| 453 | 540 | if wf.RunName == nil { | |
| 454 | - | return commitTitle | |
| 541 | + | return "", false | |
| 455 | 542 | } | |
| 456 | 543 | gh, err := expr.FromJSON([]byte(githubJSON)) | |
| 457 | 544 | if err != nil { | |
| 458 | - | return commitTitle | |
| 545 | + | return "", false | |
| 459 | 546 | } | |
| 460 | 547 | env := template.Env{Contexts: map[string]expr.Value{ | |
| 461 | 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 | 550 | name, err := template.EvaluateString(ctx, wf.RunName, env) | |
| 464 | 551 | if err != nil { | |
| 465 | 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") | |
| 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 | |
| 468 | 555 | } | |
| 469 | 556 | if name = firstLine(name); name == "" { | |
| 470 | - | return commitTitle | |
| 557 | + | return "", false | |
| 471 | 558 | } | |
| 472 | - | return name | |
| 559 | + | return name, true | |
| 473 | 560 | } | |
| 474 | 561 | ||
| 475 | 562 | // 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 | 564 | // evaluate here, and that error is ignored), | |
| 478 | 565 | // and returns the error text of the first strategy that produces more | |
| 479 | 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 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 | |
| 481 | 571 | // of a strategy is ignored here and stays a job failure, found when the job is | |
| 482 | 572 | // planned. Whether GitHub counts the matrix of a job that "if:" skips is not | |
| 483 | 573 | // recorded yet (needs an "if: false" scenario over 256). The github context | |
| 484 | 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 | 576 | 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 | |
| 488 | 578 | } | |
| 489 | 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 | 580 | gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f))) | |
| 491 | 581 | if err != nil { | |
| 492 | - | return "" | |
| 582 | + | return matrixIssue{} | |
| 493 | 583 | } | |
| 494 | 584 | env := template.Env{Contexts: map[string]expr.Value{ | |
| 495 | 585 | "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(), | |
| 496 | 586 | }} | |
| 497 | 587 | 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+": ")} | |
| 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 | 607 | // --- 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 []string | 166 | 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 | continue | 184 | 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.CommitID | 206 | f.commit = res.CommitID | ||
| 203 | if isTag { | 207 | if isTag { | ||
| 204 | - | // Recorded (push-tag, skip-directive-tag): the event of a tag carries | 208 | + | // Recorded (push-tag, skip-directive-tag, push-tags-limit): the event of |
| 205 | - | // the commits that the default branch does not have, like a created | 209 | + | // 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 not | 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 | |||
| 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, the | 371 | // 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.Name | 414 | + | 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 = wfPath | 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) | |||
| 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-name | 469 | + | // 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 the | 470 | + | // "name:". |
| 449 | - | // schema allows there), else the first line of the head commit message. A | 471 | + | func workflowName(wf *workflow.Workflow, path string) string { |
| 450 | - | // run-name that does not evaluate, or that evaluates to nothing, falls back to | 472 | + | 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 commitTitle | 541 | + | 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 commitTitle | 545 | + | 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 commitTitle | 554 | + | return "", false |
| 468 | } | 555 | } | ||
| 469 | if name = firstLine(name); name == "" { | 556 | if name = firstLine(name); name == "" { | ||
| 470 | - | return commitTitle | 557 | + | return "", false |
| 471 | } | 558 | } | ||
| 472 | - | return name | 559 | + | return name, true |
| 473 | } | 560 | } | ||
| 474 | 561 | ||||
| 475 | // matrixProblem expands the strategy of every job of the file, whatever its | 562 | // 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 more | 565 | // 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 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 | |||
| 481 | // of a strategy is ignored here and stays a job failure, found when the job is | 571 | // of a strategy is ignored here and stays a job failure, found when the job is | ||
| 482 | // planned. Whether GitHub counts the matrix of a job that "if:" skips is not | 572 | // planned. Whether GitHub counts the matrix of a job that "if:" skips is not | ||
| 483 | // recorded yet (needs an "if: false" scenario over 256). The github context | 573 | // 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 build | 577 | + | 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) } | |||
| 15 | 15 | // Sweep exposes the daily sweep to the tests. | |
| 16 | 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 | 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 | 27 | // 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 { | |||
| 95 | 95 | google.protobuf.Timestamp completed_at = 17; | |
| 96 | 96 | bool cancel_requested = 20; | |
| 97 | 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 | 104 | // 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 planned | 104 | // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned | ||