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
4 changes: 2 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
13 changes: 2 additions & 11 deletions packages/publish/src/command.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import {
errorEnvelope,
exitCodeForError,
setJsonMode,
setLogLevel,
successEnvelope,
toEnvelopeError,
writeEnvelope,
writeErrorEnvelope,
} from '@releasekit/core';
Expand All @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/publish/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions packages/publish/src/pipeline-error-envelope.ts
Original file line number Diff line number Diff line change
@@ -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),
});
}
5 changes: 5 additions & 0 deletions packages/release/src/commands/emitResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
}

Expand Down
27 changes: 26 additions & 1 deletion packages/release/test/unit/commands/cross-bundle-errors.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
});
});
33 changes: 33 additions & 0 deletions packages/release/test/unit/standing-pr-command.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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']);
Expand Down
Loading