Skip to content

Commit a8896ee

Browse files
authored
update release script to also create pr (#4880)
1 parent 25ae8e7 commit a8896ee

File tree

5 files changed

+174
-65
lines changed

5 files changed

+174
-65
lines changed

.gitignore

-1
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@ typings/
106106

107107
# End of https://www.gitignore.io/api/node,macos,visualstudiocode
108108

109-
.github/notes
110109
.next
111110
package-lock.json
112111
out

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"type:test": "cd docs && yarn && yarn test",
1616
"lint": "node scripts/check_licenses.js && eslint . && yarn audit",
1717
"lint-fix": "node scripts/check_licenses.js && eslint . --fix && yarn audit",
18+
"release:proposal": "node scripts/release/proposal",
1819
"services": "node ./scripts/install_plugin_modules && node packages/dd-trace/test/setup/services",
1920
"test": "SERVICES=* yarn services && mocha --expose-gc 'packages/dd-trace/test/setup/node.js' 'packages/*/test/**/*.spec.js'",
2021
"test:appsec": "mocha -r \"packages/dd-trace/test/setup/mocha.js\" --exclude \"packages/dd-trace/test/appsec/**/*.plugin.spec.js\" \"packages/dd-trace/test/appsec/**/*.spec.js\"",
+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use strict'
2+
3+
/* eslint-disable max-len */
4+
5+
const { capture, fatal } = require('./terminal')
6+
7+
const requiredScopes = ['public_repo', 'read:org']
8+
9+
// Check that the `git` CLI is installed.
10+
function checkGit () {
11+
try {
12+
capture('git --version')
13+
} catch (e) {
14+
fatal(
15+
'The "git" CLI could not be found.',
16+
'Please visit https://git-scm.com/downloads for instructions to install.'
17+
)
18+
}
19+
}
20+
21+
// Check that the `branch-diff` CLI is installed.
22+
function checkBranchDiff () {
23+
try {
24+
capture('branch-diff --version')
25+
} catch (e) {
26+
const link = [
27+
'https://datadoghq.atlassian.net/wiki/spaces/DL/pages/3125511269/Node.js+Tracer+Release+Process',
28+
'#Install-and-Configure-branch-diff-to-automate-some-operations'
29+
].join('')
30+
fatal(
31+
'The "branch-diff" CLI could not be found.',
32+
`Please visit ${link} for instructions to install.`
33+
)
34+
}
35+
}
36+
37+
// Check that the `gh` CLI is installed and authenticated.
38+
function checkGitHub () {
39+
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
40+
const link = 'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic'
41+
42+
fatal(
43+
'The GITHUB_TOKEN environment variable is missing.',
44+
`Please visit ${link} for instructions to generate a personal access token.`,
45+
`The following scopes are required when generating the token: ${requiredScopes.join(', ')}`
46+
)
47+
}
48+
49+
try {
50+
capture('gh --version')
51+
} catch (e) {
52+
fatal(
53+
'The "gh" CLI could not be found.',
54+
'Please visit https://github.com/cli/cli#installation for instructions to install.'
55+
)
56+
}
57+
58+
checkGitHubScopes()
59+
}
60+
61+
// Check that the active GITHUB_TOKEN has the required scopes.
62+
function checkGitHubScopes () {
63+
const url = 'https://api.github.com'
64+
const headers = [
65+
'Accept: application/vnd.github.v3+json',
66+
`Authorization: Bearer ${process.env.GITHUB_TOKEN || process.env.GH_TOKEN}`,
67+
'X-GitHub-Api-Version: 2022-11-28'
68+
].map(h => `-H "${h}"`).join(' ')
69+
70+
const lines = capture(`curl -sS -I ${headers} ${url}`).trim().split(/\r?\n/g)
71+
const scopeLine = lines.find(line => line.startsWith('x-oauth-scopes:')) || ''
72+
const scopes = scopeLine.replace('x-oauth-scopes:', '').trim().split(', ')
73+
const link = 'https://github.com/settings/tokens'
74+
75+
for (const req of requiredScopes) {
76+
if (!scopes.includes(req)) {
77+
fatal(
78+
`Missing "${req}" scope for GITHUB_TOKEN.`,
79+
`Please visit ${link} and make sure the following scopes are enabled: ${requiredScopes.join(' ,')}.`
80+
)
81+
}
82+
}
83+
}
84+
85+
module.exports = { checkBranchDiff, checkGitHub, checkGit }

