Skip to content

Commit

Permalink
update release script to also create pr (#4880)
Browse files Browse the repository at this point in the history
  • Loading branch information
rochdev authored Nov 15, 2024
1 parent 25ae8e7 commit a8896ee
Show file tree
Hide file tree
Showing 5 changed files with 174 additions and 65 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ typings/

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

.github/notes
.next
package-lock.json
out
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"type:test": "cd docs && yarn && yarn test",
"lint": "node scripts/check_licenses.js && eslint . && yarn audit",
"lint-fix": "node scripts/check_licenses.js && eslint . --fix && yarn audit",
"release:proposal": "node scripts/release/proposal",
"services": "node ./scripts/install_plugin_modules && node packages/dd-trace/test/setup/services",
"test": "SERVICES=* yarn services && mocha --expose-gc 'packages/dd-trace/test/setup/node.js' 'packages/*/test/**/*.spec.js'",
"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\"",
Expand Down
85 changes: 85 additions & 0 deletions scripts/release/helpers/requirements.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
'use strict'

/* eslint-disable max-len */

const { capture, fatal } = require('./terminal')

const requiredScopes = ['public_repo', 'read:org']

// Check that the `git` CLI is installed.
function checkGit () {
try {
capture('git --version')
} catch (e) {
fatal(
'The "git" CLI could not be found.',
'Please visit https://git-scm.com/downloads for instructions to install.'
)
}
}

// Check that the `branch-diff` CLI is installed.
function checkBranchDiff () {
try {
capture('branch-diff --version')
} catch (e) {
const link = [
'https://datadoghq.atlassian.net/wiki/spaces/DL/pages/3125511269/Node.js+Tracer+Release+Process',
'#Install-and-Configure-branch-diff-to-automate-some-operations'
].join('')
fatal(
'The "branch-diff" CLI could not be found.',
`Please visit ${link} for instructions to install.`
)
}
}

// Check that the `gh` CLI is installed and authenticated.
function checkGitHub () {
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
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'

fatal(
'The GITHUB_TOKEN environment variable is missing.',
`Please visit ${link} for instructions to generate a personal access token.`,
`The following scopes are required when generating the token: ${requiredScopes.join(', ')}`
)
}

try {
capture('gh --version')
} catch (e) {
fatal(
'The "gh" CLI could not be found.',
'Please visit https://github.com/cli/cli#installation for instructions to install.'
)
}

checkGitHubScopes()
}

// Check that the active GITHUB_TOKEN has the required scopes.
function checkGitHubScopes () {
const url = 'https://api.github.com'
const headers = [
'Accept: application/vnd.github.v3+json',
`Authorization: Bearer ${process.env.GITHUB_TOKEN || process.env.GH_TOKEN}`,
'X-GitHub-Api-Version: 2022-11-28'
].map(h => `-H "${h}"`).join(' ')

const lines = capture(`curl -sS -I ${headers} ${url}`).trim().split(/\r?\n/g)
const scopeLine = lines.find(line => line.startsWith('x-oauth-scopes:')) || ''
const scopes = scopeLine.replace('x-oauth-scopes:', '').trim().split(', ')
const link = 'https://github.com/settings/tokens'

for (const req of requiredScopes) {
if (!scopes.includes(req)) {
fatal(
`Missing "${req}" scope for GITHUB_TOKEN.`,
`Please visit ${link} and make sure the following scopes are enabled: ${requiredScopes.join(' ,')}.`
)
}
}
}

module.exports = { checkBranchDiff, checkGitHub, checkGit }
51 changes: 51 additions & 0 deletions scripts/release/helpers/terminal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use strict'

/* eslint-disable no-console */

const { execSync, spawnSync } = require('child_process')

// Helpers for colored output.
const log = (...msgs) => msgs.forEach(msg => console.log(msg))
const success = (...msgs) => msgs.forEach(msg => console.log(`\x1b[32m${msg}\x1b[0m`))
const error = (...msgs) => msgs.forEach(msg => console.log(`\x1b[31m${msg}\x1b[0m`))
const whisper = (...msgs) => msgs.forEach(msg => console.log(`\x1b[90m${msg}\x1b[0m`))

// Helpers for exiting with a message.
const exit = (...msgs) => log(...msgs) || process.exit(0)
const fatal = (...msgs) => error(...msgs) || process.exit(1)

// Output a command to the terminal and execute it.
function run (cmd) {
whisper(`> ${cmd}`)

const output = execSync(cmd, {}).toString()

log(output)
}

// Ask a question in terminal and return the response.
function prompt (question) {
process.stdout.write(`${question} `)

const child = spawnSync('bash', ['-c', 'read answer && echo $answer'], {
stdio: ['inherit']
})

return child.stdout.toString()
}

// Ask whether to continue and otherwise exit the process.
function checkpoint (question) {
const answer = prompt(`${question} [Y/n]`).trim()

if (answer && answer.toLowerCase() !== 'y') {
process.exit(0)
}
}

// Run a command and capture its output to return it to the caller.
function capture (cmd) {
return execSync(cmd, {}).toString()
}

module.exports = { capture, checkpoint, error, exit, fatal, log, success, run, whisper }
101 changes: 37 additions & 64 deletions scripts/release/proposal.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,23 @@
'use strict'

/* eslint-disable no-console */

// TODO: Support major versions.

const { execSync } = require('child_process')
const fs = require('fs')
const os = require('os')
const path = require('path')
const { capture, checkpoint, exit, fatal, success, run } = require('./helpers/terminal')
const { checkBranchDiff, checkGitHub, checkGit } = require('./helpers/requirements')

// Helpers for colored output.
const log = msg => console.log(msg)
const success = msg => console.log(`\x1b[32m${msg}\x1b[0m`)
const error = msg => console.log(`\x1b[31m${msg}\x1b[0m`)
const whisper = msg => console.log(`\x1b[90m${msg}\x1b[0m`)
checkGit()
checkBranchDiff()

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

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

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

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

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

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

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

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

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

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

if (major === lastMajor && minor === lastMinor && patch > lastPatch) {
return true
}
}
checkGitHub()

return false
}
run('git push -f -u origin HEAD')

// Output a command to the terminal and execute it.
function run (cmd) {
whisper(`> ${cmd}`)

const output = execSync(cmd, {}).toString()

log(output)
// Create or edit the PR. This will also automatically output a link to the PR.
try {
run(`gh pr create -d -B v${releaseLine}.x -t "v${newVersion} proposal" -F ${notesFile}`)
} catch (e) {
// PR already exists so update instead.
// TODO: Keep existing non-release-notes PR description if there is one.
run(`gh pr edit -F "${notesFile}"`)
}

// Run a command and capture its output to return it to the caller.
function capture (cmd) {
return execSync(cmd, {}).toString()
}

// Write release notes to a file that can be copied to the GitHub release.
function ready () {
const notesDir = path.join(__dirname, '..', '..', '.github', 'release_notes')
const notesFile = path.join(notesDir, `${newVersion}.md`)
const lineDiff = capture(`${diffCmd} --markdown=true v${releaseLine}.x master`)

fs.mkdirSync(notesDir, { recursive: true })
fs.writeFileSync(notesFile, lineDiff)

success('Release proposal is ready.')
success(`Changelog at .github/release_notes/${newVersion}.md`)

process.exit(0)
}
success('Release PR is ready.')

0 comments on commit a8896ee

Please sign in to comment.