diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80a7561..8d5d374 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,14 @@ permissions: actions: read pull-requests: read +# A new push to a PR supersedes its running check: the first run of every PR +# fails by design until Prepare commits the version bump, so cancelling it +# only removes a red run. Pushes keep a group of their own (run_id) and never +# cancel: a shared master group would drop pending runs and their releases. +concurrency: + group: ci-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: check: runs-on: ubuntu-latest diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 76d723a..9c359e4 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -177,3 +177,31 @@ describe("step and job names render as written", () => { expect(steps).toContain("Post to #alerting"); }); }); + +describe("only a superseded pull-request run is cancelled", () => { + // A shared group for master pushes would make GitHub drop pending runs (one + // pending run per group), and with them the releases they carry. + it("groups pull requests by number and every other run by its own run id", () => { + expect(workflow.concurrency.group).toBe("ci-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }}"); + }); + + it("cancels in progress for pull_request only", () => { + expect(workflow.concurrency["cancel-in-progress"]).toBe("${{ github.event_name == 'pull_request' }}"); + for (const [name, job] of Object.entries(workflow.jobs) as Array<[string, any]>) expect(job.concurrency, `${name} has its own concurrency`).toBeUndefined(); + }); + + it("never cancels the version writer or the publication", () => { + const prepare = parse(readFileSync(new URL("../.github/workflows/prepare-version.yml", import.meta.url), "utf8")); + expect(prepare.concurrency).toBeUndefined(); + expect(prepare.jobs.prepare.concurrency["cancel-in-progress"]).toBe(false); + expect(publish.concurrency["cancel-in-progress"]).toBe(false); + }); + + // A cancelled PR run must stay silent in #pipelines and #alerting. + it("notifies Slack from master pushes only", () => { + expect(workflow.jobs["notify-slack"].if).toContain("github.event_name == 'push'"); + expect(workflow.jobs["notify-slack"].if).toContain("github.ref == 'refs/heads/master'"); + const slackJobs = Object.entries(workflow.jobs as Record).filter(([, job]) => JSON.stringify(job).includes("slack-github-action")).map(([name]) => name); + expect(slackJobs).toEqual(["notify-slack"]); + }); +});