scripts/release/helpers/terminal.js

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use strict'
2+
3+
/* eslint-disable no-console */
4+
5+
const { execSync, spawnSync } = require('child_process')
6+
7+
// Helpers for colored output.
8+
const log = (...msgs) => msgs.forEach(msg => console.log(msg))
9+
const success = (...msgs) => msgs.forEach(msg => console.log(`\x1b[32m${msg}\x1b[0m`))
10+
const error = (...msgs) => msgs.forEach(msg => console.log(`\x1b[31m${msg}\x1b[0m`))
11+
const whisper = (...msgs) => msgs.forEach(msg => console.log(`\x1b[90m${msg}\x1b[0m`))
12+
13+
// Helpers for exiting with a message.
14+
const exit = (...msgs) => log(...msgs) || process.exit(0)
15+
const fatal = (...msgs) => error(...msgs) || process.exit(1)
16+
17+
// Output a command to the terminal and execute it.
18+
function run (cmd) {
19+
whisper(`> ${cmd}`)
20+
21+
const output = execSync(cmd, {}).toString()
22+
23+
log(output)
24+
}
25+
26+
// Ask a question in terminal and return the response.
27+
function prompt (question) {
28+
process.stdout.write(`${question} `)
29+
30+
const child = spawnSync('bash', ['-c', 'read answer && echo $answer'], {
31+
stdio: ['inherit']
32+
})
33+
34+
return child.stdout.toString()
35+
}
36+
37+
// Ask whether to continue and otherwise exit the process.
38+
function checkpoint (question) {
39+
const answer = prompt(`${question} [Y/n]`).trim()
40+
41+
if (answer && answer.toLowerCase() !== 'y') {
42+
process.exit(0)
43+
}
44+
}
45+
46+
// Run a command and capture its output to return it to the caller.
47+
function capture (cmd) {
48+
return execSync(cmd, {}).toString()
49+
}
50+
51+
module.exports = { capture, checkpoint, error, exit, fatal, log, success, run, whisper }

scripts/release/proposal.js

+37-64
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,23 @@
11
'use strict'
22

3-
/* eslint-disable no-console */
4-
53
// TODO: Support major versions.
64

7-
const { execSync } = require('child_process')
85
const fs = require('fs')
6+
const os = require('os')
97
const path = require('path')
8+
const { capture, checkpoint, exit, fatal, success, run } = require('./helpers/terminal')
9+
const { checkBranchDiff, checkGitHub, checkGit } = require('./helpers/requirements')
1010

11-
// Helpers for colored output.
12-
const log = msg => console.log(msg)
13-
const success = msg => console.log(`\x1b[32m${msg}\x1b[0m`)
14-
const error = msg => console.log(`\x1b[31m${msg}\x1b[0m`)
15-
const whisper = msg => console.log(`\x1b[90m${msg}\x1b[0m`)
11+
checkGit()
12+
checkBranchDiff()
1613

17-
const currentBranch = capture('git branch --show-current')
1814
const releaseLine = process.argv[2]
1915

2016
// Validate release line argument.
2117
if (!releaseLine || releaseLine === 'help' || releaseLine === '--help') {
22-
log('Usage: node scripts/release/proposal <release-line> [release-type]')
23-
process.exit(0)
18+
exit('Usage: node scripts/release/proposal <release-line> [release-type]')
2419
} else if (!releaseLine?.match(/^\d+$/)) {
25-
error('Invalid release line. Must be a whole number.')
26-
process.exit(1)
20+
fatal('Invalid release line. Must be a whole number.')
2721
}
2822

2923
// Make sure the release branch is up to date to prepare for new proposal.
@@ -36,20 +30,21 @@ const diffCmd = [
3630
'branch-diff',
3731
'--user DataDog',
3832
'--repo dd-trace-js',
39-
isActivePatch()
40-
? `--exclude-label=semver-major,semver-minor,dont-land-on-v${releaseLine}.x`
41-
: `--exclude-label=semver-major,dont-land-on-v${releaseLine}.x`
33+
`--exclude-label=semver-major,dont-land-on-v${releaseLine}.x`
4234
].join(' ')
4335

