diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml index e2d8a7e..0a98e54 100644 --- a/.github/workflows/release-trigger.yml +++ b/.github/workflows/release-trigger.yml @@ -59,6 +59,12 @@ jobs: const title = `release(version): Release ${version}`; if (pull.title !== title) throw new Error(`PR #${pullNumber} title does not match ${version}.`); + function isAllowedReleaseMergeSubject(message, expectedTitle, expectedPullNumber) { + const subject = message.split('\n', 1)[0]; + return subject === expectedTitle || + subject === `${expectedTitle} (#${expectedPullNumber})`; + } + const markerPattern = new RegExp( `', @@ -92,7 +98,7 @@ jobs: if (releaseCommit.data.parents.length !== 1 || releaseCommit.data.parents[0].sha !== baseSha) { throw new Error(`PR #${pullNumber} was not squash-merged directly onto its recorded base.`); } - if (releaseCommit.data.message.split('\n')[0] !== `${title} (#${pullNumber})`) { + if (!isAllowedReleaseMergeSubject(releaseCommit.data.message, title, pullNumber)) { throw new Error(`PR #${pullNumber} merge commit message is invalid.`); } if (releaseCommit.data.tree.sha !== headCommit.data.tree.sha) { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5a99b0..6e71b54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,12 @@ jobs: const title = `release(version): Release ${version}`; if (pull.title !== title) throw new Error(`PR #${pullNumber} title does not match ${version}.`); + function isAllowedReleaseMergeSubject(message, expectedTitle, expectedPullNumber) { + const subject = message.split('\n', 1)[0]; + return subject === expectedTitle || + subject === `${expectedTitle} (#${expectedPullNumber})`; + } + const markerPattern = new RegExp( `', @@ -98,7 +104,7 @@ jobs: if (releaseCommit.data.parents.length !== 1 || releaseCommit.data.parents[0].sha !== baseSha) { throw new Error(`PR #${pullNumber} was not squash-merged directly onto its recorded base.`); } - if (releaseCommit.data.message.split('\n')[0] !== `${title} (#${pullNumber})`) { + if (!isAllowedReleaseMergeSubject(releaseCommit.data.message, title, pullNumber)) { throw new Error(`PR #${pullNumber} merge commit message is invalid.`); } if (releaseCommit.data.tree.sha !== headCommit.data.tree.sha) { diff --git a/scripts/release-workflow-contract.test.ts b/scripts/release-workflow-contract.test.ts new file mode 100644 index 0000000..96e8fbb --- /dev/null +++ b/scripts/release-workflow-contract.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; + +const RELEASE_TITLE = "release(version): Release 2.18.0"; +const RELEASE_PR = 265; +const WORKFLOW_PATHS = [ + ".github/workflows/release-trigger.yml", + ".github/workflows/release.yml", +] as const; + +type MergeSubjectValidator = ( + message: string, + expectedTitle: string, + expectedPullNumber: number, +) => boolean; + +async function readMergeSubjectValidator(workflowPath: string) { + const workflow = await fs.readFile(path.resolve(workflowPath), "utf8"); + const functionStart = workflow.indexOf(" function isAllowedReleaseMergeSubject"); + expect( + functionStart, + `${workflowPath} must define the merge-subject validator`, + ).toBeGreaterThanOrEqual(0); + const functionEnd = workflow.indexOf("\n }", functionStart); + expect(functionEnd, `${workflowPath} must close the merge-subject validator`).toBeGreaterThan( + functionStart, + ); + const source = workflow + .slice(functionStart, functionEnd + "\n }".length) + .replace(/^ {12}/gm, ""); + return runInNewContext(`(${source})`) as MergeSubjectValidator; +} + +describe.each(WORKFLOW_PATHS)("%s release merge-subject contract", (workflowPath) => { + it.each([ + [RELEASE_TITLE, true], + [`${RELEASE_TITLE}\n\npodnotes-release-commit schema=1 version=2.18.0`, true], + [`${RELEASE_TITLE} (#${RELEASE_PR})`, true], + [`${RELEASE_TITLE} (#${RELEASE_PR})\n\nGitHub-generated body`, true], + [`${RELEASE_TITLE} (#264)`, false], + [`${RELEASE_TITLE} (#${RELEASE_PR}) extra`, false], + [` ${RELEASE_TITLE}`, false], + [`${RELEASE_TITLE} `, false], + ["release(version): Release 2.18.1", false], + ])("validates the exact merge subject in %j", async (message, expected) => { + const validate = await readMergeSubjectValidator(workflowPath); + expect(validate(message, RELEASE_TITLE, RELEASE_PR)).toBe(expected); + }); + + it("uses the validator for the fetched release commit", async () => { + const workflow = await fs.readFile(path.resolve(workflowPath), "utf8"); + expect(workflow).toContain( + "if (!isAllowedReleaseMergeSubject(releaseCommit.data.message, title, pullNumber)) {", + ); + }); +});