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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 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 | |
| @@ -270,6 +271,7 @@ jobs: | |||
| 270 | 271 | run := c.getRun(c.runs()[0].GetId()) | |
| 271 | 272 | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run.GetRun().GetConclusion()) | |
| 272 | 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 | 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 | 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 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). | |
| 441 | 443 | func TestCITagEventCarriesTheNewCommits(t *testing.T) { | |
| 442 | 444 | c := newCIHarness(t, nil) | |
| 443 | 445 | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| @@ -465,3 +467,284 @@ func TestCITagEventCarriesTheNewCommits(t *testing.T) { | |||
| 465 | 467 | assert.Equal(t, "tag", ctxs["github"]["ref_type"]) | |
| 466 | 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 | + | } | |
| @@ -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 | ||
| @@ -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 branch | 440 | + | // 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 | 441 | + | // 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 | |||
| 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, | |
| 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,14 +411,23 @@ 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 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 | 427 | draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft { | |
| 409 | 428 | run := domain.CIRun{ | |
| 410 | 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 | 431 | Status: domain.CIQueued, CreatedAt: now, | |
| 413 | 432 | } | |
| 414 | 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 | 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 | 475 | // runTitle is the title of a run, GitHub's display_title: the run-name | |
| 448 | 476 | // 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. | |
| 452 | 481 | func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string { | |
| 453 | 482 | if wf.RunName == nil { | |
| 454 | 483 | return commitTitle | |
| @@ -477,29 +506,44 @@ func (c *CI) runTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, co | |||
| 477 | 506 | // evaluate here, and that error is ignored), | |
| 478 | 507 | // and returns the error text of the first strategy that produces more | |
| 479 | 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 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 | |
| 481 | 513 | // of a strategy is ignored here and stays a job failure, found when the job is | |
| 482 | 514 | // planned. Whether GitHub counts the matrix of a job that "if:" skips is not | |
| 483 | 515 | // recorded yet (needs an "if: false" scenario over 256). The github context | |
| 484 | 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 | 518 | 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 | |
| 488 | 520 | } | |
| 489 | 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 | 522 | gh, err := expr.FromJSON([]byte(c.githubJSON(ctx, repo, probe, f))) | |
| 491 | 523 | if err != nil { | |
| 492 | - | return "" | |
| 524 | + | return matrixIssue{} | |
| 493 | 525 | } | |
| 494 | 526 | env := template.Env{Contexts: map[string]expr.Value{ | |
| 495 | 527 | "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(), | |
| 496 | 528 | }} | |
| 497 | 529 | 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+": ")} | |
| 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 | 549 | // --- 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, |
| 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.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 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-name | 475 | // 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 | 476 | // 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 | 477 | + | // 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 to | 478 | + | // 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 commitTitle | 483 | 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 more | 507 | // 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 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 | |||
| 481 | // of a strategy is ignored here and stays a job failure, found when the job is | 513 | // 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 | 514 | // 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 | 515 | // 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 build | 519 | + | 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) --- | ||