Skip to content
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
11 changes: 11 additions & 0 deletions scripts/release-gh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { execFile } from 'child_process'
import { promisify } from 'util'

const execFileAsync = promisify(execFile)

export const runGh = async (args: string[]) => {
const env = { ...process.env }
// mise-backed gh wrappers can otherwise mix setup messages into JSON stdout.
env.MISE_QUIET = '1'
return execFileAsync('gh', args, { env })
}
41 changes: 40 additions & 1 deletion scripts/release-pr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'

import { createDraftReleasePr } from './release-pr'
import { createDraftReleasePr, listOpenMainPrs } from './release-pr'

test('passes real newlines and literal shell characters to gh', async () => {
const directory = mkdtempSync(join(tmpdir(), 'release-pr-'))
Expand Down Expand Up @@ -37,3 +37,42 @@ test('passes real newlines and literal shell characters to gh', async () => {
rmSync(directory, { recursive: true, force: true })
}
})

test('keeps mise wrapper output out of PR JSON and explains unexpected output', async () => {
const directory = mkdtempSync(join(tmpdir(), 'release-pr-json-'))
const originalPath = process.env.PATH
const originalQuiet = process.env.MISE_QUIET
try {
const prs = [{ title: 'chore: release v1.10.0', headRefName: 'release' }]
writeFileSync(
join(directory, 'gh'),
`#!/usr/bin/env python3
import json, os, sys
assert sys.argv[1:] == ['pr', 'list', '--base', 'main', '--state', 'open', '--json', 'title,headRefName']
if os.environ.get('MISE_QUIET') != '1':
print('mise setup output')
print(${JSON.stringify(JSON.stringify(prs))})
print('mise diagnostic on stderr', file=sys.stderr)
`,
{ mode: 0o755 }
)
process.env.PATH = `${directory}:${originalPath}`
process.env.MISE_QUIET = '0'
assert.deepEqual(await listOpenMainPrs(), prs)
assert.equal(process.env.MISE_QUIET, '0')

writeFileSync(join(directory, 'gh'), '#!/bin/sh\nprintf "mise unexpected output\\n[]\\n"\n', { mode: 0o755 })
await assert.rejects(listOpenMainPrs(), /Could not parse JSON from gh pr list.*stderr/)

writeFileSync(join(directory, 'gh'), '#!/bin/sh\nprintf "authentication failed\\n" >&2\nexit 1\n', {
mode: 0o755,
})
await assert.rejects(listOpenMainPrs(), /authentication failed/)
} finally {
if (originalPath === undefined) delete process.env.PATH
else process.env.PATH = originalPath
if (originalQuiet === undefined) delete process.env.MISE_QUIET
else process.env.MISE_QUIET = originalQuiet
rmSync(directory, { recursive: true, force: true })
}
})
18 changes: 13 additions & 5 deletions scripts/release-pr.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { execFile } from 'child_process'
import { promisify } from 'util'

const execFileAsync = promisify(execFile)
import { runGh } from './release-gh'

export const createDraftReleasePr = async (title: string, messages: string[]) =>
execFileAsync('gh', ['pr', 'create', '--draft', '--base', 'main', '--title', title, '--body', messages.join('\n')])
runGh(['pr', 'create', '--draft', '--base', 'main', '--title', title, '--body', messages.join('\n')])

export const listOpenMainPrs = async (): Promise<{ title: string; headRefName: string }[]> => {
const { stdout } = await runGh(['pr', 'list', '--base', 'main', '--state', 'open', '--json', 'title,headRefName'])
try {
return JSON.parse(stdout)
} catch {
throw new Error(
'Could not parse JSON from gh pr list. Ensure your gh wrapper writes setup messages to stderr instead of stdout.'
)
}
}
28 changes: 10 additions & 18 deletions scripts/release.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
#!/usr/bin/env bun

import { exec } from 'child_process'
import { promisify } from 'util'

import chalk from 'chalk'
import inquirer from 'inquirer'
import pify from 'pify'
import semver from 'semver'
import { simpleGit as git } from 'simple-git'

import { getRegularReleaseCommits } from './release-commits'
import { createDraftReleasePr } from './release-pr'
import { runGh } from './release-gh'
import { createDraftReleasePr, listOpenMainPrs } from './release-pr'
import { exit, formatReleasePrTitle, getLatestSemverTag, parseReleasePrTitle } from './utils'

const assertIsCleanRepo = async (): Promise<void> => {
Expand All @@ -23,15 +20,18 @@ const assertIsCleanRepo = async (): Promise<void> => {

const assertGhInstalled = async (): Promise<void> => {
try {
await pify(exec)('hash gh')
} catch {
exit(chalk.red('Please install GitHub CLI https://github.com/cli/cli#installation'))
await runGh(['--version'])
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
exit(chalk.red('Please install GitHub CLI https://github.com/cli/cli#installation'))
}
throw error
}
}

const assertGhAuth = async (): Promise<void> => {
try {
await pify(exec)('gh auth status')
await runGh(['auth', 'status'])
} catch (e) {
exit(chalk.red((e as Error).message))
}
Expand Down Expand Up @@ -96,16 +96,8 @@ const inquireCleanBranchOffMain = async (): Promise<boolean> => {
return isCleanlyBranched
}

const execAsync = promisify(exec)

const getStdout = async (command: string): Promise<string> => {
const { stdout } = await execAsync(command)
return stdout
}

const getPendingReleaseVersion = async (): Promise<string> => {
const stdout = await getStdout('gh pr list --base main --state open --json title,headRefName')
const prs = JSON.parse(stdout) as { title: string; headRefName: string }[]
const prs = await listOpenMainPrs()
const releasePr = prs.find(({ headRefName }) => headRefName === 'release')
const parsed = releasePr && parseReleasePrTitle(releasePr.title)

Expand Down
Loading