Skip to content

Commit

Permalink
chore: add release manager script
Browse files Browse the repository at this point in the history
  • Loading branch information
lukekarrys committed Apr 10, 2022
1 parent 1617bce commit add8e77
Show file tree
Hide file tree
Showing 2 changed files with 265 additions and 10 deletions.
27 changes: 17 additions & 10 deletions scripts/changelog.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
})

0 comments on commit add8e77

Please sign in to comment.