Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 41 additions & 10 deletions .github/workflows/merge-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ on:

jobs:
merge-gate:
name: Maintainer Approval Required
name: merge-gate
runs-on: ubuntu-latest
permissions:
pull-requests: read
statuses: write
steps:
- name: Require maintainer approval
- name: Check maintainer approval
uses: actions/github-script@v7
with:
script: |
Expand All @@ -24,6 +25,16 @@ jobs:
const prAuthor = context.payload.pull_request.user.login;
const currentHead = context.payload.pull_request.head.sha;

// Get the latest commit's timestamp to detect stale reviews
// GitHub updates review.commit_id to current HEAD, making it
// unreliable for staleness checks. Timestamps are immutable.
const { data: headCommit } = await github.rest.repos.getCommit({
owner,
repo,
ref: currentHead,
});
const headCommitDate = new Date(headCommit.commit.committer.date);

// Get all reviews on this PR
const { data: reviews } = await github.rest.pulls.listReviews({
owner,
Expand All @@ -38,15 +49,16 @@ jobs:
}

// Check each approver's repo permission level
// Only count approvals against the current HEAD commit
// Only count approvals submitted AFTER the latest commit
const validApprovals = [];
const staleApprovals = [];
for (const [login, review] of latestReviews) {
if (review.state !== 'APPROVED') continue;
if (login === prAuthor) continue;

// Reject approvals made against an older commit
if (review.commit_id !== currentHead) {
// Reject approvals submitted before the latest commit
const reviewDate = new Date(review.submitted_at);
if (reviewDate < headCommitDate) {
staleApprovals.push(login);
continue;
}
Expand All @@ -62,13 +74,32 @@ jobs:
}
}

// Use the Commit Status API instead of core.setFailed()
// This ensures multiple runs for the same commit overwrite
// each other instead of creating conflicting check runs
if (validApprovals.length === 0) {
let message = `❌ Requires approval from a user with Maintain or Admin permission before merging.\n` +
`Contributors (Write permission) cannot satisfy this requirement.`;
let description = 'Requires approval from a Maintain/Admin user';
if (staleApprovals.length > 0) {
message += `\n\n⚠️ Stale approvals (new commits pushed since review): ${staleApprovals.join(', ')}`;
description = `Stale approvals (pushed after review): ${staleApprovals.join(', ')}`;
}
core.setFailed(message);
await github.rest.repos.createCommitStatus({
owner,
repo,
sha: currentHead,
state: 'failure',
context: 'Maintainer Approval Required',
description,
});
core.info(`❌ ${description}`);
} else {
core.info(`✅ Approved by maintainer(s): ${validApprovals.join(', ')}`);
const approvedBy = validApprovals.join(', ');
await github.rest.repos.createCommitStatus({
owner,
repo,
sha: currentHead,
state: 'success',
context: 'Maintainer Approval Required',
description: `Approved by: ${approvedBy}`,
});
core.info(`✅ Approved by maintainer(s): ${approvedBy}`);
}