Skip to content

Commit f2a098e

Browse files
authored
Merge branch 'master' into global-resolvers
2 parents 6784138 + b9e6f3b commit f2a098e

File tree

13 files changed

+1092
-75
lines changed

13 files changed

+1092
-75
lines changed
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
name: Release Proposal Approval Tracker
2+
3+
on:
4+
pull_request_review:
5+
types: [submitted, dismissed]
6+
pull_request:
7+
types: [labeled, unlabeled, synchronize, closed]
8+
9+
permissions:
10+
contents: read
11+
pull-requests: write
12+
issues: write
13+
14+
jobs:
15+
check-approvals:
16+
name: Track Maintainer Approvals
17+
runs-on: ubuntu-latest
18+
# Only run on PRs with release-proposal label
19+
if: contains(github.event.pull_request.labels.*.name, 'release-proposal') && github.event.pull_request.state == 'open'
20+
21+
steps:
22+
- name: Check approvals and update PR
23+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
24+
env:
25+
MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }}
26+
with:
27+
script: |
28+
const pr = context.payload.pull_request;
29+
30+
// Extract version from PR title (e.g., "Release Proposal: v1.2.3")
31+
const versionMatch = pr.title.match(/Release Proposal:\s*(v[\d.]+(?:-[\w.]+)?)/);
32+
const commitMatch = pr.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/);
33+
34+
if (!versionMatch || !commitMatch) {
35+
console.log('Could not extract version from title or commit from body');
36+
return;
37+
}
38+
39+
const version = versionMatch[1];
40+
const targetCommit = commitMatch[1];
41+
42+
console.log(`Version: ${version}, Target Commit: ${targetCommit}`);
43+
44+
// Get all reviews
45+
const reviews = await github.rest.pulls.listReviews({
46+
owner: context.repo.owner,
47+
repo: context.repo.repo,
48+
pull_number: pr.number
49+
});
50+
51+
// Get list of maintainers
52+
const maintainerLoginsRaw = process.env.MAINTAINER_LOGINS || '';
53+
const maintainerLogins = maintainerLoginsRaw
54+
.split(/[,;]/)
55+
.map(login => login.trim())
56+
.filter(login => login.length > 0);
57+
58+
console.log(`Maintainer logins: ${maintainerLogins.join(', ')}`);
59+
60+
// Get the latest review from each user
61+
const latestReviewsByUser = {};
62+
reviews.data.forEach(review => {
63+
const username = review.user.login;
64+
if (!latestReviewsByUser[username] || new Date(review.submitted_at) > new Date(latestReviewsByUser[username].submitted_at)) {
65+
latestReviewsByUser[username] = review;
66+
}
67+
});
68+
69+
// Count approvals from maintainers
70+
const maintainerApprovals = Object.entries(latestReviewsByUser)
71+
.filter(([username, review]) =>
72+
maintainerLogins.includes(username) &&
73+
review.state === 'APPROVED'
74+
)
75+
.map(([username, review]) => username);
76+
77+
const approvalCount = maintainerApprovals.length;
78+
console.log(`Found ${approvalCount} maintainer approvals from: ${maintainerApprovals.join(', ')}`);
79+
80+
// Get current labels
81+
const currentLabels = pr.labels.map(label => label.name);
82+
const hasApprovedLabel = currentLabels.includes('approved');
83+
const hasAwaitingApprovalLabel = currentLabels.includes('awaiting-approval');
84+
85+
if (approvalCount >= 2 && !hasApprovedLabel) {
86+
console.log('✅ Quorum reached! Updating PR...');
87+
88+
// Remove awaiting-approval label if present
89+
if (hasAwaitingApprovalLabel) {
90+
await github.rest.issues.removeLabel({
91+
owner: context.repo.owner,
92+
repo: context.repo.repo,
93+
issue_number: pr.number,
94+
name: 'awaiting-approval'
95+
}).catch(e => console.log('Label not found:', e.message));
96+
}
97+
98+
// Add approved label
99+
await github.rest.issues.addLabels({
100+
owner: context.repo.owner,
101+
repo: context.repo.repo,
102+
issue_number: pr.number,
103+
labels: ['approved']
104+
});
105+
106+
// Add comment with tagging instructions
107+
const approversList = maintainerApprovals.map(u => `@${u}`).join(', ');
108+
const commentBody = [
109+
'## ✅ Approval Quorum Reached',
110+
'',
111+
`This release proposal has been approved by ${approvalCount} maintainers: ${approversList}`,
112+
'',
113+
'### Tagging Instructions',
114+
'',
115+
'A maintainer should now create and push the signed tag:',
116+
'',
117+
'```bash',
118+
`git checkout ${targetCommit}`,
119+
`git tag -s ${version} -m "Release ${version}"`,
120+
`git push origin ${version}`,
121+
`git checkout -`,
122+
'```',
123+
'',
124+
'The release workflow will automatically start when the tag is pushed.'
125+
].join('\n');
126+
127+
await github.rest.issues.createComment({
128+
owner: context.repo.owner,
129+
repo: context.repo.repo,
130+
issue_number: pr.number,
131+
body: commentBody
132+
});
133+
134+
console.log('Posted tagging instructions');
135+
} else if (approvalCount < 2 && hasApprovedLabel) {
136+
console.log('⚠️ Approval count dropped below quorum, removing approved label');
137+
138+
// Remove approved label
139+
await github.rest.issues.removeLabel({
140+
owner: context.repo.owner,
141+
repo: context.repo.repo,
142+
issue_number: pr.number,
143+
name: 'approved'
144+
}).catch(e => console.log('Label not found:', e.message));
145+
146+
// Add awaiting-approval label
147+
if (!hasAwaitingApprovalLabel) {
148+
await github.rest.issues.addLabels({
149+
owner: context.repo.owner,
150+
repo: context.repo.repo,
151+
issue_number: pr.number,
152+
labels: ['awaiting-approval']
153+
});
154+
}
155+
} else {
156+
console.log(`⏳ Waiting for more approvals (${approvalCount}/2 required)`);
157+
}
158+
159+
handle-pr-closed:
160+
name: Handle PR Closed Without Tag
161+
runs-on: ubuntu-latest
162+
if: |
163+
contains(github.event.pull_request.labels.*.name, 'release-proposal') &&
164+
github.event.action == 'closed' && !contains(github.event.pull_request.labels.*.name, 'released')
165+
166+
steps:
167+
- name: Add cancelled label and comment
168+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
169+
with:
170+
script: |
171+
const pr = context.payload.pull_request;
172+
173+
// Check if the release-in-progress label is present
174+
const hasReleaseInProgress = pr.labels.some(label => label.name === 'release-in-progress');
175+
176+
if (hasReleaseInProgress) {
177+
// PR was closed while release was in progress - this is unusual
178+
await github.rest.issues.createComment({
179+
owner: context.repo.owner,
180+
repo: context.repo.repo,
181+
issue_number: pr.number,
182+
body: '⚠️ **Warning:** This PR was closed while a release was in progress. This may indicate an error. Please verify the release status.'
183+
});
184+
} else {
185+
// PR was closed before tag was created - this is normal cancellation
186+
const versionMatch = pr.title.match(/Release Proposal:\s*(v[\d.]+(?:-[\w.]+)?)/);
187+
const version = versionMatch ? versionMatch[1] : 'unknown';
188+
189+
await github.rest.issues.createComment({
190+
owner: context.repo.owner,
191+
repo: context.repo.repo,
192+
issue_number: pr.number,
193+
body: `## 🚫 Release Proposal Cancelled\n\nThis release proposal for ${version} was closed without creating the tag.\n\nIf you want to proceed with this release later, you can create a new release proposal.`
194+
});
195+
}
196+
197+
// Add cancelled label
198+
await github.rest.issues.addLabels({
199+
owner: context.repo.owner,
200+
repo: context.repo.repo,
201+
issue_number: pr.number,
202+
labels: ['cancelled']
203+
});
204+
205+
// Remove other workflow labels if present
206+
const labelsToRemove = ['awaiting-approval', 'approved', 'release-in-progress'];
207+
for (const label of labelsToRemove) {
208+
try {
209+
await github.rest.issues.removeLabel({
210+
owner: context.repo.owner,
211+
repo: context.repo.repo,
212+
issue_number: pr.number,
213+
name: label
214+
});
215+
} catch (e) {
216+
console.log(`Label ${label} not found or already removed`);
217+
}
218+
}
219+
220+
console.log('Added cancelled label and cleaned up workflow labels');
221+

0 commit comments

Comments
 (0)