Repository navigation
Relax PR merge rules, enforce them via CI #91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kolyshkin
wants to merge
2
commits into
opencontainers:main
Choose a base branch
from
kolyshkin:review-policy
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+217
−4
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| # Enforce the pull request merge rules described in MAINTAINERS_GUIDE.md | ||
| # ("Who decides what?"). This job is a required status check. | ||
| # | ||
| # It only runs when a review is submitted, edited, or dismissed, and evaluates | ||
| # the current state of the pull request. Other events (pull_request) are not | ||
| # used on purpose: branch protection requires the latest run for each event | ||
| # type to pass, so a failed pull_request run (say, "needs an LGTM") would keep | ||
| # blocking the merge after a review fixes it. This means: | ||
| # | ||
| # - after new commits are pushed, the check is missing (so the pull request | ||
| # can't be merged) until a review is submitted (a comment-only review by | ||
| # anyone, including the author, is fine); | ||
| # - if the check fails because of a review period, re-run it once the | ||
| # period is over. | ||
| # | ||
| # As with any pull_request_review workflow, this file is run from the pull | ||
| # request, so a pull request can change it. Therefore it is a governance file | ||
| # (see isGovernanceFile below), and changes to it must be reviewed carefully. | ||
| # Also, keep it read-only: do NOT add write permissions, secrets, or | ||
| # actions/checkout. | ||
| name: review-policy | ||
| on: | ||
| pull_request_review: | ||
| types: [submitted, edited, dismissed] | ||
|
|
||
| permissions: {} | ||
|
|
||
| jobs: | ||
| review-policy: | ||
| runs-on: ubuntu-26.04 | ||
| permissions: | ||
| contents: read | ||
| pull-requests: read | ||
| steps: | ||
| - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| with: | ||
| script: | | ||
| const { owner, repo } = context.repo; | ||
| const defaultBranch = context.payload.repository.default_branch; | ||
| // Review period for maintainer's pull requests with one LGTM. | ||
| const days = 7; | ||
| const largeDays = 14; | ||
| const largeLines = 100; | ||
|
|
||
| // MAINTAINERS lines look like "Name <email> (@login)". Always read | ||
| // it from the default branch, even for pull requests against | ||
| // release branches: this is the single source of truth. | ||
| const { data: file } = await github.rest.repos.getContent({ | ||
| owner, repo, path: 'MAINTAINERS', ref: defaultBranch, | ||
| }); | ||
| const maintainers = new Set( | ||
| Buffer.from(file.content, 'base64').toString() | ||
| .split('\n') | ||
| .map((line) => line.trim().split(' ').pop()) | ||
| .filter((word) => word.startsWith('(@') && word.endsWith(')')) | ||
| .map((word) => word.slice(2, -1).toLowerCase())); | ||
| if (maintainers.size === 0) { | ||
| core.setFailed('No maintainers found in MAINTAINERS.'); | ||
| return; | ||
| } | ||
| core.info(`Maintainers: ${[...maintainers].join(' ')}`); | ||
|
|
||
| // Use the current state rather than the event payload, which is stale | ||
| // when the job is re-run. | ||
| const { data: pr } = await github.rest.pulls.get({ | ||
| owner, repo, pull_number: context.payload.pull_request.number, | ||
| }); | ||
|
|
||
| // When the pull request was (last) marked as ready for review. | ||
| // A pull request opened as non-draft has no such event. | ||
| async function readySince(pr) { | ||
| // This returns all events (from all pages), oldest first. | ||
| const events = await github.paginate(github.rest.issues.listEvents, { | ||
| owner, repo, issue_number: pr.number, per_page: 100, | ||
| }); | ||
| const ready = events.filter((e) => e.event === 'ready_for_review').pop(); | ||
| return new Date(ready ? ready.created_at : pr.created_at); | ||
| } | ||
|
|
||
| // Files defining the project rules, or enforcing them. | ||
| function isGovernanceFile(f) { | ||
| return f === 'MAINTAINERS' || f === 'CODEOWNERS' || | ||
| (f.endsWith('.md') && !f.includes('/') && f !== 'README.md') || | ||
| f.startsWith('.github/workflows/review-policy'); | ||
| } | ||
|
|
||
| // Documentation and CI configuration. | ||
| function isRoutineFile(f) { | ||
| return f.endsWith('.md') || f.startsWith('.github/'); | ||
| } | ||
|
|
||
| // Returns 'governance', 'routine', or 'normal'. | ||
| function changeKind(pr, files) { | ||
| // The API returns at most 3000 files, so a governance file | ||
| // might be missing from the list. | ||
| if (files.length >= 3000) { | ||
| return 'governance'; | ||
| } | ||
| const names = files.flatMap((f) => | ||
| f.previous_filename ? [f.filename, f.previous_filename] : [f.filename]); | ||
| if (names.some(isGovernanceFile)) { | ||
| return 'governance'; | ||
| } | ||
| if (pr.user.login === 'dependabot[bot]' || names.every(isRoutineFile)) { | ||
| return 'routine'; | ||
| } | ||
| return 'normal'; | ||
| } | ||
|
|
||
| async function evaluate(pr) { | ||
| if (pr.draft) { | ||
| return ['pending', 'Draft pull request.']; | ||
| } | ||
| const author = pr.user.login.toLowerCase(); | ||
| const reviews = await github.paginate(github.rest.pulls.listReviews, { | ||
| owner, repo, pull_number: pr.number, per_page: 100, | ||
| }); | ||
| // The latest non-comment review of each reviewer is what counts. | ||
| const latest = new Map(); | ||
| for (const r of reviews) { | ||
| if (r.user && r.state !== 'COMMENTED' && r.state !== 'PENDING') { | ||
| latest.set(r.user.login.toLowerCase(), r.state); | ||
| } | ||
| } | ||
| const approved = []; | ||
| const blocked = []; | ||
| for (const [user, state] of latest) { | ||
| if (user === author || !maintainers.has(user)) { | ||
| continue; | ||
| } | ||
| if (state === 'APPROVED') { | ||
| approved.push(user); | ||
| } else if (state === 'CHANGES_REQUESTED') { | ||
| blocked.push(user); | ||
| } | ||
| } | ||
|
|
||
| if (blocked.length > 0) { | ||
| return ['failure', `Changes requested by ${blocked.join(', ')}.`]; | ||
| } | ||
| if (approved.length >= 2) { | ||
| return ['success', `LGTMs from ${approved.join(', ')}.`]; | ||
| } | ||
| const files = await github.paginate(github.rest.pulls.listFiles, { | ||
| owner, repo, pull_number: pr.number, per_page: 100, | ||
| }); | ||
| const kind = changeKind(pr, files); | ||
| if (kind === 'routine') { | ||
| if (approved.length === 1) { | ||
| return ['success', `LGTM from ${approved[0]} (routine change).`]; | ||
| } | ||
| return ['pending', 'Needs 1 maintainer LGTM (routine change).']; | ||
| } | ||
| if (kind === 'governance') { | ||
| return ['pending', `Needs 2 maintainer LGTMs (governance change), got ${approved.length}.`]; | ||
| } | ||
| if (approved.length === 0 || !maintainers.has(author)) { | ||
| return ['pending', `Needs 2 maintainer LGTMs, got ${approved.length}.`]; | ||
| } | ||
| const lines = files.reduce((sum, f) => sum + f.additions + f.deletions, 0); | ||
| const period = lines > largeLines ? largeDays : days; | ||
| const until = new Date((await readySince(pr)).getTime() + period * 86400 * 1000); | ||
| if (Date.now() >= until.getTime()) { | ||
| return ['success', `LGTM from ${approved[0]}, and ${period} days passed.`]; | ||
| } | ||
| const date = until.toISOString().slice(0, 16).replace('T', ' '); | ||
| return ['pending', `Needs another LGTM, or re-run this check after ${date} UTC.`]; | ||
| } | ||
|
|
||
| // Check runs belong to a commit, not to a pull request, so pull | ||
| // requests having the same head commit can't be told apart. | ||
| const open = await github.paginate(github.rest.pulls.list, { | ||
| owner, repo, state: 'open', per_page: 100, | ||
| }); | ||
| const shared = open | ||
| .filter((p) => p.head.sha === pr.head.sha) | ||
| .map((p) => `#${p.number}`); | ||
| const [state, description] = shared.length > 1 | ||
| ? ['failure', `Same head commit in ${shared.join(', ')}; push a different commit to all but one.`] | ||
| : await evaluate(pr); | ||
| core.info(`#${pr.number} (${pr.head.sha}): ${state}: ${description}`); | ||
| await core.summary | ||
| .addRaw(`${description} See [the rules](${context.serverUrl}/${owner}/${repo}/blob/${defaultBranch}/MAINTAINERS_GUIDE.md#who-decides-what).`) | ||
| .write(); | ||
| if (state !== 'success') { | ||
| core.setFailed(description); | ||
| } |
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
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we load an action yaml from the runc repo?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good idea actually; let me see if that's possible
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@AkihiroSuda looks like it's possible to have a single action for two repos but it needs a simple change in runc repo; opened #5573, PTAL
Once merged, here we pin it by sha and use from here like this: