@marcel91200 / caddy Mirror · read-only
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 01:00 UTC

master
js 121 lines 4.39 KB
Raw
1 // Shared release proposal logic for the release workflows, loaded from
2 // actions/github-script with require(). Keeping it in one place makes the
3 // approval tracker and the release job agree on what an approved proposal is.
4 //
5 // A proposal is a draft PR labelled "release-proposal", titled exactly
6 // "Release Proposal: <version>", whose head commit message carries a
7 // "Target-Commit: <full sha>" trailer. The trailer is written by the
8 // Release Proposal workflow; unlike the PR body, it can't be changed
9 // without pushing a new commit, which makes earlier approvals stale.
10 'use strict';
11
12 const VERSION_RE = /^v\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/;
13 const TARGET_COMMIT_RE = /^Target-Commit: ([0-9a-f]{40})$/m;
14 const REQUIRED_APPROVALS = 2;
15
16 function proposalTitle(version) {
17 return `Release Proposal: ${version}`;
18 }
19
20 function validateVersion(version) {
21 if (!VERSION_RE.test(version)) {
22 throw new Error(`invalid release version ${JSON.stringify(version)}`);
23 }
24 }
25
26 // parseMaintainers splits the MAINTAINER_LOGINS secret (comma or semicolon
27 // separated) into lowercased logins. GitHub logins are case-insensitive.
28 function parseMaintainers(raw) {
29 return (raw || '')
30 .split(/[,;]/)
31 .map(login => login.trim().toLowerCase())
32 .filter(login => login.length > 0);
33 }
34
35 // findOpenProposals returns every open release proposal for the version.
36 async function findOpenProposals({ github, context, version }) {
37 validateVersion(version);
38 const prs = await github.paginate(github.rest.pulls.list, {
39 owner: context.repo.owner,
40 repo: context.repo.repo,
41 state: 'open',
42 per_page: 100,
43 });
44 return prs.filter(pr =>
45 pr.title === proposalTitle(version) &&
46 pr.labels.some(label => label.name === 'release-proposal'));
47 }
48
49 // targetCommit reads the Target-Commit trailer from the proposal's head commit.
50 async function targetCommit({ github, context, pr }) {
51 const { data: commit } = await github.rest.git.getCommit({
52 owner: context.repo.owner,
53 repo: context.repo.repo,
54 commit_sha: pr.head.sha,
55 });
56 const match = commit.message.match(TARGET_COMMIT_RE);
57 if (!match) {
58 throw new Error(`proposal #${pr.number} head commit ${pr.head.sha} has no Target-Commit trailer`);
59 }
60 return match[1];
61 }
62
63 // maintainerApprovals returns the maintainers whose latest review approves
64 // the proposal's current head commit. Plain comments don't change a
65 // reviewer's verdict, and approvals of an earlier head commit don't count.
66 async function maintainerApprovals({ github, context, pr, maintainers }) {
67 const reviews = await github.paginate(github.rest.pulls.listReviews, {
68 owner: context.repo.owner,
69 repo: context.repo.repo,
70 pull_number: pr.number,
71 per_page: 100,
72 });
73 const latest = new Map();
74 for (const review of reviews) {
75 if (!review.user || !['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) {
76 continue;
77 }
78 const login = review.user.login.toLowerCase();
79 const previous = latest.get(login);
80 if (!previous || new Date(review.submitted_at) > new Date(previous.submitted_at)) {
81 latest.set(login, review);
82 }
83 }
84 return [...latest.entries()]
85 .filter(([login, review]) =>
86 maintainers.includes(login) &&
87 review.state === 'APPROVED' &&
88 review.commit_id === pr.head.sha)
89 .map(([, review]) => review.user.login);
90 }
91
92 // checkProposal finds the single open proposal for the version and returns
93 // it with its target commit and maintainer approvals. It throws if there
94 // isn't exactly one proposal or the maintainer list is empty, so callers
95 // fail closed.
96 async function checkProposal({ github, context, version, maintainerLogins }) {
97 const maintainers = parseMaintainers(maintainerLogins);
98 if (maintainers.length === 0) {
99 throw new Error('MAINTAINER_LOGINS is empty; cannot count approvals');
100 }
101 const proposals = await findOpenProposals({ github, context, version });
102 if (proposals.length !== 1) {
103 throw new Error(`expected exactly one open PR titled "${proposalTitle(version)}" with the release-proposal label, found ${proposals.length}`);
104 }
105 const pr = proposals[0];
106 const approvers = await maintainerApprovals({ github, context, pr, maintainers });
107 return {
108 pr,
109 targetCommit: await targetCommit({ github, context, pr }),
110 approvers,
111 approved: approvers.length >= REQUIRED_APPROVALS,
112 };
113 }
114
115 module.exports = {
116 REQUIRED_APPROVALS,
117 checkProposal,
118 findOpenProposals,
119 proposalTitle,
120 validateVersion,
121 };