180 lines
6.1 KiB
YAML
180 lines
6.1 KiB
YAML
name: Release Merge Freeze
|
|
|
|
on:
|
|
pull_request:
|
|
branches:
|
|
- master
|
|
types:
|
|
- opened
|
|
- reopened
|
|
- synchronize
|
|
- ready_for_review
|
|
- edited
|
|
- labeled
|
|
- unlabeled
|
|
- closed
|
|
|
|
env:
|
|
RELEASE_FREEZE_AUTHORS: ${{ vars.RELEASE_OWNER || 'TheCookieLab' }}
|
|
|
|
permissions:
|
|
pull-requests: write
|
|
issues: write
|
|
|
|
jobs:
|
|
enforce-release-freeze:
|
|
name: Enforce release freeze on master merges
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- name: Block non-release merges while release PR is open
|
|
uses: actions/github-script@v9
|
|
with:
|
|
github-token: ${{ github.token }}
|
|
script: |
|
|
const owner = context.repo.owner;
|
|
const repo = context.repo.repo;
|
|
const pr = context.payload.pull_request;
|
|
const baseBranch = pr?.base?.ref || context.payload.repository?.default_branch;
|
|
const noticeMarker = "<!-- ta4j:release-freeze-notice -->";
|
|
const releaseLabel = "release";
|
|
const action = context.payload.action;
|
|
const trustedAuthors = new Set(
|
|
(process.env.RELEASE_FREEZE_AUTHORS || "TheCookieLab")
|
|
.split(",")
|
|
.map((author) => (author || "").trim().toLowerCase())
|
|
.filter((author) => author.length > 0),
|
|
);
|
|
|
|
if (!pr || !baseBranch) {
|
|
core.info(`Skipping release freeze check: missing pull request context or base branch.`);
|
|
return;
|
|
}
|
|
|
|
const hasLabel = (labels, name) =>
|
|
(labels || []).some((label) => label.name === name);
|
|
|
|
const isReleaseBranch = (candidate) =>
|
|
(candidate?.head?.ref || "").startsWith("release/");
|
|
|
|
const isTrustedReleaseAuthor = (candidate) =>
|
|
trustedAuthors.has((candidate?.user?.login || "").toLowerCase());
|
|
|
|
const isTrustedReleasePr = (candidate) =>
|
|
hasLabel(candidate.labels, releaseLabel) &&
|
|
isTrustedReleaseAuthor(candidate) &&
|
|
isReleaseBranch(candidate);
|
|
|
|
const buildReleaseSummary = (releasePr) =>
|
|
releasePr.map((candidate) => `- [#${candidate.number}](${candidate.html_url})`).join("\n");
|
|
|
|
const findNoticeComment = async (issueNumber) => {
|
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
owner,
|
|
repo,
|
|
issue_number: issueNumber,
|
|
per_page: 100
|
|
});
|
|
|
|
return comments.find((comment) =>
|
|
comment.user?.type === "Bot" && comment.body && comment.body.includes(noticeMarker)
|
|
);
|
|
};
|
|
|
|
const removeNoticeComment = async (issueNumber) => {
|
|
const notice = await findNoticeComment(issueNumber);
|
|
if (!notice) {
|
|
return;
|
|
}
|
|
|
|
await github.rest.issues.deleteComment({
|
|
owner,
|
|
repo,
|
|
comment_id: notice.id
|
|
});
|
|
};
|
|
|
|
const upsertNoticeComment = async (issueNumber, releasePrLinks) => {
|
|
const existing = await findNoticeComment(issueNumber);
|
|
const body = [
|
|
`${noticeMarker}`,
|
|
"⚠️ Release Freeze is active.",
|
|
"",
|
|
`One or more trusted release PRs (label \`release\`, branch \`release/*\`) are currently open against \`${baseBranch}\`, so non-release merges are blocked until these PRs merge or close:`,
|
|
releasePrLinks,
|
|
"",
|
|
"Please merge/close the active release PRs first."
|
|
].join("\n");
|
|
|
|
if (existing && existing.body === body) {
|
|
return;
|
|
}
|
|
|
|
if (existing) {
|
|
await github.rest.issues.updateComment({
|
|
owner,
|
|
repo,
|
|
comment_id: existing.id,
|
|
body
|
|
});
|
|
return;
|
|
}
|
|
|
|
await github.rest.issues.createComment({
|
|
owner,
|
|
repo,
|
|
issue_number: issueNumber,
|
|
body
|
|
});
|
|
};
|
|
|
|
if (!pr || pr.base.ref !== baseBranch) {
|
|
core.info(`Skipping release freeze check for PR #${pr?.number}. Base is ${pr?.base?.ref ?? "unknown"}, expected ${baseBranch}.`);
|
|
return;
|
|
}
|
|
|
|
const isReleasePr = isTrustedReleasePr(pr);
|
|
|
|
const openPrs = await github.paginate(github.rest.pulls.list, {
|
|
owner,
|
|
repo,
|
|
state: "open",
|
|
base: baseBranch
|
|
});
|
|
|
|
const releasePrs = openPrs.filter((candidate) => isTrustedReleasePr(candidate));
|
|
|
|
const openNonReleasePrs = openPrs.filter((candidate) =>
|
|
!hasLabel(candidate.labels, releaseLabel)
|
|
);
|
|
const releasePrLinks = buildReleaseSummary(releasePrs);
|
|
|
|
for (const candidate of openNonReleasePrs) {
|
|
try {
|
|
if (releasePrs.length > 0) {
|
|
await upsertNoticeComment(candidate.number, releasePrLinks);
|
|
} else {
|
|
await removeNoticeComment(candidate.number);
|
|
}
|
|
} catch (error) {
|
|
core.warning(`Failed to sync release-freeze notice for PR #${candidate.number}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
if (releasePrs.length === 0) {
|
|
core.info("No open release PRs detected; merge allowed.");
|
|
return;
|
|
}
|
|
|
|
if (isReleasePr) {
|
|
core.info(`PR #${pr.number} is a trusted release PR; freeze check is informational only.`);
|
|
return;
|
|
}
|
|
|
|
if (action === "closed") {
|
|
core.info(`Skipping merge block for closed PR #${pr.number}; freeze notice cleanup already handled.`);
|
|
return;
|
|
}
|
|
|
|
core.setFailed(`Release freeze is active on ${baseBranch}. Merge blocked while release PRs are open: ${releasePrLinks}. Close or merge the release PR(s) before merging this PR.`);
|