Skip to content

Commit b6f2154

Browse files
authored
fix: stream custom build/publish command output and surface real failures (#118)
## Problem During a release, `@varlock/bumpy` ran user-defined `buildCommand`/`publishCommand` through `runAsync`, which **buffered** all child output (nothing reached CI logs live) and, on failure, threw an error containing only `stderr` — discarding `stdout`. When a custom publish like `vsce publish` wrote its real error (an expired marketplace `VSCE_PAT`) to **stdout** and exited non-zero, the actual cause was invisible — CI showed only a generic `Command failed` wrapper plus bun's exit notice. This degraded every non-npm / custom publish path (vsce, ovsx, anything bespoke), making failures un-debuggable. npm publishing has its own path and was unaffected. ## Fix - **New `runStreaming(cmd, opts)`** in `shell.ts` — runs via `spawn` (`shell: true`), **tees** child stdout/stderr to the parent process live so output appears in CI as it happens, while also capturing both streams. On non-zero exit it rejects with `Command failed (exit code N): <cmd>` plus the captured output. - **`buildCommand` / `publishCommand`** in the publish pipeline now use `runStreaming`. Internal git/npm/pack helpers (which parse captured stdout) stay on the capturing runners, including `checkPublished`. - **Belt-and-suspenders:** `runAsync` / `runArgsAsync` now include `stdout` (not just `stderr`) in their thrown errors. ## Tests New `test/utils/shell.test.ts` asserts a command writing its error to **stdout** then exiting non-zero surfaces that text in the rejected error (for `runStreaming`, `runAsync`, and `runArgsAsync`), plus stderr and exit-code coverage. - `bun run test` → **303 pass, 0 fail** - `tsc --noEmit`, `oxlint`, `oxfmt --check` all clean ## Acceptance criteria - [x] A failing `publishCommand` surfaces the child's actual stdout **and** stderr - [x] `buildCommand`/`publishCommand` output streams live, not just on failure - [x] Internal git/npm capture-and-parse operations unchanged (full suite passes) - [x] Unit test for the stdout-only failure case - [x] Changeset / bump file added (patch → 1.14.1) ## Incident reference https://github.com/dmno-dev/varlock/actions/runs/27600876636/job/81602352962
1 parent 0a9292c commit b6f2154

4 files changed

Lines changed: 99 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@varlock/bumpy': patch
3+
---
4+
5+
Stream `buildCommand`/`publishCommand` output live to the parent process and surface the child's real failure reason. Custom publish commands (vsce, ovsx, anything bespoke) previously ran through a buffering runner that discarded stdout and never streamed output, so a failure like an expired marketplace token produced only a generic `Command failed` wrapper with no usable cause in CI logs. These commands now run through a streaming runner (`spawn` with piped+teed stdio) that prints output as it happens and includes both stdout and stderr in the thrown error. The capturing `runAsync`/`runArgsAsync` helpers (still used for internal git/npm calls whose output is parsed) also now include stdout in their error messages.

packages/bumpy/src/core/publish-pipeline.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { resolve } from 'node:path';
22
import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
33
import { unlink } from 'node:fs/promises';
44
import { readJson, updateJsonNestedField } from '../utils/fs.ts';
5-
import { runAsync, runArgsAsync, tryRunArgs, sq } from '../utils/shell.ts';
5+
import { runStreaming, runArgsAsync, tryRunArgs, sq } from '../utils/shell.ts';
66
import { log, colorize } from '../utils/logger.ts';
77
import { createTag, tagExists } from './git.ts';
88
import { DependencyGraph } from './dep-graph.ts';
@@ -217,7 +217,7 @@ export async function publishPackages(
217217
if (pkgConfig.buildCommand) {
218218
log.dim(` Building...`);
219219
if (!opts.dryRun) {
220-
await runAsync(pkgConfig.buildCommand, { cwd: pkg.dir });
220+
await runStreaming(pkgConfig.buildCommand, { cwd: pkg.dir });
221221
}
222222
}
223223

@@ -243,7 +243,7 @@ export async function publishPackages(
243243
.replace(/\{\{name\}\}/g, sq(release.name));
244244
log.dim(` Running: ${expanded}`);
245245
if (!opts.dryRun) {
246-
await runAsync(expanded, { cwd: pkg.dir });
246+
await runStreaming(expanded, { cwd: pkg.dir });
247247
}
248248
}
249249
} else if (!pkgConfig.skipNpmPublish) {

packages/bumpy/src/utils/shell.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execSync, execFileSync, exec, execFile } from 'node:child_process';
1+
import { execSync, execFileSync, exec, execFile, spawn } from 'node:child_process';
22

33
/**
44
* Escape a value for safe interpolation inside a single-quoted shell string.
@@ -53,7 +53,7 @@ export function runAsync(cmd: string, opts?: { cwd?: string; input?: string }):
5353
return new Promise((resolve, reject) => {
5454
const child = exec(cmd, { cwd: opts?.cwd, encoding: 'utf-8' }, (err, stdout, stderr) => {
5555
if (err) {
56-
reject(new Error(`Command failed: ${cmd}\n${stderr}`));
56+
reject(new Error(`Command failed: ${cmd}\n${stdout}\n${stderr}`.trim()));
5757
} else {
5858
resolve(stdout.trim());
5959
}
@@ -65,6 +65,55 @@ export function runAsync(cmd: string, opts?: { cwd?: string; input?: string }):
6565
});
6666
}
6767

68+
/**
69+
* Run a shell command, streaming its stdout/stderr to the parent process live
70+
* (so output appears in CI logs as it happens) while also capturing it so the
71+
* thrown error on failure includes the child's real output.
72+
*
73+
* Use this for user-defined commands (build/publish) where we don't need to
74+
* parse the output but DO want it visible — unlike the capturing `runAsync`,
75+
* which buffers everything and is meant for internal helpers whose stdout we
76+
* parse. A failing custom command (e.g. `vsce publish`) writes its real error
77+
* to stdout; this surfaces both streams instead of swallowing them.
78+
*/
79+
export function runStreaming(cmd: string, opts?: { cwd?: string; input?: string }): Promise<void> {
80+
const result = checkIntercept(cmd.split(/\s+/), opts);
81+
if (result?.intercepted) {
82+
if ('error' in result) return Promise.reject(new Error(result.error));
83+
return Promise.resolve();
84+
}
85+
return new Promise((resolve, reject) => {
86+
const child = spawn(cmd, {
87+
cwd: opts?.cwd,
88+
shell: true,
89+
stdio: [opts?.input ? 'pipe' : 'inherit', 'pipe', 'pipe'],
90+
});
91+
let stdout = '';
92+
let stderr = '';
93+
child.stdout?.on('data', (chunk) => {
94+
stdout += chunk;
95+
process.stdout.write(chunk);
96+
});
97+
child.stderr?.on('data', (chunk) => {
98+
stderr += chunk;
99+
process.stderr.write(chunk);
100+
});
101+
if (opts?.input) {
102+
child.stdin?.write(opts.input);
103+
child.stdin?.end();
104+
}
105+
child.on('error', (err) => reject(err));
106+
child.on('close', (code) => {
107+
if (code === 0) {
108+
resolve();
109+
} else {
110+
const detail = `${stdout}\n${stderr}`.trim();
111+
reject(new Error(`Command failed (exit code ${code}): ${cmd}${detail ? `\n${detail}` : ''}`));
112+
}
113+
});
114+
});
115+
}
116+
68117
export function tryRun(cmd: string, opts?: { cwd?: string }): string | null {
69118
try {
70119
return run(cmd, opts);
@@ -102,7 +151,7 @@ export function runArgsAsync(args: string[], opts?: { cwd?: string; input?: stri
102151
return new Promise((resolve, reject) => {
103152
const child = execFile(cmd!, rest, { cwd: opts?.cwd, encoding: 'utf-8' }, (err, stdout, stderr) => {
104153
if (err) {
105-
reject(new Error(`Command failed: ${args.join(' ')}\n${stderr}`));
154+
reject(new Error(`Command failed: ${args.join(' ')}\n${stdout}\n${stderr}`.trim()));
106155
} else {
107156
resolve(stdout.trim());
108157
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { test, expect, describe } from 'bun:test';
2+
import { runStreaming, runAsync, runArgsAsync } from '../../src/utils/shell.ts';
3+
4+
describe('runStreaming', () => {
5+
test('resolves for a successful command', async () => {
6+
await expect(runStreaming('true')).resolves.toBeUndefined();
7+
});
8+
9+
test('surfaces output written to stdout (not stderr) when a command fails', async () => {
10+
// The command writes its real error to stdout, then exits non-zero — this is
11+
// exactly the vsce failure mode that used to be swallowed.
12+
const cmd = `node -e "console.log('REAL_ERROR_ON_STDOUT'); process.exit(1)"`;
13+
await expect(runStreaming(cmd)).rejects.toThrow(/REAL_ERROR_ON_STDOUT/);
14+
});
15+
16+
test('surfaces output written to stderr when a command fails', async () => {
17+
const cmd = `node -e "console.error('REAL_ERROR_ON_STDERR'); process.exit(1)"`;
18+
await expect(runStreaming(cmd)).rejects.toThrow(/REAL_ERROR_ON_STDERR/);
19+
});
20+
21+
test('includes the exit code and command in the error', async () => {
22+
await expect(runStreaming('exit 3')).rejects.toThrow(/exit code 3/);
23+
});
24+
});
25+
26+
describe('runAsync error reporting', () => {
27+
test('includes stdout in the thrown error when a command writes its error there', async () => {
28+
const cmd = `node -e "console.log('STDOUT_FAILURE_REASON'); process.exit(1)"`;
29+
await expect(runAsync(cmd)).rejects.toThrow(/STDOUT_FAILURE_REASON/);
30+
});
31+
});
32+
33+
describe('runArgsAsync error reporting', () => {
34+
test('includes stdout in the thrown error when a command writes its error there', async () => {
35+
await expect(runArgsAsync(['node', '-e', "console.log('ARGS_STDOUT_FAILURE'); process.exit(1)"])).rejects.toThrow(
36+
/ARGS_STDOUT_FAILURE/,
37+
);
38+
});
39+
});

0 commit comments

Comments
 (0)