Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/release-trigger.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<!-- podnotes-release-pr schema=1 version=${version.replaceAll('.', '\\.')}` +
' base=([0-9a-f]{40}) -->',
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<!-- podnotes-release-pr schema=1 version=${version.replaceAll('.', '\\.')}` +
' base=([0-9a-f]{40}) -->',
Expand Down Expand Up @@ -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) {
Expand Down
58 changes: 58 additions & 0 deletions scripts/release-workflow-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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)) {",
);
});
});