Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

update release script to also create pr #4880

Merged
merged 10 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.')
Loading