detached 86e16c3

fix(ci): match GitHub for tag push events, tag run titles and invalid strategy run names

commit
86e16c349068463962f9074d3a7d7ebf819323c4
author
brice · · 2026-10-13 03:21 UTC
parent
27d907d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +359 -32
M internal/app/ci_parity_test.go +286 -3
@@ -15,6 +15,7 @@ import (
1515 "github.com/stretchr/testify/require"
1616
1717 pb "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/domain"
1819 )
1920
2021 // conformance is testdata/conformance/ctx-job-runner.contexts.json: the
@@ -270,6 +271,7 @@ jobs:
270271 run := c.getRun(c.runs()[0].GetId())
271272 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run.GetRun().GetConclusion())
272273 assert.Empty(t, run.GetJobs(), "no job")
274+ assert.Equal(t, ".github/workflows/m.yml", run.GetRun().GetWorkflowName(), "no name: in this file: the path")
273275 assert.Contains(t, run.GetRun().GetError(), "Strategy for job 'too-many' produced 272 configurations which exceeds the maximum of 256 configurations")
274276 }
275277
@@ -435,9 +437,9 @@ jobs:
435437 assert.Equal(t, "Display name of", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetName())
436438 }
437439
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).
440+// A tag push carries the commits that no branch has, newest as head_commit, and
441+// the compare URL of several commits is "<first>^...<last>" (recorded:
442+// skip-directive-tag, where no branch exists).
441443 func TestCITagEventCarriesTheNewCommits(t *testing.T) {
442444 c := newCIHarness(t, nil)
443445 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -465,3 +467,284 @@ func TestCITagEventCarriesTheNewCommits(t *testing.T) {
465467 assert.Equal(t, "tag", ctxs["github"]["ref_type"])
466468 assert.Equal(t, true, ev["created"])
467469 }
470+
471+// tagEvent pushes the tag and returns the push event and the title of the run
472+// it starts, after `want` runs exist.
473+func (c *ciHarness) tagEvent(runner *fakeRunner, tag string, want int) (map[string]any, string) {
474+ c.t.Helper()
475+ c.mustGit(c.wt, "tag", tag)
476+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), tag)
477+ job := runner.await()
478+ var ctxs map[string]map[string]any
479+ require.NoError(c.t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
480+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
481+ c.waitFor("the run exists", func() bool { return len(c.runs()) == want })
482+ var title string
483+ for _, r := range c.runs() {
484+ if r.GetRef() == "refs/tags/"+tag {
485+ title = r.GetTitle()
486+ }
487+ }
488+ return ctxs["github"]["event"].(map[string]any), title
489+}
490+
491+func eventMessages(ev map[string]any) []string {
492+ var msgs []string
493+ for _, cm := range ev["commits"].([]any) {
494+ msgs = append(msgs, cm.(map[string]any)["message"].(string))
495+ }
496+ return msgs
497+}
498+
499+// A tag on a commit that a branch has carries that commit alone, and the
500+// compare URL is its page, not a range from the previous push (recorded:
501+// filter-tags, filter-paths-tag, filter-branches-ignore). The tag run is
502+// titled with the workflow name (filter-*).
503+func TestCITagEventOfACommitABranchHas(t *testing.T) {
504+ c := newCIHarness(t, nil)
505+ runner := c.registerRunner("alpha", "ubuntu-latest")
506+ // Two commits on main, pushed with the workflow; both are on a branch.
507+ 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"}, "")
508+ older := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
509+ c.writeFile(c.wt, "x.txt", []byte("x\n"))
510+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main")
511+ newer := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
512+
513+ ev, title := c.tagEvent(runner, "v1", 1)
514+ assert.Equal(t, []string{"add x.txt"}, eventMessages(ev), "the tagged commit alone, not the push range")
515+ assert.Equal(t, newer, ev["head_commit"].(map[string]any)["id"])
516+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+newer[:12], ev["compare"])
517+ assert.Equal(t, "Release", title, "a tag run is titled with the workflow name, not the commit message")
518+
519+ // A tag on an older commit of the branch: that commit alone.
520+ c.mustGit(c.wt, "tag", "v0", older)
521+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v0")
522+ job := runner.await()
523+ var ctxs map[string]map[string]any
524+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
525+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
526+ ev = ctxs["github"]["event"].(map[string]any)
527+ assert.Equal(t, older, ev["after"])
528+ assert.Equal(t, older, ev["head_commit"].(map[string]any)["id"])
529+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+older[:12], ev["compare"])
530+}
531+
532+// A tag on a new commit on top of the branch (recorded: push-tag) carries that
533+// commit alone: the commit of the branch under it is not part of the event.
534+func TestCITagEventOfANewCommitOnTopOfABranch(t *testing.T) {
535+ c := newCIHarness(t, nil)
536+ runner := c.registerRunner("alpha", "ubuntu-latest")
537+ 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")
538+ first := runner.await()
539+ runner.runJob(first, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
540+ c.waitFor("the branch run ends", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
541+ assert.Equal(t, "branch commit", c.runs()[0].GetTitle(), "a branch run keeps the commit message as title")
542+
543+ c.writeFile(c.wt, "t.txt", []byte("t\n")) // "add t.txt", only the tag is pushed
544+ tagged := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
545+ ev, title := c.tagEvent(runner, "v1", 2)
546+ assert.Equal(t, []string{"add t.txt"}, eventMessages(ev))
547+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+tagged[:12], ev["compare"])
548+ assert.Equal(t, ".github/workflows/ci.yml", title, "no name:, so the workflow name is the path of the file (assumed, not recorded)")
549+ assert.Equal(t, true, ev["created"])
550+ assert.Equal(t, domain.ZeroOID, ev["before"])
551+}
552+
553+// Several tags in one push start one run each, with the same event: the
554+// commit alone (recorded: push-tags-limit), titled with the workflow name.
555+func TestCIThreeTagsInOnePush(t *testing.T) {
556+ c := newCIHarness(t, nil)
557+ runner := c.registerRunner("alpha", "ubuntu-latest")
558+ 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"}, "")
559+ c.writeFile(c.wt, "t.txt", []byte("t\n"))
560+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
561+ for _, tag := range []string{"v1", "v2", "v3"} {
562+ c.mustGit(c.wt, "tag", tag)
563+ }
564+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1", "v2", "v3")
565+ for range 3 {
566+ job := runner.await()
567+ var ctxs map[string]map[string]any
568+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
569+ ev := ctxs["github"]["event"].(map[string]any)
570+ assert.Equal(t, []string{"add t.txt"}, eventMessages(ev))
571+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+sha[:12], ev["compare"])
572+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
573+ }
574+ c.waitFor("three runs", func() bool { return len(c.runs()) == 3 })
575+ for _, r := range c.runs() {
576+ assert.Equal(t, "Tags", r.GetTitle())
577+ }
578+}
579+
580+// A tag run with a run-name keeps the evaluated run-name as its title (the
581+// run-name of a tag push is not recorded; branch pushes are, ctx-github-names).
582+func TestCITagRunKeepsItsRunName(t *testing.T) {
583+ c := newCIHarness(t, nil)
584+ runner := c.registerRunner("alpha", "ubuntu-latest")
585+ 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"}, "")
586+ _, title := c.tagEvent(runner, "v1", 1)
587+ assert.Equal(t, "release v1", title)
588+}
589+
590+// An exclude entry with a key that is no key of the matrix makes the workflow
591+// invalid, and the run is named after the file (recorded: matrix-edge-values,
592+// a-exclude-unknown-key.yml: name = path, no job), while an over-256 matrix
593+// keeps the name of the workflow (matrix-over-256).
594+func TestCIInvalidStrategyRunNames(t *testing.T) {
595+ c := newCIHarness(t, nil)
596+ c.registerRunner("alpha", "ubuntu-latest")
597+ var a, b []string
598+ for i := 1; i <= 17; i++ {
599+ a = append(a, itoa(i))
600+ }
601+ for i := 1; i <= 16; i++ {
602+ b = append(b, itoa(i))
603+ }
604+ c.push("main", map[string]string{
605+ ".github/workflows/over.yml": `name: matrix-over-256
606+on: push
607+jobs:
608+ too-many:
609+ runs-on: ubuntu-latest
610+ strategy:
611+ matrix:
612+ a: [` + strings.Join(a, ", ") + `]
613+ b: [` + strings.Join(b, ", ") + `]
614+ steps:
615+ - run: echo
616+`,
617+ ".github/workflows/exclude.yml": `name: a-exclude-unknown-key
618+on: push
619+jobs:
620+ main:
621+ runs-on: ubuntu-latest
622+ strategy:
623+ matrix:
624+ os: [one, two]
625+ exclude:
626+ - nokey: x
627+ - os: one
628+ nokey: y
629+ steps:
630+ - run: echo "os=${{ matrix.os }}"
631+`,
632+ ".github/workflows/noname.yml": `on: push
633+jobs:
634+ main:
635+ runs-on: ubuntu-latest
636+ strategy:
637+ matrix:
638+ os: [one]
639+ exclude:
640+ - nokey: x
641+ steps:
642+ - run: echo
643+`}, "")
644+ c.waitFor("three failed runs", func() bool {
645+ rs := c.runs()
646+ if len(rs) != 3 {
647+ return false
648+ }
649+ for _, r := range rs {
650+ if r.GetStatus() != pb.CiStatus_CI_STATUS_COMPLETED {
651+ return false
652+ }
653+ }
654+ return true
655+ })
656+ names := map[string]string{}
657+ for _, r := range c.runs() {
658+ names[r.GetWorkflowPath()] = r.GetWorkflowName()
659+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, r.GetConclusion(), r.GetWorkflowPath())
660+ assert.Empty(t, c.getRun(r.GetId()).GetJobs(), "no job: %s", r.GetWorkflowPath())
661+ if r.GetWorkflowPath() == ".github/workflows/exclude.yml" {
662+ one := ".github/workflows/exclude.yml: Matrix exclude key 'nokey' does not match any key within the matrix"
663+ assert.Equal(t, one+"\n"+one, r.GetError(), "once per offending entry")
664+ }
665+ }
666+ assert.Equal(t, map[string]string{
667+ ".github/workflows/over.yml": "matrix-over-256",
668+ ".github/workflows/exclude.yml": ".github/workflows/exclude.yml",
669+ ".github/workflows/noname.yml": ".github/workflows/noname.yml",
670+ }, names)
671+ // The check of a failed run carries the same workflow name.
672+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
673+ checkNames := map[string]string{}
674+ for _, ck := range c.checks(sha) {
675+ checkNames[ck.GetName()] = ck.GetWorkflowName()
676+ }
677+ assert.Equal(t, "matrix-over-256", checkNames[".github/workflows/over.yml"])
678+ assert.Equal(t, ".github/workflows/exclude.yml", checkNames[".github/workflows/exclude.yml"])
679+}
680+
681+// The default success() of a job looks at the whole chain of its needs, not
682+// only at the direct ones (recorded: needs-failed-dependency): chain-after-always
683+// needs with-always, which ran through always() after broken failed, and is
684+// skipped. with-always and with-failure still run.
685+func TestCIDefaultSuccessLooksAtTheWholeChain(t *testing.T) {
686+ c := newCIHarness(t, nil)
687+ runner := c.registerRunner("alpha", "ubuntu-latest")
688+ c.push("main", map[string]string{".github/workflows/n.yml": `name: needs-failed-dependency
689+on: push
690+jobs:
691+ broken:
692+ runs-on: ubuntu-latest
693+ steps:
694+ - run: exit 1
695+ plain:
696+ needs: broken
697+ runs-on: ubuntu-latest
698+ steps:
699+ - run: echo SHOULD-NOT-RUN
700+ with-always:
701+ needs: broken
702+ if: always()
703+ runs-on: ubuntu-latest
704+ steps:
705+ - run: echo "result=${{ needs.broken.result }}"
706+ with-failure:
707+ needs: broken
708+ if: failure()
709+ runs-on: ubuntu-latest
710+ steps:
711+ - run: echo "result=${{ needs.broken.result }}"
712+ chain-after-always:
713+ needs: with-always
714+ runs-on: ubuntu-latest
715+ steps:
716+ - run: echo chain-after-always-runs-because-with-always-succeeded
717+ chain-with-failure:
718+ needs: with-always
719+ if: failure()
720+ runs-on: ubuntu-latest
721+ steps:
722+ - run: echo "failure() is true when an ancestor failed"
723+ chain-always:
724+ needs: with-always
725+ if: always()
726+ runs-on: ubuntu-latest
727+ steps:
728+ - run: echo always
729+`}, "")
730+ broken := runner.await()
731+ assert.Equal(t, "broken", broken.GetJobKey())
732+ runner.runJob(broken, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x")
733+ ran := map[string]bool{}
734+ for range 4 {
735+ j := runner.await()
736+ ran[j.GetJobKey()] = true
737+ runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
738+ }
739+ assert.Equal(t, map[string]bool{"with-always": true, "with-failure": true, "chain-with-failure": true, "chain-always": true}, ran)
740+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
741+ got := map[string]pb.CiConclusion{}
742+ for _, j := range c.getRun(c.runs()[0].GetId()).GetJobs() {
743+ got[j.GetName()] = j.GetConclusion()
744+ }
745+ skipped, ok := pb.CiConclusion_CI_CONCLUSION_SKIPPED, pb.CiConclusion_CI_CONCLUSION_SUCCESS
746+ assert.Equal(t, map[string]pb.CiConclusion{
747+ "broken": pb.CiConclusion_CI_CONCLUSION_FAILURE, "plain": skipped, "with-always": ok, "with-failure": ok,
748+ "chain-after-always": skipped, "chain-with-failure": ok, "chain-always": ok,
749+ }, got)
750+}
@@ -15,6 +15,7 @@ import (
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16 16
17 pb "bnema.dev/ermite-api/gen/ermite"17 pb "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/domain"
18 )19 )
19 20
20 // conformance is testdata/conformance/ctx-job-runner.contexts.json: the21 // conformance is testdata/conformance/ctx-job-runner.contexts.json: the
@@ -270,6 +271,7 @@ jobs:
270 run := c.getRun(c.runs()[0].GetId())271 run := c.getRun(c.runs()[0].GetId())
271 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run.GetRun().GetConclusion())272 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run.GetRun().GetConclusion())
272 assert.Empty(t, run.GetJobs(), "no job")273 assert.Empty(t, run.GetJobs(), "no job")
274+ 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")275 assert.Contains(t, run.GetRun().GetError(), "Strategy for job 'too-many' produced 272 configurations which exceeds the maximum of 256 configurations")
274 }276 }
275 277
@@ -435,9 +437,9 @@ jobs:
435 assert.Equal(t, "Display name of", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetName())437 assert.Equal(t, "Display name of", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetName())
436 }438 }
437 439
438-// A tag push carries, like a created branch, the commits the default branch440+// 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 is441+// the compare URL of several commits is "<first>^...<last>" (recorded:
440-// "<first>^...<last>" (recorded: push-tag, skip-directive-tag).442+// skip-directive-tag, where no branch exists).
441 func TestCITagEventCarriesTheNewCommits(t *testing.T) {443 func TestCITagEventCarriesTheNewCommits(t *testing.T) {
442 c := newCIHarness(t, nil)444 c := newCIHarness(t, nil)
443 runner := c.registerRunner("alpha", "ubuntu-latest")445 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -465,3 +467,284 @@ func TestCITagEventCarriesTheNewCommits(t *testing.T) {
465 assert.Equal(t, "tag", ctxs["github"]["ref_type"])467 assert.Equal(t, "tag", ctxs["github"]["ref_type"])
466 assert.Equal(t, true, ev["created"])468 assert.Equal(t, true, ev["created"])
467 }469 }
470+
471+// tagEvent pushes the tag and returns the push event and the title of the run
472+// it starts, after `want` runs exist.
473+func (c *ciHarness) tagEvent(runner *fakeRunner, tag string, want int) (map[string]any, string) {
474+ c.t.Helper()
475+ c.mustGit(c.wt, "tag", tag)
476+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), tag)
477+ job := runner.await()
478+ var ctxs map[string]map[string]any
479+ require.NoError(c.t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
480+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
481+ c.waitFor("the run exists", func() bool { return len(c.runs()) == want })
482+ var title string
483+ for _, r := range c.runs() {
484+ if r.GetRef() == "refs/tags/"+tag {
485+ title = r.GetTitle()
486+ }
487+ }
488+ return ctxs["github"]["event"].(map[string]any), title
489+}
490+
491+func eventMessages(ev map[string]any) []string {
492+ var msgs []string
493+ for _, cm := range ev["commits"].([]any) {
494+ msgs = append(msgs, cm.(map[string]any)["message"].(string))
495+ }
496+ return msgs
497+}
498+
499+// A tag on a commit that a branch has carries that commit alone, and the
500+// compare URL is its page, not a range from the previous push (recorded:
501+// filter-tags, filter-paths-tag, filter-branches-ignore). The tag run is
502+// titled with the workflow name (filter-*).
503+func TestCITagEventOfACommitABranchHas(t *testing.T) {
504+ c := newCIHarness(t, nil)
505+ runner := c.registerRunner("alpha", "ubuntu-latest")
506+ // Two commits on main, pushed with the workflow; both are on a branch.
507+ 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"}, "")
508+ older := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
509+ c.writeFile(c.wt, "x.txt", []byte("x\n"))
510+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main")
511+ newer := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
512+
513+ ev, title := c.tagEvent(runner, "v1", 1)
514+ assert.Equal(t, []string{"add x.txt"}, eventMessages(ev), "the tagged commit alone, not the push range")
515+ assert.Equal(t, newer, ev["head_commit"].(map[string]any)["id"])
516+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+newer[:12], ev["compare"])
517+ assert.Equal(t, "Release", title, "a tag run is titled with the workflow name, not the commit message")
518+
519+ // A tag on an older commit of the branch: that commit alone.
520+ c.mustGit(c.wt, "tag", "v0", older)
521+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v0")
522+ job := runner.await()
523+ var ctxs map[string]map[string]any
524+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
525+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
526+ ev = ctxs["github"]["event"].(map[string]any)
527+ assert.Equal(t, older, ev["after"])
528+ assert.Equal(t, older, ev["head_commit"].(map[string]any)["id"])
529+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+older[:12], ev["compare"])
530+}
531+
532+// A tag on a new commit on top of the branch (recorded: push-tag) carries that
533+// commit alone: the commit of the branch under it is not part of the event.
534+func TestCITagEventOfANewCommitOnTopOfABranch(t *testing.T) {
535+ c := newCIHarness(t, nil)
536+ runner := c.registerRunner("alpha", "ubuntu-latest")
537+ 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")
538+ first := runner.await()
539+ runner.runJob(first, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
540+ c.waitFor("the branch run ends", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
541+ assert.Equal(t, "branch commit", c.runs()[0].GetTitle(), "a branch run keeps the commit message as title")
542+
543+ c.writeFile(c.wt, "t.txt", []byte("t\n")) // "add t.txt", only the tag is pushed
544+ tagged := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
545+ ev, title := c.tagEvent(runner, "v1", 2)
546+ assert.Equal(t, []string{"add t.txt"}, eventMessages(ev))
547+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+tagged[:12], ev["compare"])
548+ assert.Equal(t, ".github/workflows/ci.yml", title, "no name:, so the workflow name is the path of the file (assumed, not recorded)")
549+ assert.Equal(t, true, ev["created"])
550+ assert.Equal(t, domain.ZeroOID, ev["before"])
551+}
552+
553+// Several tags in one push start one run each, with the same event: the
554+// commit alone (recorded: push-tags-limit), titled with the workflow name.
555+func TestCIThreeTagsInOnePush(t *testing.T) {
556+ c := newCIHarness(t, nil)
557+ runner := c.registerRunner("alpha", "ubuntu-latest")
558+ 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"}, "")
559+ c.writeFile(c.wt, "t.txt", []byte("t\n"))
560+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
561+ for _, tag := range []string{"v1", "v2", "v3"} {
562+ c.mustGit(c.wt, "tag", tag)
563+ }
564+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1", "v2", "v3")
565+ for range 3 {
566+ job := runner.await()
567+ var ctxs map[string]map[string]any
568+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
569+ ev := ctxs["github"]["event"].(map[string]any)
570+ assert.Equal(t, []string{"add t.txt"}, eventMessages(ev))
571+ assert.Equal(t, publicOrigin+"/owner/proj/commit/"+sha[:12], ev["compare"])
572+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
573+ }
574+ c.waitFor("three runs", func() bool { return len(c.runs()) == 3 })
575+ for _, r := range c.runs() {
576+ assert.Equal(t, "Tags", r.GetTitle())
577+ }
578+}
579+
580+// A tag run with a run-name keeps the evaluated run-name as its title (the
581+// run-name of a tag push is not recorded; branch pushes are, ctx-github-names).
582+func TestCITagRunKeepsItsRunName(t *testing.T) {
583+ c := newCIHarness(t, nil)
584+ runner := c.registerRunner("alpha", "ubuntu-latest")
585+ 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"}, "")
586+ _, title := c.tagEvent(runner, "v1", 1)
587+ assert.Equal(t, "release v1", title)
588+}
589+
590+// An exclude entry with a key that is no key of the matrix makes the workflow
591+// invalid, and the run is named after the file (recorded: matrix-edge-values,
592+// a-exclude-unknown-key.yml: name = path, no job), while an over-256 matrix
593+// keeps the name of the workflow (matrix-over-256).
594+func TestCIInvalidStrategyRunNames(t *testing.T) {
595+ c := newCIHarness(t, nil)
596+ c.registerRunner("alpha", "ubuntu-latest")
597+ var a, b []string
598+ for i := 1; i <= 17; i++ {
599+ a = append(a, itoa(i))
600+ }
601+ for i := 1; i <= 16; i++ {
602+ b = append(b, itoa(i))
603+ }
604+ c.push("main", map[string]string{
605+ ".github/workflows/over.yml": `name: matrix-over-256
606+on: push
607+jobs:
608+ too-many:
609+ runs-on: ubuntu-latest
610+ strategy:
611+ matrix:
612+ a: [` + strings.Join(a, ", ") + `]
613+ b: [` + strings.Join(b, ", ") + `]
614+ steps:
615+ - run: echo
616+`,
617+ ".github/workflows/exclude.yml": `name: a-exclude-unknown-key
618+on: push
619+jobs:
620+ main:
621+ runs-on: ubuntu-latest
622+ strategy:
623+ matrix:
624+ os: [one, two]
625+ exclude:
626+ - nokey: x
627+ - os: one
628+ nokey: y
629+ steps:
630+ - run: echo "os=${{ matrix.os }}"
631+`,
632+ ".github/workflows/noname.yml": `on: push
633+jobs:
634+ main:
635+ runs-on: ubuntu-latest
636+ strategy:
637+ matrix:
638+ os: [one]
639+ exclude:
640+ - nokey: x
641+ steps:
642+ - run: echo
643+`}, "")
644+ c.waitFor("three failed runs", func() bool {
645+ rs := c.runs()
646+ if len(rs) != 3 {
647+ return false
648+ }
649+ for _, r := range rs {
650+ if r.GetStatus() != pb.CiStatus_CI_STATUS_COMPLETED {
651+ return false
652+ }
653+ }
654+ return true
655+ })
656+ names := map[string]string{}
657+ for _, r := range c.runs() {
658+ names[r.GetWorkflowPath()] = r.GetWorkflowName()
659+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, r.GetConclusion(), r.GetWorkflowPath())
660+ assert.Empty(t, c.getRun(r.GetId()).GetJobs(), "no job: %s", r.GetWorkflowPath())
661+ if r.GetWorkflowPath() == ".github/workflows/exclude.yml" {
662+ one := ".github/workflows/exclude.yml: Matrix exclude key 'nokey' does not match any key within the matrix"
663+ assert.Equal(t, one+"\n"+one, r.GetError(), "once per offending entry")
664+ }
665+ }
666+ assert.Equal(t, map[string]string{
667+ ".github/workflows/over.yml": "matrix-over-256",
668+ ".github/workflows/exclude.yml": ".github/workflows/exclude.yml",
669+ ".github/workflows/noname.yml": ".github/workflows/noname.yml",
670+ }, names)
671+ // The check of a failed run carries the same workflow name.
672+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
673+ checkNames := map[string]string{}
674+ for _, ck := range c.checks(sha) {
675+ checkNames[ck.GetName()] = ck.GetWorkflowName()
676+ }
677+ assert.Equal(t, "matrix-over-256", checkNames[".github/workflows/over.yml"])
678+ assert.Equal(t, ".github/workflows/exclude.yml", checkNames[".github/workflows/exclude.yml"])
679+}
680+
681+// The default success() of a job looks at the whole chain of its needs, not
682+// only at the direct ones (recorded: needs-failed-dependency): chain-after-always
683+// needs with-always, which ran through always() after broken failed, and is
684+// skipped. with-always and with-failure still run.
685+func TestCIDefaultSuccessLooksAtTheWholeChain(t *testing.T) {
686+ c := newCIHarness(t, nil)
687+ runner := c.registerRunner("alpha", "ubuntu-latest")
688+ c.push("main", map[string]string{".github/workflows/n.yml": `name: needs-failed-dependency
689+on: push
690+jobs:
691+ broken:
692+ runs-on: ubuntu-latest
693+ steps:
694+ - run: exit 1
695+ plain:
696+ needs: broken
697+ runs-on: ubuntu-latest
698+ steps:
699+ - run: echo SHOULD-NOT-RUN
700+ with-always:
701+ needs: broken
702+ if: always()
703+ runs-on: ubuntu-latest
704+ steps:
705+ - run: echo "result=${{ needs.broken.result }}"
706+ with-failure:
707+ needs: broken
708+ if: failure()
709+ runs-on: ubuntu-latest
710+ steps:
711+ - run: echo "result=${{ needs.broken.result }}"
712+ chain-after-always:
713+ needs: with-always
714+ runs-on: ubuntu-latest
715+ steps:
716+ - run: echo chain-after-always-runs-because-with-always-succeeded
717+ chain-with-failure:
718+ needs: with-always
719+ if: failure()
720+ runs-on: ubuntu-latest
721+ steps:
722+ - run: echo "failure() is true when an ancestor failed"
723+ chain-always:
724+ needs: with-always
725+ if: always()
726+ runs-on: ubuntu-latest
727+ steps:
728+ - run: echo always
729+`}, "")
730+ broken := runner.await()
731+ assert.Equal(t, "broken", broken.GetJobKey())
732+ runner.runJob(broken, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x")
733+ ran := map[string]bool{}
734+ for range 4 {
735+ j := runner.await()
736+ ran[j.GetJobKey()] = true
737+ runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
738+ }
739+ assert.Equal(t, map[string]bool{"with-always": true, "with-failure": true, "chain-with-failure": true, "chain-always": true}, ran)
740+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
741+ got := map[string]pb.CiConclusion{}
742+ for _, j := range c.getRun(c.runs()[0].GetId()).GetJobs() {
743+ got[j.GetName()] = j.GetConclusion()
744+ }
745+ skipped, ok := pb.CiConclusion_CI_CONCLUSION_SKIPPED, pb.CiConclusion_CI_CONCLUSION_SUCCESS
746+ assert.Equal(t, map[string]pb.CiConclusion{
747+ "broken": pb.CiConclusion_CI_CONCLUSION_FAILURE, "plain": skipped, "with-always": ok, "with-failure": ok,
748+ "chain-after-always": skipped, "chain-with-failure": ok, "chain-always": ok,
749+ }, got)
750+}
M internal/usecase/ci_trigger.go +73 -29
@@ -161,13 +161,13 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag
161161 wf, perrs := workflow.Parse(f.path, f.content)
162162 switch {
163163 case f.problem != "":
164- errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.problem))
164+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.path, f.problem))
165165 case len(perrs) > 0:
166166 var lines []string
167167 for _, e := range perrs {
168168 lines = append(lines, e.Error())
169169 }
170- errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, strings.Join(lines, "\n")))
170+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.path, strings.Join(lines, "\n")))
171171 case skip || !wf.MatchPush(workflow.PushInfo{
172172 Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount,
173173 }):
@@ -175,8 +175,12 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag
175175 if facts.payload == nil {
176176 facts.payload = c.pushPayload(ctx, repo, facts)
177177 }
178- if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem != "" {
179- errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem))
178+ if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem.text != "" {
179+ name := f.path
180+ if problem.named {
181+ name = workflowName(wf, f.path)
182+ }
183+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, name, problem.text))
180184 continue
181185 }
182186 errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))
@@ -201,14 +205,18 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
201205 }
202206 f.commit = res.CommitID
203207 if isTag {
204- // Recorded (push-tag, skip-directive-tag): the event of a tag carries
205- // the commits that the default branch does not have, like a created
206- // branch, newest as head_commit. No file list: paths filters are not
208+ // Recorded (push-tag, skip-directive-tag, push-tags-limit): the event of
209+ // a tag carries the commits that no branch has, newest as head_commit
210+ // ("compare" is then that commit's page, or "<first>^...<head>" for
211+ // several). Not the range from the previous push, and not the commits
212+ // the default branch lacks: in push-tag the branch commit under the
213+ // tagged one is not listed, in skip-directive-tag, which has no branch,
214+ // both commits are. A tag on a commit that a branch has lists that
215+ // commit alone (not recorded). No file list: paths filters are not
207216 // evaluated for tags.
208- base := c.defaultBase(ctx, repo, "", f.commit)
209- cs, _, err := c.cfg.Git.ListRange(ctx, repo, base, f.commit, ports.MaxCommitLimit)
217+ cs, _, err := c.cfg.Diff.CommitsOffBranches(ctx, repo, f.commit, ports.MaxCommitLimit)
210218 if err != nil {
211- return f, fmt.Errorf("read the commit of the tag: %w", err)
219+ return f, fmt.Errorf("read the commits of the tag: %w", err)
212220 }
213221 if len(cs) == 0 {
214222 cs, _, err = c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
@@ -218,8 +226,6 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
218226 if len(cs) == 0 {
219227 return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
220228 }
221- f.head = cs[0]
222- return f, nil
223229 }
224230 f.head = cs[0]
225231 f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)])
@@ -364,11 +370,15 @@ func firstLine(s string) string {
364370
365371 // startInvalid records the failed run of an invalid workflow file: no job, the
366372 // errors as Run.error, like GitHub's "Invalid workflow file".
367-func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, problem string) error {
373+//
374+// name is what the run is called: the path of the file when the file is not a
375+// workflow GitHub accepts, or the workflow's own name for a workflow that
376+// parses but whose strategy GitHub refuses (recorded: matrix-over-256).
377+func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, name, problem string) error {
368378 now := c.now()
369379 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
370380 run := domain.CIRun{
371- Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: wfPath,
381+ Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name,
372382 Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
373383 Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError),
374384 CreatedAt: now, StartedAt: now, CompletedAt: now,
@@ -377,7 +387,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
377387 Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}"},
378388 Checks: []domain.CICheck{{
379389 ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted,
380- Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: wfPath, CreatedAt: now, StartedAt: now, CompletedAt: now,
390+ Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: name, CreatedAt: now, StartedAt: now, CompletedAt: now,
381391 }},
382392 }
383393 })
@@ -401,14 +411,23 @@ func (c *CI) publishRun(ctx context.Context, repo domain.Repo, run domain.CIRun)
401411 // job of the file, then plans the jobs that can start.
402412 func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error {
403413 now := c.now()
404- name := wf.Name
405- if name == "" {
406- name = wfPath
414+ name := workflowName(wf, wfPath)
415+ // The title of a run without run-name is the commit message for a branch;
416+ // for a tag GitHub shows the workflow name, not the commit message
417+ // (recorded: filter-tags, filter-paths-tag, filter-branches-ignore, where
418+ // every file has its own name). The four scenarios whose file is ci.yml
419+ // (push-tag, push-tags-limit, push-delete-tag, skip-directive-tag) recorded
420+ // "push-branch-create", which is the name of another scenario's ci.yml and
421+ // not that of the workflow of the run: a leftover on github.com's side that
422+ // no rule produces, so those four titles cannot match.
423+ title := firstLine(f.head.Message)
424+ if f.isTag {
425+ title = name
407426 }
408427 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
409428 run := domain.CIRun{
410429 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+ Title: title, Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
412431 Status: domain.CIQueued, CreatedAt: now,
413432 }
414433 githubJSON := c.githubJSON(ctx, repo, run, f)
@@ -444,11 +463,21 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
444463 return c.advanceRun(ctx, draft.Run.ID)
445464 }
446465
466+// workflowName is the name of a workflow, the path of its file when it has no
467+// "name:".
468+func workflowName(wf *workflow.Workflow, path string) string {
469+ if wf.Name == "" {
470+ return path
471+ }
472+ return wf.Name
473+}
474+
447475 // runTitle is the title of a run, GitHub's display_title: the run-name
448476 // 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.
477+// schema allows there), else commitTitle, which the caller sets to the first
478+// line of the head commit message (the workflow name for a tag). A run-name
479+// that does not evaluate, or that evaluates to nothing, falls back to
480+// commitTitle, and the failure is logged.
452481 func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
453482 if wf.RunName == nil {
454483 return commitTitle
@@ -477,29 +506,44 @@ func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, co
477506 // evaluate here, and that error is ignored),
478507 // and returns the error text of the first strategy that produces more
479508 // 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
509+// like an invalid workflow file (recorded: matrix-over-256; the run keeps the
510+// name of the workflow). An "exclude" entry with a key that is no vector of the
511+// matrix is refused too, and there GitHub names the run after the file path
512+// (recorded: matrix-edge-values, a-exclude-unknown-key.yml). Any other error
481513 // of a strategy is ignored here and stays a job failure, found when the job is
482514 // planned. Whether GitHub counts the matrix of a job that "if:" skips is not
483515 // recorded yet (needs an "if: false" scenario over 256). The github context
484516 // 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 {
517+func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) matrixIssue {
486518 if !slices.ContainsFunc(wf.Jobs, func(j *workflow.Job) bool { return j.Strategy != nil }) {
487- return "" // nothing to expand: no context to build
519+ return matrixIssue{} // nothing to expand: no context to build
488520 }
489521 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()}
490522 gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f)))
491523 if err != nil {
492- return ""
524+ return matrixIssue{}
493525 }
494526 env := template.Env{Contexts: map[string]expr.Value{
495527 "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
496528 }}
497529 for _, j := range wf.Jobs {
498- if _, err := j.EvaluateStrategy(ctx, env); errors.Is(err, workflow.ErrTooManyConfigurations) {
499- return wfPath + ": " + err.Error()
530+ _, err := j.EvaluateStrategy(ctx, env)
531+ switch {
532+ case errors.Is(err, workflow.ErrTooManyConfigurations):
533+ return matrixIssue{text: wfPath + ": " + err.Error(), named: true}
534+ case errors.Is(err, workflow.ErrExcludeUnknownKey):
535+ // One line per offending entry, each with the file.
536+ return matrixIssue{text: wfPath + ": " + strings.ReplaceAll(err.Error(), "\n", "\n"+wfPath+": ")}
500537 }
501538 }
502- return ""
539+ return matrixIssue{}
540+}
541+
542+// matrixIssue is a strategy GitHub refuses: the error text, and whether the run
543+// keeps the name of the workflow (else it is named after the file).
544+type matrixIssue struct {
545+ text string
546+ named bool
503547 }
504548
505549 // --- the github context (see ci_github.go) ---
@@ -161,13 +161,13 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag
161 wf, perrs := workflow.Parse(f.path, f.content)161 wf, perrs := workflow.Parse(f.path, f.content)
162 switch {162 switch {
163 case f.problem != "":163 case f.problem != "":
164- errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.problem))164+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.path, f.problem))
165 case len(perrs) > 0:165 case len(perrs) > 0:
166 var lines []string166 var lines []string
167 for _, e := range perrs {167 for _, e := range perrs {
168 lines = append(lines, e.Error())168 lines = append(lines, e.Error())
169 }169 }
170- errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, strings.Join(lines, "\n")))170+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.path, strings.Join(lines, "\n")))
171 case skip || !wf.MatchPush(workflow.PushInfo{171 case skip || !wf.MatchPush(workflow.PushInfo{
172 Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount,172 Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount,
173 }):173 }):
@@ -175,8 +175,12 @@ func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tag
175 if facts.payload == nil {175 if facts.payload == nil {
176 facts.payload = c.pushPayload(ctx, repo, facts)176 facts.payload = c.pushPayload(ctx, repo, facts)
177 }177 }
178- if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem != "" {178+ if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem.text != "" {
179- errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, problem))179+ name := f.path
180+ if problem.named {
181+ name = workflowName(wf, f.path)
182+ }
183+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, name, problem.text))
180 continue184 continue
181 }185 }
182 errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))186 errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))
@@ -201,14 +205,18 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
201 }205 }
202 f.commit = res.CommitID206 f.commit = res.CommitID
203 if isTag {207 if isTag {
204- // Recorded (push-tag, skip-directive-tag): the event of a tag carries208+ // Recorded (push-tag, skip-directive-tag, push-tags-limit): the event of
205- // the commits that the default branch does not have, like a created209+ // a tag carries the commits that no branch has, newest as head_commit
206- // branch, newest as head_commit. No file list: paths filters are not210+ // ("compare" is then that commit's page, or "<first>^...<head>" for
211+ // several). Not the range from the previous push, and not the commits
212+ // the default branch lacks: in push-tag the branch commit under the
213+ // tagged one is not listed, in skip-directive-tag, which has no branch,
214+ // both commits are. A tag on a commit that a branch has lists that
215+ // commit alone (not recorded). No file list: paths filters are not
207 // evaluated for tags.216 // evaluated for tags.
208- base := c.defaultBase(ctx, repo, "", f.commit)217+ cs, _, err := c.cfg.Diff.CommitsOffBranches(ctx, repo, f.commit, ports.MaxCommitLimit)
209- cs, _, err := c.cfg.Git.ListRange(ctx, repo, base, f.commit, ports.MaxCommitLimit)
210 if err != nil {218 if err != nil {
211- return f, fmt.Errorf("read the commit of the tag: %w", err)219+ return f, fmt.Errorf("read the commits of the tag: %w", err)
212 }220 }
213 if len(cs) == 0 {221 if len(cs) == 0 {
214 cs, _, err = c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)222 cs, _, err = c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
@@ -218,8 +226,6 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
218 if len(cs) == 0 {226 if len(cs) == 0 {
219 return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)227 return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
220 }228 }
221- f.head = cs[0]
222- return f, nil
223 }229 }
224 f.head = cs[0]230 f.head = cs[0]
225 f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)])231 f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)])
@@ -364,11 +370,15 @@ func firstLine(s string) string {
364 370
365 // startInvalid records the failed run of an invalid workflow file: no job, the371 // startInvalid records the failed run of an invalid workflow file: no job, the
366 // errors as Run.error, like GitHub's "Invalid workflow file".372 // errors as Run.error, like GitHub's "Invalid workflow file".
367-func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, problem string) error {373+//
374+// name is what the run is called: the path of the file when the file is not a
375+// workflow GitHub accepts, or the workflow's own name for a workflow that
376+// parses but whose strategy GitHub refuses (recorded: matrix-over-256).
377+func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, name, problem string) error {
368 now := c.now()378 now := c.now()
369 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {379 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
370 run := domain.CIRun{380 run := domain.CIRun{
371- Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: wfPath,381+ Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name,
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,14 +411,23 @@ func (c *CI) publishRun(ctx context.Context, repo domain.Repo, run domain.CIRun)
401 // job of the file, then plans the jobs that can start.411 // job of the file, then plans the jobs that can start.
402 func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error {412 func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error {
403 now := c.now()413 now := c.now()
404- name := wf.Name414+ name := workflowName(wf, wfPath)
405- if name == "" {415+ // The title of a run without run-name is the commit message for a branch;
406- name = wfPath416+ // for a tag GitHub shows the workflow name, not the commit message
417+ // (recorded: filter-tags, filter-paths-tag, filter-branches-ignore, where
418+ // every file has its own name). The four scenarios whose file is ci.yml
419+ // (push-tag, push-tags-limit, push-delete-tag, skip-directive-tag) recorded
420+ // "push-branch-create", which is the name of another scenario's ci.yml and
421+ // not that of the workflow of the run: a leftover on github.com's side that
422+ // no rule produces, so those four titles cannot match.
423+ title := firstLine(f.head.Message)
424+ if f.isTag {
425+ title = name
407 }426 }
408 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {427 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
409 run := domain.CIRun{428 run := domain.CIRun{
410 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name,429 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+ Title: title, Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
412 Status: domain.CIQueued, CreatedAt: now,431 Status: domain.CIQueued, CreatedAt: now,
413 }432 }
414 githubJSON := c.githubJSON(ctx, repo, run, f)433 githubJSON := c.githubJSON(ctx, repo, run, f)
@@ -444,11 +463,21 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
444 return c.advanceRun(ctx, draft.Run.ID)463 return c.advanceRun(ctx, draft.Run.ID)
445 }464 }
446 465
466+// workflowName is the name of a workflow, the path of its file when it has no
467+// "name:".
468+func workflowName(wf *workflow.Workflow, path string) string {
469+ if wf.Name == "" {
470+ return path
471+ }
472+ return wf.Name
473+}
474+
447 // runTitle is the title of a run, GitHub's display_title: the run-name475 // 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 the476 // evaluated with the github, inputs and vars contexts (the contexts the
449-// schema allows there), else the first line of the head commit message. A477+// schema allows there), else commitTitle, which the caller sets to the first
450-// run-name that does not evaluate, or that evaluates to nothing, falls back to478+// line of the head commit message (the workflow name for a tag). A run-name
451-// the commit message, and the failure is logged.479+// that does not evaluate, or that evaluates to nothing, falls back to
480+// commitTitle, and the failure is logged.
452 func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {481 func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
453 if wf.RunName == nil {482 if wf.RunName == nil {
454 return commitTitle483 return commitTitle
@@ -477,29 +506,44 @@ func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, co
477 // evaluate here, and that error is ignored),506 // evaluate here, and that error is ignored),
478 // and returns the error text of the first strategy that produces more507 // 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,508 // configurations than GitHub allows: GitHub then fails the run with no job,
480-// like an invalid workflow file (recorded: matrix-over-256). Any other error509+// like an invalid workflow file (recorded: matrix-over-256; the run keeps the
510+// name of the workflow). An "exclude" entry with a key that is no vector of the
511+// matrix is refused too, and there GitHub names the run after the file path
512+// (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 is513 // 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 not514 // 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 context515 // 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.516 // 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 {517+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 }) {518 if !slices.ContainsFunc(wf.Jobs, func(j *workflow.Job) bool { return j.Strategy != nil }) {
487- return "" // nothing to expand: no context to build519+ return matrixIssue{} // nothing to expand: no context to build
488 }520 }
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()}521 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)))522 gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f)))
491 if err != nil {523 if err != nil {
492- return ""524+ return matrixIssue{}
493 }525 }
494 env := template.Env{Contexts: map[string]expr.Value{526 env := template.Env{Contexts: map[string]expr.Value{
495 "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),527 "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
496 }}528 }}
497 for _, j := range wf.Jobs {529 for _, j := range wf.Jobs {
498- if _, err := j.EvaluateStrategy(ctx, env); errors.Is(err, workflow.ErrTooManyConfigurations) {530+ _, err := j.EvaluateStrategy(ctx, env)
499- return wfPath + ": " + err.Error()531+ switch {
532+ case errors.Is(err, workflow.ErrTooManyConfigurations):
533+ return matrixIssue{text: wfPath + ": " + err.Error(), named: true}
534+ case errors.Is(err, workflow.ErrExcludeUnknownKey):
535+ // One line per offending entry, each with the file.
536+ return matrixIssue{text: wfPath + ": " + strings.ReplaceAll(err.Error(), "\n", "\n"+wfPath+": ")}
500 }537 }
501 }538 }
502- return ""539+ return matrixIssue{}
540+}
541+
542+// matrixIssue is a strategy GitHub refuses: the error text, and whether the run
543+// keeps the name of the workflow (else it is named after the file).
544+type matrixIssue struct {
545+ text string
546+ named bool
503 }547 }
504 548
505 // --- the github context (see ci_github.go) ---549 // --- the github context (see ci_github.go) ---

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close