diff --git a/.gitignore b/.gitignore index 773f16d5a90..a8dcafe063b 100644 --- a/.gitignore +++ b/.gitignore @@ -106,7 +106,6 @@ typings/ # End of https://www.gitignore.io/api/node,macos,visualstudiocode -.github/notes .next package-lock.json out diff --git a/package.json b/package.json index d11595db1ef..7640f0c0f99 100644 --- a/package.json +++ b/package.json @@ -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\"", diff --git a/scripts/release/helpers/requirements.js b/scripts/release/helpers/requirements.js new file mode 100644 index 00000000000..2911b6982a5 --- /dev/null +++ b/scripts/release/helpers/requirements.js @@ -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 } diff --git a/scripts/release/helpers/terminal.js b/scripts/release/helpers/terminal.js new file mode 100644 index 00000000000..302a9ba5e42 --- /dev/null +++ b/scripts/release/helpers/terminal.js @@ -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 } diff --git a/scripts/release/proposal.js b/scripts/release/proposal.js index b5c16de4c0e..13dc95f4a2e 100644 --- a/scripts/release/proposal.js +++ b/scripts/release/proposal.js @@ -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-type]') - process.exit(0) + exit('Usage: node scripts/release/proposal [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. @@ -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() @@ -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.')