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
| 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 | }; |