diff --git a/docs/cli.md b/docs/cli.md index 8c9ca2ca..b701f98d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -33,7 +33,7 @@ Every command that produces a JSON result — `release`, `gate`, `standing-pr`, } ``` -- **`data`** carries the command's payload verbatim — the envelope wraps it, never replaces it. `releasekit release --json` exposes its `VersionOutput` at `data.versionOutput`, and `releasekit version --json` at `data`. It is `null` on error, unless `publish` failed partway (see below). +- **`data`** carries the command's payload verbatim — the envelope wraps it, never replaces it. `releasekit release --json` exposes its `VersionOutput` at `data.versionOutput`, and `releasekit version --json` at `data`. It is `null` on error, unless a publish failed partway (see below). - **`changed`** separates real work from a no-op: - A dry run is never `changed`. - `publish`: a registry publish or a new release commit. @@ -57,7 +57,7 @@ releasekit publish --input version.json A bare `VersionOutput` is still accepted. Feeding a failed stage's envelope forward fails as an input error (exit 3) whose message names that stage's code, rather than a schema error about missing fields. -When `publish` fails partway, its error envelope carries what already landed in `data`, reports `changed: true` if anything did, and names the failing stage in the error `message`. Re-running `publish` on the same tree resumes it: it reuses the release commit and tags already at HEAD, skips versions already on the registry, and retries the rest. +When `publish` fails partway, its error envelope carries what already landed in `data`, reports `changed: true` if anything did, and names the failing stage in the error `message`. `release` and `standing-pr publish` report a failure in their publish stage the same way, so `data` then holds the publish output rather than their usual payload. Re-running `publish` on the same tree resumes it: it reuses the release commit and tags already at HEAD, skips versions already on the registry, and retries the rest. ### Error codes and exit codes diff --git a/packages/publish/src/command.ts b/packages/publish/src/command.ts index bb32d95a..8b26f56b 100644 --- a/packages/publish/src/command.ts +++ b/packages/publish/src/command.ts @@ -1,10 +1,8 @@ import { - errorEnvelope, exitCodeForError, setJsonMode, setLogLevel, successEnvelope, - toEnvelopeError, writeEnvelope, writeErrorEnvelope, } from '@releasekit/core'; @@ -13,6 +11,7 @@ import { loadConfig } from './config.js'; import { BasePublishError, PipelineError } from './errors/index.js'; import { publishDidChange } from './output.js'; import { runPipeline } from './pipeline/index.js'; +import { pipelineErrorEnvelope } from './pipeline-error-envelope.js'; import { parseInput } from './stages/input.js'; import type { PublishCliOptions } from './types.js'; @@ -68,15 +67,7 @@ export function createPublishCommand(): Command { writeEnvelope(successEnvelope(output, { changed: publishDidChange(output) }), io); } catch (err) { if (err instanceof PipelineError) { - // The envelope has no stage field, so name the failed stage in the message; `data` keeps what landed. - const error = toEnvelopeError(err); - writeEnvelope( - errorEnvelope([{ ...error, message: `publish failed in the ${err.failedStage} stage: ${error.message}` }], { - data: err.partialOutput, - changed: publishDidChange(err.partialOutput), - }), - io, - ); + writeEnvelope(pipelineErrorEnvelope(err), io); err.logError(); } else { writeErrorEnvelope(err, io); diff --git a/packages/publish/src/index.ts b/packages/publish/src/index.ts index f1179f70..d321211b 100644 --- a/packages/publish/src/index.ts +++ b/packages/publish/src/index.ts @@ -3,6 +3,7 @@ export { getDefaultConfig, loadConfig } from './config.js'; export { BasePublishError, createPublishError, PipelineError, PublishError, PublishErrorCode } from './errors/index.js'; export { publishDidChange } from './output.js'; export { runPipeline } from './pipeline/index.js'; +export { pipelineErrorEnvelope } from './pipeline-error-envelope.js'; export { parseInput } from './stages/input.js'; export type { CargoConfig, diff --git a/packages/publish/src/pipeline-error-envelope.ts b/packages/publish/src/pipeline-error-envelope.ts new file mode 100644 index 00000000..6c9bc48e --- /dev/null +++ b/packages/publish/src/pipeline-error-envelope.ts @@ -0,0 +1,12 @@ +import { type Envelope, errorEnvelope, toEnvelopeError } from '@releasekit/core'; +import type { PipelineError } from './errors/index.js'; +import { publishDidChange } from './output.js'; + +/** The envelope has no stage field, so the message names the failed stage; `data` keeps what landed. */ +export function pipelineErrorEnvelope(err: PipelineError): Envelope { + const error = toEnvelopeError(err); + return errorEnvelope([{ ...error, message: `publish failed in the ${err.failedStage} stage: ${error.message}` }], { + data: err.partialOutput, + changed: publishDidChange(err.partialOutput), + }); +} diff --git a/packages/release/src/commands/emitResult.ts b/packages/release/src/commands/emitResult.ts index a767373a..27cb38a0 100644 --- a/packages/release/src/commands/emitResult.ts +++ b/packages/release/src/commands/emitResult.ts @@ -6,6 +6,7 @@ import { writeEnvelope, writeErrorEnvelope, } from '@releasekit/core'; +import { PipelineError, pipelineErrorEnvelope } from '@releasekit/publish'; /** `data` goes into the envelope verbatim: the envelope wraps, never replaces. */ export function emitResult( @@ -16,6 +17,10 @@ export function emitResult( } export function emitError(error: unknown, opts: { json?: boolean; output?: string }): void { + if (error instanceof PipelineError) { + writeEnvelope(pipelineErrorEnvelope(error), opts); + return; + } writeErrorEnvelope(error, opts); } diff --git a/packages/release/test/unit/commands/cross-bundle-errors.spec.ts b/packages/release/test/unit/commands/cross-bundle-errors.spec.ts index cff03f3f..3caf326c 100644 --- a/packages/release/test/unit/commands/cross-bundle-errors.spec.ts +++ b/packages/release/test/unit/commands/cross-bundle-errors.spec.ts @@ -1,6 +1,6 @@ import { EXIT_CODES, ReleaseKitError } from '@releasekit/core'; import { LLMError } from '@releasekit/notes'; -import { BasePublishError, PublishErrorCode } from '@releasekit/publish'; +import { BasePublishError, PipelineError, PublishErrorCode, type PublishOutput } from '@releasekit/publish'; import { BaseVersionError } from '@releasekit/version'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -58,4 +58,29 @@ describe('release --json when a stage fails', () => { expect(envelope.errors).toMatchObject([{ code, retryable, message: error.message }]); expect(exitSpy).toHaveBeenCalledWith(exit); }); + + it('should keep a PipelineError partial output and name the failed stage', async () => { + const partialOutput = { + dryRun: false, + git: { committed: true, tags: ['v1.0.0'], pushed: false }, + npm: [{ packageName: '@acme/a', version: '1.0.0', registry: 'npm', success: true, skipped: false }], + cargo: [], + pub: [], + verification: [], + githubReleases: [], + publishSucceeded: false, + } as PublishOutput; + vi.mocked(runRelease).mockRejectedValue(new PipelineError('registry rejected', 'npm', partialOutput)); + + await createReleaseCommand().parseAsync(['node', 'release', '--json']); + + const envelope = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(envelope.status).toBe('error'); + expect(envelope.changed).toBe(true); + expect(envelope.data).toEqual(partialOutput); + expect(envelope.errors[0].code).toBe('PIPELINE_STAGE_ERROR'); + expect(envelope.errors[0].message).toContain('npm'); + expect(envelope.errors[0].message).toContain('registry rejected'); + expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.PUBLISH_ERROR); + }); }); diff --git a/packages/release/test/unit/standing-pr-command.spec.ts b/packages/release/test/unit/standing-pr-command.spec.ts index 0a2161b5..90f02df9 100644 --- a/packages/release/test/unit/standing-pr-command.spec.ts +++ b/packages/release/test/unit/standing-pr-command.spec.ts @@ -1,3 +1,4 @@ +import { PipelineError, type PublishOutput } from '@releasekit/publish'; import { describe, expect, it, vi } from 'vitest'; import { createStandingPRCommand } from '../../src/commands/standing-pr-command.js'; @@ -146,6 +147,38 @@ describe('createStandingPRCommand', () => { }); }); + it('should keep a publish PipelineError partial output in the json envelope', async () => { + const { runStandingPRPublish } = await import('../../src/standing-pr/standing-pr.js'); + const partialOutput = { + dryRun: false, + git: { committed: false, tags: ['v2.0.0'], pushed: false }, + npm: [{ packageName: '@acme/widget', version: '2.0.0', registry: 'npm', success: true, skipped: false }], + cargo: [], + pub: [], + verification: [], + githubReleases: [], + publishSucceeded: false, + } as PublishOutput; + vi.mocked(runStandingPRPublish).mockRejectedValueOnce( + new PipelineError('push rejected', 'git-push', partialOutput), + ); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await parseCommand(['publish', '--project-dir', '/test', '--json', '--pr', '189']); + const envelope = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(envelope.status).toBe('error'); + expect(envelope.changed).toBe(true); + expect(envelope.data).toEqual(partialOutput); + expect(envelope.errors[0].message).toContain('git-push'); + } finally { + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + } + }); + it('should pass --verbose, --quiet, --json flags through', async () => { const { runStandingPRUpdate } = await import('../../src/standing-pr/standing-pr.js'); await parseCommand(['update', '--verbose', '--quiet', '--json']);