{{ message }}
Repository navigation
Expand file tree
/
Copy pathrelease-readiness.cjs
More file actions
309 lines (291 loc) · 10.6 KB
/
Copy pathrelease-readiness.cjs
File metadata and controls
309 lines (291 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
const checkName = 'release-readiness'
const releaseBranchPattern = /^chore\/release-/
const noVersionChangeTitle = 'No package version change'
function isReleaseBranch(pull) {
return releaseBranchPattern.test(pull.head.ref)
}
async function contentAt(github, repo, ref, path) {
const { data } = await github.rest.repos.getContent({ ...repo, path, ref })
if (data.type !== 'file' || data.encoding !== 'base64') {
throw new Error(`Cannot read ${path} at ${ref}`)
}
return Buffer.from(data.content, 'base64').toString('utf8')
}
async function versionAt(github, repo, ref) {
const { version } = JSON.parse(await contentAt(github, repo, ref, 'package.json'))
if (typeof version !== 'string' || !version.trim()) {
throw new Error(`Missing package.json version at ${ref}`)
}
return version
}
function result(conclusion, title, summary) {
return { conclusion, title, summary }
}
function stableVersion(version) {
if (typeof version !== 'string' || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) {
throw new Error(`Expected a stable release version, received ${version}`)
}
return version.split('.').map(BigInt)
}
function allowedVersions(baseVersion, recommendedVersion) {
const [major, minor, patch] = stableVersion(baseVersion)
const recommended = stableVersion(recommendedVersion)
const increments = [
[major, minor, patch + 1n],
[major, minor + 1n, 0n],
[major + 1n, 0n, 0n],
]
return increments
.filter(increment => {
const difference = increment.findIndex((part, index) => part !== recommended[index])
return difference === -1 || increment[difference] > recommended[difference]
})
.map(increment => increment.join('.'))
}
async function recommendedVersionAt(mainSha, cwd = process.cwd()) {
const { execFile } = require('node:child_process')
const { promisify } = require('node:util')
const execute = promisify(execFile)
const options = { cwd, timeout: 120_000, maxBuffer: 10 * 1024 * 1024 }
const { stdout: checkedOutSha } = await execute('git', ['rev-parse', 'HEAD'], options)
if (checkedOutSha.trim() !== mainSha) {
throw new Error('Checked-out main is stale. Run Release Readiness again.')
}
const { stdout: latestTag } = await execute(
'git',
['describe', '--tags', '--match=*', '--abbrev=0'],
options,
)
const plugin = '--plugins.@release-it/conventional-changelog'
const { stdout } = await execute(
process.execPath,
[
require('node:path').join(require.resolve('release-it/package.json'), '../bin/release-it.js'),
'--release-version',
'--dry-run',
'--no-git',
`${plugin}.commitsOpts.from=${latestTag.trim()}`,
`${plugin}.gitRawCommitsOpts.from=${latestTag.trim()}`,
],
options,
)
const version = stdout.trim()
stableVersion(version)
return version
}
async function evaluatePullRequest(
github,
repo,
pull,
mainSha,
recommendVersion = recommendedVersionAt,
) {
const headSha = pull.head.sha
const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
...repo,
basehead: `${mainSha}...${headSha}`,
per_page: 1,
})
const [baseVersion, headVersion] = await Promise.all([
versionAt(github, repo, comparison.merge_base_commit.sha),
versionAt(github, repo, headSha),
])
if (baseVersion === headVersion) {
return result('success', noVersionChangeTitle, 'Release-specific review is not required.')
}
if (!['ahead', 'identical'].includes(comparison.status)) {
return result(
'failure',
'Release branch is behind main',
'Update the branch with main, refresh the changelog, then request approval of the new commit.',
)
}
const recommendedVersion = await recommendVersion(mainSha)
const allowed = allowedVersions(baseVersion, recommendedVersion)
if (!allowed.includes(headVersion)) {
return result(
'failure',
'Release version does not match release-it',
`release-it recommends ${recommendedVersion}. Set package.json to one of: ${allowed.join(', ')}. Received ${headVersion}.`,
)
}
const changelog = await contentAt(github, repo, headSha, 'CHANGELOG.md')
const firstEntry = changelog.match(/^##[ \t]+(.+)$/m)?.[1]
if (!firstEntry?.startsWith(`[${headVersion}]`)) {
return result(
'failure',
'Changelog version does not match package.json',
`The first CHANGELOG.md entry must be ## [${headVersion}].`,
)
}
const reviews = await github.paginate(github.rest.pulls.listReviews, {
...repo,
pull_number: pull.number,
per_page: 100,
})
const latestDecisions = new Map()
for (const review of [...reviews].sort((left, right) => left.id - right.id)) {
if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) {
latestDecisions.set(review.user.login, review)
}
}
let approved = false
for (const review of latestDecisions.values()) {
if (review.user.login === pull.user.login || review.user.type !== 'User') continue
if (!['APPROVED', 'CHANGES_REQUESTED'].includes(review.state)) continue
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
...repo,
username: review.user.login,
})
if (!['write', 'maintain', 'admin'].includes(data.permission)) continue
if (review.state === 'CHANGES_REQUESTED') {
return result('failure', 'Changes requested', 'Resolve outstanding maintainer reviews.')
}
if (review.commit_id === headSha) approved = true
}
return approved
? result('success', 'Release commit approved', `${headVersion} is approved at ${headSha}.`)
: result(
'failure',
'Latest release commit needs approval',
`Ask another maintainer to approve ${headSha}. Any new commit, including an update from main, requires fresh approval.`,
)
}
async function queueEntries(github, repo) {
const entries = []
let after = null
do {
const data = await github.graphql(
`query($owner: String!, $repo: String!, $after: String) {
repository(owner: $owner, name: $repo) {
mergeQueue(branch: "main") {
entries(first: 100, after: $after) {
nodes { position headCommit { oid } pullRequest { number headRefOid } }
pageInfo { hasNextPage endCursor }
}
}
}
}`,
{ ...repo, after },
)
const page = data.repository.mergeQueue?.entries
entries.push(...(page?.nodes ?? []))
after = page?.pageInfo.hasNextPage ? page.pageInfo.endCursor : null
} while (after)
return entries.sort((left, right) => left.position - right.position)
}
function queueMembers(entries, sha) {
const matching = entries.filter(entry => entry.headCommit?.oid === sha)
if (!matching.length) return []
const position = Math.max(...matching.map(entry => entry.position))
return entries.filter(entry => entry.position <= position)
}
async function mainCommit(github, repo) {
const { data } = await github.rest.git.getRef({ ...repo, ref: 'heads/main' })
return data.object.sha
}
async function stillCurrent(github, repo, pulls, mainSha) {
if ((await mainCommit(github, repo)) !== mainSha) return false
for (const pull of pulls) {
const { data } = await github.rest.pulls.get({ ...repo, pull_number: pull.number })
if (data.state !== 'open' || data.base.ref !== 'main' || data.head.sha !== pull.head.sha) {
return false
}
}
return true
}
async function report(github, repo, sha, evaluate) {
const { data: check } = await github.rest.checks.create({
...repo,
name: checkName,
head_sha: sha,
status: 'in_progress',
})
let outcome
try {
outcome = await evaluate()
} catch (error) {
outcome = result('failure', 'Unable to verify release readiness', error.message)
}
await github.rest.checks.update({
...repo,
check_run_id: check.id,
status: 'completed',
conclusion: outcome.conclusion,
output: { title: outcome.title, summary: outcome.summary },
})
return outcome
}
async function run({ github, context, core, recommendVersion = recommendedVersionAt }) {
const repo = context.repo
const mainSha = await mainCommit(github, repo)
const pulls = await github.paginate(github.rest.pulls.list, {
...repo,
state: 'open',
base: 'main',
per_page: 100,
})
const outcomes = new Map()
let recommendation
const cachedRecommendation = () => (recommendation ??= recommendVersion(mainSha))
for (const pull of pulls) {
const outcome = await report(github, repo, pull.head.sha, async () => {
if (!isReleaseBranch(pull)) {
return result('success', 'Not a release branch', 'Release-specific review is not required.')
}
const evaluated = await evaluatePullRequest(github, repo, pull, mainSha, cachedRecommendation)
if (evaluated.title === noVersionChangeTitle) {
return evaluated
}
if (!(await stillCurrent(github, repo, [pull], mainSha))) {
return result(
'failure',
'PR or main changed during evaluation',
'Run Release Readiness again.',
)
}
return evaluated
})
outcomes.set(pull.number, { pull, outcome })
core.info(`#${pull.number}: ${outcome.title}`)
}
const entries = await queueEntries(github, repo)
const groupShas = new Set(entries.map(entry => entry.headCommit?.oid).filter(Boolean))
for (const sha of groupShas) {
await report(github, repo, sha, async () => {
const members = queueMembers(entries, sha)
const memberPulls = []
for (const entry of members) {
const evaluated = outcomes.get(entry.pullRequest.number)
if (!evaluated || evaluated.pull.head.sha !== entry.pullRequest.headRefOid) {
return result(
'failure',
'Merge queue changed during evaluation',
'Run Release Readiness again.',
)
}
if (evaluated.outcome.conclusion !== 'success') return evaluated.outcome
memberPulls.push(evaluated.pull)
}
if (memberPulls.some(isReleaseBranch)) {
const currentMembers = queueMembers(await queueEntries(github, repo), sha)
if (
JSON.stringify(currentMembers) !== JSON.stringify(members) ||
!(await stillCurrent(github, repo, memberPulls, mainSha))
) {
return result(
'failure',
'Merge queue changed during evaluation',
'Run Release Readiness again.',
)
}
}
return result(
'success',
'Queued PRs are ready',
'Approval was checked against each original PR head.',
)
})
}
}
module.exports = { allowedVersions, evaluatePullRequest, recommendedVersionAt, queueMembers, run }
You can’t perform that action at this time.
