Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/npm/cli.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Karrys <luke@lukekarrys.com>2022-04-14 00:24:51 +0300
committerGitHub <noreply@github.com>2022-04-14 00:24:51 +0300
commita4ee396ee63cb1ce0f427906ee6f3d8097fcb64b (patch)
treef92bb700f5b387c40d072d4f02e24f0d87060346 /scripts
parentb5701cc2ad82fbc7a8bde47756afa874b916871a (diff)
chore: add release manager script (#4716)
Diffstat (limited to 'scripts')
-rw-r--r--scripts/changelog.js27
-rw-r--r--scripts/release-manager.js248
2 files changed, 265 insertions, 10 deletions
diff --git a/scripts/changelog.js b/scripts/changelog.js
index 0c50b562d..3db16d6ca 100644
--- a/scripts/changelog.js
+++ b/scripts/changelog.js
@@ -8,6 +8,11 @@ const config = require('@npmcli/template-oss')
const { resolve, relative } = require('path')
const exec = (...args) => execSync(...args).toString().trim()
+const today = () => {
+ const d = new Date()
+ const pad = s => s.toString().padStart(2, '0')
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
+}
const usage = () => `
node ${relative(process.cwd(), __filename)} [--read|-r] [--write|-w] [tag]
@@ -74,7 +79,7 @@ const RELEASE = {
return s.startsWith(TAG_PREFIX) ? s : TAG_PREFIX + s
},
date (d) {
- return `(${d || exec('date +%Y-%m-%d')})`
+ return `(${d})`
},
title (v, d) {
return `${this.heading}${this.version(v)} ${this.date(d)}`
@@ -313,7 +318,7 @@ const generateRelease = async (args) => {
// this doesnt work with majors but we dont do those very often
const semverBump = commits.Features.length ? 'minor' : 'patch'
const version = TAG_PREFIX + semver.parse(args.startTag).inc(semverBump).version
- const date = args.endTag && exec(`git log -1 --date=short --format=%ad ${args.endTag}`)
+ const date = args.endTag ? exec(`git log -1 --date=short --format=%ad ${args.endTag}`) : today()
const output = logger(RELEASE.title(version, date) + '\n')
@@ -350,6 +355,7 @@ const generateRelease = async (args) => {
}
return {
+ date,
version,
release: output.toString(),
}
@@ -370,10 +376,13 @@ const main = async (argv) => {
}
// otherwise fetch the requested release from github
- const { release, version } = await generateRelease(args)
+ const { release, version, date } = await generateRelease(args)
- let msg = 'Edit release notes and run:\n'
- msg += `git add CHANGELOG.md && git commit -m 'chore: changelog for ${version}'`
+ try {
+ exec(`node scripts/release-manager.js --update --version=${version.slice(1)} --date=${date}`)
+ } catch {
+ // optionally update release manager issue
+ }
if (args.write) {
const { release: existing, changelog } = findRelease(args, version)
@@ -386,14 +395,12 @@ const main = async (argv) => {
: changelog.replace(RELEASE.h1, RELEASE.h1 + release + RELEASE.sep),
'utf-8'
)
- return console.error([
- `Release notes for ${version} written to "./${relative(process.cwd(), args.file)}".`,
- msg,
- ].join('\n'))
+ return console.log(
+ `Release notes for ${version} written to "./${relative(process.cwd(), args.file)}".`
+ )
}
console.log(release)
- console.error('\n' + msg)
}
main(process.argv.slice(2))
diff --git a/scripts/release-manager.js b/scripts/release-manager.js
new file mode 100644
index 000000000..8622ac998
--- /dev/null
+++ b/scripts/release-manager.js
@@ -0,0 +1,248 @@
+#!/usr/bin/env node
+
+const { basename, relative } = require('path')
+const cp = require('child_process')
+
+const usage = () => `
+ node ${relative(process.cwd(), __filename)} [flags]
+
+ Copies the release process checklist to a GitHub issue, optionally updating the
+ version and date of the instructions.
+
+ Flags: [--create] [--update[=<issue-num>]] [--date=<YYYY-MM-DD>] [--version=X.Y.Z]
+
+ [--create] (default: true)
+ By default this will create a new issue in the repo.
+
+ [--update[=<issue-num>]]
+ Update a specific issue number, or if set without a value it will update the most
+ recent issue created with the default tag.
+
+ [--tag=<tag>] (default: "release-manager")
+ Issues will be created and looked up with this tag.
+
+ [--version=X.Y.Z]
+ This script can be run before the next version number is known and then rerun
+ with this flag to update the checklist with the correct version number.
+
+ [--date=<YYYY-MM-DD>] (default: ${date()})
+ Set the date of the release in the release process checklist.
+`
+
+const spawnSync = (cmd, args, options) => {
+ const res = cp.spawnSync(cmd, args, { ...options, encoding: 'utf-8' })
+ if (res.status !== 0) {
+ throw new Error(res.stderr)
+ }
+ return res.stdout.trim()
+}
+
+const get = url =>
+ new Promise((resolve, reject) => {
+ require('https')
+ .get(url, resp => {
+ let d = ''
+ resp.on('data', c => (d += c))
+ resp.on('end', () => resolve(d))
+ })
+ .on('error', reject)
+ })
+
+const date = () => {
+ const d = new Date()
+ const pad = s => s.toString().padStart(2, '0')
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
+}
+
+const replaceAll = (str, rep) =>
+ Object.entries(rep).reduce(
+ (a, [k, v]) => a.replace(new RegExp(k, 'g'), v),
+ str
+ )
+
+const ghIssue = args => {
+ const label = ['-l', args.label]
+ const assignee = ['-a', args.assignee]
+ const title = ['-t', args.title]
+ const json = ['--json', 'body,title,number']
+ const body = ['--body-file', '-']
+
+ const issue = (cmd, a, options) =>
+ spawnSync('gh', ['issue', cmd, '-R', args.repo, ...a.flat()], options)
+
+ const listIssues = () => {
+ const issues = JSON.parse(issue('list', [label, json]))
+ const ids = issues.map(i => '#' + i.number)
+ const msg = `Found existing label:${args.label} issues: ${ids.join(', ')}.`
+ return { issues, msg }
+ }
+
+ switch (args.command) {
+ case 'list': {
+ // get the first matching issue
+ const { issues, msg } = listIssues()
+ if (issues.length > 1) {
+ throw new Error(`${msg} Rerun with --update=<id> to target a specific issue.`)
+ }
+ return issues[0]
+ }
+ case 'view':
+ // get an issue by id
+ return JSON.parse(issue('view', [args.number, json]))
+ case 'create': {
+ const { issues, msg } = listIssues()
+ if (issues.length) {
+ throw new Error(`${msg} Close before creating a new one.`)
+ }
+ // create an issue
+ return issue('create', [assignee, label, title, body], { input: args.body })
+ }
+ case 'edit':
+ // edit title and body of an issue
+ return issue('edit', [args.number, title, body], { input: args.body })
+ default:
+ throw new Error(`Unknown command: ${JSON.stringify(args.command)}`)
+ }
+}
+
+const getSection = (content, args) => {
+ const [, heading, section] = args.section.match(/^(#+)\s(.*)/)
+
+ // remove the title since we are making a new one
+ const [title, ...lines] = content
+ .split(`${heading} `)
+ .find(s => s.split('\n')[0].match(new RegExp(section, 'i')))
+ .trim()
+ .split('\n')
+
+ // first task is to run this script, so thats done
+ const body = lines.join('\n').replace('- [ ] **0', '- [x] **0')
+ const created = `${basename(args.release)}${heading}${title}`
+
+ return {
+ title: `Release Manager: v${args.version} (${args.date})`,
+ body: [
+ `**Target Version**: v${args.version}`,
+ `**Target Date**: ${args.date}`,
+ // github markdown: 2x backticks + space will escape backticks within title
+ `**Created From:** [\`\` ${created} \`\`](${args.release})`,
+ body,
+ ]
+ .join('\n')
+ .trim(),
+ }
+}
+
+const main = async args => {
+ const replace = s => replaceAll(s, args.replacements)
+
+ const { body, title, number } = args.create
+ // get a section of the release process wiki doc
+ ? getSection(await get(args.release), args)
+ // get the contents of an existing gh issue by id
+ // or it will default to the most recent one by label
+ // this is so it will preserve state of checked todo items
+ : await ghIssue({
+ ...args,
+ command: typeof args.update === 'string' ? 'view' : 'list',
+ number: args.update,
+ })
+
+ return ghIssue({
+ ...args,
+ command: number ? 'edit' : 'create',
+ number,
+ body: replace(body),
+ title: replace(title),
+ })
+}
+
+const parseArgs = raw => {
+ const result = {
+ create: false,
+ update: null,
+ repo: 'npm/cli',
+ label: 'release: manager',
+ assignee: '@me',
+ date: date(),
+ version: 'X.Y.Z',
+ // look for that heading level with a match for the portion after
+ section: '### .*cli.*',
+ release:
+ 'https://raw.githubusercontent.com/wiki/npm/cli/Release-Process.md',
+ }
+
+ const replacements = {}
+
+ const clean = {
+ // this script will not work correctly with the tag style
+ // of the version (prefixed with a v) so strip it out
+ version: v => v.replace(/^v/g, ''),
+ }
+
+ const shorts = {
+ R: 'repo',
+ l: 'label',
+ a: 'assignee',
+ d: 'date',
+ v: 'version',
+ c: 'create',
+ u: 'update',
+ }
+
+ const camel = k => k.replace(/-([a-z])/g, a => a[1].toUpperCase())
+
+ // parse argv into array of [k,v] pairs
+ // works with --x=1 --x 1 --x -x
+ const argv = raw
+ .join(' ') // join to a string
+ .split(/(?:^|\s+)-/g) // split on starting dashses
+ .map(x => x.trim().replace(/\s+/g, ' ')) // collapse spaces
+ .filter(Boolean) // remove empties
+ .map(x => x.split(/[=\s]/)) // split on equal or space
+ .map(([k, v]) => [
+ // we split on the initial dash previously so now
+ // 1 dash means 2 and 0 means 1
+ ...(k.startsWith('-') ? ['--', k.slice(1)] : ['-', k]),
+ v ?? true, // default to true for no value
+ ])
+ .map(([dash, key, value]) => ({ dash, key: camel(key), value }))
+
+ for (const { dash, key, value } of argv) {
+ const k = dash.length < 2 ? shorts[key] : key
+ if (Object.hasOwn(result, k)) {
+ result[k] = clean[k] ? clean[k](value) : value
+ } else {
+ // any unknown arg is a replacement value
+ replacements[k] = value
+ }
+ }
+
+ if (!result.create && !result.update) {
+ // set default to create if no command is specified
+ result.create = true
+ } else if (result.create && result.update) {
+ throw new Error('Cannot set both create and update')
+ }
+
+ if (result.help) {
+ console.error(usage())
+ return process.exit(0)
+ }
+
+ return {
+ ...result,
+ replacements: {
+ '(\\d+\\.\\d+\\.\\d+|X\\.Y\\.Z)': result.version,
+ '(\\d{4}-\\d{2}-\\d{2}|YYYY-MM-DD)': result.date,
+ ...replacements,
+ },
+ }
+}
+
+main(parseArgs(process.argv.slice(2)))
+ .then(d => console.log(d))
+ .catch(err => {
+ console.error(err)
+ process.exitCode = 1
+ })