-
Notifications
You must be signed in to change notification settings - Fork 31.9k
Expand file tree
/
Copy pathrelease-github-api.js
More file actions
290 lines (254 loc) · 9.04 KB
/
Copy pathrelease-github-api.js
File metadata and controls
290 lines (254 loc) · 9.04 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
// @ts-check
const execa = require('execa')
const fs = require('fs/promises')
const semver = require('semver')
const {
replayLocalCommitsAsSigned,
githubRequest,
alignLocalBranchWithSignedCommit,
} = require('./github-utils/signed-commit')
const { generateChangelog } = require('./release-changelog')
const REPO_API_PATH = '/repos/vercel/next.js'
async function git(args, options = {}) {
const { captureOutput = false, ...execaOptions } = options
const { stdout } = await execa('git', args, {
stdio: captureOutput ? 'pipe' : 'inherit',
...execaOptions,
})
return typeof stdout === 'string' ? stdout.trim() : stdout
}
/**
* Verify the local Lerna release commit has the version tag implied by
* lerna.json, then return that tag name for GitHub ref creation.
*/
async function getLocalReleaseTagName(commitSha) {
const { version } = JSON.parse(await fs.readFile('lerna.json', 'utf8'))
const expectedTagName = `v${version}`
const tags = String(
await git(['tag', '--points-at', commitSha], { captureOutput: true })
)
.split('\n')
.map((tag) => tag.trim())
.filter(Boolean)
if (!tags.includes(expectedTagName)) {
throw new Error(
`Expected local Lerna release commit ${commitSha} to be tagged with ${expectedTagName}; found ${tags.join(
', '
)}`
)
}
return expectedTagName
}
/**
* Return the local Lerna release commit's single parent so the GitHub-created
* commit can replay the same tree change on top of the same base commit.
*/
async function getSingleParent(commitSha) {
const revList = String(
await git(['rev-list', '--parents', '-n', '1', commitSha], {
captureOutput: true,
})
)
// git rev-list --parents emits "<commit> <parent...>".
const [, ...parents] = revList.split(' ')
if (parents.length !== 1) {
throw new Error(
`Expected release commit ${commitSha} to have exactly one parent; found ${parents.length}`
)
}
return parents[0]
}
/**
* Replace Lerna's local release commit(s) with equivalent GitHub-signed
* commits, then move the release tag and current branch in a single branch
* push.
*
* Signs every local commit between the remote base and local HEAD. The release
* tag is placed on the signed commit that corresponds to the local Lerna
* release commit; the branch is fast-forwarded to the final signed commit.
*
* For a normal release this is a single commit (tag == branch head). For an
* ad-hoc preview release the local history is two commits — the preview
* version bump (tagged) followed by a revert restoring the canary version — so
* the tag points at the preview commit while the branch ends on the revert.
* `options.baseSha` and `options.tagName` let the caller pin both explicitly
* (required for preview, since after the revert `lerna.json` no longer matches
* HEAD).
*
* `options.githubRequest`
* @param {string} token GitHub API token with repo access
* @param {object} options
* @param {string} [options.baseSha] The remote base commit to replay on top of
* @param {string} [options.tagName] The release tag name to create
* @param {import('./github-utils/signed-commit').githubRequest} [options.githubRequest]
* A custom GitHub client e.g. for using a logging mock when doing a dry run.
*/
async function createGitHubReleaseCommit(token, options = {}) {
const request = options.githubRequest ?? githubRequest
const usingMockClient = options.githubRequest != null
const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'], {
captureOutput: true,
})
if (branch === 'HEAD') {
throw new Error('Cannot create a GitHub release commit from detached HEAD')
}
const localHead = await git(['rev-parse', 'HEAD'], {
captureOutput: true,
})
const baseSha = options.baseSha ?? (await getSingleParent(localHead))
const tagName = options.tagName ?? (await getLocalReleaseTagName(localHead))
const localTaggedSha = await git(['rev-list', '-n', '1', tagName], {
captureOutput: true,
})
console.log(
`Creating GitHub-signed release commit(s) for ${tagName} from local commits ${baseSha}..${localHead}`
)
const { headSha, signedCommits } = await replayLocalCommitsAsSigned({
token,
owner: 'vercel',
repo: 'next.js',
fromBaseSha: baseSha,
toLocalSha: localHead,
request,
})
const taggedCommit = signedCommits.find(
(entry) => entry.localSha === localTaggedSha
)
if (!taggedCommit) {
throw new Error(
`Could not find a signed commit for the release tag ${tagName} (local ${localTaggedSha})`
)
}
const signedTagSha = taggedCommit.signedSha
let createdTag = false
try {
await request(token, 'POST', `${REPO_API_PATH}/git/refs`, {
ref: `refs/tags/${tagName}`,
sha: signedTagSha,
})
createdTag = true
await request(token, 'PATCH', `${REPO_API_PATH}/git/refs/heads/${branch}`, {
sha: headSha,
force: false,
})
} catch (error) {
if (createdTag) {
await request(
token,
'DELETE',
`${REPO_API_PATH}/git/refs/tags/${tagName}`
).catch((deleteError) => {
console.error(`Failed to delete ${tagName} after release failure`)
console.error(deleteError)
})
}
throw error
}
if (usingMockClient) {
// The signed commits only exist in the mock; there is nothing on the remote
// to sync the local branch against.
console.log(
`Dry run: skipping local branch sync; would set ${branch} to ${headSha} and tag ${tagName} at ${signedTagSha}`
)
} else {
await alignLocalBranchWithSignedCommit(branch, headSha, { tagName })
}
console.log(
`Created GitHub-signed release tag ${tagName} at ${signedTagSha}; branch ${branch} now at ${headSha}`
)
return {
branch,
sha: signedTagSha,
tagName,
headSha,
baseSha,
}
}
/**
* Find the previous release tag for a changelog range: the highest-semver tag
* reachable from `tagCommitSha` whose version is below `newVersion`. Returns
* `null` when there is no earlier tag (e.g. the very first release).
*/
async function getPreviousReleaseTag(tagCommitSha, newVersion) {
const output = String(
await git(['tag', '--merged', tagCommitSha, 'v*'], { captureOutput: true })
)
const previous = output
.split('\n')
.map((tag) => tag.trim())
.filter(Boolean)
.map((tag) => ({ tag, version: semver.valid(tag.replace(/^v/, '')) }))
.filter(
(entry) => entry.version != null && semver.lt(entry.version, newVersion)
)
.sort((a, b) => semver.rcompare(a.version, b.version))[0]
return previous ? previous.tag : null
}
/**
* List the commits that make up a release, newest range endpoint inclusive,
* excluding merge commits and the version-bump commits Lerna creates (whose
* title is itself a version like `v16.3.0-canary.62`).
*/
async function getReleaseCommits(fromTag, tagCommitSha) {
const range = fromTag ? `${fromTag}..${tagCommitSha}` : tagCommitSha
const output = String(
await git(['log', '--no-merges', '--format=%H%x1f%s', range], {
captureOutput: true,
})
)
return output
.split('\n')
.filter(Boolean)
.map((line) => {
const [hash, title] = line.split('\x1f')
return { hash, title }
})
.filter((commit) => semver.valid(commit.title.replace(/^v/, '')) == null)
}
/**
* Create the GitHub release for an exact, already-created tag, replacing the
* third-party `release` CLI (which always published the highest-semver tag
* reachable from HEAD -- once an ad-hoc `@preview` tag lands on canary it would
* hijack every canary release). The release is created as a prerelease draft;
* `publish-release.js` un-drafts it once the npm publish succeeds.
*
* @param {string} token GitHub API token with repo access
* @param {object} options
* @param {string} options.tagName The release tag name to create
* @param {import('./github-utils/signed-commit').githubRequest} [options.githubRequest]
* A custom GitHub client e.g. for using a logging mock when doing a dry run.
*/
async function createGitHubRelease(
token,
{ tagName, githubRequest: request = githubRequest }
) {
const newVersion = tagName.replace(/^v/, '')
const tagCommitSha = await git(['rev-list', '-n', '1', tagName], {
captureOutput: true,
})
const previousTag = await getPreviousReleaseTag(tagCommitSha, newVersion)
const commits = await getReleaseCommits(previousTag, tagCommitSha)
const getPullRequest = (number) =>
request(token, 'GET', `${REPO_API_PATH}/pulls/${number}`).catch((error) => {
console.warn(`Failed to fetch PR #${number} for changelog: ${error}`)
return null
})
const changelog = await generateChangelog({ commits, getPullRequest })
console.log(
`Creating GitHub release ${tagName} (changelog range ${
previousTag ?? '(initial)'
}..${tagName}, ${commits.length} commits)`
)
await request(token, 'POST', `${REPO_API_PATH}/releases`, {
tag_name: tagName,
name: tagName,
body: changelog || 'Initial release',
prerelease: true,
draft: true,
})
console.log(`Created draft prerelease ${tagName}`)
}
module.exports = {
createGitHubReleaseCommit,
createGitHubRelease,
}