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

chore: add release manager script #4716

Merged
merged 1 commit into from Apr 13, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
27 changes: 17 additions & 10 deletions scripts/changelog.js
Expand Up @@ -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]
Expand Down Expand Up @@ -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)}`
Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -350,6 +355,7 @@ const generateRelease = async (args) => {
}

return {
date,
version,
release: output.toString(),
}
Expand All @@ -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)
Expand All @@ -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))
248 changes: 248 additions & 0 deletions 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
})