122 lines
4.4 KiB
JavaScript
122 lines
4.4 KiB
JavaScript
// Shared release proposal logic for the release workflows, loaded from
|
|
// actions/github-script with require(). Keeping it in one place makes the
|
|
// approval tracker and the release job agree on what an approved proposal is.
|
|
//
|
|
// A proposal is a draft PR labelled "release-proposal", titled exactly
|
|
// "Release Proposal: <version>", whose head commit message carries a
|
|
// "Target-Commit: <full sha>" trailer. The trailer is written by the
|
|
// Release Proposal workflow; unlike the PR body, it can't be changed
|
|
// without pushing a new commit, which makes earlier approvals stale.
|
|
'use strict';
|
|
|
|
const VERSION_RE = /^v\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/;
|
|
const TARGET_COMMIT_RE = /^Target-Commit: ([0-9a-f]{40})$/m;
|
|
const REQUIRED_APPROVALS = 2;
|
|
|
|
function proposalTitle(version) {
|
|
return `Release Proposal: ${version}`;
|
|
}
|
|
|
|
function validateVersion(version) {
|
|
if (!VERSION_RE.test(version)) {
|
|
throw new Error(`invalid release version ${JSON.stringify(version)}`);
|
|
}
|
|
}
|
|
|
|
// parseMaintainers splits the MAINTAINER_LOGINS secret (comma or semicolon
|
|
// separated) into lowercased logins. GitHub logins are case-insensitive.
|
|
function parseMaintainers(raw) {
|
|
return (raw || '')
|
|
.split(/[,;]/)
|
|
.map(login => login.trim().toLowerCase())
|
|
.filter(login => login.length > 0);
|
|
}
|
|
|
|
// findOpenProposals returns every open release proposal for the version.
|
|
async function findOpenProposals({ github, context, version }) {
|
|
validateVersion(version);
|
|
const prs = await github.paginate(github.rest.pulls.list, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
state: 'open',
|
|
per_page: 100,
|
|
});
|
|
return prs.filter(pr =>
|
|
pr.title === proposalTitle(version) &&
|
|
pr.labels.some(label => label.name === 'release-proposal'));
|
|
}
|
|
|
|
// targetCommit reads the Target-Commit trailer from the proposal's head commit.
|
|
async function targetCommit({ github, context, pr }) {
|
|
const { data: commit } = await github.rest.git.getCommit({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
commit_sha: pr.head.sha,
|
|
});
|
|
const match = commit.message.match(TARGET_COMMIT_RE);
|
|
if (!match) {
|
|
throw new Error(`proposal #${pr.number} head commit ${pr.head.sha} has no Target-Commit trailer`);
|
|
}
|
|
return match[1];
|
|
}
|
|
|
|
// maintainerApprovals returns the maintainers whose latest review approves
|
|
// the proposal's current head commit. Plain comments don't change a
|
|
// reviewer's verdict, and approvals of an earlier head commit don't count.
|
|
async function maintainerApprovals({ github, context, pr, maintainers }) {
|
|
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: pr.number,
|
|
per_page: 100,
|
|
});
|
|
const latest = new Map();
|
|
for (const review of reviews) {
|
|
if (!review.user || !['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) {
|
|
continue;
|
|
}
|
|
const login = review.user.login.toLowerCase();
|
|
const previous = latest.get(login);
|
|
if (!previous || new Date(review.submitted_at) > new Date(previous.submitted_at)) {
|
|
latest.set(login, review);
|
|
}
|
|
}
|
|
return [...latest.entries()]
|
|
.filter(([login, review]) =>
|
|
maintainers.includes(login) &&
|
|
review.state === 'APPROVED' &&
|
|
review.commit_id === pr.head.sha)
|
|
.map(([, review]) => review.user.login);
|
|
}
|
|
|
|
// checkProposal finds the single open proposal for the version and returns
|
|
// it with its target commit and maintainer approvals. It throws if there
|
|
// isn't exactly one proposal or the maintainer list is empty, so callers
|
|
// fail closed.
|
|
async function checkProposal({ github, context, version, maintainerLogins }) {
|
|
const maintainers = parseMaintainers(maintainerLogins);
|
|
if (maintainers.length === 0) {
|
|
throw new Error('MAINTAINER_LOGINS is empty; cannot count approvals');
|
|
}
|
|
const proposals = await findOpenProposals({ github, context, version });
|
|
if (proposals.length !== 1) {
|
|
throw new Error(`expected exactly one open PR titled "${proposalTitle(version)}" with the release-proposal label, found ${proposals.length}`);
|
|
}
|
|
const pr = proposals[0];
|
|
const approvers = await maintainerApprovals({ github, context, pr, maintainers });
|
|
return {
|
|
pr,
|
|
targetCommit: await targetCommit({ github, context, pr }),
|
|
approvers,
|
|
approved: approvers.length >= REQUIRED_APPROVALS,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
REQUIRED_APPROVALS,
|
|
checkProposal,
|
|
findOpenProposals,
|
|
proposalTitle,
|
|
validateVersion,
|
|
};
|