From dd48334f0d7a02d1fa5d0ca9507cc71eeafb1c6a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 2 Sep 2023 14:20:59 +0200 Subject: [PATCH] fix: use "spawnAsync" to stream publish script --- src/commands/__test__/publish.test.ts | 11 +++-- src/commands/publish.ts | 39 +++++++++--------- src/utils/execAsync.ts | 1 + src/utils/spawnAsync.ts | 59 +++++++++++++++++++++++++++ test/env.ts | 9 ++++ 5 files changed, 94 insertions(+), 25 deletions(-) create mode 100644 src/utils/spawnAsync.ts diff --git a/src/commands/__test__/publish.test.ts b/src/commands/__test__/publish.test.ts index 7a12a1d..2f21de7 100644 --- a/src/commands/__test__/publish.test.ts +++ b/src/commands/__test__/publish.test.ts @@ -456,9 +456,8 @@ setTimeout(() => process.exit(0), 150) await publish.run() // Must log the release script stdout. - expect(log.info).toHaveBeenCalledWith( - 'publishing script done, see the process output below:\n\n--- stdout ---\nhello\nworld\n\n', - ) + expect(log.info).toHaveBeenCalledWith('hello\n') + expect(log.info).toHaveBeenCalledWith('world\n') // Must report a successful release. expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0') @@ -517,11 +516,11 @@ setTimeout(() => process.exit(0), 150) await publish.run() // Must log the release script stdout. - expect(log.info).toHaveBeenCalledWith( - 'publishing script done, see the process output below:\n\n--- stderr ---\nsomething\nwent wrong\n\n', - ) + expect(log.error).toHaveBeenCalledWith('something\n') + expect(log.error).toHaveBeenCalledWith('went wrong\n') // Must report a successful release. + // As long as the publish script doesn't exit, it is successful. expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0') expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!') }) diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 515df18..9a1f3ca 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -12,6 +12,7 @@ import { getLatestRelease } from '../utils/git/getLatestRelease' import { bumpPackageJson } from '../utils/bumpPackageJson' import { getTags } from '../utils/git/getTags' import { execAsync } from '../utils/execAsync' +import { spawnAsync } from '../utils/spawnAsync' import { commit } from '../utils/git/commit' import { createTag } from '../utils/git/createTag' import { push } from '../utils/git/push' @@ -21,7 +22,7 @@ import { createComment } from '../utils/github/createComment' import { createReleaseComment } from '../utils/createReleaseComment' import { demandGitHubToken, demandNpmToken } from '../utils/env' import { Notes } from './notes' -import { ReleaseProfile } from 'utils/getConfig' +import { ReleaseProfile } from '../utils/getConfig' interface PublishArgv { profile: string @@ -260,31 +261,31 @@ export class Publish extends Command { this.profile.use, ) - const publishResult = await until(async () => { - const releaseScriptStd = await execAsync(this.profile.use, { + const [releaseScriptProcess, releaseScriptPromise] = spawnAsync( + this.profile.use, + { env: { ...process.env, ...env, }, - }) - - this.log.info(`publishing script done, see the process output below: + }, + ) -${[ - ['--- stdout ---', releaseScriptStd.stdout], - ['--- stderr ---', releaseScriptStd.stderr], -] - .filter(([, data]) => !!data) - .map(([header, data]) => `${header}\n${data}`) - .join('\n\n')} -`) + // Forward the publish script's stdio to the logger. + releaseScriptProcess.stdout?.on('data', (chunk) => { + this.log.info(Buffer.from(chunk).toString('utf8')) + }) + releaseScriptProcess.stderr?.on('data', (chunk) => { + this.log.error(Buffer.from(chunk).toString('utf8')) }) - invariant( - publishResult.error == null, - 'Failed to publish: the publish script exited.\n%s', - publishResult.error?.message, - ) + await releaseScriptPromise.catch((error) => { + this.log.error(error) + this.log.error( + 'Failed to publish: the publih script errored. See the original error above.', + ) + process.exit(releaseScriptProcess.exitCode || 1) + }) this.log.info('published successfully!') } diff --git a/src/utils/execAsync.ts b/src/utils/execAsync.ts index a24cde3..6f73fd0 100644 --- a/src/utils/execAsync.ts +++ b/src/utils/execAsync.ts @@ -6,6 +6,7 @@ export type ExecAsyncFn = { command: string, options?: ExecOptions, ): DeferredPromise + mockContext(options: ExecOptions): void restoreContext(): void contextOptions: ExecOptions diff --git a/src/utils/spawnAsync.ts b/src/utils/spawnAsync.ts new file mode 100644 index 0000000..3a9a913 --- /dev/null +++ b/src/utils/spawnAsync.ts @@ -0,0 +1,59 @@ +import { ChildProcess, SpawnOptions, spawn } from 'node:child_process' +import { format } from 'outvariant' +import { DeferredPromise } from '@open-draft/deferred-promise' + +type SpawnAsyncFunction = { + (command: string, options?: SpawnOptions): [ + ChildProcess, + DeferredPromise, + ] + + contextOptions: SpawnOptions + mockContext(options: SpawnOptions): void + restoreContext(): void +} + +const DEFAULT_SPAWN_CONTEXT: Partial = { + cwd: process.cwd(), +} + +/** + * Spawns the given `command` in a new process and gives that + * child process' reference as well as the command exit promise. + */ +export const spawnAsync = ((command, options) => { + const commandPromise = new DeferredPromise() + const [commandName, ...args] = command.split(' ') + const io = spawn(commandName, args, { + ...spawnAsync.contextOptions, + ...options, + }) + + io.once('exit', (exitCode) => { + if (exitCode !== 0) { + return commandPromise.reject( + new Error( + format( + 'Running "%s" failed: process exited with code %d', + command, + exitCode, + ), + ), + ) + } + + commandPromise.resolve() + }) + + return [io, commandPromise] +}) + +spawnAsync.mockContext = (options) => { + spawnAsync.contextOptions = options +} + +spawnAsync.restoreContext = () => { + spawnAsync.contextOptions = DEFAULT_SPAWN_CONTEXT +} + +spawnAsync.restoreContext() diff --git a/test/env.ts b/test/env.ts index b758635..ebd2584 100644 --- a/test/env.ts +++ b/test/env.ts @@ -5,6 +5,7 @@ import { createTeardown, TeardownApi } from 'fs-teardown' import { log } from '../src/logger' import { initGit, createGitProvider } from './utils' import { execAsync } from '../src/utils/execAsync' +import { spawnAsync } from '../src/utils/spawnAsync' import { requiredGitHubTokenScopes } from '../src/utils/github/validateAccessToken' export const api = setupServer( @@ -66,6 +67,9 @@ export function testEnvironment( jest.spyOn(log, 'warn').mockImplementation() jest.spyOn(log, 'error').mockImplementation() + spawnAsync.mockContext({ + cwd: fs.resolve(), + }) execAsync.mockContext({ cwd: fs.resolve(), }) @@ -90,6 +94,11 @@ export function testEnvironment( await repoFs.prepare() subscriptions.push(() => repoFs.cleanup()) + spawnAsync.mockContext({ + cwd: absoluteRootDir, + }) + subscriptions.push(() => spawnAsync.restoreContext()) + execAsync.mockContext({ cwd: absoluteRootDir, })