44-
// Determine the new version.
45-
const [lastMajor, lastMinor, lastPatch] = require('../../package.json').version.split('.').map(Number)
46-
const lineDiff = capture(`${diffCmd} v${releaseLine}.x master`)
36+
// Determine the new version and release notes location.
37+
const [, lastMinor, lastPatch] = require('../../package.json').version.split('.').map(Number)
38+
const lineDiff = capture(`${diffCmd} --markdown=true v${releaseLine}.x master`)
4739
const newVersion = lineDiff.includes('SEMVER-MINOR')
4840
? `${releaseLine}.${lastMinor + 1}.0`
4941
: `${releaseLine}.${lastMinor}.${lastPatch + 1}`
42+
const notesDir = path.join(os.tmpdir(), 'release_notes')
43+
const notesFile = path.join(notesDir, `${newVersion}.md`)
5044

51-
// Checkout new branch and output new changes.
45+
// Checkout new or existing branch.
5246
run(`git checkout v${newVersion}-proposal || git checkout -b v${newVersion}-proposal`)
47+
run(`git remote show origin | grep v${newVersion} && git pull || exit 0`)
5348

5449
// Get the hashes of the last version and the commits to add.
5550
const lastCommit = capture('git log -1 --pretty=%B').trim()
@@ -69,60 +64,38 @@ if (proposalDiff) {
6964
try {
7065
run(`echo "${proposalDiff}" | xargs git cherry-pick`)
7166
} catch (err) {
72-
error('Cherry-pick failed. Resolve the conflicts and run `git cherry-pick --continue` to continue.')
73-
error('When all conflicts have been resolved, run this script again.')
74-
process.exit(1)
67+
fatal(
68+
'Cherry-pick failed. Resolve the conflicts and run `git cherry-pick --continue` to continue.',
69+
'When all conflicts have been resolved, run this script again.'
70+
)
7571
}
7672
}
7773

7874
// Update package.json with new version.
79-
run(`npm version --git-tag-version=false ${newVersion}`)
75+
run(`npm version --allow-same-version --git-tag-version=false ${newVersion}`)
8076
run(`git commit -uno -m v${newVersion} package.json || exit 0`)
8177

82-
ready()
78+
// Write release notes to a file that can be copied to the GitHub release.
79+
fs.mkdirSync(notesDir, { recursive: true })
80+
fs.writeFileSync(notesFile, lineDiff)
8381

84-
// Check if current branch is already an active patch proposal branch to avoid
85-
// creating a new minor proposal branch if new minor commits are added to the
86-
// main branch during a existing patch release.
87-
function isActivePatch () {
88-
const currentMatch = currentBranch.match(/^(\d+)\.(\d+)\.(\d+)-proposal$/)
82+
success('Release proposal is ready.')
83+
success(`Changelog at ${os.tmpdir()}/release_notes/${newVersion}.md`)
8984

90-
if (currentMatch) {
91-
const [major, minor, patch] = currentMatch.slice(1).map(Number)
85+
// Stop and ask the user if they want to proceed with pushing everything upstream.
86+
checkpoint('Push the release upstream and create/update PR?')
9287

93-
if (major === lastMajor && minor === lastMinor && patch > lastPatch) {
94-
return true
95-
}
96-
}
88+
checkGitHub()
9789

98-
return false
99-
}
90+
run('git push -f -u origin HEAD')
10091

101-
// Output a command to the terminal and execute it.
102-
function run (cmd) {
103-
whisper(`> ${cmd}`)
104-
105-
const output = execSync(cmd, {}).toString()
106-
107-
log(output)
92+
// Create or edit the PR. This will also automatically output a link to the PR.
93+
try {
94+
run(`gh pr create -d -B v${releaseLine}.x -t "v${newVersion} proposal" -F ${notesFile}`)
95+
} catch (e) {
96+
// PR already exists so update instead.
97+
// TODO: Keep existing non-release-notes PR description if there is one.
98+
run(`gh pr edit -F "${notesFile}"`)
10899
}
109100

110-
// Run a command and capture its output to return it to the caller.
111-
function capture (cmd) {
112-
return execSync(cmd, {}).toString()
113-
}
114-
115-
// Write release notes to a file that can be copied to the GitHub release.
116-
function ready () {
117-
const notesDir = path.join(__dirname, '..', '..', '.github', 'release_notes')
118-
const notesFile = path.join(notesDir, `${newVersion}.md`)
119-
const lineDiff = capture(`${diffCmd} --markdown=true v${releaseLine}.x master`)
120-
121-
fs.mkdirSync(notesDir, { recursive: true })
122-
fs.writeFileSync(notesFile, lineDiff)
123-
124-
success('Release proposal is ready.')
125-
success(`Changelog at .github/release_notes/${newVersion}.md`)
126-
127-
process.exit(0)
128-
}
101+
success('Release PR is ready.')

0 commit comments

Comments
 (0)