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
21 changes: 20 additions & 1 deletion packages/core/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { log } from './logger.js';

/**
* Brand recognised across copies of this module. `@releasekit/release` bundles version, notes and
* publish from their own builds, and each of those inlines its own copy of core — so one process can
* hold several distinct `ReleaseKitError` classes, and `instanceof` against any one of them misses an
* error thrown through another (its code then degrades to GENERAL_ERROR). `Symbol.for` keys a
* process-wide registry, so every copy stamps and recognises the same brand.
*/
const RELEASEKIT_ERROR_BRAND = Symbol.for('@releasekit/core:ReleaseKitError');

/**
* Base error class that all releasekit errors should extend.
* Provides consistent error handling with codes and suggestions.
Expand All @@ -11,6 +20,8 @@ export abstract class ReleaseKitError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
// Non-enumerable, so the brand never shows up in a spread, JSON output, or a test's toEqual.
Object.defineProperty(this, RELEASEKIT_ERROR_BRAND, { value: true });
}

logError(): void {
Expand All @@ -24,8 +35,16 @@ export abstract class ReleaseKitError extends Error {
}
}

/** True for an error from any copy of core (see {@link RELEASEKIT_ERROR_BRAND}), not just this one. */
static isReleaseKitError(error: unknown): error is ReleaseKitError {
return error instanceof ReleaseKitError;
if (error instanceof ReleaseKitError) {
return true;
}
return (
typeof error === 'object' &&
error !== null &&
(error as { [RELEASEKIT_ERROR_BRAND]?: unknown })[RELEASEKIT_ERROR_BRAND] === true
);
}
}

Expand Down
19 changes: 19 additions & 0 deletions packages/core/test/unit/errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,25 @@ describe('ReleaseKitError', () => {
expect(ReleaseKitError.isReleaseKitError('error')).toBe(false);
expect(ReleaseKitError.isReleaseKitError({})).toBe(false);
});

it('should recognise an error from another copy of this module', async () => {
vi.resetModules();
const other = await import('../../src/errors.js');
class OtherCopyError extends other.ReleaseKitError {
readonly code = 'CONFIG_ERROR';
readonly suggestions: string[] = [];
}
const error = new OtherCopyError('from the other copy');

expect(other.ReleaseKitError).not.toBe(ReleaseKitError);
expect(error instanceof ReleaseKitError).toBe(false);
expect(ReleaseKitError.isReleaseKitError(error)).toBe(true);
});

it('should keep the brand out of enumerable properties', () => {
const error = new TestError('Test');
expect(Object.getOwnPropertySymbols({ ...error })).toEqual([]);
});
});
});

Expand Down
61 changes: 61 additions & 0 deletions packages/release/test/unit/commands/cross-bundle-errors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { EXIT_CODES, ReleaseKitError } from '@releasekit/core';
import { LLMError } from '@releasekit/notes';
import { BasePublishError, PublishErrorCode } from '@releasekit/publish';
import { BaseVersionError } from '@releasekit/version';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('../../../src/release.js');

import { createReleaseCommand } from '../../../src/commands/release-command.js';
import { runRelease } from '../../../src/release.js';

describe('release --json when a stage fails', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
vi.restoreAllMocks();
});

it.each([
{
stage: 'version',
error: new BaseVersionError('git tag failed', 'GIT_ERROR'),
code: 'GIT_ERROR',
retryable: false,
exit: EXIT_CODES.GIT_ERROR,
},
{
stage: 'notes',
error: new LLMError('provider timed out', { retryable: true }),
code: 'LLM_ERROR',
retryable: true,
exit: EXIT_CODES.LLM_ERROR,
},
{
stage: 'publish',
error: new BasePublishError('npm auth is not configured', PublishErrorCode.CONFIG_ERROR),
code: 'CONFIG_ERROR',
retryable: false,
exit: EXIT_CODES.CONFIG_ERROR,
},
])('should report $code from the $stage stage and exit $exit', async ({ stage, error, code, retryable, exit }) => {
expect(
error,
`the ${stage} build shares release's ReleaseKitError, so this case no longer tests a separate copy of core`,
).not.toBeInstanceOf(ReleaseKitError);
vi.mocked(runRelease).mockRejectedValue(error);

await createReleaseCommand().parseAsync(['node', 'release', '--json']);

const envelope = JSON.parse(logSpy.mock.calls[0]?.[0] as string);
expect(envelope.errors).toMatchObject([{ code, retryable, message: error.message }]);
expect(exitSpy).toHaveBeenCalledWith(exit);
});
});
Loading