Repository navigation
design: help text for content fields #1729
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Approval | ||
|
Check warning on line 1 in .github/workflows/approval.yml
|
||
| # Maintains the informational `Human Maintainer approval` check and the four mutually | ||
| # exclusive review/* labels from one calculation, so a label never disagrees | ||
| # with the check. Pure actions/github-script: no checkout, no secrets, no agent. | ||
| # | ||
| # Do not add a pull_request_review trigger: it would run the PR branch's copy of | ||
| # this workflow. Instead the reviewer re-applies the PR's review label from the | ||
| # review webhook (infra/flue-review/.flue/lib/review-state.ts), and the labeled | ||
| # event runs the default branch's copy. Label changes made here use GITHUB_TOKEN | ||
| # and do not trigger it again. | ||
| on: | ||
| pull_request_target: | ||
| types: [opened, reopened, synchronize, ready_for_review, converted_to_draft, labeled] | ||
| push: | ||
| # Re-evaluate every open PR when the rules or the Maintainers list change. | ||
| branches: [main] | ||
| paths: | ||
| - ".github/workflows/approval.yml" | ||
| - "GOVERNANCE.md" | ||
| schedule: | ||
| # Backstop sweep so the check and labels stay correct if an event was missed. | ||
| - cron: "15 */6 * * *" | ||
| workflow_dispatch: | ||
| permissions: | ||
| contents: read | ||
| jobs: | ||
| approval: | ||
| name: Update human review status | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
| permissions: | ||
| checks: write | ||
| contents: read | ||
| pull-requests: write | ||
| concurrency: | ||
| group: approval-${{ github.event.pull_request.number || 'sweep' }} | ||
| cancel-in-progress: false | ||
| steps: | ||
| - name: Update approval check and review labels | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| with: | ||
| script: | | ||
| const { owner, repo } = context.repo; | ||
| const checkName = 'Human Maintainer approval'; | ||
| const detailsUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; | ||
| const stateNames = [ | ||
| 'review/needs-review', | ||
| 'review/awaiting-author', | ||
| 'review/needs-rereview', | ||
| 'review/approved', | ||
| ]; | ||
| const privilegedPaths = [ | ||
| /^GOVERNANCE\.md$/, | ||
| /^MAINTAINING\.md$/, | ||
| /^AI_POLICY\.md$/, | ||
| /^CONTRIBUTING\.md$/, | ||
| /^TRIAGE\.md$/, | ||
| /^AGENTS\.md$/, | ||
| /^SECURITY\.md$/, | ||
| /^proposals\/(README|feature-plan-template|rfc-template)\.md$/, | ||
| /^rfcs\/README\.md$/, | ||
| /^\.github\/CODEOWNERS$/, | ||
| /^\.github\/PULL_REQUEST_TEMPLATE(?:\.md|\/)/, | ||
| /^\.github\/ISSUE_TEMPLATE\//, | ||
| /^\.github\/DISCUSSION_TEMPLATE\//, | ||
| /^\.github\/dependabot\.yml$/, | ||
| /^\.github\/codeql-config\.yml$/, | ||
| /^\.github\/zizmor\.yml$/, | ||
| /^\.github\/workflows\//, | ||
| /^\.github\/actions\//, | ||
| /^\.github\/scripts\//, | ||
| /^\.changeset\/config\.json$/, | ||
| /^renovate\.json$/, | ||
| /^pnpm-workspace\.yaml$/, | ||
| /^apps\/release-action\//, | ||
| /^apps\/release-service\//, | ||
| /^apps\/release-verifier\//, | ||
| /^infra\/flue-review\//, | ||
| ]; | ||
| // Read from the default branch, so neither a PR nor its base branch can | ||
| // change who counts as a Maintainer. | ||
| let governanceFile; | ||
| try { | ||
| ({ data: governanceFile } = await github.rest.repos.getContent({ | ||
| owner, | ||
| repo, | ||
| path: 'GOVERNANCE.md', | ||
| })); | ||
| } catch (error) { | ||
| if (error.status !== 404) throw error; | ||
| core.notice('GOVERNANCE.md is not on the default branch, so no approval can be evaluated.'); | ||
| return; | ||
| } | ||
| if (Array.isArray(governanceFile) || governanceFile.type !== 'file') { | ||
| core.setFailed('GOVERNANCE.md was not returned as a file.'); | ||
| return; | ||
| } | ||
| const governance = Buffer.from(governanceFile.content, 'base64').toString('utf8'); | ||
| const maintainerBlock = governance.match( | ||
| /<!-- maintainers:start -->([\s\S]*?)<!-- maintainers:end -->/, | ||
| )?.[1]; | ||
| const maintainers = new Set( | ||
| [...(maintainerBlock || '').matchAll(/https:\/\/github\.com\/([a-z\d-]+)/gi)] | ||
| .map(match => match[1].toLowerCase()), | ||
| ); | ||
| if (maintainers.size === 0) { | ||
| core.setFailed('GOVERNANCE.md does not contain a marked Maintainers list.'); | ||
| return; | ||
| } | ||
| async function setReviewLabel(pr, desired) { | ||
| const current = new Set((pr.labels || []).map(label => label.name)); | ||
| if (desired && !current.has(desired)) { | ||
| await github.rest.issues.addLabels({ | ||
| owner, | ||
| repo, | ||
| issue_number: pr.number, | ||
| labels: [desired], | ||
| }); | ||
| } | ||
| for (const name of stateNames) { | ||
| if (name === desired || !current.has(name)) continue; | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, | ||
| repo, | ||
| issue_number: pr.number, | ||
| name, | ||
| }); | ||
| } catch (error) { | ||
| if (error.status !== 404) throw error; | ||
| } | ||
| } | ||
| } | ||
| async function setApprovalStatus(pr, approved, title, summary) { | ||
| const runs = await github.paginate(github.rest.checks.listForRef, { | ||
| owner, | ||
| repo, | ||
| ref: pr.head.sha, | ||
| check_name: checkName, | ||
| filter: 'all', | ||
| per_page: 100, | ||
| }); | ||
| const checks = runs.filter(run => run.name === checkName); | ||
| const check = checks[0]; | ||
| const output = { title, summary }; | ||
| const outcome = { | ||
| status: 'completed', | ||
| conclusion: approved ? 'success' : 'neutral', | ||
| completed_at: new Date().toISOString(), | ||
| }; | ||
| if (check) { | ||
| await github.rest.checks.update({ | ||
| owner, | ||
| repo, | ||
| check_run_id: check.id, | ||
| ...outcome, | ||
| details_url: detailsUrl, | ||
| output, | ||
| }); | ||
| } else { | ||
| await github.rest.checks.create({ | ||
| owner, | ||
| repo, | ||
| name: checkName, | ||
| head_sha: pr.head.sha, | ||
| ...outcome, | ||
| details_url: detailsUrl, | ||
| output, | ||
| }); | ||
| } | ||
| for (const older of checks.slice(1)) { | ||
| if (older.status === 'completed') continue; | ||
| await github.rest.checks.update({ | ||
| owner, | ||
| repo, | ||
| check_run_id: older.id, | ||
| ...outcome, | ||
| details_url: detailsUrl, | ||
| output, | ||
| }); | ||
| } | ||
| } | ||
| async function evaluate(pr) { | ||
| if (pr.draft) { | ||
| await setApprovalStatus( | ||
| pr, | ||
| false, | ||
| 'Draft pull request', | ||
| 'Maintainer review is not requested while this pull request is a draft.', | ||
| ); | ||
| await setReviewLabel(pr, null); | ||
| core.info(`#${pr.number}: draft, no approval required yet`); | ||
| return; | ||
| } | ||
| const [reviews, commits, files] = await Promise.all([ | ||
| github.paginate(github.rest.pulls.listReviews, { | ||
| owner, | ||
| repo, | ||
| pull_number: pr.number, | ||
| per_page: 100, | ||
| }), | ||
| github.paginate(github.rest.pulls.listCommits, { | ||
| owner, | ||
| repo, | ||
| pull_number: pr.number, | ||
| per_page: 100, | ||
| }), | ||
| github.paginate(github.rest.pulls.listFiles, { | ||
| owner, | ||
| repo, | ||
| pull_number: pr.number, | ||
| per_page: 100, | ||
| }), | ||
| ]); | ||
| // A review covers the current revision when every later commit is a | ||
| // merge commit that updates the branch. | ||
| const commitIndex = new Map(commits.map((commit, index) => [commit.sha, index])); | ||
| const coversCurrentRevision = review => { | ||
| const reviewedIndex = commitIndex.get(review.commit_id); | ||
| if (reviewedIndex === undefined) return false; | ||
| return commits | ||
| .slice(reviewedIndex + 1) | ||
| .every(commit => (commit.parents || []).length > 1); | ||
| }; | ||
| const maintainerReviews = reviews | ||
| .filter(review => | ||
| review.user && | ||
| review.user.type !== 'Bot' && | ||
| review.user.login !== pr.user.login && | ||
| maintainers.has(review.user.login.toLowerCase()) && | ||
| Number.isFinite(Date.parse(review.submitted_at || '')) | ||
| ) | ||
| .toSorted((a, b) => Date.parse(a.submitted_at) - Date.parse(b.submitted_at)); | ||
| const latestDecisionByReviewer = new Map(); | ||
| let lastReview = null; | ||
| for (const review of maintainerReviews) { | ||
| if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) { | ||
| latestDecisionByReviewer.set(review.user.login, review); | ||
| } | ||
| if (['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED'].includes(review.state)) { | ||
| lastReview = review; | ||
| } | ||
| } | ||
| const decisions = [...latestDecisionByReviewer.values()]; | ||
| const approvers = decisions | ||
| .filter(review => review.state === 'APPROVED' && coversCurrentRevision(review)) | ||
| .map(review => review.user.login); | ||
| const changeRequests = decisions.filter(review => review.state === 'CHANGES_REQUESTED'); | ||
| const privileged = files.some(file => | ||
| privilegedPaths.some(pattern => pattern.test(file.filename)) | ||
| ); | ||
| const required = privileged ? 2 : 1; | ||
| const met = changeRequests.length === 0 && approvers.length >= required; | ||
| let label; | ||
| if (met) { | ||
| label = 'review/approved'; | ||
| } else if (!lastReview) { | ||
| label = 'review/needs-review'; | ||
| } else if ( | ||
| !coversCurrentRevision(lastReview) || | ||
| changeRequests.some(review => !coversCurrentRevision(review)) | ||
| ) { | ||
| label = 'review/needs-rereview'; | ||
| } else if (changeRequests.length > 0 || lastReview.state === 'COMMENTED') { | ||
| label = 'review/awaiting-author'; | ||
| } else { | ||
| label = 'review/needs-review'; | ||
| } | ||
| if (met) { | ||
| await setApprovalStatus( | ||
| pr, | ||
| true, | ||
| 'Human Maintainer approval requirement met', | ||
| `Current revision approved by ${approvers.join(', ')}.`, | ||
| ); | ||
| } else if (changeRequests.length > 0) { | ||
| const requesters = changeRequests.map(review => review.user.login); | ||
| await setApprovalStatus( | ||
| pr, | ||
| false, | ||
| 'Changes requested by a human Maintainer', | ||
| `Current revision has ${approvers.length} of ${required} expected human Maintainer approvals. Changes are still requested by ${requesters.join(', ')}.`, | ||
| ); | ||
| } else { | ||
| const category = privileged ? 'governance or privileged supply-chain' : 'normal'; | ||
| await setApprovalStatus( | ||
| pr, | ||
| false, | ||
| 'Awaiting human Maintainer approval', | ||
| `${category} changes require ${required} human Maintainer approval${required === 1 ? '' : 's'} on the current revision; found ${approvers.length}.`, | ||
| ); | ||
| } | ||
| // Bot-authored PRs have a review status check but carry no review label. This | ||
| // also removes the label the reviewer adds to re-run the workflow. | ||
| await setReviewLabel(pr, pr.user.type === 'Bot' ? null : label); | ||
| core.info(`#${pr.number}: ${label}${met ? '' : ', approval pending'}`); | ||
| } | ||
| if (context.payload.pull_request) { | ||
| await evaluate(context.payload.pull_request); | ||
| return; | ||
| } | ||
| const prs = await github.paginate(github.rest.pulls.list, { | ||
| owner, | ||
| repo, | ||
| state: 'open', | ||
| per_page: 100, | ||
| }); | ||
| core.info(`Sweeping ${prs.length} open PR(s)`); | ||
| let failures = 0; | ||
| for (const pr of prs) { | ||
| try { | ||
| await evaluate(pr); | ||
| } catch (error) { | ||
| failures += 1; | ||
| core.warning(`#${pr.number}: failed to update approval state: ${error.message}`); | ||
| } | ||
| } | ||
| if (failures > 0) core.setFailed(`${failures} PR(s) could not be updated.`); | ||