From 6e29fb63892bc39e8fa0e7c1ec4c900068613fa5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 1 Aug 2026 21:23:13 +0100 Subject: [PATCH 01/19] feat(cli): envelope the version and publish commands, unwrap on the pipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the envelope scope of #544. `version --json` and `publish --json` were the two commands still printing a bare payload; both now emit the uniform envelope and accept `--output ` for parity with the orchestration commands. `notes` has no JSON output, so the surface is now uniform. The coupling this was deferred for is the pipe: `version | notes | publish` carries JSON between processes, so enveloping the producer means teaching the consumers to unwrap on input. Both do, via `unwrapEnvelope` in core — called once inside `parseVersionOutput` and `parseInput` so the string, file, and stdin entries are all covered. A bare `VersionOutput` still passes through, so a hand-assembled file or an older releasekit keeps working. Piping a failed stage forward now fails with that stage's message rather than a schema complaint about missing fields, which pointed at the wrong thing. Two things fell out of doing this honestly: `errorEnvelope` takes optional `data` and `changed`. Enveloping publish's failure path would otherwise have dropped `partialOutput` — what already landed before the failure — leaving the enveloped output less informative than the prose it replaced, and reporting `changed: false` for a partial publish that did change state. Publishes are idempotent and retried, so that is exactly what the retry reads. `failedStage` has no envelope field, so it is named in the error message rather than dropped. Readers that ignore `data` when `status` is `error` are unaffected, so schemaVersion is unchanged. The publish-changed predicate moved to `@releasekit/publish` as `publishDidChange`. The publish CLI and `standing-pr publish` must answer `changed` the same way, and the logic is subtle enough (tags, `git.pushed`, and already-existing GitHub releases all report success without anything having published) that two copies would drift. It is also now tolerant of absent registry arrays: it runs on the partial output of a failed pipeline, where throwing would replace the publish error the caller needs. Note `version` now exits with the mapped code from the documented table rather than always 1 — in practice only `GIT_ERROR`, which becomes 7. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FADRmu9uhtCk3StMoqwV27 --- docs/cli.md | 22 ++++- packages/core/src/cli.ts | 16 ++++ packages/core/src/envelope.ts | 42 +++++++- packages/core/src/index.ts | 4 +- packages/core/test/unit/envelope.spec.ts | 44 +++++++++ packages/notes/src/input/version-output.ts | 13 ++- .../test/unit/input/version-output.spec.ts | 13 ++- packages/publish/src/command.ts | 41 +++++--- packages/publish/src/index.ts | 2 + packages/publish/src/output.ts | 21 ++++ packages/publish/src/stages/input.ts | 9 +- packages/publish/test/unit/cli.spec.ts | 36 +++++-- packages/publish/test/unit/output.spec.ts | 74 ++++++++++++++ .../publish/test/unit/stages/input.spec.ts | 21 ++++ packages/release/src/commands/changed.ts | 19 ++-- .../src/commands/standing-pr-command.ts | 6 +- .../test/unit/commands/changed.spec.ts | 96 ------------------- packages/version/src/command.ts | 28 +++++- packages/version/src/utils/jsonOutput.ts | 17 +++- .../test/integration/sync-tags.spec.ts | 6 +- .../test/unit/utils/jsonOutput.spec.ts | 29 +++++- packages/version/test/utils/cli.ts | 14 +++ 22 files changed, 407 insertions(+), 166 deletions(-) create mode 100644 packages/publish/src/output.ts create mode 100644 packages/publish/test/unit/output.spec.ts delete mode 100644 packages/release/test/unit/commands/changed.spec.ts diff --git a/docs/cli.md b/docs/cli.md index 50278aa4..fad366c7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,26 +20,36 @@ The `releasekit` dispatcher re-exports `version`, `notes`, and `publish`, so `re ## JSON output contract -The orchestration commands — `release`, `gate`, and `standing-pr` — emit their `-j, --json` result as a single **envelope**, one uniform shape shared by humans, CI, and agents. `--output ` writes the same envelope to a file instead of stdout (the reliable channel for the GitHub Action, since stdout can be polluted by subprocess or log noise, and a single stray byte breaks JSON parsing). - -> **Not yet enveloped:** the pipe commands `version`, `notes`, and `publish` still print their payload bare. They pipe JSON between processes, so wrapping their output means teaching the consuming side to unwrap on input — a separate change. Don't unwrap `.data` from those; check for `schemaVersion` if you need to handle both. +Every command that produces a JSON result — `release`, `gate`, `standing-pr`, `version`, and `publish` — emits it as a single **envelope**, one uniform shape shared by humans, CI, and agents. `--output ` writes the same envelope to a file instead of stdout (the reliable channel for the GitHub Action, since stdout can be polluted by subprocess or log noise, and a single stray byte breaks JSON parsing). ```jsonc { "schemaVersion": 1, // envelope contract version; stable across minor releases "status": "success", // "success" | "error" "changed": true, // did the command change state, or was everything already as desired? - "data": { /* … */ }, // command-specific payload (VersionOutput, gate result, …); null on error + "data": { /* … */ }, // command-specific payload (VersionOutput, gate result, …); null on error, except partial progress "warnings": [{ "code": "…", "message": "…" }], "errors": [{ "code": "…", "category": "…", "retryable": false, "message": "…" }] } ``` -- **`data`** carries the command's payload verbatim — the envelope wraps it, never replaces it. `releasekit release --json` still exposes its `VersionOutput` at `data.versionOutput`. +- **`data`** carries the command's payload verbatim — the envelope wraps it, never replaces it. `releasekit release --json` still exposes its `VersionOutput` at `data.versionOutput`, and `releasekit version --json` exposes it at `data`. It is `null` on error, except where a command failed partway with real progress to report (see the pipe section below). - **`changed`** separates real work from a no-op: a dry run is never `changed`; `standing-pr publish` reads its registry results, so a re-run where every version was already published reports `changed: false`; `gate` is read-only and always `changed: false`. - **`errors[]`** replaces prose-only failures in JSON mode. Each carries a stable machine `code`, a coarse `category`, a `retryable` flag (only `true` for known-transient failures — a timeout, 429, or 5xx from a provider — so an agent never retries an unknown failure), and a human `message`. - **Stream discipline:** the envelope is the only thing on stdout; all diagnostics (progress, warnings, error text) go to stderr. Parsing stdout as JSON is always safe, and no command prompts interactively on a CI path. +### The `version | notes | publish` pipe + +The pipe is unchanged: `notes` and `publish` unwrap the envelope on the way in and read the `VersionOutput` from `data`, so the three still chain directly. + +```sh +releasekit version --json | releasekit notes | releasekit publish +``` + +A bare `VersionOutput` is still accepted on input, so a hand-assembled file or output from an older releasekit keeps working. Piping a *failed* stage forward fails with that stage's error rather than a schema complaint about missing fields — the message names the upstream code, so the report points at the stage that actually broke. + +When `publish` fails partway, its error envelope carries what already landed in `data` and reports `changed: true`; the failing stage is named in the error `message`. Publishes are idempotent, so a retry uses that to skip what is already out. + ### Error codes and exit codes | `code` | `category` | Exit code | @@ -275,6 +285,7 @@ Version a package or packages based on configuration and conventional commits. A | `--allow-first-bump` | boolean | `false` | Acknowledge applying a bump on a first release with an already-stable manifest (silences the overshoot warning; see [`version.allowFirstBump`](./configuration.md#version)) | | `-s, --sync` | boolean | config | Use synchronized versioning across all packages | | `-j, --json` | boolean | `false` | Output results as JSON | +| `--output ` | string | stdout | Write the JSON result to a file instead of stdout | | `-t, --target ` | string | all | Comma-delimited list of package names to target | | `--include-prerequisites` | boolean | `false` | Also release the changed internal dependencies of `--target` packages (and the rest of their groups) | | `--project-dir ` | string | `cwd` | Project directory to run commands in | @@ -360,6 +371,7 @@ Publish packages to registries with git tagging and GitHub releases. Reads a ver | `--skip-github-release` | boolean | `false` | Skip GitHub Release creation | | `--skip-verification` | boolean | `false` | Skip post-publish verification | | `--json` | boolean | `false` | Output results as JSON | +| `--output ` | string | stdout | Write the JSON result to a file instead of stdout | | `--verbose` | boolean | `false` | Verbose logging | ```bash diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 055aceb9..0a2778e3 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -1,6 +1,22 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { Envelope } from './envelope.js'; + +/** + * Write an envelope to the JSON channel: `--output` when given, otherwise stdout under `--json`, and + * nothing at all when neither is set. `--output` is the reliable channel — stdout can be polluted by + * subprocess or log noise, and a single stray byte breaks JSON parsing. + */ +export function writeEnvelope(envelope: Envelope, opts: { json?: boolean; output?: string }): void { + if (!opts.json && !opts.output) return; + const text = JSON.stringify(envelope, null, 2); + if (opts.output) { + fs.writeFileSync(opts.output, text); + } else { + console.log(text); + } +} /** * Reads the version from the package.json nearest to the given module URL. diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 2e1cb23c..41d32f47 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -52,17 +52,53 @@ export function successEnvelope( }; } -export function errorEnvelope(errors: EnvelopeError[], opts: { warnings?: EnvelopeWarning[] } = {}): Envelope { +/** + * `data` and `changed` exist for commands that fail partway with real progress to report: a publish + * that failed after landing some packages knows what landed, and did change state. Hardcoding + * `null`/`false` here would drop that, and the retry is what needs it. + */ +export function errorEnvelope( + errors: EnvelopeError[], + opts: { warnings?: EnvelopeWarning[]; data?: T; changed?: boolean } = {}, +): Envelope { return { schemaVersion: ENVELOPE_SCHEMA_VERSION, status: 'error', - changed: false, - data: null, + changed: opts.changed ?? false, + data: opts.data ?? null, warnings: opts.warnings ?? [], errors, }; } +/** Structural test for the envelope. */ +export function isEnvelope(value: unknown): value is Envelope { + return ( + typeof value === 'object' && value !== null && 'schemaVersion' in value && 'status' in value && 'data' in value + ); +} + +/** + * Unwrap a piped envelope to its payload; a bare payload passes through unchanged, so a + * hand-assembled input or an older releasekit still works. + * + * An error envelope throws rather than yielding null: piping a failed stage into the next one should + * fail with the upstream message, not with a schema complaint about missing fields that sends the + * reader looking in the wrong place. + */ +export function unwrapEnvelope(value: unknown): unknown { + if (!isEnvelope(value)) return value; + if (value.status === 'error') { + const first = value.errors[0]; + throw new Error( + first + ? `Upstream releasekit stage failed (${first.code}): ${first.message}` + : 'Upstream releasekit stage failed, but reported no error detail.', + ); + } + return value.data; +} + const CODE_TO_EXIT: Record = { CONFIG_ERROR: EXIT_CODES.CONFIG_ERROR, INPUT_ERROR: EXIT_CODES.INPUT_ERROR, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a3b89e88..d24cb205 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,7 +6,7 @@ export { parseGitHubOwnerRepo, renderIssueRefs, } from './changelogRefs.js'; -export { readPackageVersion } from './cli.js'; +export { readPackageVersion, writeEnvelope } from './cli.js'; export { buildDependencyGraph, type Ecosystem, @@ -21,8 +21,10 @@ export { type EnvelopeWarning, errorEnvelope, exitCodeForError, + isEnvelope, successEnvelope, toEnvelopeError, + unwrapEnvelope, } from './envelope.js'; export { EXIT_CODES, type ExitCode, InputError, ReleaseKitError } from './errors.js'; export { diff --git a/packages/core/test/unit/envelope.spec.ts b/packages/core/test/unit/envelope.spec.ts index bed046cc..ced3d68f 100644 --- a/packages/core/test/unit/envelope.spec.ts +++ b/packages/core/test/unit/envelope.spec.ts @@ -3,8 +3,10 @@ import { ENVELOPE_SCHEMA_VERSION, errorEnvelope, exitCodeForError, + isEnvelope, successEnvelope, toEnvelopeError, + unwrapEnvelope, } from '../../src/envelope.js'; import { EXIT_CODES, ReleaseKitError } from '../../src/errors.js'; @@ -98,4 +100,46 @@ describe('envelope', () => { expect(exitCodeForError(new Error('x'))).toBe(EXIT_CODES.GENERAL_ERROR); }); }); + + describe('errorEnvelope partial progress', () => { + it('should carry data and changed when a command failed partway with progress to report', () => { + const env = errorEnvelope([toEnvelopeError(new Error('registry rejected'))], { + data: { npm: ['@acme/a'] }, + changed: true, + }); + expect(env.status).toBe('error'); + expect(env.data).toEqual({ npm: ['@acme/a'] }); + expect(env.changed).toBe(true); + }); + }); + + describe('isEnvelope', () => { + it('should accept an envelope and reject bare payloads', () => { + expect(isEnvelope(successEnvelope({ tags: [] }))).toBe(true); + expect(isEnvelope({ dryRun: false, updates: [], tags: [] })).toBe(false); + expect(isEnvelope(null)).toBe(false); + expect(isEnvelope('a string')).toBe(false); + }); + }); + + describe('unwrapEnvelope', () => { + it('should return the payload from a success envelope', () => { + const payload = { dryRun: false, updates: [{ packageName: 'a' }], tags: ['v1.0.0'] }; + expect(unwrapEnvelope(successEnvelope(payload))).toEqual(payload); + }); + + it('should pass a bare payload through unchanged', () => { + const bare = { dryRun: false, updates: [], tags: [] }; + expect(unwrapEnvelope(bare)).toBe(bare); + }); + + it('should throw with the upstream message and code for an error envelope', () => { + const env = errorEnvelope([toEnvelopeError(new TestConfigError('bad config'))]); + expect(() => unwrapEnvelope(env)).toThrow(/CONFIG_ERROR.*bad config/); + }); + + it('should throw even when an error envelope carries no error detail', () => { + expect(() => unwrapEnvelope(errorEnvelope([]))).toThrow(/reported no error detail/); + }); + }); }); diff --git a/packages/notes/src/input/version-output.ts b/packages/notes/src/input/version-output.ts index ab4ebe53..79a9f845 100644 --- a/packages/notes/src/input/version-output.ts +++ b/packages/notes/src/input/version-output.ts @@ -1,5 +1,5 @@ import * as fs from 'node:fs'; -import type { VersionOutput } from '@releasekit/core'; +import { unwrapEnvelope, type VersionOutput } from '@releasekit/core'; import type { ChangelogEntry, ChangelogInput, PackageChangelog } from '../core/types.js'; import { InputParseError } from '../errors/index.js'; @@ -57,7 +57,7 @@ export function versionOutputToChangelogInput(data: VersionOutput): ChangelogInp } export function parseVersionOutput(json: string): ChangelogInput { - let data: VersionOutput; + let data: unknown; try { data = JSON.parse(json); @@ -65,7 +65,14 @@ export function parseVersionOutput(json: string): ChangelogInput { throw new InputParseError(`Invalid JSON input: ${error instanceof Error ? error.message : String(error)}`); } - return versionOutputToChangelogInput(data); + // Unwrap here rather than at each call site — this covers the string, file, and stdin entries at once. + try { + data = unwrapEnvelope(data); + } catch (error) { + throw new InputParseError(error instanceof Error ? error.message : String(error)); + } + + return versionOutputToChangelogInput(data as VersionOutput); } export function parseVersionOutputFile(filePath: string): ChangelogInput { diff --git a/packages/notes/test/unit/input/version-output.spec.ts b/packages/notes/test/unit/input/version-output.spec.ts index 660f1fa3..86f6ae6f 100644 --- a/packages/notes/test/unit/input/version-output.spec.ts +++ b/packages/notes/test/unit/input/version-output.spec.ts @@ -1,4 +1,4 @@ -import type { VersionOutput } from '@releasekit/core'; +import { errorEnvelope, successEnvelope, type VersionOutput } from '@releasekit/core'; import { describe, expect, it } from 'vitest'; import { parseVersionOutput, versionOutputToChangelogInput } from '../../../src/input/version-output.js'; @@ -195,4 +195,15 @@ describe('parseVersionOutput', () => { it('should throw on invalid JSON', () => { expect(() => parseVersionOutput('not valid json')).toThrow('Invalid JSON input'); }); + + it('should unwrap the envelope emitted by `version --json`', () => { + const result = parseVersionOutput(JSON.stringify(successEnvelope(baseVersionOutput, { changed: true }))); + expect(result.source).toBe('version'); + expect(result.packages[0]?.packageName).toBe('@scope/my-package'); + }); + + it('should surface an upstream failure rather than a shape complaint', () => { + const failed = errorEnvelope([{ code: 'GIT_ERROR', category: 'git', retryable: false, message: 'no such ref' }]); + expect(() => parseVersionOutput(JSON.stringify(failed))).toThrow(/GIT_ERROR.*no such ref/); + }); }); diff --git a/packages/publish/src/command.ts b/packages/publish/src/command.ts index 3b3e1f2c..45b8dcc6 100644 --- a/packages/publish/src/command.ts +++ b/packages/publish/src/command.ts @@ -1,7 +1,16 @@ -import { EXIT_CODES, setJsonMode, setLogLevel } from '@releasekit/core'; +import { + EXIT_CODES, + errorEnvelope, + setJsonMode, + setLogLevel, + successEnvelope, + toEnvelopeError, + writeEnvelope, +} from '@releasekit/core'; import { Command } from 'commander'; import { loadConfig } from './config.js'; import { BasePublishError, PipelineError } from './errors/index.js'; +import { publishDidChange } from './output.js'; import { runPipeline } from './pipeline/index.js'; import { parseInput } from './stages/input.js'; import type { PublishCliOptions } from './types.js'; @@ -19,8 +28,11 @@ export function createPublishCommand(): Command { .option('--skip-github-release', 'Skip GitHub Release creation', false) .option('--skip-verification', 'Skip post-publish verification', false) .option('--json', 'Output results as JSON', false) + .option('--output ', 'Write the JSON result to a file instead of stdout') .option('--verbose', 'Verbose logging', false) .action(async (options) => { + const io = { json: options.json, output: options.output }; + if (options.verbose) setLogLevel('debug'); if (options.json) setJsonMode(true); @@ -48,24 +60,23 @@ export function createPublishCommand(): Command { const output = await runPipeline(input, config, cliOptions); - if (options.json) { - console.log(JSON.stringify(output, null, 2)); - } + writeEnvelope(successEnvelope(output, { changed: publishDidChange(output) }), io); } catch (err) { - if (err instanceof PipelineError && options.json) { - console.log( - JSON.stringify( - { - error: err.message, - failedStage: err.failedStage, - partialOutput: err.partialOutput, - }, - null, - 2, - ), + if (err instanceof PipelineError) { + // Carry what landed before the failure — the retry needs it. `failedStage` has no envelope + // field of its own, so it goes in the message rather than being dropped. + 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, ); + err.logError(); process.exit(EXIT_CODES.PUBLISH_ERROR); } + writeEnvelope(errorEnvelope([toEnvelopeError(err)]), io); if (BasePublishError.isPublishError(err)) { err.logError(); process.exit(EXIT_CODES.PUBLISH_ERROR); diff --git a/packages/publish/src/index.ts b/packages/publish/src/index.ts index 6e8cd68a..733cf44e 100644 --- a/packages/publish/src/index.ts +++ b/packages/publish/src/index.ts @@ -4,6 +4,8 @@ export { getDefaultConfig, loadConfig } from './config.js'; // Errors export { BasePublishError, createPublishError, PipelineError, PublishError, PublishErrorCode } from './errors/index.js'; // Pipeline +export { publishDidChange } from './output.js'; + export { runPipeline } from './pipeline/index.js'; // Input parsing export { parseInput } from './stages/input.js'; diff --git a/packages/publish/src/output.ts b/packages/publish/src/output.ts new file mode 100644 index 00000000..12a3718a --- /dev/null +++ b/packages/publish/src/output.ts @@ -0,0 +1,21 @@ +import type { PublishOutput } from './types.js'; + +/** + * Did this publish actually publish anything? + * + * The registry results are the one honest signal in a {@link PublishOutput}. Release tags are created + * before the pipeline runs, `git.pushed` is set for any push attempt, and the GitHub-release stage + * reports `success: true` for an already-existing release — none of those distinguish a real publish + * from an idempotent no-op. A registry result does: `skipped` and `alreadyPublished` mark the packages + * that were passed over. Same predicate the verify stage uses to pick what to verify. + * + * Answers `changed` for both the `publish` CLI and `standing-pr publish`, which is why it lives here + * rather than in either caller. + */ +export function publishDidChange(output: PublishOutput | null | undefined): boolean { + if (!output || output.dryRun) return false; + // Tolerate absent registry arrays: this also runs on the partial output of a failed pipeline, and + // throwing here would replace the publish error the caller needs with a TypeError about our own shape. + const results = [...(output.npm ?? []), ...(output.cargo ?? []), ...(output.pub ?? [])]; + return results.some((r) => r.success && !r.skipped && !r.alreadyPublished); +} diff --git a/packages/publish/src/stages/input.ts b/packages/publish/src/stages/input.ts index 80f925da..103a2775 100644 --- a/packages/publish/src/stages/input.ts +++ b/packages/publish/src/stages/input.ts @@ -1,5 +1,5 @@ import * as fs from 'node:fs'; -import { info, type VersionOutput } from '@releasekit/core'; +import { info, unwrapEnvelope, type VersionOutput } from '@releasekit/core'; import { z } from 'zod'; import { createPublishError, PublishErrorCode } from '../errors/index.js'; @@ -54,6 +54,13 @@ export async function parseInput(inputPath?: string): Promise { throw createPublishError(PublishErrorCode.INPUT_PARSE_ERROR, 'Input is not valid JSON'); } + // Re-raise an upstream failure as an input error, so it reaches the caller as this stage's own code. + try { + parsed = unwrapEnvelope(parsed); + } catch (err) { + throw createPublishError(PublishErrorCode.INPUT_PARSE_ERROR, err instanceof Error ? err.message : String(err)); + } + const result = VersionOutputSchema.safeParse(parsed); if (!result.success) { const issues = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n'); diff --git a/packages/publish/test/unit/cli.spec.ts b/packages/publish/test/unit/cli.spec.ts index 9b68090e..d1cacb0f 100644 --- a/packages/publish/test/unit/cli.spec.ts +++ b/packages/publish/test/unit/cli.spec.ts @@ -1,6 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('@releasekit/core'); +// Partial mock: the envelope builders and writer must stay real, since these tests assert on the +// envelope the command actually emits. Only the logging setters are stubbed, to assert they're wired. +vi.mock('@releasekit/core', async (importOriginal) => ({ + ...(await importOriginal()), + setJsonMode: vi.fn(), + setLogLevel: vi.fn(), +})); vi.mock('../../src/config.js'); vi.mock('../../src/pipeline/index.js'); vi.mock('../../src/stages/input.js'); @@ -46,8 +52,10 @@ const mockOutput: PublishOutput = { git: { committed: false, tags: [], pushed: false }, npm: [], cargo: [], + pub: [], verification: [], githubReleases: [], + publishSucceeded: true, }; describe('createPublishCommand', () => { @@ -106,12 +114,15 @@ describe('createPublishCommand', () => { }); describe('JSON output', () => { - it('should print output as JSON when --json is passed and pipeline succeeds', async () => { + it('should print a success envelope wrapping the output when --json is passed', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); await createPublishCommand().parseAsync(['node', 'test', '--json']); - expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(mockOutput, null, 2)); + const envelope = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); + expect(envelope.status).toBe('success'); + // The payload rides in `data` verbatim, so the pipe's contract is unchanged. + expect(envelope.data).toEqual(mockOutput); consoleSpy.mockRestore(); }); @@ -126,16 +137,25 @@ describe('createPublishCommand', () => { }); describe('error handling', () => { - it('should print PipelineError as JSON and exit with PUBLISH_ERROR when --json is passed', async () => { - const partialOutput: PublishOutput = { ...mockOutput }; - const pipelineError = new PipelineError('stage failed', 'npm', partialOutput); - vi.mocked(runPipeline).mockRejectedValue(pipelineError); + it('should print a PipelineError envelope carrying the partial output and exit with PUBLISH_ERROR', async () => { + const partialOutput: PublishOutput = { + ...mockOutput, + npm: [{ packageName: '@acme/a', version: '1.0.0', registry: 'npm', success: true, skipped: false }], + }; + vi.mocked(runPipeline).mockRejectedValue(new PipelineError('registry rejected', 'npm', partialOutput)); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); await createPublishCommand().parseAsync(['node', 'test', '--json']); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"failedStage": "npm"')); + const envelope = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); + expect(envelope.status).toBe('error'); + // What landed before the failure survives — the retry needs it — and the stage that failed is + // named in the message, since the envelope has no field for it. + expect(envelope.data).toEqual(partialOutput); + expect(envelope.changed).toBe(true); + expect(envelope.errors[0].message).toContain('npm'); + expect(envelope.errors[0].message).toContain('registry rejected'); expect(mockExit).toHaveBeenCalledWith(EXIT_CODES.PUBLISH_ERROR); consoleSpy.mockRestore(); }); diff --git a/packages/publish/test/unit/output.spec.ts b/packages/publish/test/unit/output.spec.ts new file mode 100644 index 00000000..c5c8a20f --- /dev/null +++ b/packages/publish/test/unit/output.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { publishDidChange } from '../../src/output.js'; +import type { PublishOutput, PublishResult } from '../../src/types.js'; + +function result(overrides: Partial = {}): PublishResult { + return { + packageName: '@acme/widget', + version: '2.0.0', + registry: 'npm', + success: true, + skipped: false, + ...overrides, + }; +} + +function output(overrides: Partial = {}): PublishOutput { + return { + dryRun: false, + git: { committed: false, tags: ['v2.0.0'], pushed: true }, + npm: [], + cargo: [], + pub: [], + verification: [], + githubReleases: [], + publishSucceeded: true, + ...overrides, + }; +} + +describe('publishDidChange', () => { + it('should be true when a package actually reached a registry', () => { + expect(publishDidChange(output({ npm: [result()] }))).toBe(true); + }); + + it('should be false when every package was already published', () => { + expect(publishDidChange(output({ npm: [result({ skipped: true, alreadyPublished: true })] }))).toBe(false); + }); + + it('should be false when every package was skipped as private', () => { + expect(publishDidChange(output({ npm: [result({ skipped: true, reason: 'private' })] }))).toBe(false); + }); + + it('should be false for a push of pre-existing tags and releases with nothing published', () => { + // git.pushed is set for any push attempt and the GitHub-release stage reports success for an + // already-existing release, so neither can stand in for "something happened". + const out = output({ + npm: [result({ skipped: true, alreadyPublished: true })], + git: { committed: true, tags: ['v2.0.0'], pushed: true }, + githubReleases: [{ tag: 'v2.0.0', draft: false, prerelease: false, success: true }], + }); + expect(publishDidChange(out)).toBe(false); + }); + + it('should be true when one package published among already-published ones', () => { + const out = output({ + npm: [result({ packageName: '@acme/a', skipped: true, alreadyPublished: true }), result()], + }); + expect(publishDidChange(out)).toBe(true); + }); + + it('should read cargo and pub results alongside npm', () => { + expect(publishDidChange(output({ cargo: [result({ registry: 'cargo' })] }))).toBe(true); + expect(publishDidChange(output({ pub: [result({ registry: 'pub' })] }))).toBe(true); + }); + + it('should be false for a dry run that reports successful publishes', () => { + expect(publishDidChange(output({ dryRun: true, npm: [result()] }))).toBe(false); + }); + + it('should be false for missing output', () => { + expect(publishDidChange(undefined)).toBe(false); + expect(publishDidChange(null)).toBe(false); + }); +}); diff --git a/packages/publish/test/unit/stages/input.spec.ts b/packages/publish/test/unit/stages/input.spec.ts index 59910320..6a2c2468 100644 --- a/packages/publish/test/unit/stages/input.spec.ts +++ b/packages/publish/test/unit/stages/input.spec.ts @@ -1,6 +1,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { errorEnvelope, successEnvelope } from '@releasekit/core'; import { afterEach, describe, expect, it } from 'vitest'; import { PublishErrorCode } from '../../../src/errors/index.js'; import { parseInput } from '../../../src/stages/input.js'; @@ -33,6 +34,26 @@ describe('input stage', () => { expect(result.commitMessage).toContain('release'); }); + it('should unwrap the envelope emitted by `version --json`', async () => { + const fixturePath = path.resolve(__dirname, '../../fixtures/version-output.json'); + const bare = JSON.parse(fs.readFileSync(fixturePath, 'utf-8')); + const file = path.join(createTmpDir(), 'enveloped.json'); + fs.writeFileSync(file, JSON.stringify(successEnvelope(bare, { changed: true }))); + + const result = await parseInput(file); + + expect(result.updates).toHaveLength(2); + expect(result.updates[0]?.packageName).toBe('@releasekit/version'); + }); + + it('should surface an upstream failure rather than a schema complaint', async () => { + const failed = errorEnvelope([{ code: 'GIT_ERROR', category: 'git', retryable: false, message: 'no such ref' }]); + const file = path.join(createTmpDir(), 'failed.json'); + fs.writeFileSync(file, JSON.stringify(failed)); + + await expect(parseInput(file)).rejects.toThrow(/GIT_ERROR.*no such ref/); + }); + it('should parse pre-release version output', async () => { const fixturePath = path.resolve(__dirname, '../../fixtures/version-output-prerelease.json'); const result = await parseInput(fixturePath); diff --git a/packages/release/src/commands/changed.ts b/packages/release/src/commands/changed.ts index 2f5518fe..6eecc198 100644 --- a/packages/release/src/commands/changed.ts +++ b/packages/release/src/commands/changed.ts @@ -1,20 +1,13 @@ +import { publishDidChange } from '@releasekit/publish'; import type { ReleaseOutput } from '../types.js'; /** * Did a `standing-pr publish` actually publish anything? * - * `versionOutput.updates` cannot answer this. The manifest carries the versions the merge already - * landed on `main`, so it is populated on every run — including a re-run where every package was - * already published and nothing happened. Only `publishOutput` records what *this* invocation did. - * - * Within `publishOutput`, the registry results are the one honest signal. Release tags are created - * before the pipeline runs, `git.pushed` is set for any push attempt, and the GitHub-release stage - * reports `success: true` for an already-existing release — none of those distinguish a real publish - * from an idempotent no-op. A registry result does: `skipped` and `alreadyPublished` mark the - * packages that were passed over. Same predicate the verify stage uses to pick what to verify. + * Not answerable from `versionOutput.updates`: the manifest carries versions the merge already landed + * on `main`, so it is populated even on a re-run where nothing published. Only `publishOutput` records + * what this invocation did. */ -export function publishDidChange(result: ReleaseOutput | null | undefined): boolean { - const output = result?.publishOutput; - if (!output || output.dryRun) return false; - return [...output.npm, ...output.cargo, ...output.pub].some((r) => r.success && !r.skipped && !r.alreadyPublished); +export function releaseDidPublish(result: ReleaseOutput | null | undefined): boolean { + return publishDidChange(result?.publishOutput); } diff --git a/packages/release/src/commands/standing-pr-command.ts b/packages/release/src/commands/standing-pr-command.ts index e6f33595..0d856fe2 100644 --- a/packages/release/src/commands/standing-pr-command.ts +++ b/packages/release/src/commands/standing-pr-command.ts @@ -2,7 +2,7 @@ import { exitCodeForError } from '@releasekit/core'; import { Command } from 'commander'; import type { StandingPROptions } from '../standing-pr/standing-pr.js'; import { runStandingPRMerge, runStandingPRPublish, runStandingPRUpdate } from '../standing-pr/standing-pr.js'; -import { publishDidChange } from './changed.js'; +import { releaseDidPublish } from './changed.js'; import { emitError, emitResult, failInput } from './emitResult.js'; export function createStandingPRCommand(): Command { @@ -89,8 +89,8 @@ export function createStandingPRCommand(): Command { try { const result = await runStandingPRPublish(options, prNumber); - // Read the publish effects, not the manifest's versions — see publishDidChange. - emitResult(result, { json: opts.json, output: opts.output, changed: publishDidChange(result) }); + // Read the publish effects, not the manifest's versions — see releaseDidPublish. + emitResult(result, { json: opts.json, output: opts.output, changed: releaseDidPublish(result) }); } catch (err) { emitError(err, { json: opts.json, output: opts.output }); console.error(err instanceof Error ? err.message : String(err)); diff --git a/packages/release/test/unit/commands/changed.spec.ts b/packages/release/test/unit/commands/changed.spec.ts deleted file mode 100644 index efec06e1..00000000 --- a/packages/release/test/unit/commands/changed.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { PublishOutput, PublishResult } from '@releasekit/publish'; -import { describe, expect, it } from 'vitest'; -import { publishDidChange } from '../../../src/commands/changed.js'; -import type { ReleaseOutput } from '../../../src/types.js'; - -function publishResult(overrides: Partial = {}): PublishResult { - return { - packageName: '@acme/widget', - version: '2.0.0', - registry: 'npm', - success: true, - skipped: false, - ...overrides, - }; -} - -function publishOutput(overrides: Partial = {}): PublishOutput { - return { - dryRun: false, - git: { committed: false, tags: ['v2.0.0'], pushed: true }, - npm: [], - cargo: [], - pub: [], - verification: [], - githubReleases: [], - publishSucceeded: true, - ...overrides, - }; -} - -function releaseOutput(publish?: PublishOutput): ReleaseOutput { - return { - // Always populated from the manifest, whether or not this run published anything — the reason - // `changed` can't be derived from it. - versionOutput: { - dryRun: false, - updates: [{ packageName: '@acme/widget', currentVersion: '1.4.2', newVersion: '2.0.0', bumpType: 'major' }], - changelogs: [], - tags: ['v2.0.0'], - } as ReleaseOutput['versionOutput'], - notesGenerated: false, - publishOutput: publish, - }; -} - -describe('publishDidChange', () => { - it('should be true when a package actually reached a registry', () => { - expect(publishDidChange(releaseOutput(publishOutput({ npm: [publishResult()] })))).toBe(true); - }); - - it('should be false when every package was already published', () => { - const output = publishOutput({ - npm: [publishResult({ skipped: true, alreadyPublished: true, reason: 'already published' })], - }); - expect(publishDidChange(releaseOutput(output))).toBe(false); - }); - - it('should be false when every package was skipped as private', () => { - const output = publishOutput({ npm: [publishResult({ skipped: true, reason: 'private' })] }); - expect(publishDidChange(releaseOutput(output))).toBe(false); - }); - - it('should be false for a push of pre-existing tags and releases with nothing published', () => { - const output = publishOutput({ - npm: [publishResult({ skipped: true, alreadyPublished: true })], - // Both report success for an already-existing tag/release, so neither is a change signal. - git: { committed: true, tags: ['v2.0.0'], pushed: true }, - githubReleases: [{ tag: 'v2.0.0', draft: false, prerelease: false, success: true }], - }); - expect(publishDidChange(releaseOutput(output))).toBe(false); - }); - - it('should be true when one package published among already-published ones', () => { - const output = publishOutput({ - npm: [publishResult({ packageName: '@acme/a', skipped: true, alreadyPublished: true }), publishResult()], - }); - expect(publishDidChange(releaseOutput(output))).toBe(true); - }); - - it('should read cargo and pub results alongside npm', () => { - expect(publishDidChange(releaseOutput(publishOutput({ cargo: [publishResult({ registry: 'cargo' })] })))).toBe( - true, - ); - expect(publishDidChange(releaseOutput(publishOutput({ pub: [publishResult({ registry: 'pub' })] })))).toBe(true); - }); - - it('should be false for a dry run that reports successful publishes', () => { - const output = publishOutput({ dryRun: true, npm: [publishResult()] }); - expect(publishDidChange(releaseOutput(output))).toBe(false); - }); - - it('should be false when there is no publish output at all', () => { - expect(publishDidChange(releaseOutput(undefined))).toBe(false); - expect(publishDidChange(null)).toBe(false); - }); -}); diff --git a/packages/version/src/command.ts b/packages/version/src/command.ts index 20f08cf8..ceb6ae29 100644 --- a/packages/version/src/command.ts +++ b/packages/version/src/command.ts @@ -1,4 +1,11 @@ -import { EXIT_CODES } from '@releasekit/core'; +import { + EXIT_CODES, + errorEnvelope, + exitCodeForError, + InputError, + toEnvelopeError, + writeEnvelope, +} from '@releasekit/core'; import { Command } from 'commander'; import { loadConfig } from './config.js'; import { VersionEngine } from './core/versionEngine.js'; @@ -6,6 +13,11 @@ import type { Config } from './types.js'; import { enableJsonOutput, printJsonOutput } from './utils/jsonOutput.js'; import { log } from './utils/logging.js'; +/** Report a failure on the JSON channel, so a `--json` caller gets the same shape as on success. */ +function emitErrorEnvelope(error: unknown, opts: { json?: boolean; output?: string }): void { + writeEnvelope(errorEnvelope([toEnvelopeError(error)]), opts); +} + export function createVersionCommand(): Command { return new Command('version') .description('Version a package or packages based on configuration') @@ -21,6 +33,7 @@ export function createVersionCommand(): Command { ) .option('-s, --sync', 'Use synchronized versioning across all packages') .option('-j, --json', 'Output results as JSON', false) + .option('--output ', 'Write the JSON result to a file instead of stdout') .option('-t, --target ', 'Comma-delimited list of package names to target') .option( '--include-prerequisites', @@ -29,12 +42,16 @@ export function createVersionCommand(): Command { ) .option('--project-dir ', 'Project directory to run commands in', process.cwd()) .action(async (options) => { + const io = { json: options.json, output: options.output }; + if (options.stable && options.prerelease) { - console.error('Error: Cannot use both --stable and --prerelease at the same time'); + const message = 'Cannot use both --stable and --prerelease at the same time'; + emitErrorEnvelope(new InputError(message), io); + console.error(`Error: ${message}`); process.exit(EXIT_CODES.INPUT_ERROR); } - if (options.json) { + if (options.json || options.output) { enableJsonOutput(options.dryRun); } @@ -110,16 +127,17 @@ export function createVersionCommand(): Command { log('Versioning process completed.', 'success'); - printJsonOutput(); + printJsonOutput(options.output); } catch (error) { const { BaseVersionError } = await import('./errors/baseError.js'); + emitErrorEnvelope(error, io); if (BaseVersionError.isVersionError(error)) { error.logError(); } else { log(`Error: ${error instanceof Error ? error.message : String(error)}`, 'error'); } - process.exit(1); + process.exit(exitCodeForError(error)); } }); } diff --git a/packages/version/src/utils/jsonOutput.ts b/packages/version/src/utils/jsonOutput.ts index 3676222b..41ac1f52 100644 --- a/packages/version/src/utils/jsonOutput.ts +++ b/packages/version/src/utils/jsonOutput.ts @@ -7,10 +7,12 @@ import fs from 'node:fs'; import path from 'node:path'; import { deriveReleaseChannel, + successEnvelope, type VersionAction, type VersionChangelogEntry, type VersionOutput, type VersionPackageChangelog, + writeEnvelope, } from '@releasekit/core'; /** @deprecated Use {@link VersionOutput} from `@releasekit/core` instead. */ @@ -287,10 +289,15 @@ export function getJsonData(): VersionOutput { } /** - * Print JSON output at the end of execution + * Emit the collected `VersionOutput` inside the uniform CLI envelope. The payload rides in `data` + * verbatim — `notes` and `publish` unwrap it on the way in, so the pipe's contract is unchanged. + * + * A dry run writes nothing, so it is never `changed`. */ -export function printJsonOutput(): void { - if (_jsonOutputMode) { - console.log(JSON.stringify(_jsonData, null, 2)); - } +export function printJsonOutput(outputPath?: string): void { + if (!_jsonOutputMode) return; + writeEnvelope(successEnvelope(_jsonData, { changed: !_jsonData.dryRun && _jsonData.updates.length > 0 }), { + json: true, + output: outputPath, + }); } diff --git a/packages/version/test/integration/sync-tags.spec.ts b/packages/version/test/integration/sync-tags.spec.ts index 352aa1e3..a643b9a1 100644 --- a/packages/version/test/integration/sync-tags.spec.ts +++ b/packages/version/test/integration/sync-tags.spec.ts @@ -12,7 +12,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { executeCliCommand } from '../utils/cli.js'; +import { executeCliCommand, parseCliEnvelope } from '../utils/cli.js'; import { createConventionalCommit, initGitRepo } from '../utils/git.js'; import { createPackageJson } from '../utils/package.js'; import { cleanupTempDir, symlinkNodeModules } from '../utils/tempFixture.js'; @@ -65,7 +65,7 @@ describe('sync strategy — JSON tag output', () => { const result = executeCliCommand('--json', tempDir); expect(result.status).toBe(0); - const output = JSON.parse(result.stdout); + const { data: output } = parseCliEnvelope(result.stdout); // @test/pkg-a is sanitized to test-pkg-a (@ stripped, / replaced with -) expect(output.tags).toContain('test-pkg-a-v0.2.0'); @@ -88,7 +88,7 @@ describe('sync strategy — JSON tag output', () => { const result = executeCliCommand('--json', tempDir); expect(result.status).toBe(0); - const output = JSON.parse(result.stdout); + const { data: output } = parseCliEnvelope(result.stdout); expect(output.tags).toContain('v0.2.0'); expect(output.tags).toHaveLength(1); diff --git a/packages/version/test/unit/utils/jsonOutput.spec.ts b/packages/version/test/unit/utils/jsonOutput.spec.ts index f4ad55c4..aa4a4891 100644 --- a/packages/version/test/unit/utils/jsonOutput.spec.ts +++ b/packages/version/test/unit/utils/jsonOutput.spec.ts @@ -525,10 +525,31 @@ describe('JSON Output Utilities', () => { }); printJsonOutput(); - const output = consoleSpy.mock.calls[0][0]; - const parsed = JSON.parse(output); - expect(parsed.changelogs).toHaveLength(1); - expect(parsed.changelogs[0].entries[0].description).toBe('New feature'); + const envelope = JSON.parse(consoleSpy.mock.calls[0][0]); + expect(envelope.status).toBe('success'); + // The VersionOutput rides in `data` verbatim — notes and publish unwrap it back on the way in. + expect(envelope.data.changelogs).toHaveLength(1); + expect(envelope.data.changelogs[0].entries[0].description).toBe('New feature'); + }); + + it('should report changed only when a real run produced updates', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + enableJsonOutput(true); + addPackageUpdate('test-package', '1.0.0', '/path/to/package.json'); + printJsonOutput(); + expect(JSON.parse(consoleSpy.mock.calls[0][0]).changed).toBe(false); + + consoleSpy.mockClear(); + enableJsonOutput(false); + addPackageUpdate('test-package', '1.0.0', '/path/to/package.json'); + printJsonOutput(); + expect(JSON.parse(consoleSpy.mock.calls[0][0]).changed).toBe(true); + + consoleSpy.mockClear(); + enableJsonOutput(false); + printJsonOutput(); + expect(JSON.parse(consoleSpy.mock.calls[0][0]).changed).toBe(false); }); }); diff --git a/packages/version/test/utils/cli.ts b/packages/version/test/utils/cli.ts index 2e1a3d8d..eda88df1 100644 --- a/packages/version/test/utils/cli.ts +++ b/packages/version/test/utils/cli.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; +import { type Envelope, isEnvelope, type VersionOutput } from '@releasekit/core'; /** * Execute the CLI command in the given working directory (tempDir) @@ -13,3 +14,16 @@ export function executeCliCommand(command: string, cwd: string, dryRun = true) { if (dryRun) args.push('--dry-run'); return spawnSync('node', [cliPath, ...args], { cwd, encoding: 'utf-8' }); } + +/** + * Parse a `--json` run's stdout and return the `VersionOutput` it carries, asserting the envelope on + * the way through. Exercises the boundary the pipe actually crosses — a process wrote this text and + * `notes`/`publish` read it back — which a unit test on the emitter can't cover. + */ +export function parseCliEnvelope(stdout: string): { envelope: Envelope; data: VersionOutput } { + const envelope = JSON.parse(stdout) as Envelope; + if (!isEnvelope(envelope)) { + throw new Error(`Expected an envelope on stdout, got: ${stdout.slice(0, 200)}`); + } + return { envelope, data: envelope.data as VersionOutput }; +} From d2e8a1d669e06e89ccff2c35e067e5c8fd8ba689 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 1 Aug 2026 22:16:14 +0100 Subject: [PATCH 02/19] fix(cli): complete the exit-code table and validate envelopes on unwrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from Greptile review of #616; both were real. **Exit classification.** The code → exit-code map only held the nine family names, but packages throw specific codes — NOT_GIT_REPO, TAG_ALREADY_EXISTS, PACKAGES_NOT_FOUND, NPM_AUTH_ERROR and the rest — so almost every real failure fell through to exit 1 while docs/cli.md advertised a mapping. Automation could not tell a git failure from a config one. Every code the version, notes, and publish packages throw is now mapped to its family, with a test that fails when a new code isn't, since the degradation is silent. The family assignment is a judgement call in two places worth a second opinion: workspace/package-discovery failures (PACKAGES_NOT_FOUND, WORKSPACE_ERROR) are treated as config errors on the reasoning that the `packages` globs matched nothing, and manifest-parse failures (CARGO_TOML_ERROR, PUBSPEC_YAML_ERROR) likewise. **Envelope validation.** `unwrapEnvelope` accepted anything with the three keys, so an envelope from a newer producer, an unrecognised status, or a success envelope with a null payload all flowed through — the last surfacing in `notes` as a null-property error about `changelogs` rather than an input error. It now rejects each with a message naming the actual cause. This is what schemaVersion was for; nothing had been reading it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FADRmu9uhtCk3StMoqwV27 --- docs/cli.md | 4 +- packages/core/src/envelope.ts | 76 +++++++++++++++++++++-- packages/core/test/unit/envelope.spec.ts | 79 ++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 5 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index fad366c7..ab8e9de7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -64,7 +64,9 @@ When `publish` fails partway, its error envelope carries what already landed in | `VERSION_ERROR` | `version` | 8 | | `PUBLISH_ERROR` | `publish` | 9 | -`schemaVersion` bumps only on a breaking change to the envelope shape and is stable across minor releases, so agents and CI can pin against it. +Commands throw more specific codes than these nine families — `TAG_ALREADY_EXISTS`, `NPM_AUTH_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so `releasekit version` failing on `NOT_GIT_REPO` exits `7` and a script can branch on "git problem" without enumerating every code. + +`schemaVersion` bumps only on a breaking change to the envelope shape and is stable across minor releases, so agents and CI can pin against it. A consumer reading an envelope from a **newer** producer fails with a version-mismatch error rather than acting on a payload whose shape it may not understand. --- diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 41d32f47..6672e8cd 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -78,37 +78,105 @@ export function isEnvelope(value: unknown): value is Envelope { ); } +const KNOWN_STATUSES = new Set(['success', 'error']); + /** * Unwrap a piped envelope to its payload; a bare payload passes through unchanged, so a * hand-assembled input or an older releasekit still works. * - * An error envelope throws rather than yielding null: piping a failed stage into the next one should - * fail with the upstream message, not with a schema complaint about missing fields that sends the - * reader looking in the wrong place. + * Throws rather than returning something unusable, so the failure is reported where the cause is still + * known: on an error envelope (with the upstream message), on a `schemaVersion` this build is too old + * to read, on an unrecognised status, and on a success envelope carrying no payload. Each of those + * would otherwise surface further away as a shape error about the payload. + * + * A caller that wants a nullable payload should read `.data` directly instead. */ export function unwrapEnvelope(value: unknown): unknown { if (!isEnvelope(value)) return value; + + // What `schemaVersion` is for. A newer producer may have moved the payload or changed its meaning, + // so handing `data` to a consumer that predates the change would fail later and further away, as a + // shape error about the payload rather than a version mismatch. + if (typeof value.schemaVersion !== 'number' || value.schemaVersion > ENVELOPE_SCHEMA_VERSION) { + throw new Error( + `Unsupported releasekit envelope schemaVersion ${String(value.schemaVersion)} (this build understands ` + + `up to ${ENVELOPE_SCHEMA_VERSION}). Upgrade the consuming releasekit to match the producer.`, + ); + } + + if (!KNOWN_STATUSES.has(value.status)) { + throw new Error(`Unrecognised releasekit envelope status "${String(value.status)}".`); + } + if (value.status === 'error') { - const first = value.errors[0]; + const first = value.errors?.[0]; throw new Error( first ? `Upstream releasekit stage failed (${first.code}): ${first.message}` : 'Upstream releasekit stage failed, but reported no error detail.', ); } + + // A success envelope with no payload can't be what the caller asked for, and reporting it here beats + // the null-property error the consumer would raise once it started reading fields off it. + if (value.data === null || value.data === undefined) { + throw new Error('Upstream releasekit stage reported success but produced no data.'); + } + return value.data; } +/** + * The documented code → exit-code table (docs/cli.md). Packages throw more specific codes than the + * nine families, so each is mapped to its family here rather than falling through to the generic + * exit 1 — an automation that branches on "this was a git problem" needs the family, and only a + * complete table gives it one. Core owns the map because the exit codes are a cross-package contract. + */ const CODE_TO_EXIT: Record = { CONFIG_ERROR: EXIT_CODES.CONFIG_ERROR, INPUT_ERROR: EXIT_CODES.INPUT_ERROR, INPUT_PARSE_ERROR: EXIT_CODES.INPUT_ERROR, + INPUT_VALIDATION_ERROR: EXIT_CODES.INPUT_ERROR, TEMPLATE_ERROR: EXIT_CODES.TEMPLATE_ERROR, LLM_ERROR: EXIT_CODES.LLM_ERROR, GITHUB_ERROR: EXIT_CODES.GITHUB_ERROR, GIT_ERROR: EXIT_CODES.GIT_ERROR, VERSION_ERROR: EXIT_CODES.VERSION_ERROR, PUBLISH_ERROR: EXIT_CODES.PUBLISH_ERROR, + + // @releasekit/version. Config and workspace discovery are configuration problems (the `packages` + // globs matched nothing, or the config is unreadable); the rest are git or versioning failures. + CONFIG_REQUIRED: EXIT_CODES.CONFIG_ERROR, + INVALID_CONFIG: EXIT_CODES.CONFIG_ERROR, + UNSAFE_CONFIG_PATH: EXIT_CODES.CONFIG_ERROR, + PACKAGES_NOT_FOUND: EXIT_CODES.CONFIG_ERROR, + PACKAGE_NOT_FOUND: EXIT_CODES.CONFIG_ERROR, + WORKSPACE_ERROR: EXIT_CODES.CONFIG_ERROR, + NOT_GIT_REPO: EXIT_CODES.GIT_ERROR, + GIT_PROCESS_ERROR: EXIT_CODES.GIT_ERROR, + TAG_ALREADY_EXISTS: EXIT_CODES.GIT_ERROR, + VERSION_CALCULATION_ERROR: EXIT_CODES.VERSION_ERROR, + STRICT_REACHABLE_UNREACHABLE: EXIT_CODES.VERSION_ERROR, + NO_COMMIT_MESSAGE: EXIT_CODES.VERSION_ERROR, + NO_FILES: EXIT_CODES.VERSION_ERROR, + + // @releasekit/publish. Registry auth and publish failures are publish errors; a malformed package + // manifest is a configuration problem; the git and GitHub stages map to their own families. + PIPELINE_STAGE_ERROR: EXIT_CODES.PUBLISH_ERROR, + NPM_AUTH_ERROR: EXIT_CODES.PUBLISH_ERROR, + NPM_PUBLISH_ERROR: EXIT_CODES.PUBLISH_ERROR, + CARGO_AUTH_ERROR: EXIT_CODES.PUBLISH_ERROR, + CARGO_PUBLISH_ERROR: EXIT_CODES.PUBLISH_ERROR, + PUB_AUTH_ERROR: EXIT_CODES.PUBLISH_ERROR, + PUB_PUBLISH_ERROR: EXIT_CODES.PUBLISH_ERROR, + FILE_COPY_ERROR: EXIT_CODES.PUBLISH_ERROR, + VERIFICATION_FAILED: EXIT_CODES.PUBLISH_ERROR, + CARGO_TOML_ERROR: EXIT_CODES.CONFIG_ERROR, + PUBSPEC_YAML_ERROR: EXIT_CODES.CONFIG_ERROR, + GIT_COMMIT_ERROR: EXIT_CODES.GIT_ERROR, + GIT_PUSH_ERROR: EXIT_CODES.GIT_ERROR, + GIT_TAG_ERROR: EXIT_CODES.GIT_ERROR, + GITHUB_RELEASE_ERROR: EXIT_CODES.GITHUB_ERROR, }; /** Map any thrown value to its process exit code, defaulting to GENERAL_ERROR. */ diff --git a/packages/core/test/unit/envelope.spec.ts b/packages/core/test/unit/envelope.spec.ts index ced3d68f..02a39461 100644 --- a/packages/core/test/unit/envelope.spec.ts +++ b/packages/core/test/unit/envelope.spec.ts @@ -141,5 +141,84 @@ describe('envelope', () => { it('should throw even when an error envelope carries no error detail', () => { expect(() => unwrapEnvelope(errorEnvelope([]))).toThrow(/reported no error detail/); }); + + it('should refuse an envelope from a newer producer', () => { + // The point of schemaVersion: a newer producer may have moved the payload, so handing `data` + // onward would fail later as a shape error rather than a version mismatch. + const future = { ...successEnvelope({ updates: [] }), schemaVersion: ENVELOPE_SCHEMA_VERSION + 1 }; + expect(() => unwrapEnvelope(future)).toThrow(/schemaVersion/); + }); + + it('should accept an envelope from an older producer', () => { + const older = { ...successEnvelope({ updates: [] }), schemaVersion: 1 }; + expect(unwrapEnvelope(older)).toEqual({ updates: [] }); + }); + + it('should refuse an unrecognised status', () => { + const odd = { ...successEnvelope({ updates: [] }), status: 'partial' }; + expect(() => unwrapEnvelope(odd)).toThrow(/Unrecognised.*status/); + }); + + it('should refuse a success envelope carrying no payload', () => { + // Otherwise the consumer reads fields off null and reports a shape error about the payload. + expect(() => unwrapEnvelope(successEnvelope(null))).toThrow(/success but produced no data/); + }); + }); + + describe('exit-code table coverage', () => { + it('should map every error code the packages throw to a family, not the generic exit 1', () => { + // Guards the table against drift: a new code that isn't mapped silently degrades to exit 1, so + // automation can no longer tell a git failure from a config one. Update the map when this fails. + const thrownCodes = [ + // @releasekit/version + 'CONFIG_REQUIRED', + 'INVALID_CONFIG', + 'UNSAFE_CONFIG_PATH', + 'PACKAGES_NOT_FOUND', + 'PACKAGE_NOT_FOUND', + 'WORKSPACE_ERROR', + 'NOT_GIT_REPO', + 'GIT_PROCESS_ERROR', + 'TAG_ALREADY_EXISTS', + 'VERSION_CALCULATION_ERROR', + 'STRICT_REACHABLE_UNREACHABLE', + 'NO_COMMIT_MESSAGE', + 'NO_FILES', + 'GIT_ERROR', + // @releasekit/publish + 'PIPELINE_STAGE_ERROR', + 'NPM_AUTH_ERROR', + 'NPM_PUBLISH_ERROR', + 'CARGO_AUTH_ERROR', + 'CARGO_PUBLISH_ERROR', + 'CARGO_TOML_ERROR', + 'PUB_AUTH_ERROR', + 'PUB_PUBLISH_ERROR', + 'PUBSPEC_YAML_ERROR', + 'FILE_COPY_ERROR', + 'VERIFICATION_FAILED', + 'GIT_COMMIT_ERROR', + 'GIT_PUSH_ERROR', + 'GIT_TAG_ERROR', + 'GITHUB_RELEASE_ERROR', + 'INPUT_VALIDATION_ERROR', + 'INPUT_PARSE_ERROR', + // @releasekit/notes + 'CONFIG_ERROR', + 'GITHUB_ERROR', + 'LLM_ERROR', + 'TEMPLATE_ERROR', + ]; + + const unmapped = thrownCodes.filter((code) => { + class Coded extends ReleaseKitError { + readonly code = code; + readonly suggestions: string[] = []; + } + return exitCodeForError(new Coded('x')) === EXIT_CODES.GENERAL_ERROR; + }); + + expect(unmapped).toEqual([]); + }); }); }); From 6ae27a536e4c8e1ba8b9f9b510b072f86ac16990 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:48:36 +0000 Subject: [PATCH 03/19] fix(cli): report publish commits as changes and exit publish by code family Review follow-ups for the envelope work: - `publish --json` reported `changed: false` after creating the release commit and tags whenever no registry published (GitHub-release-only, `--skip-publish`). `publishDidChange` now also counts `git.committed`, which is only set for a commit this run created; `standing-pr publish` never commits (`skipGitCommit`), so its `changed` is unaffected. - `publish` exited 9/1 regardless of the error code, contradicting the code-to-exit table. It now exits via `exitCodeForError`, like `version`: config failures exit 2, input failures 3, pipeline failures still 9. - `version --output` with a relative path landed inside `--project-dir` (the command chdirs first). Resolve it against the invocation directory up front, as `release --output` does. - docs: the `version | notes | publish` pipe never worked (`notes` does not pass the VersionOutput on); document chaining through a file. Correct the `changed`/retry wording and the exit-code example. Update the version and publish READMEs for the envelope and `--output`. - release: reuse core's `writeEnvelope` instead of a private copy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/architecture.md | 6 +- docs/cli.md | 18 +++--- packages/publish/README.md | 3 +- packages/publish/src/command.ts | 24 +++---- packages/publish/src/output.ts | 14 +++-- packages/publish/test/unit/cli.spec.ts | 45 +++++++++++++- packages/publish/test/unit/output.spec.ts | 13 +++- packages/release/src/commands/emitResult.ts | 18 +----- packages/version/README.md | 62 +++++++++++-------- packages/version/src/command.ts | 8 ++- .../test/integration/sync-tags.spec.ts | 38 +++++++++++- 11 files changed, 171 insertions(+), 78 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index cb910424..75fb50a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,10 +58,12 @@ A conceptual overview for contributors and users evaluating ReleaseKit. ## Why three separate CLIs -Each package — `@releasekit/version`, `@releasekit/notes`, `@releasekit/publish` — is a standalone CLI that reads from stdin or a file and writes to stdout or disk. They can be piped together: +Each package — `@releasekit/version`, `@releasekit/notes`, `@releasekit/publish` — is a standalone CLI that reads from stdin or a file and writes to stdout or disk. They chain through the `VersionOutput` file (`notes` consumes it without passing it on, so it can't sit mid-pipe): ``` -releasekit-version --json | releasekit-notes | releasekit-publish +releasekit-version --output version.json +releasekit-notes --input version.json +releasekit-publish --input version.json ``` `@releasekit/release` is orchestration sugar: it calls each tool's API directly (no subprocesses) and passes the typed `VersionOutput` through without serialising to JSON. A team that only wants changelog generation can install `@releasekit/notes` alone. diff --git a/docs/cli.md b/docs/cli.md index ab8e9de7..ae0e7ef6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -33,22 +33,26 @@ 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` still exposes its `VersionOutput` at `data.versionOutput`, and `releasekit version --json` exposes it at `data`. It is `null` on error, except where a command failed partway with real progress to report (see the pipe section below). -- **`changed`** separates real work from a no-op: a dry run is never `changed`; `standing-pr publish` reads its registry results, so a re-run where every version was already published reports `changed: false`; `gate` is read-only and always `changed: false`. +- **`data`** carries the command's payload verbatim — the envelope wraps it, never replaces it. `releasekit release --json` still exposes its `VersionOutput` at `data.versionOutput`, and `releasekit version --json` exposes it at `data`. It is `null` on error, except where a command failed partway with real progress to report (see the chaining section below). +- **`changed`** separates real work from a no-op: a dry run is never `changed`; `publish` is `changed` when it published to a registry or created the release commit; `standing-pr publish` reads its registry results (the merge already made the commit), so a re-run where every version was already published reports `changed: false`; `gate` is read-only and always `changed: false`. - **`errors[]`** replaces prose-only failures in JSON mode. Each carries a stable machine `code`, a coarse `category`, a `retryable` flag (only `true` for known-transient failures — a timeout, 429, or 5xx from a provider — so an agent never retries an unknown failure), and a human `message`. - **Stream discipline:** the envelope is the only thing on stdout; all diagnostics (progress, warnings, error text) go to stderr. Parsing stdout as JSON is always safe, and no command prompts interactively on a CI path. -### The `version | notes | publish` pipe +### Chaining `version`, `notes`, and `publish` -The pipe is unchanged: `notes` and `publish` unwrap the envelope on the way in and read the `VersionOutput` from `data`, so the three still chain directly. +`notes` and `publish` unwrap the envelope on the way in and read the `VersionOutput` from `data`. `notes` consumes the `VersionOutput` and writes changelogs and release notes; it never passes the `VersionOutput` on, so it can't sit in the middle of a pipe. Hand the same file to each stage instead: ```sh -releasekit version --json | releasekit notes | releasekit publish +releasekit version --output version.json +releasekit notes --input version.json +releasekit publish --input version.json ``` +`publish` also reads stdin, so `releasekit version --json | releasekit publish` works when no notes step is needed. + A bare `VersionOutput` is still accepted on input, so a hand-assembled file or output from an older releasekit keeps working. Piping a *failed* stage forward fails with that stage's error rather than a schema complaint about missing fields — the message names the upstream code, so the report points at the stage that actually broke. -When `publish` fails partway, its error envelope carries what already landed in `data` and reports `changed: true`; the failing stage is named in the error `message`. Publishes are idempotent, so a retry uses that to skip what is already out. +When `publish` fails partway, its error envelope carries what already landed in `data`, and reports `changed: true` if anything did (a registry publish or the release commit); the failing stage is named in the error `message`. A retry is safe: publishes are idempotent, and versions already on the registry are skipped. ### Error codes and exit codes @@ -64,7 +68,7 @@ When `publish` fails partway, its error envelope carries what already landed in | `VERSION_ERROR` | `version` | 8 | | `PUBLISH_ERROR` | `publish` | 9 | -Commands throw more specific codes than these nine families — `TAG_ALREADY_EXISTS`, `NPM_AUTH_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so `releasekit version` failing on `NOT_GIT_REPO` exits `7` and a script can branch on "git problem" without enumerating every code. +Commands throw more specific codes than these nine families — `TAG_ALREADY_EXISTS`, `NPM_AUTH_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's — `version` failing on `TAG_ALREADY_EXISTS` or `GIT_PROCESS_ERROR` exits `7` — so a script can branch on "git problem" without enumerating every code. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message; config and input failures before the pipeline starts exit `2` and `3`. `schemaVersion` bumps only on a breaking change to the envelope shape and is stable across minor releases, so agents and CI can pin against it. A consumer reading an envelope from a **newer** producer fails with a version-mismatch error rather than acting on a payload whose shape it may not understand. diff --git a/packages/publish/README.md b/packages/publish/README.md index 94d7caa2..5b07f1f4 100644 --- a/packages/publish/README.md +++ b/packages/publish/README.md @@ -82,7 +82,8 @@ Before failing the stage, the npm and cargo publish steps automatically retry ** | `--skip-publish` | Skip registry publishing | `false` | | `--skip-github-release` | Skip GitHub Release creation | `false` | | `--skip-verification` | Skip post-publish verification | `false` | -| `--json` | Output results as JSON | `false` | +| `--json` | Output results as a JSON [envelope](../../docs/cli.md#json-output-contract) | `false` | +| `--output ` | Write the JSON envelope to a file instead of stdout | — | | `--verbose` | Verbose logging | `false` | ## Integration with @releasekit/version diff --git a/packages/publish/src/command.ts b/packages/publish/src/command.ts index 45b8dcc6..598bd383 100644 --- a/packages/publish/src/command.ts +++ b/packages/publish/src/command.ts @@ -1,6 +1,6 @@ import { - EXIT_CODES, errorEnvelope, + exitCodeForError, setJsonMode, setLogLevel, successEnvelope, @@ -63,8 +63,8 @@ export function createPublishCommand(): Command { writeEnvelope(successEnvelope(output, { changed: publishDidChange(output) }), io); } catch (err) { if (err instanceof PipelineError) { - // Carry what landed before the failure — the retry needs it. `failedStage` has no envelope - // field of its own, so it goes in the message rather than being dropped. + // Carry what landed before the failure, so the caller can see what is already out. `failedStage` + // has no envelope field of its own, so it goes in the message rather than being dropped. const error = toEnvelopeError(err); writeEnvelope( errorEnvelope([{ ...error, message: `publish failed in the ${err.failedStage} stage: ${error.message}` }], { @@ -74,15 +74,17 @@ export function createPublishCommand(): Command { io, ); err.logError(); - process.exit(EXIT_CODES.PUBLISH_ERROR); + } else { + writeEnvelope(errorEnvelope([toEnvelopeError(err)]), io); + if (BasePublishError.isPublishError(err)) { + err.logError(); + } else { + console.error(err instanceof Error ? err.message : String(err)); + } } - writeEnvelope(errorEnvelope([toEnvelopeError(err)]), io); - if (BasePublishError.isPublishError(err)) { - err.logError(); - process.exit(EXIT_CODES.PUBLISH_ERROR); - } - console.error(err instanceof Error ? err.message : String(err)); - process.exit(EXIT_CODES.GENERAL_ERROR); + // Exit by the code's family (docs/cli.md), the same table every other command uses, so a config + // or input failure isn't reported as a publish failure. + process.exit(exitCodeForError(err)); } }); } diff --git a/packages/publish/src/output.ts b/packages/publish/src/output.ts index 12a3718a..a9d1ee4b 100644 --- a/packages/publish/src/output.ts +++ b/packages/publish/src/output.ts @@ -1,19 +1,21 @@ import type { PublishOutput } from './types.js'; /** - * Did this publish actually publish anything? + * Did this publish change anything? * - * The registry results are the one honest signal in a {@link PublishOutput}. Release tags are created - * before the pipeline runs, `git.pushed` is set for any push attempt, and the GitHub-release stage - * reports `success: true` for an already-existing release — none of those distinguish a real publish - * from an idempotent no-op. A registry result does: `skipped` and `alreadyPublished` mark the packages - * that were passed over. Same predicate the verify stage uses to pick what to verify. + * Two honest signals. A registry result is one: `skipped` and `alreadyPublished` mark the packages + * that were passed over. The release commit is the other: `git.committed` is only set when the + * git-commit stage created a new commit, so it is never true on an idempotent re-run — and never true + * under `standing-pr publish`, whose merge already made the commit (`skipGitCommit`). Release tags, + * `git.pushed` (set for any push attempt), and the GitHub-release stage (which reports `success: true` + * for an already-existing release) can't distinguish a real publish from a no-op, so they don't count. * * Answers `changed` for both the `publish` CLI and `standing-pr publish`, which is why it lives here * rather than in either caller. */ export function publishDidChange(output: PublishOutput | null | undefined): boolean { if (!output || output.dryRun) return false; + if (output.git?.committed) return true; // Tolerate absent registry arrays: this also runs on the partial output of a failed pipeline, and // throwing here would replace the publish error the caller needs with a TypeError about our own shape. const results = [...(output.npm ?? []), ...(output.cargo ?? []), ...(output.pub ?? [])]; diff --git a/packages/publish/test/unit/cli.spec.ts b/packages/publish/test/unit/cli.spec.ts index d1cacb0f..fdc3ff52 100644 --- a/packages/publish/test/unit/cli.spec.ts +++ b/packages/publish/test/unit/cli.spec.ts @@ -14,7 +14,7 @@ vi.mock('../../src/stages/input.js'); import { EXIT_CODES, setJsonMode, setLogLevel } from '@releasekit/core'; import { createPublishCommand } from '../../src/cli.js'; import { loadConfig } from '../../src/config.js'; -import { BasePublishError, PipelineError } from '../../src/errors/index.js'; +import { BasePublishError, PipelineError, PublishErrorCode } from '../../src/errors/index.js'; import { runPipeline } from '../../src/pipeline/index.js'; import { parseInput } from '../../src/stages/input.js'; import type { PublishConfig, PublishOutput } from '../../src/types.js'; @@ -126,6 +126,19 @@ describe('createPublishCommand', () => { consoleSpy.mockRestore(); }); + it('should report changed when the release commit was created with no registry publish', async () => { + vi.mocked(runPipeline).mockResolvedValue({ + ...mockOutput, + git: { committed: true, tags: ['v1.1.0'], pushed: false }, + }); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await createPublishCommand().parseAsync(['node', 'test', '--json']); + + expect(JSON.parse(consoleSpy.mock.calls[0]?.[0] as string).changed).toBe(true); + consoleSpy.mockRestore(); + }); + it('should not print JSON output when --json is not passed', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -160,8 +173,8 @@ describe('createPublishCommand', () => { consoleSpy.mockRestore(); }); - it('should call logError and exit with PUBLISH_ERROR for BasePublishError', async () => { - const publishError = new BasePublishError('publish failed', 'PUBLISH_FAILED'); + it('should call logError and exit with PUBLISH_ERROR for a registry failure', async () => { + const publishError = new BasePublishError('publish failed', PublishErrorCode.NPM_PUBLISH_ERROR); const logErrorSpy = vi.spyOn(publishError, 'logError').mockImplementation(() => undefined); vi.mocked(runPipeline).mockRejectedValue(publishError); @@ -171,6 +184,32 @@ describe('createPublishCommand', () => { expect(mockExit).toHaveBeenCalledWith(EXIT_CODES.PUBLISH_ERROR); }); + it('should exit with CONFIG_ERROR for a configuration failure', async () => { + const configError = new BasePublishError('bad npm.auth', PublishErrorCode.CONFIG_ERROR); + vi.spyOn(configError, 'logError').mockImplementation(() => undefined); + vi.mocked(loadConfig).mockImplementation(() => { + throw configError; + }); + + await createPublishCommand().parseAsync(['node', 'test']); + + expect(mockExit).toHaveBeenCalledWith(EXIT_CODES.CONFIG_ERROR); + }); + + it('should exit with INPUT_ERROR when the input cannot be parsed', async () => { + const inputError = new BasePublishError('not JSON', PublishErrorCode.INPUT_PARSE_ERROR); + vi.spyOn(inputError, 'logError').mockImplementation(() => undefined); + vi.mocked(parseInput).mockRejectedValue(inputError); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await createPublishCommand().parseAsync(['node', 'test', '--json']); + + const envelope = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); + expect(envelope.errors[0].code).toBe('INPUT_PARSE_ERROR'); + expect(mockExit).toHaveBeenCalledWith(EXIT_CODES.INPUT_ERROR); + consoleSpy.mockRestore(); + }); + it('should log and exit with GENERAL_ERROR for unknown errors', async () => { vi.mocked(runPipeline).mockRejectedValue(new Error('unexpected')); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); diff --git a/packages/publish/test/unit/output.spec.ts b/packages/publish/test/unit/output.spec.ts index c5c8a20f..a1ad2b47 100644 --- a/packages/publish/test/unit/output.spec.ts +++ b/packages/publish/test/unit/output.spec.ts @@ -45,7 +45,7 @@ describe('publishDidChange', () => { // already-existing release, so neither can stand in for "something happened". const out = output({ npm: [result({ skipped: true, alreadyPublished: true })], - git: { committed: true, tags: ['v2.0.0'], pushed: true }, + git: { committed: false, tags: ['v2.0.0'], pushed: true }, githubReleases: [{ tag: 'v2.0.0', draft: false, prerelease: false, success: true }], }); expect(publishDidChange(out)).toBe(false); @@ -58,6 +58,17 @@ describe('publishDidChange', () => { expect(publishDidChange(out)).toBe(true); }); + it('should be true when the release commit was created, even with no registry publish', () => { + // A GitHub-release-only or --skip-publish run still commits and tags the release. `committed` is + // only set for a commit this run created, so it can't fire on an idempotent re-run. + const out = output({ git: { committed: true, tags: ['v2.0.0'], pushed: true } }); + expect(publishDidChange(out)).toBe(true); + }); + + it('should be false for a dry run that would have committed', () => { + expect(publishDidChange(output({ dryRun: true, git: { committed: true, tags: [], pushed: false } }))).toBe(false); + }); + it('should read cargo and pub results alongside npm', () => { expect(publishDidChange(output({ cargo: [result({ registry: 'cargo' })] }))).toBe(true); expect(publishDidChange(output({ pub: [result({ registry: 'pub' })] }))).toBe(true); diff --git a/packages/release/src/commands/emitResult.ts b/packages/release/src/commands/emitResult.ts index 8a213bc0..6b7510bb 100644 --- a/packages/release/src/commands/emitResult.ts +++ b/packages/release/src/commands/emitResult.ts @@ -1,29 +1,13 @@ -import { writeFileSync } from 'node:fs'; import { - type Envelope, type EnvelopeWarning, EXIT_CODES, errorEnvelope, InputError, successEnvelope, toEnvelopeError, + writeEnvelope, } from '@releasekit/core'; -/** - * Write an envelope to the JSON channel. When `--output` is given, write to that file — the reliable - * channel the GitHub Action reads, since stdout can be polluted by subprocess or log noise and a - * single stray byte breaks JSON parsing. Otherwise, when `--json` is set, print to stdout. Only the - * envelope goes to stdout; all diagnostics go to stderr via the logger (stream discipline). - */ -function writeEnvelope(envelope: Envelope, opts: { json?: boolean; output?: string }): void { - const text = JSON.stringify(envelope, null, 2); - if (opts.output) { - writeFileSync(opts.output, text); - } else if (opts.json) { - console.log(text); - } -} - /** * Emit a command's successful result inside the uniform CLI envelope. `data` becomes the envelope's * `data` payload verbatim (VersionOutput etc. are preserved — the envelope wraps, never replaces). diff --git a/packages/version/README.md b/packages/version/README.md index 41f1b799..046717e9 100644 --- a/packages/version/README.md +++ b/packages/version/README.md @@ -61,44 +61,52 @@ releasekit-version --prerelease beta | `--target ` | Target specific packages (comma-separated) | all | | `--project-dir ` | Project directory | cwd | | `--dry-run` | Preview without file changes or git operations | `false` | -| `--json` | Output results as JSON | `false` | +| `--json` | Output results as a JSON envelope (see below) | `false` | +| `--output ` | Write the JSON envelope to a file instead of stdout (resolved against the directory you run from) | — | | `--strict-reachable` | Only use tags reachable from current commit | `false` | | `--verbose` | Verbose logging | `false` | | `--quiet` | Suppress non-error output | `false` | ## JSON Output -When using `--json`, the tool outputs structured data including version bumps and changelog entries: +With `--json` (or `--output `), the result is wrapped in the uniform [CLI envelope](../../docs/cli.md#json-output-contract): `status`, `changed`, `warnings`, and `errors` sit alongside the payload, and the version data itself is at `data`: ```json { - "dryRun": true, - "updates": [ - { - "packageName": "@scope/core", - "newVersion": "1.2.3", - "filePath": "/path/to/package.json" - } - ], - "changelogs": [ - { - "packageName": "@scope/core", - "version": "1.2.3", - "previousVersion": "v1.2.2", - "revisionRange": "v1.2.2..HEAD", - "repoUrl": "https://github.com/org/repo", - "entries": [ - { "type": "added", "description": "New feature" }, - { "type": "fixed", "description": "Bug fix" } - ] - } - ], - "commitMessage": "chore: release v1.2.3", - "tags": ["v1.2.3"] + "schemaVersion": 1, + "status": "success", + "changed": false, + "data": { + "dryRun": true, + "updates": [ + { + "packageName": "@scope/core", + "newVersion": "1.2.3", + "filePath": "/path/to/package.json" + } + ], + "changelogs": [ + { + "packageName": "@scope/core", + "version": "1.2.3", + "previousVersion": "v1.2.2", + "revisionRange": "v1.2.2..HEAD", + "repoUrl": "https://github.com/org/repo", + "entries": [ + { "type": "added", "description": "New feature" }, + { "type": "fixed", "description": "Bug fix" } + ] + } + ], + "commitMessage": "chore: release v1.2.3", + "tags": ["v1.2.3"] + }, + "warnings": [], + "errors": [] } ``` -This JSON is consumed by `@releasekit/notes` for changelog generation and `@releasekit/publish` for the publish pipeline. +`@releasekit/notes` and `@releasekit/publish` unwrap the envelope on input, so their `--input` and stdin accept this output directly (a bare `data` object is still accepted too). A script reading the output itself should read `.data` — for example `jq '.data.updates[]'`. On failure, `status` is `"error"`, `data` is `null`, and `errors[]` carries a machine-readable `code`. ## Configuration @@ -147,7 +155,7 @@ Configure via `releasekit.config.json`: A few things to keep in mind when running `releasekit-version` in a pipeline: - **Always pass `fetch-depth: 0`** on checkout — the tool reads git history to determine the version bump and will produce incorrect results on a shallow clone. -- **Use `--json`** for reliable downstream parsing. Text output format can change; the JSON schema is stable. +- **Use `--json` or `--output `** for reliable downstream parsing. Text output format can change; the JSON envelope's `schemaVersion` changes only on a breaking change to its shape. - **`NO_COLOR=1`** disables ANSI colour codes in log output. Most CI environments set `CI=true` automatically, which the tool detects and adjusts for. If you are running the full release pipeline (version + changelog + publish), use `@releasekit/release` instead of invoking `releasekit-version` directly. See the [CI setup guide](../release/docs/ci-setup.md). diff --git a/packages/version/src/command.ts b/packages/version/src/command.ts index ceb6ae29..d3b27cf5 100644 --- a/packages/version/src/command.ts +++ b/packages/version/src/command.ts @@ -1,3 +1,4 @@ +import * as path from 'node:path'; import { EXIT_CODES, errorEnvelope, @@ -42,7 +43,10 @@ export function createVersionCommand(): Command { ) .option('--project-dir ', 'Project directory to run commands in', process.cwd()) .action(async (options) => { - const io = { json: options.json, output: options.output }; + // Resolve --output against the invocation directory before --project-dir changes it, so the file + // lands where the caller asked (as `release --output` does) on every path, success or error. + const output: string | undefined = options.output ? path.resolve(options.output) : undefined; + const io = { json: options.json, output }; if (options.stable && options.prerelease) { const message = 'Cannot use both --stable and --prerelease at the same time'; @@ -127,7 +131,7 @@ export function createVersionCommand(): Command { log('Versioning process completed.', 'success'); - printJsonOutput(options.output); + printJsonOutput(output); } catch (error) { const { BaseVersionError } = await import('./errors/baseError.js'); diff --git a/packages/version/test/integration/sync-tags.spec.ts b/packages/version/test/integration/sync-tags.spec.ts index a643b9a1..d7affa9a 100644 --- a/packages/version/test/integration/sync-tags.spec.ts +++ b/packages/version/test/integration/sync-tags.spec.ts @@ -8,7 +8,7 @@ * * Requires: `pnpm build` in @releasekit/version before running. */ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -94,3 +94,39 @@ describe('sync strategy — JSON tag output', () => { expect(output.tags).toHaveLength(1); }); }); + +describe('--output with --project-dir', () => { + let callerDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'rk-sync-')); + createMonorepoFixture(tempDir); + symlinkNodeModules(tempDir); + initGitRepo(tempDir); + callerDir = mkdtempSync(join(tmpdir(), 'rk-caller-')); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + cleanupTempDir(callerDir); + }); + + it('should resolve a relative --output against the invocation directory, not --project-dir', () => { + writeReleaseKitConfig(tempDir, { + preset: 'angular', + packages: ['packages/pkg-a', 'packages/pkg-b'], + sync: true, + versionPrefix: 'v', + }); + createConventionalCommit(tempDir, 'feat', 'add new feature'); + + // `version --project-dir app --output v.json && publish --input v.json` must find the file where + // the caller put it — the command chdirs into --project-dir before it writes. + const result = executeCliCommand(`--project-dir ${tempDir} --output version.json`, callerDir); + + expect(result.status).toBe(0); + expect(existsSync(join(tempDir, 'version.json'))).toBe(false); + const { data } = parseCliEnvelope(readFileSync(join(callerDir, 'version.json'), 'utf-8')); + expect(data.tags).toContain('v0.2.0'); + }); +}); From 5b158560f902cefbc2618cbfbd4d44343aa366a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:07:50 +0000 Subject: [PATCH 04/19] fix(cli): correct the chaining, retry and exit-code docs; move the --output test into CI Second-review follow-ups: - docs: the file-based `version` -> `notes` -> `publish` chain left the changelog out of the release commit (publish commits only the version files plus what is staged). Document staging it; verified end to end. - docs: a re-run of the standalone `publish` in the same tree stops at the git-commit stage, so drop the blanket "a retry is safe". - docs: the exit-code examples named codes `version` never throws (TAG_ALREADY_EXISTS / GIT_PROCESS_ERROR are unreachable). Use codes `publish` actually emits: input -> 3, config -> 2, pipeline -> 9. - test: the `--output` integration test lived in version's integration suite, which CI never runs. Replace it with a unit test in test/unit/cli.spec.ts (fails against the pre-fix command). - publishDidChange docstring: `committed` is also set in a dry run; the dry-run guard is what keeps that out of `changed`. - version README: the flag table listed --strict-reachable, --verbose and --quiet, which the command doesn't have, and omitted several it does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/architecture.md | 3 +- docs/cli.md | 7 ++-- packages/publish/src/output.ts | 7 ++-- packages/version/README.md | 18 +++++---- .../test/integration/sync-tags.spec.ts | 38 +------------------ packages/version/test/unit/cli.spec.ts | 31 +++++++++++++++ 6 files changed, 52 insertions(+), 52 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 75fb50a6..8ba11fad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,11 +58,12 @@ A conceptual overview for contributors and users evaluating ReleaseKit. ## Why three separate CLIs -Each package — `@releasekit/version`, `@releasekit/notes`, `@releasekit/publish` — is a standalone CLI that reads from stdin or a file and writes to stdout or disk. They chain through the `VersionOutput` file (`notes` consumes it without passing it on, so it can't sit mid-pipe): +Each package — `@releasekit/version`, `@releasekit/notes`, `@releasekit/publish` — is a standalone CLI that reads from stdin or a file and writes to stdout or disk. They chain through the `VersionOutput` file (`notes` consumes it without passing it on, so it can't sit mid-pipe), with the changelog staged so `publish` includes it in the release commit: ``` releasekit-version --output version.json releasekit-notes --input version.json +git add CHANGELOG.md releasekit-publish --input version.json ``` diff --git a/docs/cli.md b/docs/cli.md index ae0e7ef6..194473b6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -40,11 +40,12 @@ Every command that produces a JSON result — `release`, `gate`, `standing-pr`, ### Chaining `version`, `notes`, and `publish` -`notes` and `publish` unwrap the envelope on the way in and read the `VersionOutput` from `data`. `notes` consumes the `VersionOutput` and writes changelogs and release notes; it never passes the `VersionOutput` on, so it can't sit in the middle of a pipe. Hand the same file to each stage instead: +`notes` and `publish` unwrap the envelope on the way in and read the `VersionOutput` from `data`. `notes` consumes the `VersionOutput` and writes changelogs and release notes; it never passes the `VersionOutput` on, so it can't sit in the middle of a pipe. Hand the same file to each stage instead, and stage what `notes` wrote — `publish` commits the files `version` changed plus whatever is already staged: ```sh releasekit version --output version.json releasekit notes --input version.json +git add CHANGELOG.md # or each package's changelog in `packages` mode releasekit publish --input version.json ``` @@ -52,7 +53,7 @@ releasekit publish --input version.json A bare `VersionOutput` is still accepted on input, so a hand-assembled file or output from an older releasekit keeps working. Piping a *failed* stage forward fails with that stage's error rather than a schema complaint about missing fields — the message names the upstream code, so the report points at the stage that actually broke. -When `publish` fails partway, its error envelope carries what already landed in `data`, and reports `changed: true` if anything did (a registry publish or the release commit); the failing stage is named in the error `message`. A retry is safe: publishes are idempotent, and versions already on the registry are skipped. +When `publish` fails partway, its error envelope carries what already landed in `data`, and reports `changed: true` if anything did (a registry publish or the release commit); the failing stage is named in the error `message`. Registry publishes are idempotent — versions already on the registry are skipped on a later run — but the standalone `publish` also makes the release commit, so re-running it in the same working tree stops at the git-commit stage: there is nothing left to commit. ### Error codes and exit codes @@ -68,7 +69,7 @@ When `publish` fails partway, its error envelope carries what already landed in | `VERSION_ERROR` | `version` | 8 | | `PUBLISH_ERROR` | `publish` | 9 | -Commands throw more specific codes than these nine families — `TAG_ALREADY_EXISTS`, `NPM_AUTH_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's — `version` failing on `TAG_ALREADY_EXISTS` or `GIT_PROCESS_ERROR` exits `7` — so a script can branch on "git problem" without enumerating every code. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message; config and input failures before the pipeline starts exit `2` and `3`. +Commands throw more specific codes than these nine families — `INPUT_VALIDATION_ERROR`, `PIPELINE_STAGE_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so a script can branch on the family without enumerating every code — `publish` exits `3` for any input problem (`INPUT_PARSE_ERROR`, `INPUT_VALIDATION_ERROR`, or a failed upstream envelope) and `2` for an invalid config. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message. `schemaVersion` bumps only on a breaking change to the envelope shape and is stable across minor releases, so agents and CI can pin against it. A consumer reading an envelope from a **newer** producer fails with a version-mismatch error rather than acting on a payload whose shape it may not understand. diff --git a/packages/publish/src/output.ts b/packages/publish/src/output.ts index a9d1ee4b..27fa13a3 100644 --- a/packages/publish/src/output.ts +++ b/packages/publish/src/output.ts @@ -4,9 +4,10 @@ import type { PublishOutput } from './types.js'; * Did this publish change anything? * * Two honest signals. A registry result is one: `skipped` and `alreadyPublished` mark the packages - * that were passed over. The release commit is the other: `git.committed` is only set when the - * git-commit stage created a new commit, so it is never true on an idempotent re-run — and never true - * under `standing-pr publish`, whose merge already made the commit (`skipGitCommit`). Release tags, + * that were passed over. The release commit is the other: outside a dry run (which the dry-run guard + * below handles), `git.committed` is only set when the git-commit stage created a new commit, so it is + * never true on an idempotent re-run — and never true under `standing-pr publish`, whose merge already + * made the commit (`skipGitCommit`). Release tags, * `git.pushed` (set for any push attempt), and the GitHub-release stage (which reports `success: true` * for an already-existing release) can't distinguish a real publish from a no-op, so they don't count. * diff --git a/packages/version/README.md b/packages/version/README.md index 046717e9..d5e017d0 100644 --- a/packages/version/README.md +++ b/packages/version/README.md @@ -56,16 +56,18 @@ releasekit-version --prerelease beta | Flag | Description | Default | |------|-------------|---------| -| `--bump ` | Force bump type: `patch`, `minor`, `major` | auto | -| `--prerelease [id]` | Create prerelease version (e.g. `beta`) | — | -| `--target ` | Target specific packages (comma-separated) | all | +| `-c, --config ` | Path to config file | `releasekit.config.json` | +| `-b, --bump ` | Force bump type: `patch`, `minor`, `major`, `prerelease` | auto | +| `-p, --prerelease [id]` | Create prerelease version (e.g. `beta`) | — | +| `--stable` | Graduate prerelease packages to stable without bumping | `false` | +| `--allow-first-bump` | Acknowledge applying a bump on a first release with an already-stable manifest | `false` | +| `-s, --sync` | Use synchronized versioning across all packages | config | +| `-t, --target ` | Target specific packages (comma-separated) | all | +| `--include-prerequisites` | Also release the changed internal dependencies of `--target` packages | `false` | | `--project-dir ` | Project directory | cwd | -| `--dry-run` | Preview without file changes or git operations | `false` | -| `--json` | Output results as a JSON envelope (see below) | `false` | +| `-d, --dry-run` | Preview without file changes or git operations | `false` | +| `-j, --json` | Output results as a JSON envelope (see below) | `false` | | `--output ` | Write the JSON envelope to a file instead of stdout (resolved against the directory you run from) | — | -| `--strict-reachable` | Only use tags reachable from current commit | `false` | -| `--verbose` | Verbose logging | `false` | -| `--quiet` | Suppress non-error output | `false` | ## JSON Output diff --git a/packages/version/test/integration/sync-tags.spec.ts b/packages/version/test/integration/sync-tags.spec.ts index d7affa9a..a643b9a1 100644 --- a/packages/version/test/integration/sync-tags.spec.ts +++ b/packages/version/test/integration/sync-tags.spec.ts @@ -8,7 +8,7 @@ * * Requires: `pnpm build` in @releasekit/version before running. */ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -94,39 +94,3 @@ describe('sync strategy — JSON tag output', () => { expect(output.tags).toHaveLength(1); }); }); - -describe('--output with --project-dir', () => { - let callerDir: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'rk-sync-')); - createMonorepoFixture(tempDir); - symlinkNodeModules(tempDir); - initGitRepo(tempDir); - callerDir = mkdtempSync(join(tmpdir(), 'rk-caller-')); - }); - - afterEach(() => { - cleanupTempDir(tempDir); - cleanupTempDir(callerDir); - }); - - it('should resolve a relative --output against the invocation directory, not --project-dir', () => { - writeReleaseKitConfig(tempDir, { - preset: 'angular', - packages: ['packages/pkg-a', 'packages/pkg-b'], - sync: true, - versionPrefix: 'v', - }); - createConventionalCommit(tempDir, 'feat', 'add new feature'); - - // `version --project-dir app --output v.json && publish --input v.json` must find the file where - // the caller put it — the command chdirs into --project-dir before it writes. - const result = executeCliCommand(`--project-dir ${tempDir} --output version.json`, callerDir); - - expect(result.status).toBe(0); - expect(existsSync(join(tempDir, 'version.json'))).toBe(false); - const { data } = parseCliEnvelope(readFileSync(join(callerDir, 'version.json'), 'utf-8')); - expect(data.tags).toContain('v0.2.0'); - }); -}); diff --git a/packages/version/test/unit/cli.spec.ts b/packages/version/test/unit/cli.spec.ts index b880094a..edfa205f 100644 --- a/packages/version/test/unit/cli.spec.ts +++ b/packages/version/test/unit/cli.spec.ts @@ -1,3 +1,4 @@ +import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createVersionCommand, createVersionProgram } from '../../src/cli.js'; import * as configModule from '../../src/config.js'; @@ -159,6 +160,36 @@ describe('createVersionCommand', () => { }); }); + describe('--output', () => { + it('should resolve a relative --output against the invocation directory, before --project-dir changes it', async () => { + // `version --project-dir app --output v.json && publish --input v.json` must find the file where the + // caller put it. Model the chdir so a path resolved after it would land inside --project-dir. + const invocationDir = process.cwd(); + let cwd = invocationDir; + const chdir = vi.spyOn(process, 'chdir').mockImplementation((dir) => { + cwd = path.resolve(cwd, String(dir)); + }); + const getCwd = vi.spyOn(process, 'cwd').mockImplementation(() => cwd); + + try { + await createVersionCommand().parseAsync([ + 'node', + 'test', + '--project-dir', + path.join(invocationDir, 'app'), + '--output', + 'version.json', + ]); + + expect(chdir).toHaveBeenCalledWith(path.join(invocationDir, 'app')); + expect(jsonOutputModule.printJsonOutput).toHaveBeenCalledWith(path.join(invocationDir, 'version.json')); + } finally { + chdir.mockRestore(); + getCwd.mockRestore(); + } + }); + }); + describe('versioning strategy selection', () => { it('should use async strategy for multiple packages', async () => { await createVersionCommand().parseAsync(['node', 'test']); From 68455990e9d10faa8f507c14f72333e4918b63d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:23:42 +0000 Subject: [PATCH 05/19] docs(cli): scope the exit-code contract to the commands it holds for Third-review follow-ups: - The specific-code / family-exit contract holds for version, notes and publish, but `release` and `standing-pr` report errors raised inside their bundled version/notes/publish stages as GENERAL_ERROR (exit 1): the release bundle carries several copies of core's ReleaseKitError, so the instanceof check misses errors from the other copies. Say so rather than claim otherwise; the cause predates this PR and is fixed separately. - `data` on error: describe the actual exception (a publish pipeline failure carries partial output even when nothing landed). - core README: VersionOutput is now the envelope's `data`. - Restore unit tests for releaseDidPublish, the standing-pr `changed` wiring, dropped when the predicate moved to @releasekit/publish. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/cli.md | 4 +- packages/core/README.md | 2 +- .../test/unit/commands/changed.spec.ts | 61 +++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 packages/release/test/unit/commands/changed.spec.ts diff --git a/docs/cli.md b/docs/cli.md index 194473b6..1058a37b 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` still exposes its `VersionOutput` at `data.versionOutput`, and `releasekit version --json` exposes it at `data`. It is `null` on error, except where a command failed partway with real progress to report (see the chaining section below). +- **`data`** carries the command's payload verbatim — the envelope wraps it, never replaces it. `releasekit release --json` still exposes its `VersionOutput` at `data.versionOutput`, and `releasekit version --json` exposes it at `data`. It is `null` on error, except for a `publish` pipeline failure, which carries the partial output in `data` — check `changed` for whether anything actually landed (see the chaining section below). - **`changed`** separates real work from a no-op: a dry run is never `changed`; `publish` is `changed` when it published to a registry or created the release commit; `standing-pr publish` reads its registry results (the merge already made the commit), so a re-run where every version was already published reports `changed: false`; `gate` is read-only and always `changed: false`. - **`errors[]`** replaces prose-only failures in JSON mode. Each carries a stable machine `code`, a coarse `category`, a `retryable` flag (only `true` for known-transient failures — a timeout, 429, or 5xx from a provider — so an agent never retries an unknown failure), and a human `message`. - **Stream discipline:** the envelope is the only thing on stdout; all diagnostics (progress, warnings, error text) go to stderr. Parsing stdout as JSON is always safe, and no command prompts interactively on a CI path. @@ -69,7 +69,7 @@ When `publish` fails partway, its error envelope carries what already landed in | `VERSION_ERROR` | `version` | 8 | | `PUBLISH_ERROR` | `publish` | 9 | -Commands throw more specific codes than these nine families — `INPUT_VALIDATION_ERROR`, `PIPELINE_STAGE_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so a script can branch on the family without enumerating every code — `publish` exits `3` for any input problem (`INPUT_PARSE_ERROR`, `INPUT_VALIDATION_ERROR`, or a failed upstream envelope) and `2` for an invalid config. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message. +Commands throw more specific codes than these nine families — `INPUT_VALIDATION_ERROR`, `PIPELINE_STAGE_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so a script can branch on the family without enumerating every code — `publish` exits `3` for any input problem (`INPUT_PARSE_ERROR`, `INPUT_VALIDATION_ERROR`, or a failed upstream envelope) and `2` for an invalid config. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message. This holds for `version`, `notes`, and `publish`; `release` and `standing-pr` currently report an error raised inside their version, notes, or publish stage as `GENERAL_ERROR` (exit `1`). `schemaVersion` bumps only on a breaking change to the envelope shape and is stable across minor releases, so agents and CI can pin against it. A consumer reading an envelope from a **newer** producer fails with a version-mismatch error rather than acting on a payload whose shape it may not understand. diff --git a/packages/core/README.md b/packages/core/README.md index 0e7cfe73..3e369772 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -15,7 +15,7 @@ This is an internal package that defines the JSON contract between `@releasekit/ | Type | Description | |------|-------------| -| `VersionOutput` | Complete JSON output of `releasekit-version --json` | +| `VersionOutput` | The version stage's result — the `data` of `releasekit-version --json`'s envelope, and what `notes` / `publish` read | | `VersionPackageUpdate` | A single package update record | | `VersionPackageChangelog` | Changelog data for one package | | `VersionChangelogEntry` | A single changelog entry | diff --git a/packages/release/test/unit/commands/changed.spec.ts b/packages/release/test/unit/commands/changed.spec.ts new file mode 100644 index 00000000..36f415d0 --- /dev/null +++ b/packages/release/test/unit/commands/changed.spec.ts @@ -0,0 +1,61 @@ +import type { PublishOutput, PublishResult } from '@releasekit/publish'; +import { describe, expect, it } from 'vitest'; +import { releaseDidPublish } from '../../../src/commands/changed.js'; +import type { ReleaseOutput } from '../../../src/types.js'; + +function publishResult(overrides: Partial = {}): PublishResult { + return { + packageName: '@acme/widget', + version: '2.0.0', + registry: 'npm', + success: true, + skipped: false, + ...overrides, + }; +} + +function publishOutput(overrides: Partial = {}): PublishOutput { + return { + dryRun: false, + git: { committed: false, tags: ['v2.0.0'], pushed: true }, + npm: [], + cargo: [], + pub: [], + verification: [], + githubReleases: [], + publishSucceeded: true, + ...overrides, + }; +} + +function releaseOutput(publish?: PublishOutput): ReleaseOutput { + return { + // Always populated from the manifest, whether or not this run published anything — the reason + // `changed` can't be derived from it. + versionOutput: { + dryRun: false, + updates: [{ packageName: '@acme/widget', currentVersion: '1.4.2', newVersion: '2.0.0', bumpType: 'major' }], + changelogs: [], + tags: ['v2.0.0'], + } as ReleaseOutput['versionOutput'], + publishOutput: publish, + } as ReleaseOutput; +} + +// The predicate itself is covered in @releasekit/publish (output.spec.ts); these pin the +// standing-pr wiring: `changed` follows what this invocation published, never the manifest. +describe('releaseDidPublish', () => { + it('should be true when this run published a package', () => { + expect(releaseDidPublish(releaseOutput(publishOutput({ npm: [publishResult()] })))).toBe(true); + }); + + it('should be false on a re-run where every version was already published, despite manifest updates', () => { + const out = releaseOutput(publishOutput({ npm: [publishResult({ skipped: true, alreadyPublished: true })] })); + expect(releaseDidPublish(out)).toBe(false); + }); + + it('should be false when nothing was published at all', () => { + expect(releaseDidPublish(releaseOutput(undefined))).toBe(false); + expect(releaseDidPublish(undefined)).toBe(false); + }); +}); From 0f83cda263237526173f0c563c78af863c02dc8a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:39:12 +0000 Subject: [PATCH 06/19] fix(cli): don't crash the error path on an unwritable --output; scope the notes exit claim Fourth-review follow-ups: - docs/cli.md claimed the family exit-code contract for `notes`, but notes exits 1 for config errors and most non-parse failures; only its input-parse failures exit 3. Say exactly that. - An unwritable `--output` path made the result write throw, then the error path threw again writing its envelope to the same file, crashing with a stack and no envelope. writeEnvelope still throws for a result write (the command must fail), but notes a failed error-envelope write on stderr instead of throwing out of the error handler. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/cli.md | 2 +- packages/core/src/cli.ts | 16 ++++-- packages/core/test/unit/writeEnvelope.spec.ts | 53 +++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 packages/core/test/unit/writeEnvelope.spec.ts diff --git a/docs/cli.md b/docs/cli.md index 1058a37b..f284016e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -69,7 +69,7 @@ When `publish` fails partway, its error envelope carries what already landed in | `VERSION_ERROR` | `version` | 8 | | `PUBLISH_ERROR` | `publish` | 9 | -Commands throw more specific codes than these nine families — `INPUT_VALIDATION_ERROR`, `PIPELINE_STAGE_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so a script can branch on the family without enumerating every code — `publish` exits `3` for any input problem (`INPUT_PARSE_ERROR`, `INPUT_VALIDATION_ERROR`, or a failed upstream envelope) and `2` for an invalid config. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message. This holds for `version`, `notes`, and `publish`; `release` and `standing-pr` currently report an error raised inside their version, notes, or publish stage as `GENERAL_ERROR` (exit `1`). +Commands throw more specific codes than these nine families — `INPUT_VALIDATION_ERROR`, `PIPELINE_STAGE_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so a script can branch on the family without enumerating every code — `publish` exits `3` for any input problem (`INPUT_PARSE_ERROR`, `INPUT_VALIDATION_ERROR`, or a failed upstream envelope) and `2` for an invalid config. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message. This holds for `version` and `publish`. `notes` exits `3` when its input can't be parsed (bad JSON, a failed upstream envelope, a schema-invalid object) and `1` for most other failures, config errors included; `release` and `standing-pr` currently report an error raised inside their version, notes, or publish stage as `GENERAL_ERROR` (exit `1`). `schemaVersion` bumps only on a breaking change to the envelope shape and is stable across minor releases, so agents and CI can pin against it. A consumer reading an envelope from a **newer** producer fails with a version-mismatch error rather than acting on a payload whose shape it may not understand. diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 0a2778e3..cd3f0046 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -11,10 +11,20 @@ import type { Envelope } from './envelope.js'; export function writeEnvelope(envelope: Envelope, opts: { json?: boolean; output?: string }): void { if (!opts.json && !opts.output) return; const text = JSON.stringify(envelope, null, 2); - if (opts.output) { - fs.writeFileSync(opts.output, text); - } else { + if (!opts.output) { console.log(text); + return; + } + try { + fs.writeFileSync(opts.output, text); + } catch (err) { + // An unwritable --output fails the command: rethrow a result write so the caller's error path runs. + // That path writes its error envelope to the same file and fails again — note it on stderr instead of + // throwing out of the error handler, which would crash with a stack and bury the original failure. + if (envelope.status !== 'error') throw err; + console.error( + `Could not write the error envelope to ${opts.output}: ${err instanceof Error ? err.message : String(err)}`, + ); } } diff --git a/packages/core/test/unit/writeEnvelope.spec.ts b/packages/core/test/unit/writeEnvelope.spec.ts new file mode 100644 index 00000000..0868e7ee --- /dev/null +++ b/packages/core/test/unit/writeEnvelope.spec.ts @@ -0,0 +1,53 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { writeEnvelope } from '../../src/cli.js'; +import { errorEnvelope, successEnvelope } from '../../src/envelope.js'; + +describe('writeEnvelope', () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + dirs.length = 0; + vi.restoreAllMocks(); + }); + + it('should write the envelope to --output and leave stdout empty', () => { + const dir = mkdtempSync(join(tmpdir(), 'write-envelope-')); + dirs.push(dir); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + writeEnvelope(successEnvelope({ a: 1 }), { json: true, output: join(dir, 'out.json') }); + + expect(JSON.parse(readFileSync(join(dir, 'out.json'), 'utf8')).data).toEqual({ a: 1 }); + expect(log).not.toHaveBeenCalled(); + }); + + it('should throw when a result cannot be written to --output, so the command fails', () => { + expect(() => writeEnvelope(successEnvelope({ a: 1 }), { output: '/nonexistent-dir/out.json' })).toThrow(); + }); + + it('should not throw out of the error path when --output is unwritable', () => { + // The error path writes to the same file the result write just failed on; a second throw there + // would crash the command with a stack instead of reporting the original failure. + const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => + writeEnvelope(errorEnvelope([{ code: 'GENERAL_ERROR', category: 'general', retryable: false, message: 'x' }]), { + output: '/nonexistent-dir/out.json', + }), + ).not.toThrow(); + expect(stderr).toHaveBeenCalledWith(expect.stringContaining('/nonexistent-dir/out.json')); + }); + + it('should print to stdout under --json alone and do nothing without either flag', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + writeEnvelope(successEnvelope({ a: 1 }), { json: true }); + writeEnvelope(successEnvelope({ a: 1 }), {}); + + expect(log).toHaveBeenCalledTimes(1); + }); +}); From ba3282b150a2328545a85860e1c94d8d47861fa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:49:44 +0000 Subject: [PATCH 07/19] docs(cli): describe notes' parse check precisely; name a lost --output result Fifth-review nits: - notes only checks that its input has a `changelogs` array, so "a schema-invalid object" overstated the exit-3 case; say what it checks. - When the result write to --output fails, throw "Could not write the result to : ..." instead of the bare ENOENT, so stderr says the command's work happened but its result never reached the file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/cli.md | 2 +- packages/core/src/cli.ts | 12 ++++++------ packages/core/test/unit/writeEnvelope.spec.ts | 4 +++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index f284016e..3450b58c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -69,7 +69,7 @@ When `publish` fails partway, its error envelope carries what already landed in | `VERSION_ERROR` | `version` | 8 | | `PUBLISH_ERROR` | `publish` | 9 | -Commands throw more specific codes than these nine families — `INPUT_VALIDATION_ERROR`, `PIPELINE_STAGE_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so a script can branch on the family without enumerating every code — `publish` exits `3` for any input problem (`INPUT_PARSE_ERROR`, `INPUT_VALIDATION_ERROR`, or a failed upstream envelope) and `2` for an invalid config. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message. This holds for `version` and `publish`. `notes` exits `3` when its input can't be parsed (bad JSON, a failed upstream envelope, a schema-invalid object) and `1` for most other failures, config errors included; `release` and `standing-pr` currently report an error raised inside their version, notes, or publish stage as `GENERAL_ERROR` (exit `1`). +Commands throw more specific codes than these nine families — `INPUT_VALIDATION_ERROR`, `PIPELINE_STAGE_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so a script can branch on the family without enumerating every code — `publish` exits `3` for any input problem (`INPUT_PARSE_ERROR`, `INPUT_VALIDATION_ERROR`, or a failed upstream envelope) and `2` for an invalid config. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message. This holds for `version` and `publish`. `notes` exits `3` when its input can't be parsed (bad JSON, a failed upstream envelope, an object without a `changelogs` array) and `1` for most other failures, config errors included; `release` and `standing-pr` currently report an error raised inside their version, notes, or publish stage as `GENERAL_ERROR` (exit `1`). `schemaVersion` bumps only on a breaking change to the envelope shape and is stable across minor releases, so agents and CI can pin against it. A consumer reading an envelope from a **newer** producer fails with a version-mismatch error rather than acting on a payload whose shape it may not understand. diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index cd3f0046..e08060d7 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -18,13 +18,13 @@ export function writeEnvelope(envelope: Envelope, opts: { json?: boolean; output try { fs.writeFileSync(opts.output, text); } catch (err) { - // An unwritable --output fails the command: rethrow a result write so the caller's error path runs. - // That path writes its error envelope to the same file and fails again — note it on stderr instead of + // An unwritable --output fails the command: throw for a result write so the caller's error path runs, + // naming what was lost — the command's work is done, only its result never reached the file. That + // path writes its error envelope to the same file and fails again — note it on stderr instead of // throwing out of the error handler, which would crash with a stack and bury the original failure. - if (envelope.status !== 'error') throw err; - console.error( - `Could not write the error envelope to ${opts.output}: ${err instanceof Error ? err.message : String(err)}`, - ); + const reason = err instanceof Error ? err.message : String(err); + if (envelope.status !== 'error') throw new Error(`Could not write the result to ${opts.output}: ${reason}`); + console.error(`Could not write the error envelope to ${opts.output}: ${reason}`); } } diff --git a/packages/core/test/unit/writeEnvelope.spec.ts b/packages/core/test/unit/writeEnvelope.spec.ts index 0868e7ee..e03d7212 100644 --- a/packages/core/test/unit/writeEnvelope.spec.ts +++ b/packages/core/test/unit/writeEnvelope.spec.ts @@ -26,7 +26,9 @@ describe('writeEnvelope', () => { }); it('should throw when a result cannot be written to --output, so the command fails', () => { - expect(() => writeEnvelope(successEnvelope({ a: 1 }), { output: '/nonexistent-dir/out.json' })).toThrow(); + expect(() => writeEnvelope(successEnvelope({ a: 1 }), { output: '/nonexistent-dir/out.json' })).toThrow( + /Could not write the result to \/nonexistent-dir\/out\.json/, + ); }); it('should not throw out of the error path when --output is unwritable', () => { From 522cc2c54849d057d930733dd1181664c450f409 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 11:07:56 +0000 Subject: [PATCH 08/19] test: drop low-value review additions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the releaseDidPublish unit tests (a one-line wrapper over publishDidChange, which publish's own tests cover) and rethrow the original error when a result can't be written to --output instead of re-wrapping it — the ENOENT already names the path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- packages/core/src/cli.ts | 12 ++-- packages/core/test/unit/writeEnvelope.spec.ts | 4 +- .../test/unit/commands/changed.spec.ts | 61 ------------------- 3 files changed, 7 insertions(+), 70 deletions(-) delete mode 100644 packages/release/test/unit/commands/changed.spec.ts diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index e08060d7..cd3f0046 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -18,13 +18,13 @@ export function writeEnvelope(envelope: Envelope, opts: { json?: boolean; output try { fs.writeFileSync(opts.output, text); } catch (err) { - // An unwritable --output fails the command: throw for a result write so the caller's error path runs, - // naming what was lost — the command's work is done, only its result never reached the file. That - // path writes its error envelope to the same file and fails again — note it on stderr instead of + // An unwritable --output fails the command: rethrow a result write so the caller's error path runs. + // That path writes its error envelope to the same file and fails again — note it on stderr instead of // throwing out of the error handler, which would crash with a stack and bury the original failure. - const reason = err instanceof Error ? err.message : String(err); - if (envelope.status !== 'error') throw new Error(`Could not write the result to ${opts.output}: ${reason}`); - console.error(`Could not write the error envelope to ${opts.output}: ${reason}`); + if (envelope.status !== 'error') throw err; + console.error( + `Could not write the error envelope to ${opts.output}: ${err instanceof Error ? err.message : String(err)}`, + ); } } diff --git a/packages/core/test/unit/writeEnvelope.spec.ts b/packages/core/test/unit/writeEnvelope.spec.ts index e03d7212..8c5167c7 100644 --- a/packages/core/test/unit/writeEnvelope.spec.ts +++ b/packages/core/test/unit/writeEnvelope.spec.ts @@ -26,9 +26,7 @@ describe('writeEnvelope', () => { }); it('should throw when a result cannot be written to --output, so the command fails', () => { - expect(() => writeEnvelope(successEnvelope({ a: 1 }), { output: '/nonexistent-dir/out.json' })).toThrow( - /Could not write the result to \/nonexistent-dir\/out\.json/, - ); + expect(() => writeEnvelope(successEnvelope({ a: 1 }), { output: '/nonexistent-dir/out.json' })).toThrow(/ENOENT/); }); it('should not throw out of the error path when --output is unwritable', () => { diff --git a/packages/release/test/unit/commands/changed.spec.ts b/packages/release/test/unit/commands/changed.spec.ts deleted file mode 100644 index 36f415d0..00000000 --- a/packages/release/test/unit/commands/changed.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { PublishOutput, PublishResult } from '@releasekit/publish'; -import { describe, expect, it } from 'vitest'; -import { releaseDidPublish } from '../../../src/commands/changed.js'; -import type { ReleaseOutput } from '../../../src/types.js'; - -function publishResult(overrides: Partial = {}): PublishResult { - return { - packageName: '@acme/widget', - version: '2.0.0', - registry: 'npm', - success: true, - skipped: false, - ...overrides, - }; -} - -function publishOutput(overrides: Partial = {}): PublishOutput { - return { - dryRun: false, - git: { committed: false, tags: ['v2.0.0'], pushed: true }, - npm: [], - cargo: [], - pub: [], - verification: [], - githubReleases: [], - publishSucceeded: true, - ...overrides, - }; -} - -function releaseOutput(publish?: PublishOutput): ReleaseOutput { - return { - // Always populated from the manifest, whether or not this run published anything — the reason - // `changed` can't be derived from it. - versionOutput: { - dryRun: false, - updates: [{ packageName: '@acme/widget', currentVersion: '1.4.2', newVersion: '2.0.0', bumpType: 'major' }], - changelogs: [], - tags: ['v2.0.0'], - } as ReleaseOutput['versionOutput'], - publishOutput: publish, - } as ReleaseOutput; -} - -// The predicate itself is covered in @releasekit/publish (output.spec.ts); these pin the -// standing-pr wiring: `changed` follows what this invocation published, never the manifest. -describe('releaseDidPublish', () => { - it('should be true when this run published a package', () => { - expect(releaseDidPublish(releaseOutput(publishOutput({ npm: [publishResult()] })))).toBe(true); - }); - - it('should be false on a re-run where every version was already published, despite manifest updates', () => { - const out = releaseOutput(publishOutput({ npm: [publishResult({ skipped: true, alreadyPublished: true })] })); - expect(releaseDidPublish(out)).toBe(false); - }); - - it('should be false when nothing was published at all', () => { - expect(releaseDidPublish(releaseOutput(undefined))).toBe(false); - expect(releaseDidPublish(undefined)).toBe(false); - }); -}); From e487331a3d2749625540e4ff2129d182efb775c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 13:59:39 +0000 Subject: [PATCH 09/19] test: drop the duplicate publish exit test and the notes exit clause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INPUT→3 runs the same exit-code path as CONFIG→2, which stays as the guard against hard-coded exits. The cli.md notes clause is trimmed to the commands the table holds for. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/cli.md | 2 +- packages/publish/test/unit/cli.spec.ts | 14 -------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 3450b58c..c7172aa0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -69,7 +69,7 @@ When `publish` fails partway, its error envelope carries what already landed in | `VERSION_ERROR` | `version` | 8 | | `PUBLISH_ERROR` | `publish` | 9 | -Commands throw more specific codes than these nine families — `INPUT_VALIDATION_ERROR`, `PIPELINE_STAGE_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so a script can branch on the family without enumerating every code — `publish` exits `3` for any input problem (`INPUT_PARSE_ERROR`, `INPUT_VALIDATION_ERROR`, or a failed upstream envelope) and `2` for an invalid config. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message. This holds for `version` and `publish`. `notes` exits `3` when its input can't be parsed (bad JSON, a failed upstream envelope, an object without a `changelogs` array) and `1` for most other failures, config errors included; `release` and `standing-pr` currently report an error raised inside their version, notes, or publish stage as `GENERAL_ERROR` (exit `1`). +Commands throw more specific codes than these nine families — `INPUT_VALIDATION_ERROR`, `PIPELINE_STAGE_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so a script can branch on the family without enumerating every code — `publish` exits `3` for any input problem (`INPUT_PARSE_ERROR`, `INPUT_VALIDATION_ERROR`, or a failed upstream envelope) and `2` for an invalid config. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message. This holds for `version` and `publish`. `release` and `standing-pr` currently report an error raised inside their version, notes, or publish stage as `GENERAL_ERROR` (exit `1`). `schemaVersion` bumps only on a breaking change to the envelope shape and is stable across minor releases, so agents and CI can pin against it. A consumer reading an envelope from a **newer** producer fails with a version-mismatch error rather than acting on a payload whose shape it may not understand. diff --git a/packages/publish/test/unit/cli.spec.ts b/packages/publish/test/unit/cli.spec.ts index fdc3ff52..a2089a63 100644 --- a/packages/publish/test/unit/cli.spec.ts +++ b/packages/publish/test/unit/cli.spec.ts @@ -196,20 +196,6 @@ describe('createPublishCommand', () => { expect(mockExit).toHaveBeenCalledWith(EXIT_CODES.CONFIG_ERROR); }); - it('should exit with INPUT_ERROR when the input cannot be parsed', async () => { - const inputError = new BasePublishError('not JSON', PublishErrorCode.INPUT_PARSE_ERROR); - vi.spyOn(inputError, 'logError').mockImplementation(() => undefined); - vi.mocked(parseInput).mockRejectedValue(inputError); - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - await createPublishCommand().parseAsync(['node', 'test', '--json']); - - const envelope = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); - expect(envelope.errors[0].code).toBe('INPUT_PARSE_ERROR'); - expect(mockExit).toHaveBeenCalledWith(EXIT_CODES.INPUT_ERROR); - consoleSpy.mockRestore(); - }); - it('should log and exit with GENERAL_ERROR for unknown errors', async () => { vi.mocked(runPipeline).mockRejectedValue(new Error('unexpected')); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); From acff673bc0de29e94ceb43c1820a5064f1728925 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:09:20 +0000 Subject: [PATCH 10/19] docs(cli): release and standing-pr now report a stage's own error code #651 is merged, so an error raised inside their version, notes or publish stage keeps its code instead of degrading to GENERAL_ERROR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index c7172aa0..5e4d5356 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -69,7 +69,7 @@ When `publish` fails partway, its error envelope carries what already landed in | `VERSION_ERROR` | `version` | 8 | | `PUBLISH_ERROR` | `publish` | 9 | -Commands throw more specific codes than these nine families — `INPUT_VALIDATION_ERROR`, `PIPELINE_STAGE_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so a script can branch on the family without enumerating every code — `publish` exits `3` for any input problem (`INPUT_PARSE_ERROR`, `INPUT_VALIDATION_ERROR`, or a failed upstream envelope) and `2` for an invalid config. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message. This holds for `version` and `publish`. `release` and `standing-pr` currently report an error raised inside their version, notes, or publish stage as `GENERAL_ERROR` (exit `1`). +Commands throw more specific codes than these nine families — `INPUT_VALIDATION_ERROR`, `PIPELINE_STAGE_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so a script can branch on the family without enumerating every code — `publish` exits `3` for any input problem (`INPUT_PARSE_ERROR`, `INPUT_VALIDATION_ERROR`, or a failed upstream envelope) and `2` for an invalid config. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message. This holds for `version` and `publish`, and for `release` and `standing-pr`, which report an error from their version, notes, or publish stage with that stage's own code. `schemaVersion` bumps only on a breaking change to the envelope shape and is stable across minor releases, so agents and CI can pin against it. A consumer reading an envelope from a **newer** producer fails with a version-mismatch error rather than acting on a payload whose shape it may not understand. From 213e55013f5902992a4a4a19de2de67e1baa7d1a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 17:42:40 +0000 Subject: [PATCH 11/19] refactor(cli): share one error-envelope writer and trim review leftovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core: add writeErrorEnvelope next to writeEnvelope; version, publish and release's emitError now call it instead of each building the same single-error envelope. - core: isEnvelope is no longer exported — only unwrapEnvelope needs it, and the version test helper now reads output through unwrapEnvelope, the same boundary notes and publish cross. - publishDidChange: drop the `?.` / `?? []` guards. Every PublishOutput it sees comes from runPipeline's ctx.output (the only PipelineError is built from it), whose registry arrays are always initialised. - Drop comments that repeat the unwrapEnvelope JSDoc or another comment, the old-behaviour note on publish's exit, and the "pipe's contract is unchanged" claim (older consumers reject the envelope). - Braces on the one-line ifs and loop this PR added; give publishDidChange its own export section so runPipeline keeps its header. - Drop the "older producer" unwrap test: it used the current schemaVersion, so it duplicated the success-envelope case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- packages/core/src/cli.ts | 15 ++++++++--- packages/core/src/envelope.ts | 12 +++------ packages/core/src/index.ts | 3 +-- packages/core/test/unit/envelope.spec.ts | 20 ++------------- packages/core/test/unit/writeEnvelope.spec.ts | 6 ++--- packages/publish/src/command.ts | 5 ++-- packages/publish/src/index.ts | 4 +-- packages/publish/src/output.ts | 25 ++++++++++--------- packages/publish/test/unit/output.spec.ts | 2 -- packages/release/src/commands/emitResult.ts | 6 ++--- packages/version/src/command.ts | 18 +++---------- packages/version/src/utils/jsonOutput.ts | 8 +++--- .../test/integration/sync-tags.spec.ts | 6 ++--- .../test/unit/utils/jsonOutput.spec.ts | 1 - packages/version/test/utils/cli.ts | 16 +++--------- 15 files changed, 56 insertions(+), 91 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index cd3f0046..130cd034 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { Envelope } from './envelope.js'; +import { type Envelope, errorEnvelope, toEnvelopeError } from './envelope.js'; /** * Write an envelope to the JSON channel: `--output` when given, otherwise stdout under `--json`, and @@ -9,7 +9,9 @@ import type { Envelope } from './envelope.js'; * subprocess or log noise, and a single stray byte breaks JSON parsing. */ export function writeEnvelope(envelope: Envelope, opts: { json?: boolean; output?: string }): void { - if (!opts.json && !opts.output) return; + if (!opts.json && !opts.output) { + return; + } const text = JSON.stringify(envelope, null, 2); if (!opts.output) { console.log(text); @@ -21,13 +23,20 @@ export function writeEnvelope(envelope: Envelope, opts: { json?: boolean; output // An unwritable --output fails the command: rethrow a result write so the caller's error path runs. // That path writes its error envelope to the same file and fails again — note it on stderr instead of // throwing out of the error handler, which would crash with a stack and bury the original failure. - if (envelope.status !== 'error') throw err; + if (envelope.status !== 'error') { + throw err; + } console.error( `Could not write the error envelope to ${opts.output}: ${err instanceof Error ? err.message : String(err)}`, ); } } +/** Write a thrown value to the JSON channel as a single-error envelope. */ +export function writeErrorEnvelope(error: unknown, opts: { json?: boolean; output?: string }): void { + writeEnvelope(errorEnvelope([toEnvelopeError(error)]), opts); +} + /** * Reads the version from the package.json nearest to the given module URL. * Pass `import.meta.url` from the CLI entry file so the path resolves to diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 6672e8cd..4488a25e 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -71,8 +71,7 @@ export function errorEnvelope( }; } -/** Structural test for the envelope. */ -export function isEnvelope(value: unknown): value is Envelope { +function isEnvelope(value: unknown): value is Envelope { return ( typeof value === 'object' && value !== null && 'schemaVersion' in value && 'status' in value && 'data' in value ); @@ -92,11 +91,10 @@ const KNOWN_STATUSES = new Set(['success', 'error']); * A caller that wants a nullable payload should read `.data` directly instead. */ export function unwrapEnvelope(value: unknown): unknown { - if (!isEnvelope(value)) return value; + if (!isEnvelope(value)) { + return value; + } - // What `schemaVersion` is for. A newer producer may have moved the payload or changed its meaning, - // so handing `data` to a consumer that predates the change would fail later and further away, as a - // shape error about the payload rather than a version mismatch. if (typeof value.schemaVersion !== 'number' || value.schemaVersion > ENVELOPE_SCHEMA_VERSION) { throw new Error( `Unsupported releasekit envelope schemaVersion ${String(value.schemaVersion)} (this build understands ` + @@ -117,8 +115,6 @@ export function unwrapEnvelope(value: unknown): unknown { ); } - // A success envelope with no payload can't be what the caller asked for, and reporting it here beats - // the null-property error the consumer would raise once it started reading fields off it. if (value.data === null || value.data === undefined) { throw new Error('Upstream releasekit stage reported success but produced no data.'); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d24cb205..b2f897c7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,7 +6,7 @@ export { parseGitHubOwnerRepo, renderIssueRefs, } from './changelogRefs.js'; -export { readPackageVersion, writeEnvelope } from './cli.js'; +export { readPackageVersion, writeEnvelope, writeErrorEnvelope } from './cli.js'; export { buildDependencyGraph, type Ecosystem, @@ -21,7 +21,6 @@ export { type EnvelopeWarning, errorEnvelope, exitCodeForError, - isEnvelope, successEnvelope, toEnvelopeError, unwrapEnvelope, diff --git a/packages/core/test/unit/envelope.spec.ts b/packages/core/test/unit/envelope.spec.ts index 02a39461..5bdf97da 100644 --- a/packages/core/test/unit/envelope.spec.ts +++ b/packages/core/test/unit/envelope.spec.ts @@ -3,7 +3,6 @@ import { ENVELOPE_SCHEMA_VERSION, errorEnvelope, exitCodeForError, - isEnvelope, successEnvelope, toEnvelopeError, unwrapEnvelope, @@ -113,15 +112,6 @@ describe('envelope', () => { }); }); - describe('isEnvelope', () => { - it('should accept an envelope and reject bare payloads', () => { - expect(isEnvelope(successEnvelope({ tags: [] }))).toBe(true); - expect(isEnvelope({ dryRun: false, updates: [], tags: [] })).toBe(false); - expect(isEnvelope(null)).toBe(false); - expect(isEnvelope('a string')).toBe(false); - }); - }); - describe('unwrapEnvelope', () => { it('should return the payload from a success envelope', () => { const payload = { dryRun: false, updates: [{ packageName: 'a' }], tags: ['v1.0.0'] }; @@ -131,6 +121,8 @@ describe('envelope', () => { it('should pass a bare payload through unchanged', () => { const bare = { dryRun: false, updates: [], tags: [] }; expect(unwrapEnvelope(bare)).toBe(bare); + expect(unwrapEnvelope(null)).toBeNull(); + expect(unwrapEnvelope('a string')).toBe('a string'); }); it('should throw with the upstream message and code for an error envelope', () => { @@ -143,24 +135,16 @@ describe('envelope', () => { }); it('should refuse an envelope from a newer producer', () => { - // The point of schemaVersion: a newer producer may have moved the payload, so handing `data` - // onward would fail later as a shape error rather than a version mismatch. const future = { ...successEnvelope({ updates: [] }), schemaVersion: ENVELOPE_SCHEMA_VERSION + 1 }; expect(() => unwrapEnvelope(future)).toThrow(/schemaVersion/); }); - it('should accept an envelope from an older producer', () => { - const older = { ...successEnvelope({ updates: [] }), schemaVersion: 1 }; - expect(unwrapEnvelope(older)).toEqual({ updates: [] }); - }); - it('should refuse an unrecognised status', () => { const odd = { ...successEnvelope({ updates: [] }), status: 'partial' }; expect(() => unwrapEnvelope(odd)).toThrow(/Unrecognised.*status/); }); it('should refuse a success envelope carrying no payload', () => { - // Otherwise the consumer reads fields off null and reports a shape error about the payload. expect(() => unwrapEnvelope(successEnvelope(null))).toThrow(/success but produced no data/); }); }); diff --git a/packages/core/test/unit/writeEnvelope.spec.ts b/packages/core/test/unit/writeEnvelope.spec.ts index 8c5167c7..f5a38114 100644 --- a/packages/core/test/unit/writeEnvelope.spec.ts +++ b/packages/core/test/unit/writeEnvelope.spec.ts @@ -9,7 +9,9 @@ describe('writeEnvelope', () => { const dirs: string[] = []; afterEach(() => { - for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }); + } dirs.length = 0; vi.restoreAllMocks(); }); @@ -30,8 +32,6 @@ describe('writeEnvelope', () => { }); it('should not throw out of the error path when --output is unwritable', () => { - // The error path writes to the same file the result write just failed on; a second throw there - // would crash the command with a stack instead of reporting the original failure. const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}); expect(() => diff --git a/packages/publish/src/command.ts b/packages/publish/src/command.ts index 598bd383..439e5faf 100644 --- a/packages/publish/src/command.ts +++ b/packages/publish/src/command.ts @@ -6,6 +6,7 @@ import { successEnvelope, toEnvelopeError, writeEnvelope, + writeErrorEnvelope, } from '@releasekit/core'; import { Command } from 'commander'; import { loadConfig } from './config.js'; @@ -75,15 +76,13 @@ export function createPublishCommand(): Command { ); err.logError(); } else { - writeEnvelope(errorEnvelope([toEnvelopeError(err)]), io); + writeErrorEnvelope(err, io); if (BasePublishError.isPublishError(err)) { err.logError(); } else { console.error(err instanceof Error ? err.message : String(err)); } } - // Exit by the code's family (docs/cli.md), the same table every other command uses, so a config - // or input failure isn't reported as a publish failure. process.exit(exitCodeForError(err)); } }); diff --git a/packages/publish/src/index.ts b/packages/publish/src/index.ts index 733cf44e..d322f381 100644 --- a/packages/publish/src/index.ts +++ b/packages/publish/src/index.ts @@ -3,9 +3,9 @@ export { createPublishCommand } from './command.js'; export { getDefaultConfig, loadConfig } from './config.js'; // Errors export { BasePublishError, createPublishError, PipelineError, PublishError, PublishErrorCode } from './errors/index.js'; -// Pipeline +// Output export { publishDidChange } from './output.js'; - +// Pipeline export { runPipeline } from './pipeline/index.js'; // Input parsing export { parseInput } from './stages/input.js'; diff --git a/packages/publish/src/output.ts b/packages/publish/src/output.ts index 27fa13a3..97571dcf 100644 --- a/packages/publish/src/output.ts +++ b/packages/publish/src/output.ts @@ -4,21 +4,22 @@ import type { PublishOutput } from './types.js'; * Did this publish change anything? * * Two honest signals. A registry result is one: `skipped` and `alreadyPublished` mark the packages - * that were passed over. The release commit is the other: outside a dry run (which the dry-run guard - * below handles), `git.committed` is only set when the git-commit stage created a new commit, so it is - * never true on an idempotent re-run — and never true under `standing-pr publish`, whose merge already - * made the commit (`skipGitCommit`). Release tags, - * `git.pushed` (set for any push attempt), and the GitHub-release stage (which reports `success: true` - * for an already-existing release) can't distinguish a real publish from a no-op, so they don't count. + * that were passed over. The release commit is the other: outside a dry run, `git.committed` is only + * set when the git-commit stage created a new commit, so it is never true on an idempotent re-run — + * and never true under `standing-pr publish`, whose merge already made the commit (`skipGitCommit`). + * Release tags, `git.pushed` (set for any push attempt), and the GitHub-release stage (which reports + * `success: true` for an already-existing release) can't distinguish a real publish from a no-op, so + * they don't count. * * Answers `changed` for both the `publish` CLI and `standing-pr publish`, which is why it lives here * rather than in either caller. */ export function publishDidChange(output: PublishOutput | null | undefined): boolean { - if (!output || output.dryRun) return false; - if (output.git?.committed) return true; - // Tolerate absent registry arrays: this also runs on the partial output of a failed pipeline, and - // throwing here would replace the publish error the caller needs with a TypeError about our own shape. - const results = [...(output.npm ?? []), ...(output.cargo ?? []), ...(output.pub ?? [])]; - return results.some((r) => r.success && !r.skipped && !r.alreadyPublished); + if (!output || output.dryRun) { + return false; + } + if (output.git.committed) { + return true; + } + return [...output.npm, ...output.cargo, ...output.pub].some((r) => r.success && !r.skipped && !r.alreadyPublished); } diff --git a/packages/publish/test/unit/output.spec.ts b/packages/publish/test/unit/output.spec.ts index a1ad2b47..de6e4105 100644 --- a/packages/publish/test/unit/output.spec.ts +++ b/packages/publish/test/unit/output.spec.ts @@ -41,8 +41,6 @@ describe('publishDidChange', () => { }); it('should be false for a push of pre-existing tags and releases with nothing published', () => { - // git.pushed is set for any push attempt and the GitHub-release stage reports success for an - // already-existing release, so neither can stand in for "something happened". const out = output({ npm: [result({ skipped: true, alreadyPublished: true })], git: { committed: false, tags: ['v2.0.0'], pushed: true }, diff --git a/packages/release/src/commands/emitResult.ts b/packages/release/src/commands/emitResult.ts index 6b7510bb..01950038 100644 --- a/packages/release/src/commands/emitResult.ts +++ b/packages/release/src/commands/emitResult.ts @@ -1,11 +1,10 @@ import { type EnvelopeWarning, EXIT_CODES, - errorEnvelope, InputError, successEnvelope, - toEnvelopeError, writeEnvelope, + writeErrorEnvelope, } from '@releasekit/core'; /** @@ -22,8 +21,7 @@ export function emitResult( /** Emit a structured error envelope for a thrown value on the JSON channel. */ export function emitError(error: unknown, opts: { json?: boolean; output?: string }): void { - if (!opts.json && !opts.output) return; - writeEnvelope(errorEnvelope([toEnvelopeError(error)]), opts); + writeErrorEnvelope(error, opts); } /** diff --git a/packages/version/src/command.ts b/packages/version/src/command.ts index d3b27cf5..432f2b3e 100644 --- a/packages/version/src/command.ts +++ b/packages/version/src/command.ts @@ -1,12 +1,5 @@ import * as path from 'node:path'; -import { - EXIT_CODES, - errorEnvelope, - exitCodeForError, - InputError, - toEnvelopeError, - writeEnvelope, -} from '@releasekit/core'; +import { EXIT_CODES, exitCodeForError, InputError, writeErrorEnvelope } from '@releasekit/core'; import { Command } from 'commander'; import { loadConfig } from './config.js'; import { VersionEngine } from './core/versionEngine.js'; @@ -14,11 +7,6 @@ import type { Config } from './types.js'; import { enableJsonOutput, printJsonOutput } from './utils/jsonOutput.js'; import { log } from './utils/logging.js'; -/** Report a failure on the JSON channel, so a `--json` caller gets the same shape as on success. */ -function emitErrorEnvelope(error: unknown, opts: { json?: boolean; output?: string }): void { - writeEnvelope(errorEnvelope([toEnvelopeError(error)]), opts); -} - export function createVersionCommand(): Command { return new Command('version') .description('Version a package or packages based on configuration') @@ -50,7 +38,7 @@ export function createVersionCommand(): Command { if (options.stable && options.prerelease) { const message = 'Cannot use both --stable and --prerelease at the same time'; - emitErrorEnvelope(new InputError(message), io); + writeErrorEnvelope(new InputError(message), io); console.error(`Error: ${message}`); process.exit(EXIT_CODES.INPUT_ERROR); } @@ -135,7 +123,7 @@ export function createVersionCommand(): Command { } catch (error) { const { BaseVersionError } = await import('./errors/baseError.js'); - emitErrorEnvelope(error, io); + writeErrorEnvelope(error, io); if (BaseVersionError.isVersionError(error)) { error.logError(); } else { diff --git a/packages/version/src/utils/jsonOutput.ts b/packages/version/src/utils/jsonOutput.ts index 41ac1f52..5469dcfe 100644 --- a/packages/version/src/utils/jsonOutput.ts +++ b/packages/version/src/utils/jsonOutput.ts @@ -289,13 +289,15 @@ export function getJsonData(): VersionOutput { } /** - * Emit the collected `VersionOutput` inside the uniform CLI envelope. The payload rides in `data` - * verbatim — `notes` and `publish` unwrap it on the way in, so the pipe's contract is unchanged. + * Emit the collected `VersionOutput` as the `data` of the uniform CLI envelope, which `notes` and + * `publish` unwrap on the way in. * * A dry run writes nothing, so it is never `changed`. */ export function printJsonOutput(outputPath?: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } writeEnvelope(successEnvelope(_jsonData, { changed: !_jsonData.dryRun && _jsonData.updates.length > 0 }), { json: true, output: outputPath, diff --git a/packages/version/test/integration/sync-tags.spec.ts b/packages/version/test/integration/sync-tags.spec.ts index a643b9a1..deff3467 100644 --- a/packages/version/test/integration/sync-tags.spec.ts +++ b/packages/version/test/integration/sync-tags.spec.ts @@ -12,7 +12,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { executeCliCommand, parseCliEnvelope } from '../utils/cli.js'; +import { executeCliCommand, readVersionOutput } from '../utils/cli.js'; import { createConventionalCommit, initGitRepo } from '../utils/git.js'; import { createPackageJson } from '../utils/package.js'; import { cleanupTempDir, symlinkNodeModules } from '../utils/tempFixture.js'; @@ -65,7 +65,7 @@ describe('sync strategy — JSON tag output', () => { const result = executeCliCommand('--json', tempDir); expect(result.status).toBe(0); - const { data: output } = parseCliEnvelope(result.stdout); + const output = readVersionOutput(result.stdout); // @test/pkg-a is sanitized to test-pkg-a (@ stripped, / replaced with -) expect(output.tags).toContain('test-pkg-a-v0.2.0'); @@ -88,7 +88,7 @@ describe('sync strategy — JSON tag output', () => { const result = executeCliCommand('--json', tempDir); expect(result.status).toBe(0); - const { data: output } = parseCliEnvelope(result.stdout); + const output = readVersionOutput(result.stdout); expect(output.tags).toContain('v0.2.0'); expect(output.tags).toHaveLength(1); diff --git a/packages/version/test/unit/utils/jsonOutput.spec.ts b/packages/version/test/unit/utils/jsonOutput.spec.ts index aa4a4891..e5c6c45c 100644 --- a/packages/version/test/unit/utils/jsonOutput.spec.ts +++ b/packages/version/test/unit/utils/jsonOutput.spec.ts @@ -527,7 +527,6 @@ describe('JSON Output Utilities', () => { const envelope = JSON.parse(consoleSpy.mock.calls[0][0]); expect(envelope.status).toBe('success'); - // The VersionOutput rides in `data` verbatim — notes and publish unwrap it back on the way in. expect(envelope.data.changelogs).toHaveLength(1); expect(envelope.data.changelogs[0].entries[0].description).toBe('New feature'); }); diff --git a/packages/version/test/utils/cli.ts b/packages/version/test/utils/cli.ts index eda88df1..eaadd23e 100644 --- a/packages/version/test/utils/cli.ts +++ b/packages/version/test/utils/cli.ts @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; -import { type Envelope, isEnvelope, type VersionOutput } from '@releasekit/core'; +import { unwrapEnvelope, type VersionOutput } from '@releasekit/core'; /** * Execute the CLI command in the given working directory (tempDir) @@ -15,15 +15,7 @@ export function executeCliCommand(command: string, cwd: string, dryRun = true) { return spawnSync('node', [cliPath, ...args], { cwd, encoding: 'utf-8' }); } -/** - * Parse a `--json` run's stdout and return the `VersionOutput` it carries, asserting the envelope on - * the way through. Exercises the boundary the pipe actually crosses — a process wrote this text and - * `notes`/`publish` read it back — which a unit test on the emitter can't cover. - */ -export function parseCliEnvelope(stdout: string): { envelope: Envelope; data: VersionOutput } { - const envelope = JSON.parse(stdout) as Envelope; - if (!isEnvelope(envelope)) { - throw new Error(`Expected an envelope on stdout, got: ${stdout.slice(0, 200)}`); - } - return { envelope, data: envelope.data as VersionOutput }; +/** Read a `--json` run's stdout the way `notes` and `publish` do: unwrapped to its `VersionOutput`. */ +export function readVersionOutput(stdout: string): VersionOutput { + return unwrapEnvelope(JSON.parse(stdout)) as VersionOutput; } From d7ede14fb3ae0ddef9d3ec648c9b26d8a753bf56 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 17:43:00 +0000 Subject: [PATCH 12/19] test(cli): cover the version and publish failure paths through the commands - version --json: an engine error reaches stdout as an error envelope with its own code and exits by its family (INVALID_CONFIG -> 2); --stable with --prerelease prints an INPUT_ERROR envelope and exits 3. - publish --output: a PipelineError writes its envelope, partial output in `data`, to the file and nothing to stdout. - publish --input holding a failed upstream envelope runs the real parseInput and exits 3 with INPUT_PARSE_ERROR. It replaces the "registry failure" test, which rejected runPipeline with a bare NPM_PUBLISH_ERROR that runPipeline never throws (it wraps every stage error in a PipelineError). - Exit-code coverage moves from a hardcoded list in core to each package's own error enums (PublishErrorCode; VersionErrorCode, GitErrorCode and StrictReachableError), so a new unmapped code now fails a test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- packages/core/test/unit/envelope.spec.ts | 57 ---------------- packages/publish/test/unit/cli.spec.ts | 68 ++++++++++++++++--- packages/publish/test/unit/errors.spec.ts | 9 ++- packages/version/test/unit/cli.spec.ts | 34 ++++++++++ .../errors/errorHandling.integration.spec.ts | 16 +++++ 5 files changed, 116 insertions(+), 68 deletions(-) diff --git a/packages/core/test/unit/envelope.spec.ts b/packages/core/test/unit/envelope.spec.ts index 5bdf97da..49da3a15 100644 --- a/packages/core/test/unit/envelope.spec.ts +++ b/packages/core/test/unit/envelope.spec.ts @@ -148,61 +148,4 @@ describe('envelope', () => { expect(() => unwrapEnvelope(successEnvelope(null))).toThrow(/success but produced no data/); }); }); - - describe('exit-code table coverage', () => { - it('should map every error code the packages throw to a family, not the generic exit 1', () => { - // Guards the table against drift: a new code that isn't mapped silently degrades to exit 1, so - // automation can no longer tell a git failure from a config one. Update the map when this fails. - const thrownCodes = [ - // @releasekit/version - 'CONFIG_REQUIRED', - 'INVALID_CONFIG', - 'UNSAFE_CONFIG_PATH', - 'PACKAGES_NOT_FOUND', - 'PACKAGE_NOT_FOUND', - 'WORKSPACE_ERROR', - 'NOT_GIT_REPO', - 'GIT_PROCESS_ERROR', - 'TAG_ALREADY_EXISTS', - 'VERSION_CALCULATION_ERROR', - 'STRICT_REACHABLE_UNREACHABLE', - 'NO_COMMIT_MESSAGE', - 'NO_FILES', - 'GIT_ERROR', - // @releasekit/publish - 'PIPELINE_STAGE_ERROR', - 'NPM_AUTH_ERROR', - 'NPM_PUBLISH_ERROR', - 'CARGO_AUTH_ERROR', - 'CARGO_PUBLISH_ERROR', - 'CARGO_TOML_ERROR', - 'PUB_AUTH_ERROR', - 'PUB_PUBLISH_ERROR', - 'PUBSPEC_YAML_ERROR', - 'FILE_COPY_ERROR', - 'VERIFICATION_FAILED', - 'GIT_COMMIT_ERROR', - 'GIT_PUSH_ERROR', - 'GIT_TAG_ERROR', - 'GITHUB_RELEASE_ERROR', - 'INPUT_VALIDATION_ERROR', - 'INPUT_PARSE_ERROR', - // @releasekit/notes - 'CONFIG_ERROR', - 'GITHUB_ERROR', - 'LLM_ERROR', - 'TEMPLATE_ERROR', - ]; - - const unmapped = thrownCodes.filter((code) => { - class Coded extends ReleaseKitError { - readonly code = code; - readonly suggestions: string[] = []; - } - return exitCodeForError(new Coded('x')) === EXIT_CODES.GENERAL_ERROR; - }); - - expect(unmapped).toEqual([]); - }); - }); }); diff --git a/packages/publish/test/unit/cli.spec.ts b/packages/publish/test/unit/cli.spec.ts index a2089a63..e63cb65a 100644 --- a/packages/publish/test/unit/cli.spec.ts +++ b/packages/publish/test/unit/cli.spec.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Partial mock: the envelope builders and writer must stay real, since these tests assert on the @@ -11,7 +14,7 @@ vi.mock('../../src/config.js'); vi.mock('../../src/pipeline/index.js'); vi.mock('../../src/stages/input.js'); -import { EXIT_CODES, setJsonMode, setLogLevel } from '@releasekit/core'; +import { EXIT_CODES, errorEnvelope, setJsonMode, setLogLevel } from '@releasekit/core'; import { createPublishCommand } from '../../src/cli.js'; import { loadConfig } from '../../src/config.js'; import { BasePublishError, PipelineError, PublishErrorCode } from '../../src/errors/index.js'; @@ -60,6 +63,13 @@ const mockOutput: PublishOutput = { describe('createPublishCommand', () => { let mockExit: ReturnType; + const tmpDirs: string[] = []; + + function createTmpDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'publish-cli-')); + tmpDirs.push(dir); + return dir; + } beforeEach(() => { vi.mocked(loadConfig).mockImplementation(() => ({ ...mockConfig, npm: { ...mockConfig.npm } })); @@ -71,6 +81,10 @@ describe('createPublishCommand', () => { afterEach(() => { vi.clearAllMocks(); mockExit.mockRestore(); + for (const dir of tmpDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tmpDirs.length = 0; }); it('should return a command named publish', () => { @@ -121,7 +135,6 @@ describe('createPublishCommand', () => { const envelope = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); expect(envelope.status).toBe('success'); - // The payload rides in `data` verbatim, so the pipe's contract is unchanged. expect(envelope.data).toEqual(mockOutput); consoleSpy.mockRestore(); }); @@ -163,8 +176,6 @@ describe('createPublishCommand', () => { const envelope = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); expect(envelope.status).toBe('error'); - // What landed before the failure survives — the retry needs it — and the stage that failed is - // named in the message, since the envelope has no field for it. expect(envelope.data).toEqual(partialOutput); expect(envelope.changed).toBe(true); expect(envelope.errors[0].message).toContain('npm'); @@ -173,15 +184,52 @@ describe('createPublishCommand', () => { consoleSpy.mockRestore(); }); - it('should call logError and exit with PUBLISH_ERROR for a registry failure', async () => { - const publishError = new BasePublishError('publish failed', PublishErrorCode.NPM_PUBLISH_ERROR); - const logErrorSpy = vi.spyOn(publishError, 'logError').mockImplementation(() => undefined); - vi.mocked(runPipeline).mockRejectedValue(publishError); + it('should write the PipelineError envelope with its partial output to --output', async () => { + const partialOutput: PublishOutput = { + ...mockOutput, + npm: [{ packageName: '@acme/a', version: '1.0.0', registry: 'npm', success: true, skipped: false }], + }; + vi.mocked(runPipeline).mockRejectedValue(new PipelineError('registry rejected', 'npm', partialOutput)); + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const outputFile = join(createTmpDir(), 'publish.json'); - await createPublishCommand().parseAsync(['node', 'test']); + await createPublishCommand().parseAsync(['node', 'test', '--output', outputFile]); - expect(logErrorSpy).toHaveBeenCalled(); + const envelope = JSON.parse(readFileSync(outputFile, 'utf-8')); + expect(envelope.status).toBe('error'); + expect(envelope.data).toEqual(partialOutput); + expect(envelope.changed).toBe(true); + expect(consoleSpy).not.toHaveBeenCalled(); expect(mockExit).toHaveBeenCalledWith(EXIT_CODES.PUBLISH_ERROR); + stderrSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + it('should exit with INPUT_ERROR when --input holds a failed upstream envelope', async () => { + const actualInput = + await vi.importActual('../../src/stages/input.js'); + vi.mocked(parseInput).mockImplementation(actualInput.parseInput); + const inputFile = join(createTmpDir(), 'version.json'); + writeFileSync( + inputFile, + JSON.stringify( + errorEnvelope([{ code: 'GIT_ERROR', category: 'git', retryable: false, message: 'no such ref' }]), + ), + ); + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await createPublishCommand().parseAsync(['node', 'test', '--json', '--input', inputFile]); + + const envelope = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); + expect(envelope.errors).toMatchObject([ + { code: 'INPUT_PARSE_ERROR', message: expect.stringContaining('GIT_ERROR') }, + ]); + expect(mockExit).toHaveBeenCalledWith(EXIT_CODES.INPUT_ERROR); + expect(runPipeline).not.toHaveBeenCalled(); + stderrSpy.mockRestore(); + consoleSpy.mockRestore(); }); it('should exit with CONFIG_ERROR for a configuration failure', async () => { diff --git a/packages/publish/test/unit/errors.spec.ts b/packages/publish/test/unit/errors.spec.ts index 4d9aaa1b..a53a1bc1 100644 --- a/packages/publish/test/unit/errors.spec.ts +++ b/packages/publish/test/unit/errors.spec.ts @@ -1,4 +1,4 @@ -import { ReleaseKitError } from '@releasekit/core'; +import { EXIT_CODES, exitCodeForError, ReleaseKitError } from '@releasekit/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BasePublishError, createPublishError, PublishError, PublishErrorCode } from '../../src/errors/index.js'; @@ -75,6 +75,13 @@ describe('PublishError', () => { } }); + it('should give every error code its exit-code family rather than the generic exit 1', () => { + const unmapped = Object.values(PublishErrorCode).filter( + (code) => exitCodeForError(createPublishError(code)) === EXIT_CODES.GENERAL_ERROR, + ); + expect(unmapped).toEqual([]); + }); + it('should append details to message', () => { const error = createPublishError(PublishErrorCode.NPM_PUBLISH_ERROR, 'package @foo/bar'); expect(error.message).toBe('Failed to publish to npm: package @foo/bar'); diff --git a/packages/version/test/unit/cli.spec.ts b/packages/version/test/unit/cli.spec.ts index edfa205f..bb465b89 100644 --- a/packages/version/test/unit/cli.spec.ts +++ b/packages/version/test/unit/cli.spec.ts @@ -1,8 +1,10 @@ import * as path from 'node:path'; +import { EXIT_CODES } from '@releasekit/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createVersionCommand, createVersionProgram } from '../../src/cli.js'; import * as configModule from '../../src/config.js'; import { VersionEngine } from '../../src/core/versionEngine.js'; +import { createVersionError, VersionErrorCode } from '../../src/errors/versionError.js'; import type { Config, VersionRunOptions } from '../../src/types.js'; import * as jsonOutputModule from '../../src/utils/jsonOutput.js'; @@ -190,6 +192,38 @@ describe('createVersionCommand', () => { }); }); + describe('failures under --json', () => { + it('should print an error envelope carrying the engine error code and exit by its family', async () => { + mockRun.mockRejectedValue(createVersionError(VersionErrorCode.INVALID_CONFIG, 'groups overlap')); + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await createVersionCommand().parseAsync(['node', 'test', '--json']); + + const envelope = JSON.parse(stdoutSpy.mock.calls[0]?.[0] as string); + expect(envelope).toMatchObject({ status: 'error', changed: false, data: null }); + expect(envelope.errors).toMatchObject([ + { code: 'INVALID_CONFIG', message: expect.stringContaining('groups overlap') }, + ]); + expect(mockExit).toHaveBeenCalledWith(EXIT_CODES.CONFIG_ERROR); + stderrSpy.mockRestore(); + stdoutSpy.mockRestore(); + }); + + it('should print an INPUT_ERROR envelope and exit with INPUT_ERROR for --stable with --prerelease', async () => { + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await createVersionCommand().parseAsync(['node', 'test', '--json', '--stable', '--prerelease']); + + const envelope = JSON.parse(stdoutSpy.mock.calls[0]?.[0] as string); + expect(envelope.errors).toMatchObject([{ code: 'INPUT_ERROR' }]); + expect(mockExit).toHaveBeenCalledWith(EXIT_CODES.INPUT_ERROR); + stderrSpy.mockRestore(); + stdoutSpy.mockRestore(); + }); + }); + describe('versioning strategy selection', () => { it('should use async strategy for multiple packages', async () => { await createVersionCommand().parseAsync(['node', 'test']); diff --git a/packages/version/test/unit/errors/errorHandling.integration.spec.ts b/packages/version/test/unit/errors/errorHandling.integration.spec.ts index 6d44d2b6..5e7b238c 100644 --- a/packages/version/test/unit/errors/errorHandling.integration.spec.ts +++ b/packages/version/test/unit/errors/errorHandling.integration.spec.ts @@ -1,6 +1,8 @@ +import { EXIT_CODES, exitCodeForError } from '@releasekit/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BaseVersionError } from '../../../src/errors/baseError.js'; import { createGitError, GitError, GitErrorCode } from '../../../src/errors/gitError.js'; +import { StrictReachableError } from '../../../src/errors/strictReachableError.js'; import { createVersionError, VersionError, VersionErrorCode } from '../../../src/errors/versionError.js'; describe('Error Handling Integration', () => { @@ -147,6 +149,20 @@ describe('Error Handling Integration', () => { }); }); + describe('Exit codes', () => { + it('should give every error code its exit-code family rather than the generic exit 1', () => { + const errors: BaseVersionError[] = [ + ...Object.values(VersionErrorCode).map((code) => createVersionError(code)), + ...Object.values(GitErrorCode).map((code) => createGitError(code)), + new StrictReachableError('baseline unreachable'), + ]; + + const unmapped = errors.filter((e) => exitCodeForError(e) === EXIT_CODES.GENERAL_ERROR).map((e) => e.code); + + expect(unmapped).toEqual([]); + }); + }); + describe('Performance and efficiency', () => { it('should efficiently identify error types with single type guard call', () => { const errors = [ From 2a361206917e1e0754717a6f01a2e641ffa72eac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 17:43:14 +0000 Subject: [PATCH 13/19] docs: point --json stability at schemaVersion; keep the version README to this PR's flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - troubleshooting: "the JSON schema is stable across minor versions" no longer holds — this release changes the version/publish --json shape. Say what is versioned instead: the envelope's schemaVersion. - version README: revert the flag-table rewrite that rode along (short aliases, --stable, --allow-first-bump, --sync, --config, --include-prerequisites, and dropping --strict-reachable / --verbose / --quiet). Keep only the --json description and the new --output row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/troubleshooting.md | 2 +- packages/version/README.md | 18 ++++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 814fc390..f42b5336 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -389,6 +389,6 @@ releasekit ships three flags useful for diagnosing issues: **`--verbose`** — emits internal decision logs: which commits were found and classified, which packages were discovered, what version strategy was selected, and what LLM requests/responses look like. Use this when the tool exits cleanly but produces an unexpected result. -**`--json`** — emits structured JSON on stdout. Useful for piping between stages or for scripted inspection. The JSON schema is stable across minor versions; text log output is not. +**`--json`** — emits structured JSON on stdout. Useful for piping between stages or for scripted inspection. The result is wrapped in an envelope whose `schemaVersion` changes only on a breaking change to its shape (see the [JSON output contract](./cli.md#json-output-contract)); text log output has no such guarantee. **In CI logs:** expand the failing step to see the full output. Look for the `[dry-run]` prefix to confirm dry-run mode is active, and check for `[warn]` lines that may indicate soft failures (such as the LLM key warning) that did not abort the run but produced degraded output. diff --git a/packages/version/README.md b/packages/version/README.md index d5e017d0..046717e9 100644 --- a/packages/version/README.md +++ b/packages/version/README.md @@ -56,18 +56,16 @@ releasekit-version --prerelease beta | Flag | Description | Default | |------|-------------|---------| -| `-c, --config ` | Path to config file | `releasekit.config.json` | -| `-b, --bump ` | Force bump type: `patch`, `minor`, `major`, `prerelease` | auto | -| `-p, --prerelease [id]` | Create prerelease version (e.g. `beta`) | — | -| `--stable` | Graduate prerelease packages to stable without bumping | `false` | -| `--allow-first-bump` | Acknowledge applying a bump on a first release with an already-stable manifest | `false` | -| `-s, --sync` | Use synchronized versioning across all packages | config | -| `-t, --target ` | Target specific packages (comma-separated) | all | -| `--include-prerequisites` | Also release the changed internal dependencies of `--target` packages | `false` | +| `--bump ` | Force bump type: `patch`, `minor`, `major` | auto | +| `--prerelease [id]` | Create prerelease version (e.g. `beta`) | — | +| `--target ` | Target specific packages (comma-separated) | all | | `--project-dir ` | Project directory | cwd | -| `-d, --dry-run` | Preview without file changes or git operations | `false` | -| `-j, --json` | Output results as a JSON envelope (see below) | `false` | +| `--dry-run` | Preview without file changes or git operations | `false` | +| `--json` | Output results as a JSON envelope (see below) | `false` | | `--output ` | Write the JSON envelope to a file instead of stdout (resolved against the directory you run from) | — | +| `--strict-reachable` | Only use tags reachable from current commit | `false` | +| `--verbose` | Verbose logging | `false` | +| `--quiet` | Suppress non-error output | `false` | ## JSON Output From d8888e877fb63e490a6af6bfa9accf4dce916a50 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 18:35:50 +0000 Subject: [PATCH 14/19] style: add braces to every single-statement body Same sweep as #679, run on this branch so merging main resolves cleanly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- packages/config/src/load.ts | 36 +++- packages/config/src/merge.ts | 20 ++- .../config/test/unit/generate-schema.spec.ts | 8 +- packages/core/src/cargo.ts | 8 +- packages/core/src/changelogRefs.ts | 32 +++- packages/core/src/dependencyGraph.ts | 56 +++++-- packages/core/src/logger.ts | 16 +- packages/core/src/marker.ts | 8 +- packages/core/src/prerequisites.ts | 4 +- packages/core/test/unit/cargo.spec.ts | 4 +- packages/forge/src/errors.ts | 4 +- packages/forge/src/fake.ts | 16 +- packages/forge/src/github.ts | 48 ++++-- packages/forge/src/types.ts | 4 +- packages/forge/test/unit/github.spec.ts | 4 +- packages/git/src/errors.ts | 12 +- packages/git/src/fake.ts | 12 +- packages/git/src/git.ts | 75 ++++++--- packages/git/test/unit/git.spec.ts | 4 +- packages/notes/src/command.ts | 16 +- packages/notes/src/core/pipeline.ts | 28 +++- packages/notes/src/errors/index.ts | 4 +- .../notes/src/input/conventional-changelog.ts | 8 +- packages/notes/src/input/manual.ts | 8 +- packages/notes/src/llm/anthropic.ts | 4 +- packages/notes/src/llm/base.ts | 4 +- packages/notes/src/llm/context/prFetcher.ts | 12 +- packages/notes/src/llm/correctiveRetry.ts | 4 +- packages/notes/src/llm/examples/fetcher.ts | 20 ++- packages/notes/src/llm/examples/parser.ts | 24 ++- packages/notes/src/llm/messages.ts | 4 +- packages/notes/src/llm/ollama.ts | 4 +- packages/notes/src/llm/openai-compatible.ts | 4 +- packages/notes/src/llm/openai.ts | 4 +- packages/notes/src/llm/retryable.ts | 28 +++- packages/notes/src/llm/scopes.ts | 28 +++- packages/notes/src/llm/tasks/categorize.ts | 8 +- .../src/llm/tasks/enhance-and-categorize.ts | 19 ++- packages/notes/src/llm/tasks/shared.ts | 36 +++- packages/notes/src/monorepo/aggregator.ts | 4 +- packages/notes/src/output/markdown.ts | 52 ++++-- packages/notes/src/templates/loader.ts | 12 +- packages/notes/src/utils/concurrency.ts | 4 +- packages/notes/src/utils/json.ts | 8 +- packages/notes/src/utils/retry.ts | 4 +- packages/notes/test/eval/assertions.ts | 12 +- packages/notes/test/unit/llm.spec.ts | 20 ++- packages/notes/test/unit/llm/cache.spec.ts | 4 +- packages/notes/test/unit/llm/tasks.spec.ts | 40 +++-- packages/notes/test/unit/retry.spec.ts | 8 +- packages/notes/test/unit/templates.spec.ts | 4 +- .../notes/test/unit/utils/concurrency.spec.ts | 4 +- packages/publish/src/command.ts | 8 +- packages/publish/src/registry/cargo.ts | 16 +- packages/publish/src/registry/dispatcher.ts | 8 +- packages/publish/src/registry/npm.ts | 22 ++- packages/publish/src/registry/pub.ts | 4 +- packages/publish/src/stages/git-push.ts | 24 ++- packages/publish/src/stages/github-release.ts | 36 +++- packages/publish/src/stages/prepare.ts | 8 +- packages/publish/src/types.ts | 4 +- packages/publish/src/utils/cargo.ts | 4 +- packages/publish/src/utils/exec.ts | 4 +- packages/publish/src/utils/npm-env.ts | 4 +- packages/publish/src/utils/package-manager.ts | 12 +- packages/publish/src/utils/pub.ts | 8 +- packages/publish/src/utils/publish-retry.ts | 8 +- .../test/integration/publish-e2e.spec.ts | 16 +- .../test/unit/registry/dispatcher.spec.ts | 11 +- .../unit/stages/npm-publish-order.spec.ts | 4 +- .../release/src/backfill/github-release.ts | 16 +- packages/release/src/backfill/reconstruct.ts | 12 +- packages/release/src/backfill/repo-url.ts | 14 +- .../release/src/commands/backfill-command.ts | 12 +- packages/release/src/commands/emitResult.ts | 4 +- packages/release/src/commands/gate-command.ts | 12 +- .../release/src/commands/labels-command.ts | 4 +- packages/release/src/draft/draft.ts | 4 +- packages/release/src/duration.ts | 24 ++- .../src/failure-report/failure-report.ts | 12 +- packages/release/src/failure-report/post.ts | 16 +- packages/release/src/gate/gate.ts | 16 +- packages/release/src/git.ts | 8 +- packages/release/src/github.ts | 8 +- packages/release/src/label-definitions.ts | 4 +- packages/release/src/label-utils.ts | 28 +++- packages/release/src/preview/detect.ts | 12 +- packages/release/src/preview/format.ts | 63 +++++-- packages/release/src/preview/merge.ts | 12 +- packages/release/src/preview/preview.ts | 42 +++-- packages/release/src/preview/refresh.ts | 16 +- packages/release/src/release.ts | 40 +++-- .../release/src/standing-pr/authorization.ts | 28 +++- .../src/standing-pr/changelog-region.ts | 67 ++++++-- .../release/src/standing-pr/notes-region.ts | 12 +- .../src/standing-pr/selection-region.ts | 135 +++++++++++---- .../release/src/standing-pr/standing-pr.ts | 158 +++++++++++++----- .../release/src/standing-pr/summary-region.ts | 36 +++- .../release/test/unit/authorization.spec.ts | 7 +- .../test/unit/commands/emitResult.spec.ts | 4 +- .../test/unit/failure-report-post.spec.ts | 7 +- packages/release/test/unit/gate.spec.ts | 12 +- packages/release/test/unit/refresh.spec.ts | 4 +- .../release/test/unit/standing-pr.spec.ts | 22 ++- packages/version/src/core/baselineResolver.ts | 12 +- packages/version/src/core/groupResolution.ts | 12 +- packages/version/src/core/groupStrategy.ts | 68 ++++++-- .../version/src/core/prerequisiteScope.ts | 10 +- .../version/src/core/versionCalculator.ts | 12 +- packages/version/src/core/versionEngine.ts | 64 +++++-- .../version/src/core/versionStrategies.ts | 20 ++- packages/version/src/core/workspaceGraph.ts | 4 +- packages/version/src/git/tagsAndBranches.ts | 8 +- .../version/src/package/packageProcessor.ts | 4 +- packages/version/src/utils/formatting.ts | 8 +- packages/version/src/utils/jsonOutput.ts | 76 ++++++--- packages/version/src/utils/logging.ts | 4 +- packages/version/src/utils/versionAction.ts | 4 +- packages/version/src/workspace.ts | 4 +- .../test/unit/core/groupStrategy.spec.ts | 80 ++++++--- .../test/unit/core/versionCalculator.spec.ts | 120 +++++++++---- .../test/unit/core/versionStrategies.spec.ts | 16 +- .../unit/package/packageProcessor.spec.ts | 20 ++- .../test/unit/utils/versionUtils.spec.ts | 116 +++++++++---- packages/version/test/utils/cli.ts | 4 +- scripts/examples-smoke/build-fixture.ts | 21 ++- .../examples-smoke/generate-smoke-workflow.ts | 20 ++- scripts/generate-config-docs.ts | 56 +++++-- scripts/generate-schema.ts | 28 +++- scripts/run-action.mjs | 48 ++++-- scripts/test-harness/index.mjs | 20 ++- scripts/test-harness/run-action-local.mjs | 8 +- test/integration/examples-smoke.spec.ts | 4 +- 133 files changed, 2069 insertions(+), 688 deletions(-) diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 34b2ab45..237b4de3 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -45,13 +45,23 @@ function isRecord(value: unknown): value is Record { * keys — which would otherwise turn off a user's file output without a word. */ function assertNoRemovedReleaseNotesFields(config: unknown): void { - if (!isRecord(config) || !isRecord(config.notes) || !isRecord(config.notes.releaseNotes)) return; + if (!isRecord(config) || !isRecord(config.notes) || !isRecord(config.notes.releaseNotes)) { + return; + } const rn = config.notes.releaseNotes; const removed: string[] = []; - if ('mode' in rn) removed.push('mode'); - if ('directory' in rn) removed.push('directory'); - if (typeof rn.file === 'string') removed.push('file (string)'); - if (removed.length === 0) return; + if ('mode' in rn) { + removed.push('mode'); + } + if ('directory' in rn) { + removed.push('directory'); + } + if (typeof rn.file === 'string') { + removed.push('file (string)'); + } + if (removed.length === 0) { + return; + } throw new ConfigError( `notes.releaseNotes no longer supports ${removed.join(', ')}. Release notes go to the GitHub ` + 'release body by default; for in-repo per-version files set notes.releaseNotes.file to ' + @@ -66,12 +76,16 @@ function assertNoRemovedReleaseNotesFields(config: unknown): void { * generated JSON schema, where editors/CI reject the same unknown keys). */ function assertNoRemovedVersionFields(config: unknown): void { - if (!isRecord(config) || !isRecord(config.version)) return; + if (!isRecord(config) || !isRecord(config.version)) { + return; + } const v = config.version; const removed = ['versionStrategy', 'branchPatterns', 'defaultReleaseType', 'updateInternalDependencies'].filter( (k) => k in v, ); - if (removed.length === 0) return; + if (removed.length === 0) { + return; + } throw new ConfigError( `version no longer supports ${removed.join(', ')}. Branch-pattern versioning was never functional — ` + 'use Conventional Commits or --bump for version bumps. For coupled packages use version.groups; to ' + @@ -86,7 +100,9 @@ function assertNoRemovedVersionFields(config: unknown): void { * silently stripping these (which also diverges from the JSON schema's unknown-key rejection). */ function assertNoRemovedTopLevelFields(config: unknown): void { - if (!isRecord(config)) return; + if (!isRecord(config)) { + return; + } if (isRecord(config.ci)) { const removed = ['autoRelease', 'skipPatterns', 'minChanges'].filter((k) => k in (config.ci as object)); if (removed.length > 0) { @@ -154,7 +170,9 @@ export function loadVersionConfig(options?: LoadOptions): VersionConfig | undefi export function loadPublishConfig(options?: LoadOptions): PublishConfig | undefined { const config = loadConfig(options); - if (!config.publish) return undefined; + if (!config.publish) { + return undefined; + } const mergedGit = mergeGitConfig(config.git, config.publish.git); diff --git a/packages/config/src/merge.ts b/packages/config/src/merge.ts index 96e6b49b..6e663e9e 100644 --- a/packages/config/src/merge.ts +++ b/packages/config/src/merge.ts @@ -1,7 +1,9 @@ import type { GitConfig, PublishGitConfig } from './schema.js'; export function mergeGitConfig(topLevel?: GitConfig, packageLevel?: PublishGitConfig): GitConfig | undefined { - if (!topLevel && !packageLevel) return undefined; + if (!topLevel && !packageLevel) { + return undefined; + } const base: GitConfig = topLevel ?? { remote: 'origin', @@ -9,7 +11,9 @@ export function mergeGitConfig(topLevel?: GitConfig, packageLevel?: PublishGitCo pushMethod: 'auto', }; - if (!packageLevel) return base; + if (!packageLevel) { + return base; + } return { remote: packageLevel.remote ?? base.remote, @@ -27,9 +31,15 @@ export function deepMerge>( target: T | undefined, source: Partial | undefined, ): T | undefined { - if (!target && !source) return undefined; - if (!target) return source as T; - if (!source) return target; + if (!target && !source) { + return undefined; + } + if (!target) { + return source as T; + } + if (!source) { + return target; + } const result = { ...target }; diff --git a/packages/config/test/unit/generate-schema.spec.ts b/packages/config/test/unit/generate-schema.spec.ts index 3907c4c8..22061c68 100644 --- a/packages/config/test/unit/generate-schema.spec.ts +++ b/packages/config/test/unit/generate-schema.spec.ts @@ -37,7 +37,9 @@ describe('generate-schema', () => { let objectsChecked = 0; while (stack.length > 0) { const node = stack.pop(); - if (!node || typeof node !== 'object') continue; + if (!node || typeof node !== 'object') { + continue; + } const obj = node as Record; if (obj.type === 'object') { objectsChecked++; @@ -52,7 +54,9 @@ describe('generate-schema', () => { violations.push('object node has additionalProperties: true (silent leniency)'); } } - for (const value of Object.values(obj)) stack.push(value); + for (const value of Object.values(obj)) { + stack.push(value); + } } expect(violations).toEqual([]); expect(objectsChecked).toBeGreaterThan(10); diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index 1895e4d1..e7f5e95f 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -29,7 +29,9 @@ export function findCargoLockfile(startDir: string): string | undefined { while (true) { const lockPath = path.join(dir, 'Cargo.lock'); const hasLock = fs.existsSync(lockPath); - if (hasLock && !nearestLock) nearestLock = lockPath; + if (hasLock && !nearestLock) { + nearestLock = lockPath; + } const cargoToml = path.join(dir, 'Cargo.toml'); if (fs.existsSync(cargoToml) && isWorkspaceManifest(cargoToml)) { @@ -38,7 +40,9 @@ export function findCargoLockfile(startDir: string): string | undefined { } const parent = path.dirname(dir); - if (parent === dir) break; // reached the filesystem root + if (parent === dir) { + break; // reached the filesystem root + } dir = parent; } return nearestLock; diff --git a/packages/core/src/changelogRefs.ts b/packages/core/src/changelogRefs.ts index 0a8c150a..ff257f47 100644 --- a/packages/core/src/changelogRefs.ts +++ b/packages/core/src/changelogRefs.ts @@ -17,16 +17,22 @@ export type ChangelogRefsMode = 'strip' | 'escape' | 'link'; */ export function parseGitHubOwnerRepo(repoUrl: string): { owner: string; repo: string } | null { const scpMatch = repoUrl.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/); - if (scpMatch) return { owner: scpMatch[1]!, repo: scpMatch[2]! }; + if (scpMatch) { + return { owner: scpMatch[1]!, repo: scpMatch[2]! }; + } try { const url = new URL(repoUrl); - if (url.hostname !== 'github.com') return null; + if (url.hostname !== 'github.com') { + return null; + } const parts = url.pathname .replace(/^\//, '') .replace(/\.git$/, '') .split('/'); - if (parts.length >= 2 && parts[0] && parts[1]) return { owner: parts[0], repo: parts[1] }; + if (parts.length >= 2 && parts[0] && parts[1]) { + return { owner: parts[0], repo: parts[1] }; + } } catch { // not a parseable URL } @@ -60,14 +66,18 @@ export function renderIssueRefs( repoUrl: string | null, prNumber?: string, ): string { - if (mode === 'strip') return ''; + if (mode === 'strip') { + return ''; + } const ids = issueIds ?? []; // prNumber is normally already in issueIds; fold it in defensively so no path can silently drop it. const allIds = prNumber && !ids.some((id) => Number(id.replace(/^#/, '')) === Number(prNumber.replace(/^#/, ''))) ? [prNumber, ...ids] : ids; - if (allIds.length === 0) return ''; + if (allIds.length === 0) { + return ''; + } const ownerRepo = mode === 'link' && repoUrl ? parseGitHubOwnerRepo(repoUrl) : null; @@ -76,7 +86,9 @@ export function renderIssueRefs( const pr = prNumber.replace(/^#/, ''); const prPart = `PR [#${pr}](https://github.com/${owner}/${repo}/pull/${pr})`; const closes = allIds.map((raw) => raw.replace(/^#/, '')).filter((num) => Number(num) !== Number(pr)); - if (closes.length === 0) return `(${prPart})`; + if (closes.length === 0) { + return `(${prPart})`; + } const closesPart = closes.map((num) => `[#${num}](https://github.com/${owner}/${repo}/issues/${num})`).join(', '); return `(${prPart} · closes ${closesPart})`; } @@ -144,10 +156,14 @@ export function neutralizeDescriptionRefs( CODE_SPAN_OR_DESC_REF, (match, lead: string | undefined, parenNum?: string, bareNum?: string) => { const num = parenNum ?? bareNum; - if (num === undefined) return match; // inline-code span — leave untouched + if (num === undefined) { + return match; // inline-code span — leave untouched + } const ws = lead ?? ''; // A ref already shown in the appended label is redundant here; `strip` drops every ref. - if (appended.has(Number(num)) || mode === 'strip') return ''; + if (appended.has(Number(num)) || mode === 'strip') { + return ''; + } const inner = ownerRepo ? `[#${num}](https://github.com/${ownerRepo.owner}/${ownerRepo.repo}/issues/${num})` : `\\#${num}`; diff --git a/packages/core/src/dependencyGraph.ts b/packages/core/src/dependencyGraph.ts index 7da923e7..473d1add 100644 --- a/packages/core/src/dependencyGraph.ts +++ b/packages/core/src/dependencyGraph.ts @@ -45,13 +45,19 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende const deps = new Map>(); const dependents = new Map>(); for (const p of packages) { - if (!deps.has(p.name)) deps.set(p.name, new Set()); - if (!dependents.has(p.name)) dependents.set(p.name, new Set()); + if (!deps.has(p.name)) { + deps.set(p.name, new Set()); + } + if (!dependents.has(p.name)) { + dependents.set(p.name, new Set()); + } } for (const p of packages) { for (const d of p.deps) { // Ignore self-edges and dependencies outside the workspace (the ∩-workspace filter). - if (d === p.name || !known.has(d)) continue; + if (d === p.name || !known.has(d)) { + continue; + } deps.get(p.name)?.add(d); dependents.get(d)?.add(p.name); } @@ -65,9 +71,13 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende const stack = [...(deps.get(name) ?? [])]; while (stack.length > 0) { const cur = stack.pop(); - if (cur === undefined || cur === name || out.has(cur)) continue; + if (cur === undefined || cur === name || out.has(cur)) { + continue; + } out.add(cur); - for (const d of deps.get(cur) ?? []) stack.push(d); + for (const d of deps.get(cur) ?? []) { + stack.push(d); + } } return out; }; @@ -78,7 +88,11 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende const indegree = new Map(); for (const n of subset) { let degree = 0; - for (const dep of deps.get(n) ?? []) if (inSubset.has(dep)) degree++; + for (const dep of deps.get(n) ?? []) { + if (inSubset.has(dep)) { + degree++; + } + } indegree.set(n, degree); } // Seed with nodes that have no in-subset dependencies, preserving input order for stability. @@ -87,18 +101,28 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende const emitted = new Set(); while (ready.length > 0) { const n = ready.shift(); - if (n === undefined || emitted.has(n)) continue; + if (n === undefined || emitted.has(n)) { + continue; + } result.push(n); emitted.add(n); for (const dependent of dependents.get(n) ?? []) { - if (!inSubset.has(dependent) || emitted.has(dependent)) continue; + if (!inSubset.has(dependent) || emitted.has(dependent)) { + continue; + } const next = (indegree.get(dependent) ?? 1) - 1; indegree.set(dependent, next); - if (next === 0) ready.push(dependent); + if (next === 0) { + ready.push(dependent); + } } } // Cycle members never reach indegree 0 — append them in input order rather than dropping them. - for (const n of subset) if (!emitted.has(n)) result.push(n); + for (const n of subset) { + if (!emitted.has(n)) { + result.push(n); + } + } return result; }; @@ -107,17 +131,23 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende if (cycle === undefined) { // A full toposort that can't emit every node has a cycle. const indegree = new Map(); - for (const p of packages) indegree.set(p.name, deps.get(p.name)?.size ?? 0); + for (const p of packages) { + indegree.set(p.name, deps.get(p.name)?.size ?? 0); + } const ready = packages.filter((p) => indegree.get(p.name) === 0).map((p) => p.name); let emittedCount = 0; while (ready.length > 0) { const n = ready.shift(); - if (n === undefined) break; + if (n === undefined) { + break; + } emittedCount++; for (const dependent of dependents.get(n) ?? []) { const next = (indegree.get(dependent) ?? 1) - 1; indegree.set(dependent, next); - if (next === 0) ready.push(dependent); + if (next === 0) { + ready.push(dependent); + } } } cycle = emittedCount !== known.size; diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts index 5169100f..d194f3ed 100644 --- a/packages/core/src/logger.ts +++ b/packages/core/src/logger.ts @@ -64,14 +64,20 @@ export function getLogLevel(): LogLevel { function shouldLog(level: LogLevel): boolean { // JSON mode no longer suppresses stderr logging — JSON output goes to stdout, // logs go to stderr, so they don't interfere with each other. - if (quietMode && level !== 'error') return false; + if (quietMode && level !== 'error') { + return false; + } // Debug messages are shown when DEBUG env var is set or log level allows it - if (level === 'debug' && (process.env.DEBUG === 'true' || process.env.DEBUG === '1')) return true; + if (level === 'debug' && (process.env.DEBUG === 'true' || process.env.DEBUG === '1')) { + return true; + } return LOG_LEVELS[level] <= LOG_LEVELS[currentLevel]; } export function log(message: string, level: LogLevel = 'info'): void { - if (!shouldLog(level)) return; + if (!shouldLog(level)) { + return; + } const formatted = COLORS[level](`${PREFIXES[level]} ${message}`); @@ -91,7 +97,9 @@ export function info(message: string): void { } export function success(message: string): void { - if (!shouldLog('info')) return; + if (!shouldLog('info')) { + return; + } console.error(chalk.green(`[SUCCESS] ${message}`)); } diff --git a/packages/core/src/marker.ts b/packages/core/src/marker.ts index af91d6cf..bc9d61af 100644 --- a/packages/core/src/marker.ts +++ b/packages/core/src/marker.ts @@ -14,10 +14,14 @@ /** Linear extract of the trimmed text between `open` and `close`. */ function sliceBetween(body: string, open: string, close: string, restIfNoClose: boolean): string | undefined { const start = body.indexOf(open); - if (start === -1) return undefined; + if (start === -1) { + return undefined; + } const from = start + open.length; const end = body.indexOf(close, from); - if (end === -1) return restIfNoClose ? body.slice(from).trim() : undefined; + if (end === -1) { + return restIfNoClose ? body.slice(from).trim() : undefined; + } return body.slice(from, end).trim(); } diff --git a/packages/core/src/prerequisites.ts b/packages/core/src/prerequisites.ts index 1bbfcbaf..e0272b88 100644 --- a/packages/core/src/prerequisites.ts +++ b/packages/core/src/prerequisites.ts @@ -53,7 +53,9 @@ export function resolvePrerequisites( } } } - for (const name of prereqSet) targetSet.add(name); + for (const name of prereqSet) { + targetSet.add(name); + } // Order by the FULL release set's dependency order, then filter to the derived subset — so a // prerequisite that depends on an explicit target sorts after it. Ordering the prerequisite slice diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index bd2da50d..1769e6ec 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -14,7 +14,9 @@ describe('findCargoLockfile', () => { } afterEach(() => { - for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true }); + for (const dir of tmpDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } tmpDirs.length = 0; }); diff --git a/packages/forge/src/errors.ts b/packages/forge/src/errors.ts index 0b72c0de..dfcee186 100644 --- a/packages/forge/src/errors.ts +++ b/packages/forge/src/errors.ts @@ -7,7 +7,9 @@ export function forgeErrorStatus(error: unknown): number | undefined { if (error && typeof error === 'object' && 'status' in error) { const { status } = error as { status?: unknown }; - if (typeof status === 'number') return status; + if (typeof status === 'number') { + return status; + } } return undefined; } diff --git a/packages/forge/src/fake.ts b/packages/forge/src/fake.ts index 77ca9eb8..6d78d6ba 100644 --- a/packages/forge/src/fake.ts +++ b/packages/forge/src/fake.ts @@ -193,8 +193,12 @@ export class FakeForge implements Forge { this.updatedIssues.push({ issueNumber, changes }); const existing = this.issues[issueNumber]; if (existing) { - if (changes.body !== undefined) existing.body = changes.body; - if (changes.title !== undefined) existing.title = changes.title; + if (changes.body !== undefined) { + existing.body = changes.body; + } + if (changes.title !== undefined) { + existing.title = changes.title; + } } // Closing drops it from open-issue discovery. if (changes.state === 'closed') { @@ -226,7 +230,9 @@ export class FakeForge implements Forge { async updateComment(commentId: number, body: string): Promise { this.updatedComments.push({ commentId, body }); const comment = this.comments.find((c) => c.id === commentId); - if (comment) comment.body = body; + if (comment) { + comment.body = body; + } } async upsertMarkerComment(prNumber: number, marker: string, body: string): Promise { @@ -246,7 +252,9 @@ export class FakeForge implements Forge { async createLabel(label: NewLabel): Promise { this.createdLabels.push(label); - if (this.labelNames.includes(label.name)) return 'exists'; + if (this.labelNames.includes(label.name)) { + return 'exists'; + } this.labelNames.push(label.name); return 'created'; } diff --git a/packages/forge/src/github.ts b/packages/forge/src/github.ts index b95ee3fd..2ddf1bd5 100644 --- a/packages/forge/src/github.ts +++ b/packages/forge/src/github.ts @@ -42,8 +42,12 @@ function labelNames(labels: LabelLike[] | undefined): string[] { /** Whether a createLabel error is GitHub's idempotent "label already exists" 422. */ function isAlreadyExistsError(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false; - if ((error as { status?: number }).status !== 422) return false; + if (typeof error !== 'object' || error === null) { + return false; + } + if ((error as { status?: number }).status !== 422) { + return false; + } // A 422 for "already exists" carries an errors[].code === 'already_exists'. Other 422s (name too // long, disallowed characters, …) are real validation failures and must surface to the caller. const errors = (error as { response?: { data?: { errors?: Array<{ code?: string }> } } }).response?.data?.errors; @@ -111,7 +115,9 @@ export class GitHubForge implements Forge { for (const pr of data) { prs.push({ number: pr.number, headRef: pr.head.ref, draft: pr.draft ?? false, baseSha: pr.base.sha }); } - if (data.length < 100) break; + if (data.length < 100) { + break; + } } return prs; } @@ -166,7 +172,9 @@ export class GitHubForge implements Forge { }); for await (const response of iterator) { for (const issue of response.data) { - if (!issue.pull_request) return { number: issue.number, url: issue.html_url }; + if (!issue.pull_request) { + return { number: issue.number, url: issue.html_url }; + } } } return null; @@ -178,14 +186,18 @@ export class GitHubForge implements Forge { // Prefer `role_name` — it distinguishes maintain/triage, which the coarse `permission` // ('admin'|'write'|'read'|'none') collapses. Fall back to `permission` for older API shapes. const role = data.role_name as RepoPermission | undefined; - if (role && REPO_PERMISSIONS.includes(role)) return role; + if (role && REPO_PERMISSIONS.includes(role)) { + return role; + } return (data.permission as RepoPermission) ?? 'none'; } catch (error) { // 404 = not a collaborator / unknown user → genuinely no permission. Any other error (403 from a // mis-scoped token, 429 rate limit, 5xx, network) means we CAN'T determine permission — surface // it rather than silently reporting 'none', which would fail the gate closed for every real // actor (even admins) with no diagnostic. - if (forgeErrorStatus(error) === 404) return 'none'; + if (forgeErrorStatus(error) === 404) { + return 'none'; + } throw error; } } @@ -199,7 +211,9 @@ export class GitHubForge implements Forge { // 404 = not a member. Any other error (403 = the token lacks org-read scope, network, …) can't // be answered — surface it (the caller's gate wrapper warns and fails closed) rather than // silently reporting "not a member", which is indistinguishable from a real non-member. - if (forgeErrorStatus(error) === 404) return false; + if (forgeErrorStatus(error) === 404) { + return false; + } throw error; } } @@ -216,13 +230,17 @@ export class GitHubForge implements Forge { let humanMatch: ForgeComment | null = null; for await (const response of iterator) { for (const comment of response.data) { - if (!comment.body?.startsWith(marker)) continue; + if (!comment.body?.startsWith(marker)) { + continue; + } const found: ForgeComment = { id: comment.id, body: comment.body, user: comment.user ? { login: comment.user.login, type: comment.user.type } : undefined, }; - if (isBotComment(found)) return found; + if (isBotComment(found)) { + return found; + } humanMatch ??= found; } } @@ -256,7 +274,9 @@ export class GitHubForge implements Forge { per_page: 100, }); for await (const response of iterator) { - for (const label of response.data) names.push(label.name); + for (const label of response.data) { + names.push(label.name); + } } return names; } @@ -266,7 +286,9 @@ export class GitHubForge implements Forge { await this.octokit.rest.issues.createLabel({ ...this.base, ...label }); return 'created'; } catch (error) { - if (isAlreadyExistsError(error)) return 'exists'; + if (isAlreadyExistsError(error)) { + return 'exists'; + } throw error; } } @@ -286,7 +308,9 @@ export class GitHubForge implements Forge { for (const r of data) { releases.push({ tagName: r.tag_name, draft: r.draft, prerelease: r.prerelease, body: r.body ?? '' }); } - if (data.length < 100) break; + if (data.length < 100) { + break; + } } return releases; } diff --git a/packages/forge/src/types.ts b/packages/forge/src/types.ts index 7c342f3d..b2f35a27 100644 --- a/packages/forge/src/types.ts +++ b/packages/forge/src/types.ts @@ -99,7 +99,9 @@ export interface ForgeComment { */ export function isBotComment(comment: Pick): boolean { const user = comment.user; - if (!user) return true; + if (!user) { + return true; + } return user.type === 'Bot' || (user.login !== undefined && user.login.endsWith('[bot]')); } diff --git a/packages/forge/test/unit/github.spec.ts b/packages/forge/test/unit/github.spec.ts index 1ebed68c..d7054626 100644 --- a/packages/forge/test/unit/github.spec.ts +++ b/packages/forge/test/unit/github.spec.ts @@ -72,7 +72,9 @@ function makeOctokit() { function pages(...batches: T[][]) { return { async *[Symbol.asyncIterator]() { - for (const data of batches) yield { data }; + for (const data of batches) { + yield { data }; + } }, }; } diff --git a/packages/git/src/errors.ts b/packages/git/src/errors.ts index 50d682e3..58d2ac2f 100644 --- a/packages/git/src/errors.ts +++ b/packages/git/src/errors.ts @@ -46,7 +46,9 @@ export class GitError extends Error { export function gitExitCode(error: unknown): number | undefined { if (error && typeof error === 'object' && 'code' in error) { const { code } = error as { code?: unknown }; - if (typeof code === 'number') return code; + if (typeof code === 'number') { + return code; + } } return undefined; } @@ -57,7 +59,9 @@ export function gitExitCode(error: unknown): number | undefined { * that otherwise swallows a non-zero exit — because it is a configuration failure, not a "no" answer. */ export function isCommandNotFound(error: unknown): boolean { - if (!error || typeof error !== 'object') return false; + if (!error || typeof error !== 'object') { + return false; + } const { code } = error as { code?: unknown }; return code === 'ENOENT'; } @@ -68,6 +72,8 @@ export function isCommandNotFound(error: unknown): boolean { * push from an ordinary non-zero exit and report it as a timeout. */ export function isExecTimeout(error: unknown): boolean { - if (!error || typeof error !== 'object') return false; + if (!error || typeof error !== 'object') { + return false; + } return (error as { killed?: unknown }).killed === true; } diff --git a/packages/git/src/fake.ts b/packages/git/src/fake.ts index f0fccb7c..fd28d568 100644 --- a/packages/git/src/fake.ts +++ b/packages/git/src/fake.ts @@ -76,8 +76,12 @@ export interface FakeGitSeed { } function ancestryHas(ancestors: FakeGitSeed['ancestors'], ancestor: string, ref: string): boolean { - if (!ancestors) return false; - if (typeof ancestors === 'function') return ancestors(ancestor, ref); + if (!ancestors) { + return false; + } + if (typeof ancestors === 'function') { + return ancestors(ancestor, ref); + } return (ancestors[ref] ?? []).includes(ancestor); } @@ -210,7 +214,9 @@ export class FakeGit implements Git { async tag(name: string, opts: GitTagOptions = {}): Promise { this.tagged.push({ name, message: opts.message }); - if (!this.tagsList.includes(name)) this.tagsList.push(name); + if (!this.tagsList.includes(name)) { + this.tagsList.push(name); + } } async fetch(remote: string, _opts: GitFetchOptions = {}): Promise { diff --git a/packages/git/src/git.ts b/packages/git/src/git.ts index 468ffac4..37866d50 100644 --- a/packages/git/src/git.ts +++ b/packages/git/src/git.ts @@ -163,7 +163,9 @@ export class GitCli implements Git { async listTags(opts: GitListTagsOptions = {}): Promise { const args = ['tag']; - if (opts.sort) args.push(`--sort=${opts.sort}`); + if (opts.sort) { + args.push(`--sort=${opts.sort}`); + } const { stdout } = await this.exec(args, { cwd: this.cwd(opts.cwd) }); return splitLines(stdout); } @@ -203,18 +205,30 @@ export class GitCli implements Git { async countCommits(range: string, opts: GitCountCommitsOptions = {}): Promise { assertNotOption(range, 'range'); const args = ['rev-list', '--count', range]; - if (opts.path) args.push('--', opts.path); + if (opts.path) { + args.push('--', opts.path); + } const { stdout } = await this.exec(args, { cwd: this.cwd(opts.cwd) }); return Number.parseInt(stdout.trim(), 10) || 0; } async log(opts: GitLogOptions): Promise { - if (opts.range !== undefined) assertNotOption(opts.range, 'range'); + if (opts.range !== undefined) { + assertNotOption(opts.range, 'range'); + } const args = ['log']; - if (opts.format !== undefined) args.push(`--format=${opts.format}`); - if (opts.extraArgs) args.push(...opts.extraArgs); - if (opts.range !== undefined) args.push(opts.range); - if (opts.paths && opts.paths.length > 0) args.push('--', ...opts.paths); + if (opts.format !== undefined) { + args.push(`--format=${opts.format}`); + } + if (opts.extraArgs) { + args.push(...opts.extraArgs); + } + if (opts.range !== undefined) { + args.push(opts.range); + } + if (opts.paths && opts.paths.length > 0) { + args.push('--', ...opts.paths); + } const { stdout } = await this.exec(args, { cwd: this.cwd(opts.cwd) }); return stdout; } @@ -230,10 +244,16 @@ export class GitCli implements Git { async forEachRef(opts: GitForEachRefOptions): Promise { // `format`/`sort` are flag values by design; `pattern` is a positional ref, so guard it like // every other user-controlled positional (a leading-`-` pattern would be read as an option). - if (opts.pattern !== undefined) assertNotOption(opts.pattern, 'pattern'); + if (opts.pattern !== undefined) { + assertNotOption(opts.pattern, 'pattern'); + } const args = ['for-each-ref', `--format=${opts.format}`]; - if (opts.sort) args.push(`--sort=${opts.sort}`); - if (opts.pattern) args.push(opts.pattern); + if (opts.sort) { + args.push(`--sort=${opts.sort}`); + } + if (opts.pattern) { + args.push(opts.pattern); + } const { stdout } = await this.exec(args, { cwd: this.cwd(opts.cwd) }); return splitLines(stdout); } @@ -262,8 +282,12 @@ export class GitCli implements Git { async commit(message: string, opts: GitCommitOptions = {}): Promise { const args = ['commit', '-m', message]; - if (opts.skipHooks) args.push('--no-verify'); - if (opts.paths && opts.paths.length > 0) args.push('--', ...opts.paths); + if (opts.skipHooks) { + args.push('--no-verify'); + } + if (opts.paths && opts.paths.length > 0) { + args.push('--', ...opts.paths); + } await this.exec(args, { cwd: this.cwd(opts.cwd) }); } @@ -292,22 +316,35 @@ export class GitCli implements Git { async status(opts: GitStatusOptions = {}): Promise { const args = ['status']; - if (opts.porcelain) args.push('--porcelain'); - if (opts.paths && opts.paths.length > 0) args.push('--', ...opts.paths); + if (opts.porcelain) { + args.push('--porcelain'); + } + if (opts.paths && opts.paths.length > 0) { + args.push('--', ...opts.paths); + } const { stdout } = await this.exec(args, { cwd: this.cwd(opts.cwd) }); return stdout; } async push(opts: GitPushOptions): Promise { assertNotOption(opts.remote, 'remote'); - if (opts.ref !== undefined) assertNotOption(opts.ref, 'ref'); + if (opts.ref !== undefined) { + assertNotOption(opts.ref, 'ref'); + } const args = ['push']; - if (opts.forceWithLease) args.push('--force-with-lease'); - else if (opts.force) args.push('--force'); - if (opts.tags) args.push('--tags'); + if (opts.forceWithLease) { + args.push('--force-with-lease'); + } else if (opts.force) { + args.push('--force'); + } + if (opts.tags) { + args.push('--tags'); + } // `--end-of-options`: remote/ref can never be parsed as git options (`--receive-pack=…` → RCE). args.push('--end-of-options', opts.remote); - if (opts.ref !== undefined) args.push(opts.ref); + if (opts.ref !== undefined) { + args.push(opts.ref); + } // A push can hang on a stalled network forever; a hard timeout preserves the safety the publish // package previously got from its async exec wrapper. execFile kills the child on expiry. await this.exec(args, { cwd: this.cwd(opts.cwd), timeout: opts.timeoutMs ?? DEFAULT_PUSH_TIMEOUT_MS }); diff --git a/packages/git/test/unit/git.spec.ts b/packages/git/test/unit/git.spec.ts index edff51cc..3c98864c 100644 --- a/packages/git/test/unit/git.spec.ts +++ b/packages/git/test/unit/git.spec.ts @@ -23,7 +23,9 @@ function makeRunner(responses: { stdout?: string; stderr?: string; reject?: unkn const calls: Call[] = []; const run: GitRunner = vi.fn(async (args, options) => { calls.push({ args, options }); - if (responses.reject !== undefined) throw responses.reject; + if (responses.reject !== undefined) { + throw responses.reject; + } return { stdout: responses.stdout ?? '', stderr: responses.stderr ?? '' }; }); return { run, calls }; diff --git a/packages/notes/src/command.ts b/packages/notes/src/command.ts index 78085119..b49143b5 100644 --- a/packages/notes/src/command.ts +++ b/packages/notes/src/command.ts @@ -37,7 +37,9 @@ export function createNotesCommand(): Command { .option('-q, --quiet', 'Suppress non-error output') .action(async (options) => { setVerbosity(options.verbose); - if (options.quiet) setQuietMode(true); + if (options.quiet) { + setQuietMode(true); + } try { const loadedConfig = loadConfig({ cwd: process.cwd(), configPath: options.config }); @@ -113,9 +115,15 @@ export function createNotesCommand(): Command { model: existingLlm?.model, ...(existingLlm ?? {}), }; - if (options.llmProvider) llm.provider = options.llmProvider; - if (options.llmModel) llm.model = options.llmModel; - if (options.llmBaseUrl) llm.baseURL = options.llmBaseUrl; + if (options.llmProvider) { + llm.provider = options.llmProvider; + } + if (options.llmModel) { + llm.model = options.llmModel; + } + if (options.llmBaseUrl) { + llm.baseURL = options.llmBaseUrl; + } if (options.llmTasks) { const taskNames = (options.llmTasks as string).split(',').map((t: string) => t.trim()); llm.tasks = { diff --git a/packages/notes/src/core/pipeline.ts b/packages/notes/src/core/pipeline.ts index d4ca5942..adf29022 100644 --- a/packages/notes/src/core/pipeline.ts +++ b/packages/notes/src/core/pipeline.ts @@ -47,9 +47,13 @@ function findCompoundTagSepPos(tag: string): number { // Digit after dash — confirm semver-like by requiring ≥2 dots in the remainder let dots = 0; for (let i = afterDash; i < tag.length; i++) { - if (tag[i] === '.') dots++; + if (tag[i] === '.') { + dots++; + } + } + if (dots >= 2) { + return pos; } - if (dots >= 2) return pos; } pos = tag.lastIndexOf('-', pos - 1); } @@ -59,7 +63,9 @@ function findCompoundTagSepPos(tag: string): number { /** True when position `i` in `tag` begins a semver version: a digit, or a `v` immediately followed by a digit. */ function isVersionStartAt(tag: string, i: number): boolean { const c = tag.charCodeAt(i); - if (c >= 48 && c <= 57) return true; + if (c >= 48 && c <= 57) { + return true; + } if (tag[i] === 'v') { const n = tag.charCodeAt(i + 1); return n >= 48 && n <= 57; @@ -130,7 +136,9 @@ export function buildOrderedCategories( ): EnhancedCategory[] { const order = configCategories?.map((c) => c.name) ?? []; const mapped = rawCategories.map((c) => ({ name: c.category, entries: c.entries })); - if (order.length === 0) return mapped; + if (order.length === 0) { + return mapped; + } return mapped.sort((a, b) => { const ai = order.indexOf(a.name); const bi = order.indexOf(b.name); @@ -506,7 +514,9 @@ export async function runPipeline( } else { writeMarkdown(fileName, contexts, config, dryRun, fmtOpts); } - if (!dryRun) files.push(fileName); + if (!dryRun) { + files.push(fileName); + } } if (mode === 'packages' || mode === 'both') { @@ -545,7 +555,9 @@ export async function runPipeline( return renderTemplate(templatePath, docCtx, releaseNotesConfig.templates.engine as TemplateEngine | undefined) .content; } - if (ctx.enhanced?.releaseNotes) return ctx.enhanced.releaseNotes; + if (ctx.enhanced?.releaseNotes) { + return ctx.enhanced.releaseNotes; + } return formatVersion(ctx, fmtOpts); }; @@ -624,7 +636,9 @@ async function writeMonorepoFiles( const cwd = process.cwd(); const detected = detectMonorepo(cwd); - if (!detected.isMonorepo) return []; + if (!detected.isMonorepo) { + return []; + } const monoFiles = writeMonorepoChangelogs( contexts, diff --git a/packages/notes/src/errors/index.ts b/packages/notes/src/errors/index.ts index 6bee391c..1da789f0 100644 --- a/packages/notes/src/errors/index.ts +++ b/packages/notes/src/errors/index.ts @@ -39,7 +39,9 @@ export class LLMError extends NotesError { constructor(message: string, options?: { retryable?: boolean; cause?: unknown }) { super(message); // Chain the original provider/SDK error so its stack survives (the message only carries its text). - if (options?.cause !== undefined) this.cause = options.cause; + if (options?.cause !== undefined) { + this.cause = options.cause; + } this.retryable = options?.retryable; } } diff --git a/packages/notes/src/input/conventional-changelog.ts b/packages/notes/src/input/conventional-changelog.ts index 9a3fe4f4..e3a4d282 100644 --- a/packages/notes/src/input/conventional-changelog.ts +++ b/packages/notes/src/input/conventional-changelog.ts @@ -65,7 +65,9 @@ function extractIssueIds(text: string): { description: string; issueIds: string[ function parseEntry(line: string, sectionType: ChangelogEntry['type']): ChangelogEntry | null { const bulletMatch = line.match(BULLET); - if (!bulletMatch) return null; + if (!bulletMatch) { + return null; + } let rawText = bulletMatch[1] ?? ''; let scope: string | undefined; @@ -126,7 +128,9 @@ function parseVersionBlocks(content: string): VersionBlock[] { continue; } - if (!currentBlock) continue; + if (!currentBlock) { + continue; + } const sectionMatch = line.match(SECTION_HEADER); if (sectionMatch) { diff --git a/packages/notes/src/input/manual.ts b/packages/notes/src/input/manual.ts index 4c1bfec5..9dd48a13 100644 --- a/packages/notes/src/input/manual.ts +++ b/packages/notes/src/input/manual.ts @@ -20,7 +20,9 @@ export function parseManualInput(json: string): ChangelogInput { if (Array.isArray(input.packages)) { for (const pkg of input.packages) { - if (typeof pkg !== 'object' || pkg === null) continue; + if (typeof pkg !== 'object' || pkg === null) { + continue; + } const p = pkg as Record; @@ -97,7 +99,9 @@ function normalizeType(type: unknown): import('../core/types.js').ChangelogType if (typeof type === 'string') { const mapped = typeMap[type.toLowerCase()]; - if (mapped) return mapped; + if (mapped) { + return mapped; + } } return 'changed'; diff --git a/packages/notes/src/llm/anthropic.ts b/packages/notes/src/llm/anthropic.ts index 17cbd15f..7a1371d2 100644 --- a/packages/notes/src/llm/anthropic.ts +++ b/packages/notes/src/llm/anthropic.ts @@ -101,7 +101,9 @@ export class AnthropicProvider extends BaseLLMProvider { return { content: textBlock.text }; } catch (error) { - if (error instanceof LLMError) throw error; + if (error instanceof LLMError) { + throw error; + } if (signal.aborted) { throw new LLMError(`Anthropic request timed out after ${this.getTimeout(options)}ms`, { retryable: true }); } diff --git a/packages/notes/src/llm/base.ts b/packages/notes/src/llm/base.ts index 4fbecbb8..b260b5ea 100644 --- a/packages/notes/src/llm/base.ts +++ b/packages/notes/src/llm/base.ts @@ -20,7 +20,9 @@ export abstract class BaseLLMProvider implements LLMProvider { // - above the timer ceiling (2^31-1 ms ≈ 24.8 days) → clamp, not throw/overflow. A huge value // means "effectively no timeout", so clamping preserves intent rather than dropping to 60s. const MAX_TIMEOUT_MS = 2_147_483_647; - if (!Number.isFinite(timeout) || timeout < 1) return LLM_DEFAULTS.timeout; + if (!Number.isFinite(timeout) || timeout < 1) { + return LLM_DEFAULTS.timeout; + } return Math.min(Math.trunc(timeout), MAX_TIMEOUT_MS); } diff --git a/packages/notes/src/llm/context/prFetcher.ts b/packages/notes/src/llm/context/prFetcher.ts index 13805fa5..ba1f777e 100644 --- a/packages/notes/src/llm/context/prFetcher.ts +++ b/packages/notes/src/llm/context/prFetcher.ts @@ -29,10 +29,14 @@ function stripDetailsTags(input: string): string { const tagEnd = lower.indexOf('>', i); i = tagEnd === -1 ? input.length : tagEnd + 1; } else if (lower.startsWith('', i)) { - if (depth > 0) depth--; + if (depth > 0) { + depth--; + } i += 10; } else { - if (depth === 0) result += input[i]; + if (depth === 0) { + result += input[i]; + } i++; } } @@ -47,7 +51,9 @@ function sanitiseBody(raw: string): string { } function truncateBody(body: string): string { - if (body.length <= BODY_CAP) return body; + if (body.length <= BODY_CAP) { + return body; + } // Prefer truncating at a heading boundary const headingPos = body.lastIndexOf('\n#', BODY_CAP); diff --git a/packages/notes/src/llm/correctiveRetry.ts b/packages/notes/src/llm/correctiveRetry.ts index be1611b7..1ef65feb 100644 --- a/packages/notes/src/llm/correctiveRetry.ts +++ b/packages/notes/src/llm/correctiveRetry.ts @@ -27,7 +27,9 @@ export async function withCorrectiveRetry( const result = await call(messages, attempt === 0); const validation = validate(result); - if (validation.valid) return validation.value; + if (validation.valid) { + return validation.value; + } lastError = validation.error; diff --git a/packages/notes/src/llm/examples/fetcher.ts b/packages/notes/src/llm/examples/fetcher.ts index 4acc0b03..e5b3cc8f 100644 --- a/packages/notes/src/llm/examples/fetcher.ts +++ b/packages/notes/src/llm/examples/fetcher.ts @@ -42,7 +42,9 @@ function readCache(key: string, latestTag: string, count: number): Example[] | n const entry: CacheEntry = JSON.parse(raw); // Invalidate if the tag changed or the cached count is smaller than requested // (the user may have increased `examples` config since the last run). - if (entry.latestTag !== latestTag || entry.count < count) return null; + if (entry.latestTag !== latestTag || entry.count < count) { + return null; + } return entry.examples; } catch { return null; @@ -70,7 +72,9 @@ function matchesBareVersion(tagName: string): boolean { export async function fetchExamples(options: FetchExamplesOptions): Promise { const { owner, repo, packageName, count, githubToken, isMonorepo } = options; - if (count === 0) return []; + if (count === 0) { + return []; + } const token = githubToken ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; if (!token) { @@ -104,13 +108,19 @@ export async function fetchExamples(options: FetchExamplesOptions): Promise 0) writeCache(key, latestTag, count, examples); + if (examples.length > 0) { + writeCache(key, latestTag, count, examples); + } debug(`Fetched ${examples.length} examples for ${packageName}`); return examples; } catch (error) { diff --git a/packages/notes/src/llm/examples/parser.ts b/packages/notes/src/llm/examples/parser.ts index e9d55313..28e5f232 100644 --- a/packages/notes/src/llm/examples/parser.ts +++ b/packages/notes/src/llm/examples/parser.ts @@ -11,7 +11,9 @@ export function parseReleaseBodyToExample(markdown: string, version: string): Ex for (const raw of lines) { const line = raw.trim(); - if (!line) continue; + if (!line) { + continue; + } // ## / ### / #### Heading → new category (h1 skipped as document title) const headingMatch = /^#{2,4} (.+)/.exec(line); @@ -19,7 +21,9 @@ export function parseReleaseBodyToExample(markdown: string, version: string): Ex currentCategory = headingMatch[1]!.trim(); continue; } - if (line.startsWith('#')) continue; + if (line.startsWith('#')) { + continue; + } // Bullet entry if (BULLET_RE.test(line)) { @@ -29,18 +33,26 @@ export function parseReleaseBodyToExample(markdown: string, version: string): Ex description: leadInMatch ? text.slice(leadInMatch[0].length) : text, category: currentCategory, }; - if (leadInMatch) entry.leadIn = leadInMatch[1]; - if (/\*\*BREAKING\*\*/i.test(text)) entry.breaking = true; + if (leadInMatch) { + entry.leadIn = leadInMatch[1]; + } + if (/\*\*BREAKING\*\*/i.test(text)) { + entry.breaking = true; + } entries.push(entry); } } - if (entries.length === 0) return null; + if (entries.length === 0) { + return null; + } return { version, entries }; } export function renderExamplesBlock(examples: Example[]): string { - if (examples.length === 0) return ''; + if (examples.length === 0) { + return ''; + } // These examples are built from past release bodies and re-fed as few-shot input, and release-notes // output becomes a future example — a self-poisoning loop. Escape every interpolated field so an diff --git a/packages/notes/src/llm/messages.ts b/packages/notes/src/llm/messages.ts index 5c9a57c5..4d77ed00 100644 --- a/packages/notes/src/llm/messages.ts +++ b/packages/notes/src/llm/messages.ts @@ -11,7 +11,9 @@ export interface CompleteResult { // Opt-in via RELEASEKIT_DEBUG=1. Output may contain fetched PR bodies and // example release notes from external repos — don't enable in shared CI logs. export function debugLogMessages(providerName: string, messages: LLMMessage[]): void { - if (process.env.RELEASEKIT_DEBUG !== '1') return; + if (process.env.RELEASEKIT_DEBUG !== '1') { + return; + } console.error(`[RELEASEKIT_DEBUG] ${providerName} messages (${messages.length}):`); for (const msg of messages) { const preview = msg.content.length > 300 ? `${msg.content.slice(0, 300)}…` : msg.content; diff --git a/packages/notes/src/llm/ollama.ts b/packages/notes/src/llm/ollama.ts index 06a76258..809be773 100644 --- a/packages/notes/src/llm/ollama.ts +++ b/packages/notes/src/llm/ollama.ts @@ -129,7 +129,9 @@ export class OllamaProvider extends BaseLLMProvider { return { content }; } catch (error) { - if (error instanceof LLMError) throw error; + if (error instanceof LLMError) { + throw error; + } if (signal.aborted) { throw new LLMError(`Ollama request timed out after ${this.getTimeout(options)}ms`, { retryable: true }); } diff --git a/packages/notes/src/llm/openai-compatible.ts b/packages/notes/src/llm/openai-compatible.ts index faae2965..b3a18ea4 100644 --- a/packages/notes/src/llm/openai-compatible.ts +++ b/packages/notes/src/llm/openai-compatible.ts @@ -63,7 +63,9 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { return { content }; } catch (error) { - if (error instanceof LLMError) throw error; + if (error instanceof LLMError) { + throw error; + } if (signal.aborted) { throw new LLMError(`${this.name} request timed out after ${this.getTimeout(options)}ms`, { retryable: true }); } diff --git a/packages/notes/src/llm/openai.ts b/packages/notes/src/llm/openai.ts index aab8b556..264bcbe6 100644 --- a/packages/notes/src/llm/openai.ts +++ b/packages/notes/src/llm/openai.ts @@ -91,7 +91,9 @@ export class OpenAIProvider extends BaseLLMProvider { return { content }; } catch (error) { - if (error instanceof LLMError) throw error; + if (error instanceof LLMError) { + throw error; + } if (signal.aborted) { throw new LLMError(`OpenAI request timed out after ${this.getTimeout(options)}ms`, { retryable: true }); } diff --git a/packages/notes/src/llm/retryable.ts b/packages/notes/src/llm/retryable.ts index 9d2f555d..e983bbfd 100644 --- a/packages/notes/src/llm/retryable.ts +++ b/packages/notes/src/llm/retryable.ts @@ -19,9 +19,15 @@ const NETWORK_ERROR_CODES = new Set([ * auth, permission, validation, or not-found error that retrying can't fix — fail fast. */ export function isRetryableStatus(status: number): boolean { - if (status === 408 || status === 409 || status === 425 || status === 429) return true; - if (status >= 500) return true; - if (status >= 400) return false; + if (status === 408 || status === 409 || status === 425 || status === 429) { + return true; + } + if (status >= 500) { + return true; + } + if (status >= 400) { + return false; + } // Non-error status (shouldn't reach here for a thrown error) — retry rather than silently drop. return true; } @@ -33,10 +39,16 @@ export function isRetryableStatus(status: number): boolean { * fail-fast path is an explicit non-retryable 4xx status. */ export function isRetryableProviderError(error: unknown): boolean { - if (!error || typeof error !== 'object') return true; + if (!error || typeof error !== 'object') { + return true; + } const e = error as { status?: unknown; code?: unknown; name?: unknown }; - if (typeof e.status === 'number') return isRetryableStatus(e.status); - if (typeof e.code === 'string' && NETWORK_ERROR_CODES.has(e.code)) return true; + if (typeof e.status === 'number') { + return isRetryableStatus(e.status); + } + if (typeof e.code === 'string' && NETWORK_ERROR_CODES.has(e.code)) { + return true; + } // Everything else — abort/timeout error names (AbortError, TimeoutError, APIConnectionError, // APIConnectionTimeoutError) and unknown shapes alike — defaults to retryable; the only fail-fast // path is the explicit non-retryable 4xx status handled above. @@ -48,6 +60,8 @@ export function isRetryableProviderError(error: unknown): boolean { * any other error (unclassified) is retried by default. */ export function isRetryableLLMError(error: unknown): boolean { - if (error instanceof LLMError) return error.retryable ?? true; + if (error instanceof LLMError) { + return error.retryable ?? true; + } return true; } diff --git a/packages/notes/src/llm/scopes.ts b/packages/notes/src/llm/scopes.ts index ceab39ac..4d3e0772 100644 --- a/packages/notes/src/llm/scopes.ts +++ b/packages/notes/src/llm/scopes.ts @@ -54,9 +54,15 @@ export function resolveAllowedScopes( categories?: LLMCategory[], packageNames?: string[], ): string[] | null { - if (!scopeConfig || scopeConfig.mode === 'unrestricted') return null; - if (scopeConfig.mode === 'none') return []; - if (scopeConfig.mode === 'packages') return expandPackageScopes(packageNames ?? []); + if (!scopeConfig || scopeConfig.mode === 'unrestricted') { + return null; + } + if (scopeConfig.mode === 'none') { + return []; + } + if (scopeConfig.mode === 'packages') { + return expandPackageScopes(packageNames ?? []); + } if (scopeConfig.mode === 'restricted') { const explicit = scopeConfig.rules?.allowed ?? []; @@ -65,7 +71,9 @@ export function resolveAllowedScopes( if (categories) { const fromCategories = getAllowedScopesFromCategories(categories); for (const scopes of fromCategories.values()) { - for (const s of scopes) all.add(s); + for (const s of scopes) { + all.add(s); + } } } @@ -84,8 +92,12 @@ export function validateScope( allowedScopes: string[] | null, caseSensitive = false, ): string | undefined { - if (!scope || allowedScopes === null) return scope; - if (allowedScopes.length === 0) return undefined; + if (!scope || allowedScopes === null) { + return scope; + } + if (allowedScopes.length === 0) { + return undefined; + } const normalise = (s: string) => (caseSensitive ? s : s.toLowerCase()); const isAllowed = allowedScopes.some((a) => normalise(a) === normalise(scope)); @@ -107,7 +119,9 @@ export function validateEntryScopes( packageNames?: string[], ): ScopeValidationResult { const allowedScopes = resolveAllowedScopes(scopeConfig, categories, packageNames); - if (allowedScopes === null) return { valid: true, entries, errors: [] }; + if (allowedScopes === null) { + return { valid: true, entries, errors: [] }; + } const caseSensitive = scopeConfig?.rules?.caseSensitive ?? false; const action = scopeConfig?.rules?.invalidScopeAction ?? 'remove'; diff --git a/packages/notes/src/llm/tasks/categorize.ts b/packages/notes/src/llm/tasks/categorize.ts index ab522319..00776b26 100644 --- a/packages/notes/src/llm/tasks/categorize.ts +++ b/packages/notes/src/llm/tasks/categorize.ts @@ -55,7 +55,9 @@ export function createCategorizeValidator( return (result) => { const parsed = parseLLMResult(result); - if (!parsed.ok) return { valid: false, error: parsed.error }; + if (!parsed.ok) { + return { valid: false, error: parsed.error }; + } const zodResult = CategorizeOutputSchema.safeParse(parsed.data); if (!zodResult.success) { @@ -74,7 +76,9 @@ export function createCategorizeValidator( zodResult.data.entries, context.categories?.map((c) => c.name), ); - if (categoryError) return { valid: false, error: categoryError }; + if (categoryError) { + return { valid: false, error: categoryError }; + } // Apply scopes from LLM response const withScopes: ChangelogEntry[] = cleanEntries.map((entry, i) => { diff --git a/packages/notes/src/llm/tasks/enhance-and-categorize.ts b/packages/notes/src/llm/tasks/enhance-and-categorize.ts index 34abbcba..5e8b0374 100644 --- a/packages/notes/src/llm/tasks/enhance-and-categorize.ts +++ b/packages/notes/src/llm/tasks/enhance-and-categorize.ts @@ -74,7 +74,9 @@ export function createEnhanceAndCategorizeValidator( return (result) => { // Parse JSON (structured output or text fallback) const parsed = parseLLMResult(result); - if (!parsed.ok) return { valid: false, error: parsed.error }; + if (!parsed.ok) { + return { valid: false, error: parsed.error }; + } // Validate structure const zodResult = EnhanceAndCategorizeOutputSchema.safeParse(parsed.data); @@ -99,12 +101,16 @@ export function createEnhanceAndCategorizeValidator( zodResult.data.entries, context.categories?.map((c) => c.name), ); - if (categoryError) return { valid: false, error: categoryError }; + if (categoryError) { + return { valid: false, error: categoryError }; + } // Build enhanced entries const enhancedEntries: ChangelogEntry[] = entries.map((original, i) => { const llmEntry = zodResult.data.entries[i]; - if (!llmEntry) return original; + if (!llmEntry) { + return original; + } return { ...original, description: llmEntry.description, @@ -143,8 +149,11 @@ export function createEnhanceAndCategorizeValidator( function mergeCategories(target: CategorizedEntries[], incoming: CategorizedEntries[]): void { for (const cat of incoming) { const existing = target.find((c) => c.category === cat.category); - if (existing) existing.entries.push(...cat.entries); - else target.push({ category: cat.category, entries: [...cat.entries] }); + if (existing) { + existing.entries.push(...cat.entries); + } else { + target.push({ category: cat.category, entries: [...cat.entries] }); + } } } diff --git a/packages/notes/src/llm/tasks/shared.ts b/packages/notes/src/llm/tasks/shared.ts index 3957fecc..94a0b17c 100644 --- a/packages/notes/src/llm/tasks/shared.ts +++ b/packages/notes/src/llm/tasks/shared.ts @@ -15,7 +15,9 @@ export function escBody(s: string): string { } export function renderPRBlocks(entry: ChangelogEntry): string { - if (!entry.context?.prs.length) return ''; + if (!entry.context?.prs.length) { + return ''; + } return entry.context.prs .map( (pr) => `${pr.body ? `\n${escBody(pr.body)}\n` : ''}`, @@ -39,8 +41,12 @@ export const INSTRUCTION_HIERARCHY = */ export function renderEntry(entry: ChangelogEntry, index: number): string { const attrs = [`index="${index}"`, `type="${escAttr(entry.type)}"`]; - if (entry.scope) attrs.push(`scope="${escAttr(entry.scope)}"`); - if (entry.breaking) attrs.push('breaking="true"'); + if (entry.scope) { + attrs.push(`scope="${escAttr(entry.scope)}"`); + } + if (entry.breaking) { + attrs.push('breaking="true"'); + } const prBlocks = renderPRBlocks(entry); const body = `${escBody(entry.description)}${prBlocks ? `\n${prBlocks}` : ''}`; return `\n${body}\n`; @@ -63,8 +69,12 @@ export function groupByCategory( for (let i = 0; i < llmEntries.length; i++) { const category = llmEntries[i]?.category ?? 'Unknown'; const entry = entries[i]; - if (!entry) continue; - if (!categoryMap.has(category)) categoryMap.set(category, []); + if (!entry) { + continue; + } + if (!categoryMap.has(category)) { + categoryMap.set(category, []); + } categoryMap.get(category)!.push(entry); } const result: CategorizedEntries[] = []; @@ -80,7 +90,9 @@ export function groupByCategory( * it returns the task-specific free-form `fallback` instruction. */ export function buildCategorySection(categories: LLMCategory[] | undefined, fallback: string): string { - if (!categories || categories.length === 0) return fallback; + if (!categories || categories.length === 0) { + return fallback; + } const lines = categories.map((c) => { const scopeInfo = c.scopes?.length ? ` Allowed scopes: ${c.scopes.join(', ')}.` : ''; return `- "${c.name}": ${c.description}${scopeInfo}`; @@ -94,7 +106,9 @@ export function buildCategorySection(categories: LLMCategory[] | undefined, fall * The scope-source resolution stays in each task — only the final rendering is shared. */ export function renderScopeInstruction(pairs: Array<{ name: string; scopes: string[] }>, entrySuffix: string): string { - if (pairs.length === 0) return ''; + if (pairs.length === 0) { + return ''; + } const parts = pairs.map((p) => `For "${p.name}"${entrySuffix}, use a scope from: ${p.scopes.join(', ')}.`); return `\n${parts.join('\n')}\nOnly use scopes from these predefined lists. Set scope to null if no scope applies.`; } @@ -136,9 +150,13 @@ export function checkCategoryNames( llmEntries: Array<{ category: string }>, categoryNames: string[] | undefined, ): string | null { - if (!categoryNames?.length) return null; + if (!categoryNames?.length) { + return null; + } const invalid = llmEntries.filter((e) => !categoryNames.includes(e.category)).map((e) => e.category); - if (invalid.length === 0) return null; + if (invalid.length === 0) { + return null; + } const unique = [...new Set(invalid)]; return `Unknown categories: ${unique.join(', ')}. Valid categories: ${categoryNames.join(', ')}`; } diff --git a/packages/notes/src/monorepo/aggregator.ts b/packages/notes/src/monorepo/aggregator.ts index e54cf5c0..bfe1cc06 100644 --- a/packages/notes/src/monorepo/aggregator.ts +++ b/packages/notes/src/monorepo/aggregator.ts @@ -123,7 +123,9 @@ function buildPackageDirMap(rootPath: string, packagesPath: string): Map(); for (const entry of entries) { - if (entry.scope) scopeCounts.set(entry.scope, (scopeCounts.get(entry.scope) ?? 0) + 1); + if (entry.scope) { + scopeCounts.set(entry.scope, (scopeCounts.get(entry.scope) ?? 0) + 1); + } } const groupedScopes = new Set(); for (const [scope, count] of scopeCounts) { - if (count > 1) groupedScopes.add(scope); + if (count > 1) { + groupedScopes.add(scope); + } } if (groupedScopes.size === 0) { @@ -157,7 +163,9 @@ function formatCategorySection(name: string, entries: ChangelogEntry[], refOpts: function rerouteBreakingEntries(categories: EnhancedCategory[]): EnhancedCategory[] { const hasBreaking = categories.some((c) => c.entries.some((e) => e.breaking)); - if (!hasBreaking) return categories; + if (!hasBreaking) { + return categories; + } const breakingEntries: ChangelogEntry[] = []; const stripped = categories.map((c) => ({ @@ -180,7 +188,9 @@ function rerouteBreakingEntries(categories: EnhancedCategory[]): EnhancedCategor } function applyOrder(categories: EnhancedCategory[], order: string[]): EnhancedCategory[] { - if (order.length === 0) return categories; + if (order.length === 0) { + return categories; + } return [...categories].sort((a, b) => { const ai = order.indexOf(a.name); const bi = order.indexOf(b.name); @@ -199,18 +209,26 @@ function parseMdLink(text: string): { label: string; url: string } | null { let search = 0; while (search < text.length) { const bracketOpen = text.indexOf('[', search); - if (bracketOpen === -1) return null; + if (bracketOpen === -1) { + return null; + } const bracketClose = text.indexOf(']', bracketOpen + 1); - if (bracketClose === -1) return null; + if (bracketClose === -1) { + return null; + } if (text[bracketClose + 1] !== '(') { search = bracketClose + 1; continue; } const parenClose = text.indexOf(')', bracketClose + 2); - if (parenClose === -1) return null; + if (parenClose === -1) { + return null; + } const label = text.slice(bracketOpen + 1, bracketClose); const url = text.slice(bracketClose + 2, parenClose); - if (/^https?:\/\//.test(url)) return { label, url }; + if (/^https?:\/\//.test(url)) { + return { label, url }; + } search = parenClose + 1; } return null; @@ -223,7 +241,9 @@ function extractLinksFromPRBodies(entries: ChangelogEntry[], marker: string): Li for (const pr of entry.context?.prs ?? []) { for (const line of pr.body.split('\n')) { const trimmed = line.trim(); - if (!trimmed.startsWith(marker)) continue; + if (!trimmed.startsWith(marker)) { + continue; + } const rest = trimmed.slice(marker.length).trim(); const mdLink = parseMdLink(rest); if (mdLink) { @@ -245,7 +265,9 @@ function extractLinksFromPRBodies(entries: ChangelogEntry[], marker: string): Li } function resolveLinks(entries: ChangelogEntry[], linksConfig: LinksConfig | undefined): LinkItem[] { - if (!linksConfig) return []; + if (!linksConfig) { + return []; + } const items = linksConfig.items ?? []; const discovered = linksConfig.fromPRBodyMarker ? extractLinksFromPRBodies(entries, linksConfig.fromPRBodyMarker) @@ -308,7 +330,9 @@ export function formatVersion(context: TemplateContext, options?: FormatVersionO categories = applyOrder(categories, options?.categoryOrder ?? []); for (const category of categories) { - if (category.entries.length === 0) continue; + if (category.entries.length === 0) { + continue; + } lines.push(...formatCategorySection(category.name, category.entries, refOpts)); } @@ -325,7 +349,9 @@ export function formatVersion(context: TemplateContext, options?: FormatVersionO const grouped = groupEntriesByType(context.entries); for (const [type, entries] of grouped) { - if (entries.length === 0) continue; + if (entries.length === 0) { + continue; + } lines.push(`### ${TYPE_LABELS[type]}`); for (const entry of entries) { diff --git a/packages/notes/src/templates/loader.ts b/packages/notes/src/templates/loader.ts index 5eb72d94..e347f570 100644 --- a/packages/notes/src/templates/loader.ts +++ b/packages/notes/src/templates/loader.ts @@ -154,9 +154,15 @@ export function renderComposable( } function detectEngineFromFiles(_dir: string, files: string[]): TemplateEngine | null { - if (files.some((f) => f.endsWith('.liquid'))) return 'liquid'; - if (files.some((f) => f.endsWith('.hbs') || f.endsWith('.handlebars'))) return 'handlebars'; - if (files.some((f) => f.endsWith('.ejs'))) return 'ejs'; + if (files.some((f) => f.endsWith('.liquid'))) { + return 'liquid'; + } + if (files.some((f) => f.endsWith('.hbs') || f.endsWith('.handlebars'))) { + return 'handlebars'; + } + if (files.some((f) => f.endsWith('.ejs'))) { + return 'ejs'; + } return null; } diff --git a/packages/notes/src/utils/concurrency.ts b/packages/notes/src/utils/concurrency.ts index d6e476c6..315277d0 100644 --- a/packages/notes/src/utils/concurrency.ts +++ b/packages/notes/src/utils/concurrency.ts @@ -18,7 +18,9 @@ export async function mapWithConcurrency( // fresh work after the first failure (in-flight calls still settle; Promise.all surfaces the error). while (!failed) { const i = next++; - if (i >= items.length) return; + if (i >= items.length) { + return; + } try { results[i] = await fn(items[i] as T, i); } catch (err) { diff --git a/packages/notes/src/utils/json.ts b/packages/notes/src/utils/json.ts index e97ce113..0b460a95 100644 --- a/packages/notes/src/utils/json.ts +++ b/packages/notes/src/utils/json.ts @@ -17,10 +17,14 @@ export function extractJsonFromResponse(response: string): string { // which in practice means: first delimiter to last matching delimiter, ignoring any // preamble or postamble text the model may have added. const objectMatch = stripped.match(/\{[\s\S]*\}/); - if (objectMatch) return objectMatch[0]; + if (objectMatch) { + return objectMatch[0]; + } const arrayMatch = stripped.match(/\[[\s\S]*\]/); - if (arrayMatch) return arrayMatch[0]; + if (arrayMatch) { + return arrayMatch[0]; + } // No JSON delimiters found — return stripped response so JSON.parse produces a clear error return stripped; diff --git a/packages/notes/src/utils/retry.ts b/packages/notes/src/utils/retry.ts index 69377052..af0c9a6f 100644 --- a/packages/notes/src/utils/retry.ts +++ b/packages/notes/src/utils/retry.ts @@ -28,7 +28,9 @@ export async function withRetry(fn: () => Promise, options: RetryOptions = return await fn(); } catch (error) { lastError = error; - if (options.shouldRetry && !options.shouldRetry(error)) break; + if (options.shouldRetry && !options.shouldRetry(error)) { + break; + } if (attempt < maxAttempts - 1) { const base = Math.min(initialDelay * backoffFactor ** attempt, maxDelay); const jitter = base * 0.2 * (Math.random() * 2 - 1); diff --git a/packages/notes/test/eval/assertions.ts b/packages/notes/test/eval/assertions.ts index d942220d..dd9a17d0 100644 --- a/packages/notes/test/eval/assertions.ts +++ b/packages/notes/test/eval/assertions.ts @@ -18,8 +18,12 @@ export function findDuplicateDependencyChurn(text: string): string[] { /** Guard against a truncated (empty/near-empty) or runaway generation. */ export function checkLengthBounds(text: string, min: number, max: number): string[] { const n = text.trim().length; - if (n < min) return [`output too short: ${n} < ${min} chars`]; - if (n > max) return [`output too long: ${n} > ${max} chars`]; + if (n < min) { + return [`output too short: ${n} < ${min} chars`]; + } + if (n > max) { + return [`output too long: ${n} > ${max} chars`]; + } return []; } @@ -44,7 +48,9 @@ export function listItemLines(text: string): string[] { */ export function checkPastTenseLeaning(text: string, minRatio = 0.6): string[] { const items = listItemLines(text); - if (items.length === 0) return []; + if (items.length === 0) { + return []; + } const firstWords = items.map((line) => line.replace(/^\s*(?:[-*]|\d+\.)\s+/, '').split(/\s+/)[0] ?? ''); const pastCount = firstWords.filter(looksPastTense).length; const ratio = pastCount / items.length; diff --git a/packages/notes/test/unit/llm.spec.ts b/packages/notes/test/unit/llm.spec.ts index 0ab730c3..fe0beda8 100644 --- a/packages/notes/test/unit/llm.spec.ts +++ b/packages/notes/test/unit/llm.spec.ts @@ -43,7 +43,9 @@ function makeFailingProvider(after = 0): LLMProvider { capabilities: { systemRole: true, structuredOutputs: false, toolUse: false }, async complete(): Promise { calls++; - if (calls > after) throw new Error('provider error'); + if (calls > after) { + throw new Error('provider error'); + } return { content: 'ok' }; }, }; @@ -65,7 +67,9 @@ describe('enhanceEntry()', () => { it('should return the provider response as the new description', async () => { const provider = makeMockProvider('Add real-time streaming to the API'); const entry = sampleEntries[0]; - if (!entry) throw new Error('No sample entry'); + if (!entry) { + throw new Error('No sample entry'); + } const result = await enhanceEntry(provider, entry, llmContext); expect(result).toBe('Add real-time streaming to the API'); }); @@ -73,7 +77,9 @@ describe('enhanceEntry()', () => { it('should trim whitespace from the response', async () => { const provider = makeMockProvider(' trimmed '); const entry = sampleEntries[0]; - if (!entry) throw new Error('No sample entry'); + if (!entry) { + throw new Error('No sample entry'); + } const result = await enhanceEntry(provider, entry, llmContext); expect(result).toBe('trimmed'); }); @@ -81,7 +87,9 @@ describe('enhanceEntry()', () => { it('should call the provider exactly once', async () => { const provider = makeMockProvider('response'); const entry = sampleEntries[0]; - if (!entry) throw new Error('No sample entry'); + if (!entry) { + throw new Error('No sample entry'); + } await enhanceEntry(provider, entry, llmContext); expect(provider.callCount).toBe(1); }); @@ -136,7 +144,9 @@ describe('enhanceEntries()', () => { capabilities: { systemRole: true, structuredOutputs: false, toolUse: false }, async complete(): Promise { calls++; - if (calls === 2) throw new Error('second entry fails'); + if (calls === 2) { + throw new Error('second entry fails'); + } return { content: 'ok' }; }, }; diff --git a/packages/notes/test/unit/llm/cache.spec.ts b/packages/notes/test/unit/llm/cache.spec.ts index 90d8a9f7..0642e395 100644 --- a/packages/notes/test/unit/llm/cache.spec.ts +++ b/packages/notes/test/unit/llm/cache.spec.ts @@ -29,7 +29,9 @@ describe('withContentHashCache', () => { }; afterEach(() => { - for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); + for (const d of dirs) { + fs.rmSync(d, { recursive: true, force: true }); + } dirs.length = 0; }); diff --git a/packages/notes/test/unit/llm/tasks.spec.ts b/packages/notes/test/unit/llm/tasks.spec.ts index 76075288..9de793e2 100644 --- a/packages/notes/test/unit/llm/tasks.spec.ts +++ b/packages/notes/test/unit/llm/tasks.spec.ts @@ -45,7 +45,9 @@ describe('createCategorizeValidator()', () => { ); expect(result.valid).toBe(true); - if (!result.valid) return; + if (!result.valid) { + return; + } expect(result.value).toHaveLength(2); expect(result.value.find((c) => c.category === 'New Features')?.entries).toHaveLength(1); expect(result.value.find((c) => c.category === 'Bug Fixes')?.entries).toHaveLength(2); @@ -56,7 +58,9 @@ describe('createCategorizeValidator()', () => { const result = validate({ content: 'not json', structured: undefined }); expect(result.valid).toBe(false); - if (result.valid) return; + if (result.valid) { + return; + } expect(result.error).toContain('Invalid JSON'); }); @@ -65,7 +69,9 @@ describe('createCategorizeValidator()', () => { const result = validate(structured({ entries: [{ category: 'New', scope: null }] })); expect(result.valid).toBe(false); - if (result.valid) return; + if (result.valid) { + return; + } expect(result.error).toBe('Expected 3 entries, got 1'); }); @@ -88,7 +94,9 @@ describe('createCategorizeValidator()', () => { ); expect(result.valid).toBe(false); - if (result.valid) return; + if (result.valid) { + return; + } expect(result.error).toBe('Unknown categories: Mystery. Valid categories: New Features, Bug Fixes'); }); @@ -113,7 +121,9 @@ describe('createCategorizeValidator()', () => { ); expect(result.valid).toBe(true); - if (!result.valid) return; + if (!result.valid) { + return; + } const dev = result.value.find((c) => c.category === 'Developer'); expect(dev?.entries[0]?.scope).toBe('Dependencies'); expect(dev?.entries[1]?.scope).toBeUndefined(); @@ -148,7 +158,9 @@ describe('createEnhanceAndCategorizeValidator()', () => { ); expect(result.valid).toBe(true); - if (!result.valid) return; + if (!result.valid) { + return; + } expect(result.value.enhancedEntries).toHaveLength(3); expect(result.value.enhancedEntries[0]?.description).toBe('Added real-time streaming to the API'); expect(result.value.enhancedEntries[2]?.scope).toBe('Code Quality'); @@ -160,7 +172,9 @@ describe('createEnhanceAndCategorizeValidator()', () => { const result = validate({ content: 'not json', structured: undefined }); expect(result.valid).toBe(false); - if (result.valid) return; + if (result.valid) { + return; + } expect(result.error).toContain('Invalid JSON'); }); @@ -169,7 +183,9 @@ describe('createEnhanceAndCategorizeValidator()', () => { const result = validate(structured({ entries: [fullEntry('Only first', 'New')] })); expect(result.valid).toBe(false); - if (result.valid) return; + if (result.valid) { + return; + } expect(result.error).toBe('Expected 3 entries, got 1 (entries missing — cannot proceed)'); }); @@ -188,7 +204,9 @@ describe('createEnhanceAndCategorizeValidator()', () => { ); expect(result.valid).toBe(true); - if (!result.valid) return; + if (!result.valid) { + return; + } expect(result.value.enhancedEntries).toHaveLength(3); expect(result.value.enhancedEntries[2]?.description).toBe('Enhanced C'); }); @@ -209,7 +227,9 @@ describe('createEnhanceAndCategorizeValidator()', () => { ); expect(result.valid).toBe(false); - if (result.valid) return; + if (result.valid) { + return; + } expect(result.error).toBe('Unknown categories: Mystery. Valid categories: New, Fixed, Changed'); }); }); diff --git a/packages/notes/test/unit/retry.spec.ts b/packages/notes/test/unit/retry.spec.ts index de1df3f3..c31e008c 100644 --- a/packages/notes/test/unit/retry.spec.ts +++ b/packages/notes/test/unit/retry.spec.ts @@ -24,7 +24,9 @@ describe('withRetry()', () => { const result = await withRetry( async () => { calls++; - if (calls < 2) throw new Error('temporary error'); + if (calls < 2) { + throw new Error('temporary error'); + } return 'ok'; }, { maxAttempts: 3, initialDelay: 0 }, @@ -106,7 +108,9 @@ describe('withRetry()', () => { const result = await withRetry( async () => { calls++; - if (calls < 3) throw new Error('not yet'); + if (calls < 3) { + throw new Error('not yet'); + } return 'done'; }, { maxAttempts: 3, initialDelay: 0 }, diff --git a/packages/notes/test/unit/templates.spec.ts b/packages/notes/test/unit/templates.spec.ts index 034e2609..f23903ef 100644 --- a/packages/notes/test/unit/templates.spec.ts +++ b/packages/notes/test/unit/templates.spec.ts @@ -69,7 +69,9 @@ describe('Handlebars Engine', () => { registerHandlebarsHelpers(); const template = '{{capitalize type}}'; const entry = sampleContext.entries[0]; - if (!entry) throw new Error('No entry found'); + if (!entry) { + throw new Error('No entry found'); + } const result = renderHandlebars(template, { ...sampleContext, ...entry } as unknown as TemplateContext); expect(result).toBe('Added'); }); diff --git a/packages/notes/test/unit/utils/concurrency.spec.ts b/packages/notes/test/unit/utils/concurrency.spec.ts index 623c2e14..8fef7694 100644 --- a/packages/notes/test/unit/utils/concurrency.spec.ts +++ b/packages/notes/test/unit/utils/concurrency.spec.ts @@ -51,7 +51,9 @@ describe('mapWithConcurrency()', () => { await expect( mapWithConcurrency([0, 1, 2], 1, async (n) => { seen.push(n); - if (n === 1) throw new Error('boom'); + if (n === 1) { + throw new Error('boom'); + } return n; }), ).rejects.toThrow('boom'); diff --git a/packages/publish/src/command.ts b/packages/publish/src/command.ts index 439e5faf..5ee27333 100644 --- a/packages/publish/src/command.ts +++ b/packages/publish/src/command.ts @@ -34,8 +34,12 @@ export function createPublishCommand(): Command { .action(async (options) => { const io = { json: options.json, output: options.output }; - if (options.verbose) setLogLevel('debug'); - if (options.json) setJsonMode(true); + if (options.verbose) { + setLogLevel('debug'); + } + if (options.json) { + setJsonMode(true); + } try { const config = loadConfig({ configPath: options.config }); diff --git a/packages/publish/src/registry/cargo.ts b/packages/publish/src/registry/cargo.ts index ecd39a1b..6cefe8a2 100644 --- a/packages/publish/src/registry/cargo.ts +++ b/packages/publish/src/registry/cargo.ts @@ -28,8 +28,12 @@ async function isCratePublished(name: string, version: string): Promise signal: AbortSignal.timeout(CRATES_IO_API_TIMEOUT_MS), headers: { 'User-Agent': CRATES_IO_USER_AGENT }, }); - if (response.status === 200) return true; - if (response.status === 404) return false; + if (response.status === 200) { + return true; + } + if (response.status === 404) { + return false; + } debug(`crates.io published-check returned ${response.status} for ${name}@${version}, will attempt publish`); return false; } catch (error) { @@ -94,8 +98,12 @@ export const cargoRegistry: Registry = { async publish(target: CargoTarget, ctx: PipelineContext, session: CargoSession): Promise { const publishArgs = ['publish', '--manifest-path', target.manifestPath]; - if (ctx.config.cargo.noVerify) publishArgs.push('--no-verify'); - if (session.isDirty) publishArgs.push('--allow-dirty'); + if (ctx.config.cargo.noVerify) { + publishArgs.push('--no-verify'); + } + if (session.isDirty) { + publishArgs.push('--allow-dirty'); + } await execCommand('cargo', publishArgs, { cwd: ctx.cwd, diff --git a/packages/publish/src/registry/dispatcher.ts b/packages/publish/src/registry/dispatcher.ts index 700e4bff..5f63cdbb 100644 --- a/packages/publish/src/registry/dispatcher.ts +++ b/packages/publish/src/registry/dispatcher.ts @@ -57,7 +57,9 @@ export async function runPublishStage( result.skipped = true; result.success = true; result.reason = skip.reason; - if (skip.alreadyPublished) result.alreadyPublished = true; + if (skip.alreadyPublished) { + result.alreadyPublished = true; + } results.push(result); continue; } @@ -87,7 +89,9 @@ export async function runPublishStage( }); result.success = true; - if (!dryRun) success(`Published ${target.packageName}@${target.version} to ${registry.displayName}`); + if (!dryRun) { + success(`Published ${target.packageName}@${target.version} to ${registry.displayName}`); + } results.push(result); } catch (error) { // A surfaced already-published conflict (a publish that landed but whose response was lost, diff --git a/packages/publish/src/registry/npm.ts b/packages/publish/src/registry/npm.ts index b91e5d7f..f623a83a 100644 --- a/packages/publish/src/registry/npm.ts +++ b/packages/publish/src/registry/npm.ts @@ -46,7 +46,9 @@ export function orderNpmUpdates u.filePath.endsWith('package.json')); - if (npm.length <= 1) return updates; + if (npm.length <= 1) { + return updates; + } const other = updates.filter((u) => !u.filePath.endsWith('package.json')); const byName = new Map(npm.map((u) => [u.packageName, u])); @@ -54,8 +56,14 @@ export function orderNpmUpdates 0) { const remaining = new Set(npm.map((u) => u.packageName)); orderedNames = explicitOrder.filter((n) => remaining.has(n)); - for (const n of orderedNames) remaining.delete(n); - for (const u of npm) if (remaining.has(u.packageName)) orderedNames.push(u.packageName); + for (const n of orderedNames) { + remaining.delete(n); + } + for (const u of npm) { + if (remaining.has(u.packageName)) { + orderedNames.push(u.packageName); + } + } } else { const graphPackages: GraphPackage[] = npm.map((u) => { const pkgJsonPath = path.resolve(cwd, u.filePath); @@ -190,8 +198,12 @@ export const npmRegistry: Registry = { }); debug('Publish command completed successfully'); - if (publishResult.stdout) debug(`Publish stdout: ${publishResult.stdout}`); - if (publishResult.stderr) debug(`Publish stderr: ${publishResult.stderr}`); + if (publishResult.stdout) { + debug(`Publish stdout: ${publishResult.stdout}`); + } + if (publishResult.stderr) { + debug(`Publish stderr: ${publishResult.stderr}`); + } }, dispose(session: NpmSession): void { diff --git a/packages/publish/src/registry/pub.ts b/packages/publish/src/registry/pub.ts index 2511cde9..0f711a21 100644 --- a/packages/publish/src/registry/pub.ts +++ b/packages/publish/src/registry/pub.ts @@ -71,7 +71,9 @@ export const pubRegistry: Registry = { async isPublished(target: PubTarget): Promise { // The pub.dev REST API only makes sense for pub.dev targets; skip it for custom registries. - if (!targetsPubDev(target.publishTo)) return false; + if (!targetsPubDev(target.publishTo)) { + return false; + } return isPubPackagePublished(target.packageName, target.version); }, diff --git a/packages/publish/src/stages/git-push.ts b/packages/publish/src/stages/git-push.ts index 4f56c094..c782b3e5 100644 --- a/packages/publish/src/stages/git-push.ts +++ b/packages/publish/src/stages/git-push.ts @@ -10,8 +10,12 @@ function toGithubAuthedUrl(remoteUrl: string, token: string): string | undefined // Avoid logging token by never returning it via logs. try { const url = new URL(remoteUrl); - if (url.protocol !== 'https:') return undefined; - if (url.host !== 'github.com') return undefined; + if (url.protocol !== 'https:') { + return undefined; + } + if (url.host !== 'github.com') { + return undefined; + } // Use the recommended username for GitHub token auth in HTTPS URLs. url.username = 'x-access-token'; @@ -35,7 +39,9 @@ export interface PushSetup { export async function preparePushSetup(ctx: PipelineContext): Promise { const { config, cwd, output } = ctx; - if (!config.git.push) return null; + if (!config.git.push) { + return null; + } const { remote } = config.git; const git = createGitCli(); @@ -55,7 +61,9 @@ export async function preparePushSetup(ctx: PipelineContext): Promise 0) { const resolved = resolveTagPackage(tag, packageNames); - if (resolved) return applyTemplate(resolved.packageName, resolved.version); + if (resolved) { + return applyTemplate(resolved.packageName, resolved.version); + } } // Fallback: try splitting on last @ for unsanitized "pkg@version" tags const atIndex = tag.lastIndexOf('@'); - if (atIndex === -1) return tag; + if (atIndex === -1) { + return tag; + } return applyTemplate(tag.slice(0, atIndex), tag.slice(atIndex + 1)); } @@ -142,11 +150,15 @@ function findNotesForTag(tag: string, notes: Record): string | u const resolved = resolveTagPackage(tag, Object.keys(notes)); if (resolved) { const body = notes[resolved.packageName]; - if (body?.trim()) return body; + if (body?.trim()) { + return body; + } } // Single-package fallback for version-only tags (e.g., "v1.0.0") const entries = Object.values(notes).filter((b) => b.trim()); - if (entries.length === 1 && isVersionOnlyTag(tag)) return entries[0]; + if (entries.length === 1 && isVersionOnlyTag(tag)) { + return entries[0]; + } return undefined; } @@ -154,7 +166,9 @@ function findNotesForTag(tag: string, notes: Record): string | u * Find the changelog for a given tag and format its entries as markdown. */ function formatChangelogForTag(tag: string, changelogs: VersionPackageChangelog[]): string | undefined { - if (changelogs.length === 0) return undefined; + if (changelogs.length === 0) { + return undefined; + } const resolved = resolveTagPackage( tag, @@ -168,7 +182,9 @@ function formatChangelogForTag(tag: string, changelogs: VersionPackageChangelog[ target = changelogs[0]; } - if (!target || target.entries.length === 0) return undefined; + if (!target || target.entries.length === 0) { + return undefined; + } const lines: string[] = []; for (const entry of target.entries) { @@ -202,7 +218,9 @@ export async function runGithubReleaseStage(ctx: PipelineContext): Promise } const firstTag = tags[0]; - if (!firstTag) return; + if (!firstTag) { + return; + } const tagsToRelease = config.githubRelease.perPackage ? tags : [firstTag]; const skipPackages = config.githubRelease.skipPackages ?? []; diff --git a/packages/publish/src/stages/prepare.ts b/packages/publish/src/stages/prepare.ts index bef27d3a..f784ef1d 100644 --- a/packages/publish/src/stages/prepare.ts +++ b/packages/publish/src/stages/prepare.ts @@ -80,12 +80,16 @@ export async function runPrepareStage(ctx: PipelineContext): Promise { debug(`Updated ${cargoPath} to version ${update.newVersion}`); const lockPath = await syncCargoLockfile(pkgDir); - if (lockPath) lockfiles.add(lockPath); + if (lockPath) { + lockfiles.add(lockPath); + } } if (lockfiles.size > 0) { const existing = new Set(ctx.additionalFiles ?? []); - for (const lock of lockfiles) existing.add(lock); + for (const lock of lockfiles) { + existing.add(lock); + } ctx.additionalFiles = [...existing]; } } diff --git a/packages/publish/src/types.ts b/packages/publish/src/types.ts index 6e7f0ceb..add8ef5c 100644 --- a/packages/publish/src/types.ts +++ b/packages/publish/src/types.ts @@ -218,7 +218,9 @@ export function getDefaultConfig(): PublishConfig { export function toPublishConfig(config: BasePublishConfig | undefined): PublishConfig { const defaults = getDefaultConfig(); - if (!config) return defaults; + if (!config) { + return defaults; + } return { npm: { diff --git a/packages/publish/src/utils/cargo.ts b/packages/publish/src/utils/cargo.ts index f10b5996..303c26e0 100644 --- a/packages/publish/src/utils/cargo.ts +++ b/packages/publish/src/utils/cargo.ts @@ -23,7 +23,9 @@ export { parseCargoToml }; */ export async function syncCargoLockfile(crateDir: string): Promise { const lockPath = findCargoLockfile(crateDir); - if (!lockPath) return undefined; + if (!lockPath) { + return undefined; + } try { await execCommand('cargo', ['update', '--workspace', '--offline'], { diff --git a/packages/publish/src/utils/exec.ts b/packages/publish/src/utils/exec.ts index e6ce28d7..18a40aca 100644 --- a/packages/publish/src/utils/exec.ts +++ b/packages/publish/src/utils/exec.ts @@ -93,7 +93,9 @@ export async function execCommand(file: string, args: string[], options: ExecOpt * Combine an exec error's message, stdout, and stderr into a single string for pattern matching. */ export function getExecErrorOutput(error: unknown): string { - if (!error || typeof error !== 'object') return String(error); + if (!error || typeof error !== 'object') { + return String(error); + } const e = error as { message?: string; stdout?: string; stderr?: string }; return [e.message, e.stdout, e.stderr].filter(Boolean).join('\n'); } diff --git a/packages/publish/src/utils/npm-env.ts b/packages/publish/src/utils/npm-env.ts index d63189a1..52cc032a 100644 --- a/packages/publish/src/utils/npm-env.ts +++ b/packages/publish/src/utils/npm-env.ts @@ -44,7 +44,9 @@ export function createNpmSubprocessIsolation(options: { // Default: no isolation. Still allow targeted env unsets. const baseEnv: Record = {}; - if (!authMethod) return { env: baseEnv, cleanup: () => {} }; + if (!authMethod) { + return { env: baseEnv, cleanup: () => {} }; + } const token = process.env.NPM_TOKEN ?? process.env.NODE_AUTH_TOKEN; const registryHost = (() => { diff --git a/packages/publish/src/utils/package-manager.ts b/packages/publish/src/utils/package-manager.ts index eac533bb..120741dc 100644 --- a/packages/publish/src/utils/package-manager.ts +++ b/packages/publish/src/utils/package-manager.ts @@ -4,8 +4,12 @@ import * as path from 'node:path'; export type PackageManager = 'pnpm' | 'npm' | 'yarn'; export function detectPackageManager(cwd: string): PackageManager { - if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'; - if (fs.existsSync(path.join(cwd, 'yarn.lock'))) return 'yarn'; + if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) { + return 'pnpm'; + } + if (fs.existsSync(path.join(cwd, 'yarn.lock'))) { + return 'yarn'; + } return 'npm'; } @@ -21,7 +25,9 @@ export function buildPublishCommand( if (pm === 'pnpm') { file = 'pnpm'; args = ['publish', '--access', options.access, '--tag', options.tag]; - if (options.noGitChecks) args.push('--no-git-checks'); + if (options.noGitChecks) { + args.push('--no-git-checks'); + } } else { file = 'npm'; args = ['publish', '--access', options.access, '--tag', options.tag]; diff --git a/packages/publish/src/utils/pub.ts b/packages/publish/src/utils/pub.ts index 14199734..a0e2f7a1 100644 --- a/packages/publish/src/utils/pub.ts +++ b/packages/publish/src/utils/pub.ts @@ -18,8 +18,12 @@ export async function isPubPackagePublished(name: string, version: string): Prom signal: AbortSignal.timeout(PUB_DEV_API_TIMEOUT_MS), headers: { 'User-Agent': PUB_DEV_USER_AGENT }, }); - if (response.status === 200) return true; - if (response.status === 404) return false; + if (response.status === 200) { + return true; + } + if (response.status === 404) { + return false; + } debug(`pub.dev published-check returned ${response.status} for ${name}@${version}, will attempt publish`); return false; } catch (error) { diff --git a/packages/publish/src/utils/publish-retry.ts b/packages/publish/src/utils/publish-retry.ts index 69d49446..f6d01728 100644 --- a/packages/publish/src/utils/publish-retry.ts +++ b/packages/publish/src/utils/publish-retry.ts @@ -44,11 +44,15 @@ export function classifyPublishError(error: unknown): PublishErrorClass { const output = getExecErrorOutput(error); for (const pattern of PERMANENT_PATTERNS) { - if (pattern.test(output)) return 'permanent'; + if (pattern.test(output)) { + return 'permanent'; + } } for (const pattern of TRANSIENT_PATTERNS) { - if (pattern.test(output)) return 'transient'; + if (pattern.test(output)) { + return 'transient'; + } } return 'permanent'; diff --git a/packages/publish/test/integration/publish-e2e.spec.ts b/packages/publish/test/integration/publish-e2e.spec.ts index c829f690..a2e8847d 100644 --- a/packages/publish/test/integration/publish-e2e.spec.ts +++ b/packages/publish/test/integration/publish-e2e.spec.ts @@ -240,8 +240,12 @@ beforeAll(() => { }); afterAll(() => { - if (originalPath !== undefined) process.env.PATH = originalPath; - for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); + if (originalPath !== undefined) { + process.env.PATH = originalPath; + } + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } }); beforeEach(() => { @@ -255,7 +259,9 @@ afterEach(async () => { } process.chdir(ORIGINAL_CWD); for (const key of Object.keys(process.env)) { - if (!(key in savedEnv)) delete process.env[key]; + if (!(key in savedEnv)) { + delete process.env[key]; + } } Object.assign(process.env, savedEnv); }); @@ -363,7 +369,9 @@ describe.skipIf(!E2E_ENABLED)('Publish pipeline e2e (real npm registry)', () => // registry + token) wins, and pin globalconfig / cache / HOME to throwaway paths so no ambient // `~/.npmrc` leaks in either. The full-env restore in afterEach undoes all of this. for (const key of Object.keys(process.env)) { - if (/^npm_config_/i.test(key)) delete process.env[key]; + if (/^npm_config_/i.test(key)) { + delete process.env[key]; + } } const home = mkTemp('npm-home'); const emptyGlobalNpmrc = path.join(home, 'global-npmrc'); diff --git a/packages/publish/test/unit/registry/dispatcher.spec.ts b/packages/publish/test/unit/registry/dispatcher.spec.ts index c7104e23..b69c094e 100644 --- a/packages/publish/test/unit/registry/dispatcher.spec.ts +++ b/packages/publish/test/unit/registry/dispatcher.spec.ts @@ -111,8 +111,11 @@ describe('runPublishStage dispatcher', () => { await expect(runPublishStage(cargoRegistry, ctx)).resolves.toBeUndefined(); expect(ctx.output.cargo).toHaveLength(0); } finally { - if (savedToken === undefined) delete process.env.CARGO_REGISTRY_TOKEN; - else process.env.CARGO_REGISTRY_TOKEN = savedToken; + if (savedToken === undefined) { + delete process.env.CARGO_REGISTRY_TOKEN; + } else { + process.env.CARGO_REGISTRY_TOKEN = savedToken; + } } }); @@ -183,7 +186,9 @@ describe('runPublishStage dispatcher', () => { let calls = 0; const publish = vi.fn(async () => { calls += 1; - if (calls === 1) throw execError('503 Service Unavailable'); + if (calls === 1) { + throw execError('503 Service Unavailable'); + } }); const ctx = createContext(); diff --git a/packages/publish/test/unit/stages/npm-publish-order.spec.ts b/packages/publish/test/unit/stages/npm-publish-order.spec.ts index be3e66eb..a09df118 100644 --- a/packages/publish/test/unit/stages/npm-publish-order.spec.ts +++ b/packages/publish/test/unit/stages/npm-publish-order.spec.ts @@ -26,7 +26,9 @@ describe('orderNpmUpdates', () => { beforeEach(() => { vi.mocked(fs.readFileSync).mockImplementation((p: fs.PathOrFileDescriptor) => { const manifest = manifests[String(p)]; - if (!manifest) throw new Error(`no manifest for ${String(p)}`); + if (!manifest) { + throw new Error(`no manifest for ${String(p)}`); + } return JSON.stringify(manifest); }); }); diff --git a/packages/release/src/backfill/github-release.ts b/packages/release/src/backfill/github-release.ts index a7612086..1e8894e0 100644 --- a/packages/release/src/backfill/github-release.ts +++ b/packages/release/src/backfill/github-release.ts @@ -26,8 +26,12 @@ export function decideReleaseUpdate( onlyMissing: boolean, force: boolean = false, ): UpdateDecision { - if (existingBody === null) return { action: 'skip', reason: 'no-release' }; - if (onlyMissing && existingBody.includes(NOTES_MARKER)) return { action: 'skip', reason: 'already-backfilled' }; + if (existingBody === null) { + return { action: 'skip', reason: 'no-release' }; + } + if (onlyMissing && existingBody.includes(NOTES_MARKER)) { + return { action: 'skip', reason: 'already-backfilled' }; + } if (!force && !onlyMissing && existingBody.trim() && !existingBody.includes(NOTES_MARKER)) { return { action: 'skip', reason: 'hand-edited' }; } @@ -57,7 +61,9 @@ export function getReleaseBody(tag: string): string | null { throw new Error('GitHub CLI (`gh`) not found — install it and run `gh auth login` to update release bodies.'); } const stderr = (typeof e.stderr === 'string' ? e.stderr : e.stderr?.toString()) || e.message || ''; - if (/release not found/i.test(stderr)) return null; + if (/release not found/i.test(stderr)) { + return null; + } throw new Error(`\`gh release view ${tag}\` failed: ${stderr.trim() || 'unknown error'}`); } } @@ -84,7 +90,9 @@ export function getReleaseInfo(tag: string): { isDraft: boolean; body: string | throw new Error('GitHub CLI (`gh`) not found — install it and run `gh auth login` to update release bodies.'); } const stderr = (typeof e.stderr === 'string' ? e.stderr : e.stderr?.toString()) || e.message || ''; - if (/release not found/i.test(stderr)) return null; + if (/release not found/i.test(stderr)) { + return null; + } throw new Error(`\`gh release view ${tag}\` failed: ${stderr.trim() || 'unknown error'}`); } } diff --git a/packages/release/src/backfill/reconstruct.ts b/packages/release/src/backfill/reconstruct.ts index abee1236..31b5c7fb 100644 --- a/packages/release/src/backfill/reconstruct.ts +++ b/packages/release/src/backfill/reconstruct.ts @@ -75,10 +75,16 @@ export async function reconstructChangelogs(opts: ReconstructOptions): Promise tasks[k] !== false).length; } diff --git a/packages/release/src/commands/emitResult.ts b/packages/release/src/commands/emitResult.ts index 01950038..058c7276 100644 --- a/packages/release/src/commands/emitResult.ts +++ b/packages/release/src/commands/emitResult.ts @@ -15,7 +15,9 @@ export function emitResult( data: unknown, opts: { json?: boolean; output?: string; changed?: boolean; warnings?: EnvelopeWarning[] }, ): void { - if (!opts.json && !opts.output) return; + if (!opts.json && !opts.output) { + return; + } writeEnvelope(successEnvelope(data ?? null, { changed: opts.changed, warnings: opts.warnings }), opts); } diff --git a/packages/release/src/commands/gate-command.ts b/packages/release/src/commands/gate-command.ts index 28035a05..11799241 100644 --- a/packages/release/src/commands/gate-command.ts +++ b/packages/release/src/commands/gate-command.ts @@ -14,9 +14,15 @@ export function createGateCommand(): Command { .option('-q, --quiet', 'Suppress non-error output', false) .option('--project-dir ', 'Project directory', process.cwd()) .action(async (opts) => { - if (opts.json) setJsonMode(true); - if (opts.verbose) setLogLevel('debug'); - if (opts.quiet) setQuietMode(true); + if (opts.json) { + setJsonMode(true); + } + if (opts.verbose) { + setLogLevel('debug'); + } + if (opts.quiet) { + setQuietMode(true); + } try { const result = await runGate({ diff --git a/packages/release/src/commands/labels-command.ts b/packages/release/src/commands/labels-command.ts index 5c512125..0648f9f7 100644 --- a/packages/release/src/commands/labels-command.ts +++ b/packages/release/src/commands/labels-command.ts @@ -45,7 +45,9 @@ async function detectRepoFromGit(projectDir?: string): Promise 0) parts.push(`${days}d`); - if (hours > 0) parts.push(`${hours}h`); - if (minutes > 0) parts.push(`${minutes}m`); - if (parts.length === 0) parts.push(`${Math.ceil(ms / 1000)}s`); + if (days > 0) { + parts.push(`${days}d`); + } + if (hours > 0) { + parts.push(`${hours}h`); + } + if (minutes > 0) { + parts.push(`${minutes}m`); + } + if (parts.length === 0) { + parts.push(`${Math.ceil(ms / 1000)}s`); + } return parts.join(' '); } diff --git a/packages/release/src/failure-report/failure-report.ts b/packages/release/src/failure-report/failure-report.ts index e4bcdbd7..da0a3bba 100644 --- a/packages/release/src/failure-report/failure-report.ts +++ b/packages/release/src/failure-report/failure-report.ts @@ -137,7 +137,9 @@ export function buildLedger(versionOutput: VersionOutput, publishOutput: Publish return updates.map((u) => { const entry = byPackage.get(u.packageName); - if (entry) return entry; + if (entry) { + return entry; + } return { packageName: u.packageName, version: u.newVersion, @@ -301,7 +303,9 @@ export function renderResolvedReport(versionOutput: VersionOutput): string { * treated as `unresolved` (the conservative default — surface the warning rather than hide it). */ export function parseFailureReportStatus(body: string): FailureReportStatus | null { - if (!body.startsWith(FAILURE_MARKER)) return null; + if (!body.startsWith(FAILURE_MARKER)) { + return null; + } return STATUS_FIELD.decode(body) ?? 'unresolved'; } @@ -310,7 +314,9 @@ export function parseFailureReportStatus(body: string): FailureReportStatus | nu * is not a failure report or the data comment is missing/malformed. */ export function parseFailureReportData(body: string): FailureReportData | null { - if (!body.startsWith(FAILURE_MARKER)) return null; + if (!body.startsWith(FAILURE_MARKER)) { + return null; + } return DATA_FIELD.decode(body); } diff --git a/packages/release/src/failure-report/post.ts b/packages/release/src/failure-report/post.ts index 7ca4a966..757d05bc 100644 --- a/packages/release/src/failure-report/post.ts +++ b/packages/release/src/failure-report/post.ts @@ -70,7 +70,9 @@ export async function resolveFailureReportIfPresent( ): Promise { try { const existing = await findPreviewComment(forge, prNumber, FAILURE_MARKER); - if (existing === null) return; + if (existing === null) { + return; + } await postOrUpdateComment(forge, prNumber, renderResolvedReport(versionOutput), FAILURE_MARKER); info(`Marked publish-failure report on PR #${prNumber} as resolved`); } catch (err) { @@ -116,8 +118,12 @@ export async function detectUnresolvedFailure(forge: Forge, prNumber: number): P return null; } - if (!body) return null; - if (parseFailureReportStatus(body) !== 'unresolved') return null; + if (!body) { + return null; + } + if (parseFailureReportStatus(body) !== 'unresolved') { + return null; + } return parseReportLedgerSummary(body, prNumber); } @@ -129,7 +135,9 @@ export async function detectUnresolvedFailure(forge: Forge, prNumber: number): P */ function parseReportLedgerSummary(body: string, prNumber: number): UnresolvedFailure | null { const data = parseFailureReportData(body); - if (!data) return null; + if (!data) { + return null; + } return { previousLabel: data.label, published: data.published, diff --git a/packages/release/src/gate/gate.ts b/packages/release/src/gate/gate.ts index 7614396b..a810feaf 100644 --- a/packages/release/src/gate/gate.ts +++ b/packages/release/src/gate/gate.ts @@ -225,9 +225,15 @@ async function computeGateResult(input: ComputeGateInput): Promise { * - In `commit` mode, don't notify on `release:skip` — that's deliberate, not user error. */ function shouldNotifyPR(e: PREvaluation, trigger: 'commit' | 'label'): boolean { - if (e.shouldRelease) return false; - if (e.blocked) return true; - if (!e.hasReleaseIntent) return false; + if (e.shouldRelease) { + return false; + } + if (e.blocked) { + return true; + } + if (!e.hasReleaseIntent) { + return false; + } return trigger === 'label'; } @@ -239,7 +245,9 @@ async function notifyInsufficientLabels( ): Promise { await Promise.all( evaluations.map(async (e) => { - if (!shouldNotifyPR(e, trigger)) return; + if (!shouldNotifyPR(e, trigger)) { + return; + } try { const body = buildNotifyBody(e, labelConfig); await postOrUpdateComment(forge, e.prNumber, body, NOTIFY_MARKER); diff --git a/packages/release/src/git.ts b/packages/release/src/git.ts index 7f2bd2ee..84a4830c 100644 --- a/packages/release/src/git.ts +++ b/packages/release/src/git.ts @@ -17,10 +17,14 @@ export interface GitHubContext { export function getGitHubContext(): GitHubContext | null { const repo = process.env.GITHUB_REPOSITORY; - if (!repo) return null; + if (!repo) { + return null; + } const [owner, repoName] = repo.split('/'); - if (!owner || !repoName) return null; + if (!owner || !repoName) { + return null; + } return { owner, diff --git a/packages/release/src/github.ts b/packages/release/src/github.ts index 2781cdcf..f94ab259 100644 --- a/packages/release/src/github.ts +++ b/packages/release/src/github.ts @@ -9,7 +9,9 @@ export const MARKER = ''; * token before reaching the forge-backed paths; the assertion is a safety net for that invariant. */ export function forgeFor(context: { token: string | null | undefined; owner: string; repo: string }): Forge { - if (!context.token) throw new Error('A GitHub token is required to access the forge.'); + if (!context.token) { + throw new Error('A GitHub token is required to access the forge.'); + } return createGitHubForge({ token: context.token, owner: context.owner, repo: context.repo }); } @@ -53,7 +55,9 @@ export async function findMergedPRsSinceLastRelease(forge: Forge, projectDir: st const prGroups = await Promise.all(mergeShas.map((sha) => findMergedPRsForCommit(forge, sha))); const seen = new Set(); for (const prs of prGroups) { - for (const n of prs) seen.add(n); + for (const n of prs) { + seen.add(n); + } } return [...seen]; } diff --git a/packages/release/src/label-definitions.ts b/packages/release/src/label-definitions.ts index 17e7a94d..bf71a10a 100644 --- a/packages/release/src/label-definitions.ts +++ b/packages/release/src/label-definitions.ts @@ -107,7 +107,9 @@ export function deriveLabelDefinitions( // first (more specific) definition so each label is created exactly once. const seen = new Set(); return definitions.filter((def) => { - if (!def.name || seen.has(def.name)) return false; + if (!def.name || seen.has(def.name)) { + return false; + } seen.add(def.name); return true; }); diff --git a/packages/release/src/label-utils.ts b/packages/release/src/label-utils.ts index bd98f44e..fdb13ed8 100644 --- a/packages/release/src/label-utils.ts +++ b/packages/release/src/label-utils.ts @@ -107,17 +107,31 @@ export function detectLabelConflicts(prLabels: string[], labels: LabelConfig = D * `release:graduate` wins over everything and returns undefined (graduation is bump-less). */ export function composeBumpFromLabels(prLabels: string[], labels: LabelConfig = DEFAULT_LABELS): string | undefined { - if (prLabels.includes(labels.graduate)) return undefined; + if (prLabels.includes(labels.graduate)) { + return undefined; + } if (prLabels.includes(labels.prerelease)) { - if (prLabels.includes(labels.major)) return 'premajor'; - if (prLabels.includes(labels.minor)) return 'preminor'; - if (prLabels.includes(labels.patch)) return 'prepatch'; + if (prLabels.includes(labels.major)) { + return 'premajor'; + } + if (prLabels.includes(labels.minor)) { + return 'preminor'; + } + if (prLabels.includes(labels.patch)) { + return 'prepatch'; + } return 'prerelease'; } - if (prLabels.includes(labels.major)) return 'major'; - if (prLabels.includes(labels.minor)) return 'minor'; - if (prLabels.includes(labels.patch)) return 'patch'; + if (prLabels.includes(labels.major)) { + return 'major'; + } + if (prLabels.includes(labels.minor)) { + return 'minor'; + } + if (prLabels.includes(labels.patch)) { + return 'patch'; + } return undefined; } diff --git a/packages/release/src/preview/detect.ts b/packages/release/src/preview/detect.ts index 291e1ed9..042a699c 100644 --- a/packages/release/src/preview/detect.ts +++ b/packages/release/src/preview/detect.ts @@ -17,12 +17,16 @@ export function detectPrerelease(packagePaths: string[], projectDir: string): Pr : [path.join(projectDir, 'package.json')]; for (const pkgPath of paths) { - if (!fs.existsSync(pkgPath)) continue; + if (!fs.existsSync(pkgPath)) { + continue; + } try { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); const result = parsePrerelease(pkg.version); - if (result.isPrerelease) return result; + if (result.isPrerelease) { + return result; + } } catch { // Skip unreadable package.json files } @@ -36,7 +40,9 @@ export function detectPrerelease(packagePaths: string[], projectDir: string): Pr * E.g. "0.3.0-next.4" → { isPrerelease: true, identifier: "next" } */ export function parsePrerelease(version: string | undefined): PrereleaseDetection { - if (!version) return { isPrerelease: false }; + if (!version) { + return { isPrerelease: false }; + } const match = version.match(/-([a-zA-Z0-9][a-zA-Z0-9-]*)(?:\.\d+)*(?:\+[^\s]+)?$/); if (match) { diff --git a/packages/release/src/preview/format.ts b/packages/release/src/preview/format.ts index 6fff23a5..6f3660ab 100644 --- a/packages/release/src/preview/format.ts +++ b/packages/release/src/preview/format.ts @@ -107,7 +107,9 @@ function getIntroMessage(strategy: ReleaseStrategy, standingPrNumber?: number): } function getLabelBanner(labelContext?: LabelContext): string[] { - if (!labelContext) return []; + if (!labelContext) { + return []; + } const lines: string[] = []; @@ -129,7 +131,9 @@ function getLabelBanner(labelContext?: LabelContext): string[] { ) { descriptor.push(labelContext.bumpLabel); const bumpLabelName = labelContext.labels?.[labelContext.bumpLabel]; - if (bumpLabelName) sources.push(`\`${bumpLabelName}\``); + if (bumpLabelName) { + sources.push(`\`${bumpLabelName}\``); + } } if (labelContext.prerelease) { descriptor.push('prerelease'); @@ -159,10 +163,18 @@ function getLabelBanner(labelContext?: LabelContext): string[] { // Show what was seen, point at the override surface (the standing PR) and the bypass label. if (labelContext.advisoryInStandingPr) { const seen: string[] = []; - if (labelContext.bumpLabel) seen.push(`\`bump:${labelContext.bumpLabel}\``); - if (labelContext.scopeLabels?.length) seen.push(...labelContext.scopeLabels.map((s) => `\`${s}\``)); - if (labelContext.stable) seen.push(`\`${labelContext.labels?.graduate ?? 'release:graduate'}\``); - if (labelContext.prerelease) seen.push(`\`${labelContext.labels?.prerelease ?? 'channel:prerelease'}\``); + if (labelContext.bumpLabel) { + seen.push(`\`bump:${labelContext.bumpLabel}\``); + } + if (labelContext.scopeLabels?.length) { + seen.push(...labelContext.scopeLabels.map((s) => `\`${s}\``)); + } + if (labelContext.stable) { + seen.push(`\`${labelContext.labels?.graduate ?? 'release:graduate'}\``); + } + if (labelContext.prerelease) { + seen.push(`\`${labelContext.labels?.prerelease ?? 'channel:prerelease'}\``); + } const seenStr = seen.length ? ` (saw: ${seen.join(', ')})` : ''; const immediateLabel = labelContext.labels?.immediate ?? 'release:immediate'; lines.push( @@ -373,14 +385,23 @@ export function formatPreviewComment(result: ReleaseOutput | null, options?: For const coversAll = syncListed.length === publishableUpdates(versionOutput).length; const heading = coversAll ? 'All packages' : 'Also bumped'; bumped.push(`**${heading} → ${syncListed[0]?.newVersion}** (sync versioning)`, ''); - for (const u of syncListed) bumped.push(`- \`${u.packageName}\``); + for (const u of syncListed) { + bumped.push(`- \`${u.packageName}\``); + } } } else { - if (hasPackageChangelogs || sharedEntries) bumped.push('**Also bumped** (sync versioning)', ''); - else bumped.push('**Bumped** (sync versioning — no individual changes)', ''); - for (const u of syncBumpedOnly) bumped.push(`- \`${u.packageName}\` → ${u.newVersion}`); + if (hasPackageChangelogs || sharedEntries) { + bumped.push('**Also bumped** (sync versioning)', ''); + } else { + bumped.push('**Bumped** (sync versioning — no individual changes)', ''); + } + for (const u of syncBumpedOnly) { + bumped.push(`- \`${u.packageName}\` → ${u.newVersion}`); + } + } + if (bumped.length > 0) { + lines.push(...blockquote(bumped), ''); } - if (bumped.length > 0) lines.push(...blockquote(bumped), ''); } } @@ -423,7 +444,9 @@ function renderQueuedTable(snapshot: StandingPRSnapshot): string[] { const changelogs = snapshot.manifest.versionOutput.changelogs.filter( (cl) => cl.entries.length > 0 || cl.version !== cl.previousVersion, ); - if (changelogs.length === 0) return []; + if (changelogs.length === 0) { + return []; + } const lines: string[] = [ '', '### Currently queued for release', @@ -482,8 +505,12 @@ function renderMergeTable(rows: MergedRow[]): string[] { const standing = row.standing ?? '—'; const current = row.current ?? '—'; let afterCell = row.afterMerge; - if (row.status === 'escalated' && row.standing) afterCell += ` ⚠ escalated from ${row.standing}`; - if (row.status === 'new-from-pr') afterCell += ' (new)'; + if (row.status === 'escalated' && row.standing) { + afterCell += ` ⚠ escalated from ${row.standing}`; + } + if (row.status === 'new-from-pr') { + afterCell += ' (new)'; + } lines.push(`| \`${row.packageName}\` | ${standing} | ${current} | ${afterCell} |`); } lines.push(''); @@ -494,7 +521,9 @@ function renderEntries(entries: VersionChangelogEntry[], refs: ChangelogRefsMode const lines: string[] = []; const grouped = new Map(); for (const entry of entries) { - if (!grouped.has(entry.type)) grouped.set(entry.type, []); + if (!grouped.has(entry.type)) { + grouped.set(entry.type, []); + } grouped.get(entry.type)?.push(entry); } const renderedTypes = new Set(); @@ -563,6 +592,8 @@ function capitalize(str: string): string { * Markdown blockquote. Trailing blanks are dropped so the quote ends cleanly before the next block. */ function blockquote(lines: string[]): string[] { const trimmed = [...lines]; - while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') trimmed.pop(); + while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') { + trimmed.pop(); + } return trimmed.map((l) => (l.length > 0 ? `> ${l}` : '>')); } diff --git a/packages/release/src/preview/merge.ts b/packages/release/src/preview/merge.ts index 393f1043..ba0b1aba 100644 --- a/packages/release/src/preview/merge.ts +++ b/packages/release/src/preview/merge.ts @@ -29,7 +29,9 @@ export function mergeForPreview( const byName = new Map(); for (const cl of standingChangelogs) { - if (!cl.entries.length && cl.version === cl.previousVersion) continue; + if (!cl.entries.length && cl.version === cl.previousVersion) { + continue; + } byName.set(cl.packageName, { packageName: cl.packageName, baseline: cl.previousVersion, @@ -40,7 +42,9 @@ export function mergeForPreview( } for (const cl of currentChangelogs) { - if (!cl.entries.length && cl.version === cl.previousVersion) continue; + if (!cl.entries.length && cl.version === cl.previousVersion) { + continue; + } const existing = byName.get(cl.packageName); if (!existing) { byName.set(cl.packageName, { @@ -54,7 +58,9 @@ export function mergeForPreview( } // Only record a current version when it is parseable — an invalid string would surface // as a garbled value in the "This PR" column of the rendered table. - if (semver.valid(cl.version)) existing.current = cl.version; + if (semver.valid(cl.version)) { + existing.current = cl.version; + } // Guard semver comparison against malformed version strings (older manifests, unexpected // calculator output) — semver.gt throws on invalid input, which would otherwise abort the // entire preview. Treat unparseable versions as 'unchanged' to keep the preview non-fatal. diff --git a/packages/release/src/preview/preview.ts b/packages/release/src/preview/preview.ts index 4d85657e..03a08125 100644 --- a/packages/release/src/preview/preview.ts +++ b/packages/release/src/preview/preview.ts @@ -52,7 +52,9 @@ interface PullRequestEvent { /** Read and parse the GitHub Actions event payload once, or undefined on any error. */ function readEventPayload(): PullRequestEvent | undefined { const eventPath = process.env.GITHUB_EVENT_PATH; - if (!eventPath || !fs.existsSync(eventPath)) return undefined; + if (!eventPath || !fs.existsSync(eventPath)) { + return undefined; + } try { return JSON.parse(fs.readFileSync(eventPath, 'utf-8')) as PullRequestEvent; } catch { @@ -374,11 +376,19 @@ async function applyLabelOverrides( // do not propagate to `result`. Version analysis still runs (commit-driven) so the // preview can show this PR's contribution to the standing PR via the merge table. if (advisoryInStandingPr) { - if (prLabels.includes(labels.major)) labelContext.bumpLabel = 'major'; - else if (prLabels.includes(labels.minor)) labelContext.bumpLabel = 'minor'; - else if (prLabels.includes(labels.patch)) labelContext.bumpLabel = 'patch'; - if (prLabels.includes(labels.graduate)) labelContext.stable = true; - if (prLabels.includes(labels.prerelease)) labelContext.prerelease = true; + if (prLabels.includes(labels.major)) { + labelContext.bumpLabel = 'major'; + } else if (prLabels.includes(labels.minor)) { + labelContext.bumpLabel = 'minor'; + } else if (prLabels.includes(labels.patch)) { + labelContext.bumpLabel = 'patch'; + } + if (prLabels.includes(labels.graduate)) { + labelContext.stable = true; + } + if (prLabels.includes(labels.prerelease)) { + labelContext.prerelease = true; + } return { options: result, labelContext }; } @@ -507,10 +517,20 @@ async function applyLabelOverrides( * `preminor`, `prepatch`, `premajor`, or `prerelease` — only the first three carry a magnitude. */ function magnitudeFromBump(bump: string | undefined): string | undefined { - if (!bump) return undefined; - if (bump === 'major' || bump === 'minor' || bump === 'patch') return bump; - if (bump === 'premajor') return 'major'; - if (bump === 'preminor') return 'minor'; - if (bump === 'prepatch') return 'patch'; + if (!bump) { + return undefined; + } + if (bump === 'major' || bump === 'minor' || bump === 'patch') { + return bump; + } + if (bump === 'premajor') { + return 'major'; + } + if (bump === 'preminor') { + return 'minor'; + } + if (bump === 'prepatch') { + return 'patch'; + } return undefined; } diff --git a/packages/release/src/preview/refresh.ts b/packages/release/src/preview/refresh.ts index b187dc77..68707408 100644 --- a/packages/release/src/preview/refresh.ts +++ b/packages/release/src/preview/refresh.ts @@ -80,15 +80,23 @@ export async function refreshFeederPreviews(options: RefreshAfterReleaseOptions) const eligible: OpenPullRequest[] = []; for (const pr of open) { - if (pr.draft) continue; // drafts don't carry previews - if (pr.headRef === branch) continue; // the standing PR's manifest is authoritative, never previewed + if (pr.draft) { + continue; // drafts don't carry previews + } + if (pr.headRef === branch) { + continue; // the standing PR's manifest is authoritative, never previewed + } // Only refresh PRs that already have a preview — never manufacture one on a PR that opted out. - if (!(await forge.findComment(pr.number, MARKER))) continue; + if (!(await forge.findComment(pr.number, MARKER))) { + continue; + } eligible.push(pr); // Open PRs come back most-recently-updated first, so the first MAX_FEEDER_PR_REFRESH are exactly // the set we refresh. Stop probing once one past the cap is in hand — bounds the per-PR // findComment calls instead of fetching comments for every open PR on a busy repo. - if (eligible.length > MAX_FEEDER_PR_REFRESH) break; + if (eligible.length > MAX_FEEDER_PR_REFRESH) { + break; + } } let toRefresh = eligible; diff --git a/packages/release/src/release.ts b/packages/release/src/release.ts index 491a003f..da15899c 100644 --- a/packages/release/src/release.ts +++ b/packages/release/src/release.ts @@ -25,7 +25,9 @@ async function resolveReleaseReportTarget(): Promise<{ mode: 'direct' | 'manual'; } | null> { const githubContext = getGitHubContext(); - if (!githubContext?.token) return null; + if (!githubContext?.token) { + return null; + } const forge = forgeFor({ token: githubContext.token, owner: githubContext.owner, repo: githubContext.repo }); const { sha } = githubContext; @@ -68,8 +70,12 @@ async function reportReleaseFailure(versionOutput: VersionOutput, err: PipelineE export function resolveScopeToTarget(scopeName: string, scopeLabels: Record): string { const prefixed = `scope:${scopeName}`; - if (scopeLabels[prefixed]) return scopeLabels[prefixed]; - if (scopeLabels[scopeName]) return scopeLabels[scopeName]; + if (scopeLabels[prefixed]) { + return scopeLabels[prefixed]; + } + if (scopeLabels[scopeName]) { + return scopeLabels[scopeName]; + } const available = Object.keys(scopeLabels).join(', '); throw new Error(`Scope "${scopeName}" not found in ci.scopeLabels. Available: ${available}`); } @@ -182,22 +188,36 @@ async function applyScopeLabelsFromPR( function applyStepOverrides(options: ReleaseOptions, releaseConfig: ReleaseConfig | undefined): void { // Steps array: a step absent from the list is skipped unless CLI already set the flag. if (releaseConfig?.steps) { - if (!releaseConfig.steps.includes('notes') && !options.skipNotes) options.skipNotes = true; - if (!releaseConfig.steps.includes('publish') && !options.skipPublish) options.skipPublish = true; + if (!releaseConfig.steps.includes('notes') && !options.skipNotes) { + options.skipNotes = true; + } + if (!releaseConfig.steps.includes('publish') && !options.skipPublish) { + options.skipPublish = true; + } } // ci overrides: can suppress a step even when it appears in 'steps'. - if (releaseConfig?.ci?.notes === false && !options.skipNotes) options.skipNotes = true; - if (releaseConfig?.ci?.githubRelease === false && !options.skipGithubRelease) options.skipGithubRelease = true; + if (releaseConfig?.ci?.notes === false && !options.skipNotes) { + options.skipNotes = true; + } + if (releaseConfig?.ci?.githubRelease === false && !options.skipGithubRelease) { + options.skipGithubRelease = true; + } } export async function runRelease(inputOptions: ReleaseOptions): Promise { // Work on a copy so config-driven overrides never mutate the caller's object const options = { ...inputOptions }; - if (options.verbose) setLogLevel('debug'); - if (options.quiet) setQuietMode(true); - if (options.json) setJsonMode(true); + if (options.verbose) { + setLogLevel('debug'); + } + if (options.quiet) { + setQuietMode(true); + } + if (options.json) { + setJsonMode(true); + } // Load release config for automation behavior let releaseKitConfig: ReturnType; diff --git a/packages/release/src/standing-pr/authorization.ts b/packages/release/src/standing-pr/authorization.ts index e7ae987b..3473da91 100644 --- a/packages/release/src/standing-pr/authorization.ts +++ b/packages/release/src/standing-pr/authorization.ts @@ -37,7 +37,9 @@ export interface EventActor { /** Read the triggering actor (and the merger, for publish) from the GitHub Actions event payload. */ export function getEventActor(): EventActor { const eventPath = process.env.GITHUB_EVENT_PATH; - if (!eventPath) return {}; + if (!eventPath) { + return {}; + } try { const event = JSON.parse(fs.readFileSync(eventPath, 'utf-8')) as { action?: string; @@ -63,7 +65,9 @@ function isBot(login: string | undefined, type: string | undefined): boolean { /** An `allowedActors` entry naming a GitHub team, e.g. `@acme/releasers` → `{ org, team }`. */ function parseTeamRef(entry: string): { org: string; team: string } | undefined { - if (!entry.startsWith('@') || !entry.includes('/')) return undefined; + if (!entry.startsWith('@') || !entry.includes('/')) { + return undefined; + } const [org, team] = entry.slice(1).split('/'); return org && team ? { org, team } : undefined; } @@ -83,25 +87,35 @@ export async function isAuthorizedActor( type: string | undefined, authz: StandingPrAuthorization, ): Promise { - if (isBot(login, type)) return true; - if (!login) return false; + if (isBot(login, type)) { + return true; + } + if (!login) { + return false; + } const entries = authz.allowedActors ?? []; // 1. Username allow-list. GitHub usernames are case-insensitive, so compare case-folded — an // `allowedActors: ['Alice']` entry must still match an event delivering `login: 'alice'`. const lc = login.toLowerCase(); - if (entries.some((e) => !parseTeamRef(e) && e.toLowerCase() === lc)) return true; + if (entries.some((e) => !parseTeamRef(e) && e.toLowerCase() === lc)) { + return true; + } // 2. Repository permission threshold. const permission = await forge.getActorPermission(login); - if (PERMISSION_RANK[permission] >= PERMISSION_RANK[authz.requiredPermission]) return true; + if (PERMISSION_RANK[permission] >= PERMISSION_RANK[authz.requiredPermission]) { + return true; + } // 3. Team membership — last, since it costs an API call per team and a 403 (no org-read scope) // propagates to the caller's gate wrapper. An `@org/team` member is authorized regardless of // repo permission. for (const entry of entries) { const ref = parseTeamRef(entry); - if (ref && (await forge.isTeamMember(ref.org, ref.team, login))) return true; + if (ref && (await forge.isTeamMember(ref.org, ref.team, login))) { + return true; + } } return false; } diff --git a/packages/release/src/standing-pr/changelog-region.ts b/packages/release/src/standing-pr/changelog-region.ts index c6bae0ea..b29045f8 100644 --- a/packages/release/src/standing-pr/changelog-region.ts +++ b/packages/release/src/standing-pr/changelog-region.ts @@ -86,7 +86,9 @@ function dedupe(attributed: AttributedEntry[]): DedupedEntry[] { const byKey = new Map(); const order: DedupedEntry[] = []; for (const { entry, pkg } of attributed) { - if (entry.synthetic) continue; + if (entry.synthetic) { + continue; + } const key = dedupeKey(entry); let agg = byKey.get(key); if (!agg) { @@ -94,7 +96,9 @@ function dedupe(attributed: AttributedEntry[]): DedupedEntry[] { byKey.set(key, agg); order.push(agg); } - if (pkg) agg.pkgs.add(pkg); + if (pkg) { + agg.pkgs.add(pkg); + } } return order; } @@ -138,7 +142,11 @@ const DEMOTED_HEADING = 'Dependencies & version bumps'; * removed, so the caller's de-duplicated change count is unchanged. */ function renderGrouped(deduped: DedupedEntry[], refOpts: RefRenderOptions, demoteScopes: readonly string[]): string[] { const distinct = new Set(); - for (const d of deduped) for (const p of d.pkgs) distinct.add(p); + for (const d of deduped) { + for (const p of d.pkgs) { + distinct.add(p); + } + } // Attribution only earns its place when the list spans more than one package. const attribution = distinct.size > 1; @@ -146,8 +154,11 @@ function renderGrouped(deduped: DedupedEntry[], refOpts: RefRenderOptions, demot const promoted: DedupedEntry[] = []; const demoted: DedupedEntry[] = []; for (const d of deduped) { - if (demote && d.entry.scope !== undefined && demoteScopes.includes(d.entry.scope)) demoted.push(d); - else promoted.push(d); + if (demote && d.entry.scope !== undefined && demoteScopes.includes(d.entry.scope)) { + demoted.push(d); + } else { + promoted.push(d); + } } const byLabel = new Map(); @@ -164,12 +175,16 @@ function renderGrouped(deduped: DedupedEntry[], refOpts: RefRenderOptions, demot const lines: string[] = []; const emit = (label: string): void => { const list = byLabel.get(label); - if (!list?.length) return; + if (!list?.length) { + return; + } // `#### `, not bold `**…**`: inside the blockquote GitHub gives a heading paragraph-level // top margin, so consecutive sections stay visually separated — bold text collapses to near-zero // spacing there. Matches the preview surface, which already uses `#### ` headings. lines.push(`#### ${label}`, ''); - for (const d of list) lines.push(entryLine(d, attribution, refOpts)); + for (const d of list) { + lines.push(entryLine(d, attribution, refOpts)); + } lines.push(''); }; const rendered = new Set(); @@ -177,15 +192,23 @@ function renderGrouped(deduped: DedupedEntry[], refOpts: RefRenderOptions, demot emit(label); rendered.add(label); } - for (const label of byLabel.keys()) if (!rendered.has(label)) emit(label); + for (const label of byLabel.keys()) { + if (!rendered.has(label)) { + emit(label); + } + } // The demoted subsection trails every type bucket, so low-signal dependency churn never crowds the // user-facing changes at the top. First-seen order is preserved (deduped iteration order). if (demoted.length > 0) { lines.push(`#### ${DEMOTED_HEADING}`, ''); - for (const d of demoted) lines.push(entryLine(d, attribution, refOpts)); + for (const d of demoted) { + lines.push(entryLine(d, attribution, refOpts)); + } lines.push(''); } - if (lines[lines.length - 1] === '') lines.pop(); + if (lines[lines.length - 1] === '') { + lines.pop(); + } return lines; } @@ -238,11 +261,17 @@ export function makeRowChangelogRenderer( const attributed: AttributedEntry[] = []; for (const name of packageNames) { const cl = byPkg.get(name); - if (!cl) continue; - for (const entry of cl.entries) attributed.push({ entry, pkg: name }); + if (!cl) { + continue; + } + for (const entry of cl.entries) { + attributed.push({ entry, pkg: name }); + } } const deduped = dedupe(attributed); - if (deduped.length === 0) return ''; + if (deduped.length === 0) { + return ''; + } const summary = heldBack ? `Changelog (${pluralEntries(deduped.length)}) — held back, won’t publish` : `Changelog (${pluralEntries(deduped.length)})`; @@ -257,10 +286,14 @@ function collectAttributed(versionOutput: VersionOutput, sharedOnly: boolean): A const attributed: AttributedEntry[] = []; if (!sharedOnly) { for (const cl of versionOutput.changelogs) { - for (const entry of cl.entries) attributed.push({ entry, pkg: cl.packageName }); + for (const entry of cl.entries) { + attributed.push({ entry, pkg: cl.packageName }); + } } } - for (const entry of versionOutput.sharedEntries ?? []) attributed.push({ entry }); + for (const entry of versionOutput.sharedEntries ?? []) { + attributed.push({ entry }); + } return attributed; } @@ -286,7 +319,9 @@ export function renderCombinedFooter( opts: { sharedOnly?: boolean; refs?: ChangelogRefsMode; demoteScopes?: readonly string[] } = {}, ): string { const deduped = dedupe(collectAttributed(versionOutput, !!opts.sharedOnly)); - if (deduped.length === 0) return ''; + if (deduped.length === 0) { + return ''; + } const n = deduped.length; const summary = opts.sharedOnly ? `Show project-wide changes (${n} ${n === 1 ? 'change' : 'changes'})` diff --git a/packages/release/src/standing-pr/notes-region.ts b/packages/release/src/standing-pr/notes-region.ts index 9883a69c..faa0af32 100644 --- a/packages/release/src/standing-pr/notes-region.ts +++ b/packages/release/src/standing-pr/notes-region.ts @@ -24,7 +24,9 @@ const NOTES_TRUNCATION_MARKER = '\n\n…(truncated)'; /** Trim one package's notes to {@link MAX_NOTES_CHARS_PER_PACKAGE}, on a line boundary where possible. */ export function truncatePackageNotes(notes: string): string { - if (notes.length <= MAX_NOTES_CHARS_PER_PACKAGE) return notes; + if (notes.length <= MAX_NOTES_CHARS_PER_PACKAGE) { + return notes; + } const budget = MAX_NOTES_CHARS_PER_PACKAGE - NOTES_TRUNCATION_MARKER.length; const slice = notes.slice(0, budget); const lastNewline = slice.lastIndexOf('\n'); @@ -35,7 +37,9 @@ export function truncatePackageNotes(notes: string): string { /** Render the editable region for the given per-package notes, with keyed markers per package. */ export function renderNotesRegion(notesByPackage: Record): string { const packages = Object.keys(notesByPackage).sort(); - if (packages.length === 0) return ''; + if (packages.length === 0) { + return ''; + } const lines: string[] = [REGION_HEADING, '', REGION_HINT, '']; for (const pkg of packages) { @@ -49,7 +53,9 @@ export function extractNotesRegions(body: string, packageNames: string[]): Recor const result: Record = {}; for (const pkg of packageNames) { const region = extractNotesRegion(body, pkg); - if (region !== undefined && region.length > 0) result[pkg] = region; + if (region !== undefined && region.length > 0) { + result[pkg] = region; + } } return result; } diff --git a/packages/release/src/standing-pr/selection-region.ts b/packages/release/src/standing-pr/selection-region.ts index 2a789f07..a11feee5 100644 --- a/packages/release/src/standing-pr/selection-region.ts +++ b/packages/release/src/standing-pr/selection-region.ts @@ -53,7 +53,9 @@ function channelOf(update: Update): ReleaseChannel { /** A release unit's channel is its primary's; an unchanged primary (no `primaryUpdate`) inherits it * from its members, which are single-channel by construction for `linked`/`fixed` groups. */ function unitChannel(unit: ReleaseUnit): ReleaseChannel { - if (unit.primaryUpdate) return channelOf(unit.primaryUpdate); + if (unit.primaryUpdate) { + return channelOf(unit.primaryUpdate); + } const child = unit.children[0]; return child ? channelOf(child) : 'stable'; } @@ -64,15 +66,20 @@ function byChannel(items: readonly T[], key: (item: T) => ReleaseChannel): Ma for (const item of items) { const k = key(item); const list = map.get(k); - if (list) list.push(item); - else map.set(k, [item]); + if (list) { + list.push(item); + } else { + map.set(k, [item]); + } } return map; } /** Push a section heading, ensuring a blank line separates it from the rows above. */ function pushSectionHeading(lines: string[], channel: ReleaseChannel): void { - if (lines[lines.length - 1] !== '') lines.push(''); + if (lines[lines.length - 1] !== '') { + lines.push(''); + } lines.push(SECTION_HEADING[channel], ''); } @@ -80,7 +87,9 @@ function pushSectionHeading(lines: string[], channel: ReleaseChannel): void { * on (npm publishes a prerelease under its preid, e.g. `1.0.0-next.1` → `next`), so a mixed PR shows * per-package maturity at a glance. */ function versionDisplay(update: Update): string { - if (channelOf(update) !== 'prerelease') return update.newVersion; + if (channelOf(update) !== 'prerelease') { + return update.newVersion; + } // SEAM: the per-package "graduate to stable" affordance — gated on a `graduate:` // label — attaches to prerelease rows here once that mechanism merges. This renders the channel // grouping + dist-tag only; it deliberately renders no affordance yet. @@ -149,9 +158,13 @@ export function computeHierarchy(updates: Update[], cfg: PrimaryConfig): Selecti // A changed primary carries its group on its update; an unchanged one is resolved from config. const groupOfPrimary = (p: string): string | undefined => { const own = updateByName.get(p)?.group; - if (own) return own; + if (own) { + return own; + } for (const [name, gc] of Object.entries(groups)) { - if (shouldMatchPackageTargets(p, gc.packages ?? [])) return name; + if (shouldMatchPackageTargets(p, gc.packages ?? [])) { + return name; + } } return undefined; }; @@ -161,12 +174,24 @@ export function computeHierarchy(updates: Update[], cfg: PrimaryConfig): Selecti const seen = new Set(); const result: Update[] = []; const add = (u: Update) => { - if (u.packageName === p || declaredPrimaryNames.has(u.packageName) || seen.has(u.packageName)) return; + if (u.packageName === p || declaredPrimaryNames.has(u.packageName) || seen.has(u.packageName)) { + return; + } seen.add(u.packageName); result.push(u); }; - if (group) for (const u of updates) if (u.group === group) add(u); - for (const u of updates) if (u.role === 'prerequisite' && u.prerequisiteOf?.includes(p)) add(u); + if (group) { + for (const u of updates) { + if (u.group === group) { + add(u); + } + } + } + for (const u of updates) { + if (u.role === 'prerequisite' && u.prerequisiteOf?.includes(p)) { + add(u); + } + } return result.sort((a, b) => a.packageName.localeCompare(b.packageName)); }; @@ -176,7 +201,9 @@ export function computeHierarchy(updates: Update[], cfg: PrimaryConfig): Selecti for (const primaryName of [...declaredPrimaryNames].sort((a, b) => a.localeCompare(b))) { const children = childrenOf(primaryName); const primaryUpdate = updateByName.get(primaryName); - if (!primaryUpdate && children.length === 0) continue; // declared but nothing in its unit changed + if (!primaryUpdate && children.length === 0) { + continue; // declared but nothing in its unit changed + } const group = groupOfPrimary(primaryName); const groupSync = group ? groups[group]?.sync : undefined; units.push({ primaryName, primaryUpdate, children, groupSync }); @@ -208,12 +235,24 @@ export function cascadeDeselection(hierarchy: SelectionHierarchy, deselected: Re // one). Escalate it to holding its whole unit: a package a maintainer held back must never silently // ship, and holding the unit keeps the render coherent (the primary shows unticked). for (const [child, owners] of hierarchy.childOwners) { - if (deselected.has(child)) for (const owner of owners) deselectedPrimaries.add(owner); + if (deselected.has(child)) { + for (const owner of owners) { + deselectedPrimaries.add(owner); + } + } + } + for (const p of deselectedPrimaries) { + effective.add(p); + } + for (const orphan of hierarchy.orphans) { + if (deselected.has(orphan.packageName)) { + effective.add(orphan.packageName); + } } - for (const p of deselectedPrimaries) effective.add(p); - for (const orphan of hierarchy.orphans) if (deselected.has(orphan.packageName)) effective.add(orphan.packageName); for (const [child, owners] of hierarchy.childOwners) { - if (owners.every((o) => deselectedPrimaries.has(o))) effective.add(child); + if (owners.every((o) => deselectedPrimaries.has(o))) { + effective.add(child); + } } return effective; } @@ -295,9 +334,13 @@ function attachChangelog( heldBack: boolean, indent: string, ): void { - if (!rowChangelog) return; + if (!rowChangelog) { + return; + } const block = rowChangelog(packageNames, heldBack, indent); - if (block) lines.push(block); + if (block) { + lines.push(block); + } } /** @@ -333,7 +376,9 @@ function attachChannelToggle( indent: string, channel: ChannelToggleConfig | undefined, ): void { - if (!channel || !selected) return; + if (!channel || !selected) { + return; + } const name = update.packageName; if (channelOf(update) === 'stable') { const preid = channel.prereleaseIdentifier ?? 'next'; @@ -401,7 +446,9 @@ function renderFlat( const channels = CHANNEL_ORDER.filter((c) => (updatesByChannel.get(c)?.length ?? 0) > 0); const showHeadings = channels.length > 1; for (const channel of channels) { - if (showHeadings) pushSectionHeading(lines, channel); + if (showHeadings) { + pushSectionHeading(lines, channel); + } renderFlatSection(lines, updatesByChannel.get(channel) ?? [], selected, rowChangelog, channelToggle); } } @@ -419,13 +466,17 @@ function renderUnit( const heldBack = !selected(unit.primaryName); // The primary's channel toggle shifts the whole unit (the wiring expands the scope to its group); // an unchanged primary anchoring the unit has no version to shift, so it gets none. - if (unit.primaryUpdate) attachChannelToggle(lines, unit.primaryUpdate, !heldBack, ' ', channel); + if (unit.primaryUpdate) { + attachChannelToggle(lines, unit.primaryUpdate, !heldBack, ' ', channel); + } if (streamlined) { if (unit.children.length > 0) { // Children inside a collapsed pane as plain bullets — a blank line after lets GitHub // render the nested markdown list. lines.push(`
ships ${unit.children.length} ${unitWord(unit)}`, ''); - for (const child of unit.children) lines.push(childBullet(child, unit.groupSync)); + for (const child of unit.children) { + lines.push(childBullet(child, unit.groupSync)); + } lines.push('
'); } // The streamlined unit ships primary + its members together (coupled or bundled), so its changelog @@ -479,12 +530,16 @@ function renderHierarchical( ); const showHeadings = channels.length > 1; for (const channel of channels) { - if (showHeadings) pushSectionHeading(lines, channel); - for (const unit of unitsByChannel.get(channel) ?? []) + if (showHeadings) { + pushSectionHeading(lines, channel); + } + for (const unit of unitsByChannel.get(channel) ?? []) { renderUnit(lines, unit, selected, streamlined, rowChangelog, channelToggle); + } // Orphans stay flat top-level checkboxes (fail-safe), grouped under their own channel. - for (const orphan of orphansByChannel.get(channel) ?? []) + for (const orphan of orphansByChannel.get(channel) ?? []) { renderOrphan(lines, orphan, selected, rowChangelog, channelToggle); + } } } @@ -532,19 +587,27 @@ export function renderSelectionRegion( */ export function extractSelection(body: string): { deselected: string[] } | undefined { const region = extractSelectionRegion(body); - if (region === undefined) return undefined; + if (region === undefined) { + return undefined; + } const deselected: string[] = []; for (const line of region.split('\n')) { const markerStart = line.indexOf('', nameStart); - if (nameEnd === -1) continue; + if (nameEnd === -1) { + continue; + } const name = line.slice(nameStart, nameEnd); // A row is selected unless its checkbox glyph is explicitly empty. `includes('[ ]')` is a fixed // substring check on the row text before the marker — no prose parsing, no backtracking regex. - if (line.slice(0, markerStart).includes('[ ]')) deselected.push(name); + if (line.slice(0, markerStart).includes('[ ]')) { + deselected.push(name); + } } return { deselected }; } @@ -556,10 +619,14 @@ function markerHits(region: string, marker: string): Array<{ name: string; ticke const hits: Array<{ name: string; ticked: boolean; unticked: boolean }> = []; for (const line of region.split('\n')) { const markerStart = line.indexOf(open); - if (markerStart === -1) continue; + if (markerStart === -1) { + continue; + } const nameStart = markerStart + open.length; const nameEnd = line.indexOf(' -->', nameStart); - if (nameEnd === -1) continue; + if (nameEnd === -1) { + continue; + } const before = line.slice(0, markerStart); hits.push({ name: line.slice(nameStart, nameEnd), @@ -578,7 +645,9 @@ function markerHits(region: string, marker: string): Array<{ name: string; ticke */ export function extractChannelSelection(body: string): { prereleased: string[]; graduated: string[] } { const region = extractSelectionRegion(body); - if (region === undefined) return { prereleased: [], graduated: [] }; + if (region === undefined) { + return { prereleased: [], graduated: [] }; + } const heldBack = new Set( markerHits(region, 'rk-sel') .filter((h) => h.unticked) @@ -634,7 +703,9 @@ export function selectionWarnings( ): SelectionWarning[] { const warnings: SelectionWarning[] = []; for (const update of updates) { - if (!deselected.has(update.packageName)) continue; + if (!deselected.has(update.packageName)) { + continue; + } if (update.group && groups[update.group]?.sync === 'independent') { // Only a *partial* hold-back splits the group. When a primary toggle cascades the whole // independent group out together, every member is deselected — coherent, so don't warn. diff --git a/packages/release/src/standing-pr/standing-pr.ts b/packages/release/src/standing-pr/standing-pr.ts index ef851697..89bc166e 100644 --- a/packages/release/src/standing-pr/standing-pr.ts +++ b/packages/release/src/standing-pr/standing-pr.ts @@ -196,9 +196,13 @@ async function getHeadSha(cwd: string): Promise { * idempotent and the workflow guards out the bot's own edits, so an `edited` re-trigger is safe. */ function isStandingPrEventRun(): boolean { - if (process.env.GITHUB_EVENT_NAME !== 'pull_request') return false; + if (process.env.GITHUB_EVENT_NAME !== 'pull_request') { + return false; + } const eventPath = process.env.GITHUB_EVENT_PATH; - if (!eventPath) return false; + if (!eventPath) { + return false; + } try { const event = JSON.parse(fs.readFileSync(eventPath, 'utf-8')) as { action?: string }; return event.action === 'labeled' || event.action === 'unlabeled' || event.action === 'edited'; @@ -245,7 +249,9 @@ async function commitAndForcePush(branch: string, cwd: string): Promise { * state can't pollute the notes commit. */ async function commitNotesFiles(files: string[], versionOutput: VersionOutput, cwd: string): Promise { - if (files.length === 0) return; + if (files.length === 0) { + return; + } const git = createGitCli(); @@ -307,7 +313,9 @@ async function commitNotesFiles(files: string[], versionOutput: VersionOutput, c * publish pipeline's `--tags` push will publish whatever tags we managed to create. */ export async function createReleaseTags(tags: string[], cwd: string): Promise { - if (tags.length === 0) return; + if (tags.length === 0) { + return; + } const git = createGitCli(); @@ -371,7 +379,9 @@ async function deleteReleaseBranch(releaseBranch: string, cwd: string): Promise< export const STANDING_PR_BODY_CAP = 64000; function truncateAtLineBoundary(text: string, maxChars: number): string { - if (text.length <= maxChars) return text; + if (text.length <= maxChars) { + return text; + } const slice = text.slice(0, maxChars); const lastNewline = slice.lastIndexOf('\n'); return lastNewline > 0 ? slice.slice(0, lastNewline) : slice; @@ -423,7 +433,9 @@ function renderPrBody(versionOutput: VersionOutput, options: RenderPrBodyOptions // One-line release headline under the heading — a neutral "what will publish" scan before // the (conditional) supersede alert and the package list. Absent for sync (its own display leads). - if (summaryLine) lines.push(summaryLine, ''); + if (summaryLine) { + lines.push(summaryLine, ''); + } // While a prior release remains partially published, lead with the supersede warning so the // maintainer sees the retry-vs-supersede choice before the package list. @@ -434,7 +446,9 @@ function renderPrBody(versionOutput: VersionOutput, options: RenderPrBodyOptions // The scannable current → next table, opt-in via ci.standingPr.summaryTable, sits above the // selection region (and after any supersede warning) — it complements the checkbox list, which // GitHub renders interactively only as a list, never in a table cell. - if (summaryTable) lines.push(summaryTable, ''); + if (summaryTable) { + lines.push(summaryTable, ''); + } // The root lockstep bump (sync mode) is never published — keep it out of the package list. const updates = publishableUpdates(versionOutput); @@ -463,11 +477,17 @@ function renderPrBody(versionOutput: VersionOutput, options: RenderPrBodyOptions } } - if (footerSection) lines.push('', footerSection, ''); - if (extraNotice) lines.push('', extraNotice, ''); + if (footerSection) { + lines.push('', footerSection, ''); + } + if (extraNotice) { + lines.push('', extraNotice, ''); + } // The editable release-notes region (opt-in via the preview-notes label) sits below the changelog // and above the merge instructions, so a reviewer reads/edits it in the natural place. - if (notesSection) lines.push('', notesSection, ''); + if (notesSection) { + lines.push('', notesSection, ''); + } lines.push('---', '> Merge this PR to publish. The release will be triggered automatically.'); lines.push('', ATTRIBUTION_FOOTER); return lines.join('\n'); @@ -480,19 +500,25 @@ function renderPrBody(versionOutput: VersionOutput, options: RenderPrBodyOptions // Level 0 — the full body: per-row changelogs (non-sync) plus the combined deduped footer. const fullSelection = renderSelectionBlock?.(true); const full = build(fullSelection, footer, undefined, notesSection); - if (full.length <= STANDING_PR_BODY_CAP) return full; + if (full.length <= STANDING_PR_BODY_CAP) { + return full; + } if (renderSelectionBlock) { // Non-sync: always keep the rows (the PR must stay usable); shed changelog volume in stages. // Level 1 — keep the per-row changelogs, drop the redundant deduped footer. if (footer) { const body = build(fullSelection, '', undefined, notesSection); - if (body.length <= STANDING_PR_BODY_CAP) return body; + if (body.length <= STANDING_PR_BODY_CAP) { + return body; + } } // Level 2 — drop the per-row changelogs too (bare rows) and point reviewers at CHANGELOG.md. const bareSelection = renderSelectionBlock(false); const body = build(bareSelection, '', TRUNCATION_NOTICE, notesSection); - if (body.length <= STANDING_PR_BODY_CAP) return body; + if (body.length <= STANDING_PR_BODY_CAP) { + return body; + } // Level 3 — even the notice overflows (thousands of rows): bare rows only. return build(bareSelection, '', undefined, notesSection); } @@ -502,7 +528,9 @@ function renderPrBody(versionOutput: VersionOutput, options: RenderPrBodyOptions // 8 = 4 (\n\n around the footer) + 2 (\n\n before the notice) + 2 safety margin. const room = STANDING_PR_BODY_CAP - build(undefined, '', undefined, notesSection).length - TRUNCATION_NOTICE.length - 8; - if (room <= 0) return build(undefined, '', undefined, notesSection); + if (room <= 0) { + return build(undefined, '', undefined, notesSection); + } return build( undefined, `${truncateAtLineBoundary(footer, room)}\n\n${TRUNCATION_NOTICE}`, @@ -514,12 +542,16 @@ function renderPrBody(versionOutput: VersionOutput, options: RenderPrBodyOptions }; const primary = shed(notesRegion); - if (primary.length <= STANDING_PR_BODY_CAP || !notesRegion) return primary; + if (primary.length <= STANDING_PR_BODY_CAP || !notesRegion) { + return primary; + } // The trimmed changelog still overflows with the notes present — the notes are (part of) the excess. // Collapse them to a short notice, then drop them outright, so an inflated notes region can never // wedge the PR at GitHub's 422. const collapsed = shed(NOTES_OMITTED_NOTICE); - if (collapsed.length <= STANDING_PR_BODY_CAP) return collapsed; + if (collapsed.length <= STANDING_PR_BODY_CAP) { + return collapsed; + } return shed(undefined); } @@ -610,13 +642,19 @@ export async function fetchStandingPRSnapshot( ): Promise { const branch = ciConfig?.standingPr?.branch ?? 'release/next'; const pr = await forge.findStandingPR(branch); - if (!pr) return null; + if (!pr) { + return null; + } const comment = await findManifestComment(forge, pr.number); - if (!comment) return null; + if (!comment) { + return null; + } // findManifestComment falls back to a human-authored comment when no bot one exists; refuse to // surface a forged manifest in the preview, matching the publish path's author check. - if (!isBotComment(comment)) return null; + if (!isBotComment(comment)) { + return null; + } let manifest: StandingPRManifest; try { @@ -737,14 +775,18 @@ function graduatedPackageNames(versionOutput: VersionOutput): string[] { ); const names = new Set(); for (const u of updates) { - if (u.action === 'graduated' || (u.group && graduatedGroups.has(u.group))) names.add(u.packageName); + if (u.action === 'graduated' || (u.group && graduatedGroups.has(u.group))) { + names.add(u.packageName); + } } return [...names].sort(); } function resolveStandingPrLabelOverrides(prLabels: string[], ciConfig: CIConfig | undefined): StandingPrOverrides { // Return a fresh object rather than a shared constant — callers may mutate `conflicts`. - if (!prLabels || prLabels.length === 0) return { conflicts: [] }; + if (!prLabels || prLabels.length === 0) { + return { conflicts: [] }; + } const labels = ciConfig?.labels ?? DEFAULT_LABELS; const scopeLabels = ciConfig?.scopeLabels ?? {}; const conflicts: string[] = []; @@ -758,9 +800,13 @@ function resolveStandingPrLabelOverrides(prLabels: string[], ciConfig: CIConfig ].filter(Boolean) as string[]; if (bumpsPresent.length > 1) { conflicts.push(`Conflicting bump labels on standing PR (${bumpsPresent.join(', ')}) — remove all but one`); - } else if (prLabels.includes(labels.major)) bump = 'major'; - else if (prLabels.includes(labels.minor)) bump = 'minor'; - else if (prLabels.includes(labels.patch)) bump = 'patch'; + } else if (prLabels.includes(labels.major)) { + bump = 'major'; + } else if (prLabels.includes(labels.minor)) { + bump = 'minor'; + } else if (prLabels.includes(labels.patch)) { + bump = 'patch'; + } // Graduate / prerelease release-type const hasGraduate = prLabels.includes(labels.graduate); @@ -770,8 +816,12 @@ function resolveStandingPrLabelOverrides(prLabels: string[], ciConfig: CIConfig if (hasGraduate && hasPrerelease) { conflicts.push(`Conflicting release-type labels on standing PR (${labels.graduate} and ${labels.prerelease})`); } else { - if (hasGraduate) stable = true; - if (hasPrerelease) prerelease = true; + if (hasGraduate) { + stable = true; + } + if (hasPrerelease) { + prerelease = true; + } } // Per-package graduation: each `graduate:` label graduates that one prerelease to @@ -985,7 +1035,9 @@ export async function runStandingPRUpdate(options: StandingPROptions): Promise u.packageName === name); const groupSync = update?.group ? groups[update.group]?.sync : undefined; if (groupSync === 'fixed' || groupSync === 'linked') { @@ -1242,7 +1310,13 @@ export async function runStandingPRUpdate(options: StandingPROptions): Promise u.packageName === name)?.group; - if (grp) for (const u of dryUpdates) if (u.group === grp) out.add(u.packageName); + if (grp) { + for (const u of dryUpdates) { + if (u.group === grp) { + out.add(u.packageName); + } + } + } } return [...out]; }; @@ -1416,7 +1490,9 @@ export async function runStandingPRUpdate(options: StandingPROptions): Promise 0) notesRegion = renderNotesRegion(merged); + if (Object.keys(merged).length > 0) { + notesRegion = renderNotesRegion(merged); + } } // The interactive selection region — the package list for non-sync releases. Rendered from the @@ -1428,7 +1504,9 @@ export async function runStandingPRUpdate(options: StandingPROptions): Promise string) | undefined; if (versionOutputDry.strategy !== 'sync') { const warnings = selectionWarnings(dryUpdates, effectiveDeselected, groups); - for (const w of warnings) warn(`Selection: ${w.reason}`); + for (const w of warnings) { + warn(`Selection: ${w.reason}`); + } const warningSuffix = warnings.length > 0 ? `\n\n${warnings.map((w) => `> ⚠️ ${w.reason}`).join('\n')}` : ''; // Per-row changelogs are sourced from the DRY changelogs (the full changed set) so a held-back // row still shows its greyed changelog — the write output omits held-back packages entirely. @@ -1471,7 +1549,9 @@ export async function runStandingPRUpdate(options: StandingPROptions): Promise { const prev = baselineOf(update); - if (!prev || !semver.valid(update.newVersion)) return null; + if (!prev || !semver.valid(update.newVersion)) { + return null; + } return semver.diff(prev, update.newVersion); } @@ -54,9 +58,15 @@ function isMajorBump(update: Update): boolean { * the resolved action/channel, else a dash. */ function bumpLabel(update: Update): string { const diff = bumpDiff(update); - if (diff) return diff; - if (update.action === 'first-release') return 'first release'; - if (channelOf(update) === 'prerelease') return 'prerelease'; + if (diff) { + return diff; + } + if (update.action === 'first-release') { + return 'first release'; + } + if (channelOf(update) === 'prerelease') { + return 'prerelease'; + } return '—'; } @@ -83,11 +93,17 @@ export function renderReleaseSummaryLine(versionOutput: VersionOutput, input: Re const major = updates.filter(isMajorBump).length; const buckets: string[] = []; - if (stable > 0) buckets.push(plural(stable, 'stable', 'stable')); - if (prerelease > 0) buckets.push(plural(prerelease, 'prerelease', 'prerelease')); + if (stable > 0) { + buckets.push(plural(stable, 'stable', 'stable')); + } + if (prerelease > 0) { + buckets.push(plural(prerelease, 'prerelease', 'prerelease')); + } const segments: string[] = []; - if (buckets.length > 1) segments.push(...buckets); + if (buckets.length > 1) { + segments.push(...buckets); + } segments.push(plural(changes, 'change')); const majorClause = major > 0 ? `⚠️ ${plural(major, 'major bump')}.` : 'No major bumps.'; @@ -102,7 +118,9 @@ export function renderReleaseSummaryLine(versionOutput: VersionOutput, input: Re */ export function renderVersionSummaryTable(versionOutput: VersionOutput): string { const updates = publishableUpdates(versionOutput); - if (updates.length === 0) return ''; + if (updates.length === 0) { + return ''; + } const lines: string[] = [ `
Version summary (${plural(updates.length, 'package')})`, diff --git a/packages/release/test/unit/authorization.spec.ts b/packages/release/test/unit/authorization.spec.ts index 248e648e..a74a7fc5 100644 --- a/packages/release/test/unit/authorization.spec.ts +++ b/packages/release/test/unit/authorization.spec.ts @@ -68,8 +68,11 @@ describe('getEventActor', () => { tmpFile = `${process.env.TMPDIR ?? '/tmp'}/rk-event-${process.pid}.json`; }); afterEach(() => { - if (ORIG === undefined) delete process.env.GITHUB_EVENT_PATH; - else process.env.GITHUB_EVENT_PATH = ORIG; + if (ORIG === undefined) { + delete process.env.GITHUB_EVENT_PATH; + } else { + process.env.GITHUB_EVENT_PATH = ORIG; + } try { fs.unlinkSync(tmpFile); } catch {} diff --git a/packages/release/test/unit/commands/emitResult.spec.ts b/packages/release/test/unit/commands/emitResult.spec.ts index 67a6c641..c0a6d869 100644 --- a/packages/release/test/unit/commands/emitResult.spec.ts +++ b/packages/release/test/unit/commands/emitResult.spec.ts @@ -9,7 +9,9 @@ describe('emitResult', () => { const dirs: string[] = []; afterEach(() => { - for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }); + } dirs.length = 0; vi.restoreAllMocks(); }); diff --git a/packages/release/test/unit/failure-report-post.spec.ts b/packages/release/test/unit/failure-report-post.spec.ts index b75b7214..7cbcf71d 100644 --- a/packages/release/test/unit/failure-report-post.spec.ts +++ b/packages/release/test/unit/failure-report-post.spec.ts @@ -172,8 +172,11 @@ describe('detectUnresolvedFailure env hygiene', () => { savedSummary = process.env.GITHUB_STEP_SUMMARY; }); afterEach(() => { - if (savedSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY; - else process.env.GITHUB_STEP_SUMMARY = savedSummary; + if (savedSummary === undefined) { + delete process.env.GITHUB_STEP_SUMMARY; + } else { + process.env.GITHUB_STEP_SUMMARY = savedSummary; + } }); it('should act as a placeholder to anchor the env reset hooks', () => { expect(true).toBe(true); diff --git a/packages/release/test/unit/gate.spec.ts b/packages/release/test/unit/gate.spec.ts index e9cfb9e3..90da54d5 100644 --- a/packages/release/test/unit/gate.spec.ts +++ b/packages/release/test/unit/gate.spec.ts @@ -34,8 +34,12 @@ vi.mock('../../src/github.js', () => ({ vi.mock('../../src/release.js', () => ({ resolveScopeToTarget: (scopeName: string, scopeLabels: Record) => { const prefixed = `scope:${scopeName}`; - if (scopeLabels[prefixed]) return scopeLabels[prefixed]; - if (scopeLabels[scopeName]) return scopeLabels[scopeName]; + if (scopeLabels[prefixed]) { + return scopeLabels[prefixed]; + } + if (scopeLabels[scopeName]) { + return scopeLabels[scopeName]; + } throw new Error(`Scope "${scopeName}" not found`); }, runRelease: vi.fn(), @@ -47,7 +51,9 @@ vi.mock('../../src/git.js', () => ({ const repo = process.env.GITHUB_REPOSITORY; const sha = process.env.GITHUB_SHA; const token = process.env.GITHUB_TOKEN; - if (!repo) return null; + if (!repo) { + return null; + } const [owner, repoName] = repo.split('/'); return { owner, repo: repoName ?? '', sha: sha ?? null, token: token ?? null }; }, diff --git a/packages/release/test/unit/refresh.spec.ts b/packages/release/test/unit/refresh.spec.ts index ec7711c5..9a0eacae 100644 --- a/packages/release/test/unit/refresh.spec.ts +++ b/packages/release/test/unit/refresh.spec.ts @@ -136,7 +136,9 @@ describe('runRefreshAfterRelease', () => { it('should continue refreshing remaining PRs when one runPreview throws', async () => { mockForgeFor.mockReturnValue(fakeForge([pr(10), pr(11), pr(12)])); mockRunPreview.mockImplementation(async (opts: { pr: string }) => { - if (opts.pr === '11') throw new Error('boom'); + if (opts.pr === '11') { + throw new Error('boom'); + } }); await runRefreshAfterRelease({ projectDir: '/p' }); diff --git a/packages/release/test/unit/standing-pr.spec.ts b/packages/release/test/unit/standing-pr.spec.ts index 7b827bbf..8e7f0285 100644 --- a/packages/release/test/unit/standing-pr.spec.ts +++ b/packages/release/test/unit/standing-pr.spec.ts @@ -42,7 +42,9 @@ class ControllableGit extends FakeGit { } if (opts.format === '%H') { // createReleaseTags resolves an existing tag's commit — default to HEAD ("tag at HEAD"). - if (this.tagResolveThrows) throw new Error('cannot resolve tag'); + if (this.tagResolveThrows) { + throw new Error('cannot resolve tag'); + } return `${this.tagCommitSha ?? (await this.headSha(opts.cwd))}\n`; } return super.log(opts); @@ -1096,10 +1098,16 @@ describe('runStandingPRUpdate', () => { // Did NOT noop on the first guard — the label-triggered run proceeded to update the PR. expect(result.action).not.toBe('noop'); } finally { - if (prevName === undefined) delete process.env.GITHUB_EVENT_NAME; - else process.env.GITHUB_EVENT_NAME = prevName; - if (prevPath === undefined) delete process.env.GITHUB_EVENT_PATH; - else process.env.GITHUB_EVENT_PATH = prevPath; + if (prevName === undefined) { + delete process.env.GITHUB_EVENT_NAME; + } else { + process.env.GITHUB_EVENT_NAME = prevName; + } + if (prevPath === undefined) { + delete process.env.GITHUB_EVENT_PATH; + } else { + process.env.GITHUB_EVENT_PATH = prevPath; + } } }); @@ -2360,7 +2368,9 @@ describe('runStandingPRUpdate', () => { const bodies = forge.updatedPullRequests .map((u) => u.changes.body) .filter((b): b is string => typeof b === 'string'); - for (const b of bodies) expect(b.length).toBeLessThanOrEqual(STANDING_PR_BODY_CAP); + for (const b of bodies) { + expect(b.length).toBeLessThanOrEqual(STANDING_PR_BODY_CAP); + } }); }); }); diff --git a/packages/version/src/core/baselineResolver.ts b/packages/version/src/core/baselineResolver.ts index 76ea2e96..04572912 100644 --- a/packages/version/src/core/baselineResolver.ts +++ b/packages/version/src/core/baselineResolver.ts @@ -211,9 +211,15 @@ export class BaselineResolver { * the nearest reachable tag rather than flooding with full history. */ async sharedFloor(perPackageRange: string): Promise { - if (this.opts.baseRef) return perPackageRange; - if (this.opts.sharedChangelogFloor === 'sinceLastRelease') return this.nearestReachableRange(); - if (perPackageRange !== 'HEAD') return perPackageRange; + if (this.opts.baseRef) { + return perPackageRange; + } + if (this.opts.sharedChangelogFloor === 'sinceLastRelease') { + return this.nearestReachableRange(); + } + if (perPackageRange !== 'HEAD') { + return perPackageRange; + } return this.nearestReachableRange(); } diff --git a/packages/version/src/core/groupResolution.ts b/packages/version/src/core/groupResolution.ts index 37dc7fa9..6cd4e342 100644 --- a/packages/version/src/core/groupResolution.ts +++ b/packages/version/src/core/groupResolution.ts @@ -107,7 +107,9 @@ export function resolveGroups(config: Config, packages: Package[]): GroupResolut const name = pkg.packageJson.name; // The implicit sync group is "every package" by definition — don't route it through glob // matching, where a bare `*` would fail to cross the `/` in scoped names. - if (!matchesAll && !shouldMatchPackageTargets(name, def.packages)) continue; + if (!matchesAll && !shouldMatchPackageTargets(name, def.packages)) { + continue; + } const existing = memberOf.get(name); if (existing) { @@ -182,13 +184,17 @@ export function expandTargetsForAtomicGroups( resolution: GroupResolution, targets: string[], ): { targets: string[]; expandedGroups: string[] } { - if (targets.length === 0) return { targets, expandedGroups: [] }; + if (targets.length === 0) { + return { targets, expandedGroups: [] }; + } const expanded = new Set(targets); const expandedGroups: string[] = []; for (const group of resolution.groups) { - if (group.sync === 'linked') continue; + if (group.sync === 'linked') { + continue; + } const someTargeted = group.members.some((m) => shouldMatchPackageTargets(m.packageJson.name, targets)); const allTargeted = group.members.every((m) => shouldMatchPackageTargets(m.packageJson.name, targets)); diff --git a/packages/version/src/core/groupStrategy.ts b/packages/version/src/core/groupStrategy.ts index 5200685a..8d69cd33 100644 --- a/packages/version/src/core/groupStrategy.ts +++ b/packages/version/src/core/groupStrategy.ts @@ -84,7 +84,9 @@ interface MemberPlan { } function cleanBaseline(version: string | undefined): string { - if (!version) return '0.0.0'; + if (!version) { + return '0.0.0'; + } // semver.coerce() strips the prerelease (1.0.0-next.0 -> 1.0.0), which would graduate a linked or // fixed group off its prerelease line on every increment. Parse prerelease-preserving first: // clean() for a bare semver, then coerce({ includePrerelease }) only to pull it from a tag prefix. @@ -115,7 +117,9 @@ async function planMember( }); usedPackageSpecificTag = !!latestTag; } - if (!latestTag) latestTag = globalTag; + if (!latestTag) { + latestTag = globalTag; + } const ownNext = await calculateVersion(config, { latestTag, @@ -138,7 +142,9 @@ async function planMember( /** Derive the bump magnitude a member earned by comparing its computed next version to baseline. */ function memberBumpRank(plan: MemberPlan): number { - if (!plan.changed) return -1; + if (!plan.changed) { + return -1; + } const diff = semver.diff(plan.baseline, plan.ownNext); if (!diff) { // Same version family (e.g. prerelease increment) — treat as a patch-level change so the @@ -165,9 +171,13 @@ interface GroupComputation { * `release:graduate` path → every group graduates) or some member falls inside that scope. */ function isGroupGraduating(group: ResolvedGroup, config: Config): boolean { - if (!config.stableOnly) return false; + if (!config.stableOnly) { + return false; + } const scope = config.graduateScope; - if (!scope || scope.length === 0) return true; + if (!scope || scope.length === 0) { + return true; + } return group.members.some((m) => shouldMatchPackageTargets(m.packageJson.name, scope)); } @@ -181,9 +191,13 @@ function isGroupGraduating(group: ResolvedGroup, config: Config): boolean { * happens to land on a prerelease line yet. */ function isGroupPrereleasing(group: ResolvedGroup, config: Config): boolean { - if (!config.isPrerelease) return false; + if (!config.isPrerelease) { + return false; + } const scope = config.prereleaseScope; - if (!scope || scope.length === 0) return true; + if (!scope || scope.length === 0) { + return true; + } return group.members.some((m) => shouldMatchPackageTargets(m.packageJson.name, scope)); } @@ -298,12 +312,20 @@ function shouldProcess(pkg: Package, config: Config): boolean { function readRepoUrl(pkgDir: string): string | null { try { const pkgJsonPath = path.join(pkgDir, 'package.json'); - if (!fs.existsSync(pkgJsonPath)) return null; + if (!fs.existsSync(pkgJsonPath)) { + return null; + } const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); let url: string | undefined = typeof pkgJson.repository === 'string' ? pkgJson.repository : pkgJson.repository?.url; - if (!url) return null; - if (url.startsWith('git+')) url = url.slice(4); - if (url.endsWith('.git')) url = url.slice(0, -4); + if (!url) { + return null; + } + if (url.startsWith('git+')) { + url = url.slice(4); + } + if (url.endsWith('.git')) { + url = url.slice(0, -4); + } return url; } catch { return null; @@ -344,7 +366,9 @@ async function extractEntries( entries = await extractChangelogEntriesFromCommits(input.pkgDir, revisionRange); } catch (error) { // A strictReachable violation must abort the run, not degrade to a minimal entry. - if (error instanceof StrictReachableError) throw error; + if (error instanceof StrictReachableError) { + throw error; + } log(`Error extracting changelog entries: ${error instanceof Error ? error.message : String(error)}`, 'warning'); } if (entries.length === 0) { @@ -356,7 +380,9 @@ async function extractEntries( // swallows git failures and returns [] — so leave it unflagged and let the bump render a // visible block instead of vanishing as a no-change carry. const fallback: ChangelogEntry = { type: 'changed', description: `Update version to ${input.nextVersion}` }; - if (input.isCarry) fallback.synthetic = true; + if (input.isCarry) { + fallback.synthetic = true; + } entries = [fallback]; } return { entries, revisionRange, previousVersion }; @@ -380,7 +406,9 @@ async function releaseGroup( for (const plan of releasing) { const pkg = plan.pkg; const name = pkg.packageJson.name; - if (!shouldProcess(pkg, config)) continue; + if (!shouldProcess(pkg, config)) { + continue; + } // Independent members release on their own commit-driven line; fixed/linked members adopt the // shared group version. @@ -512,7 +540,9 @@ export function createGroupStrategy(config: Config): (packages: PackagesWithRoot for (const group of resolution.groups) { const members = group.members.filter(targetFilter).filter((p) => shouldProcess(p, config)); - if (members.length === 0) continue; + if (members.length === 0) { + continue; + } const plans = await Promise.all(members.map((pkg) => planMember(config, pkg, formattedPrefix, globalTag))); const computation = computeGroup(group, plans, config); @@ -564,9 +594,13 @@ export function createGroupStrategy(config: Config): (packages: PackagesWithRoot // Ungrouped packages version independently — same per-member machinery, no shared version. for (const pkg of resolution.ungrouped) { - if (!targetFilter(pkg) || !shouldProcess(pkg, config)) continue; + if (!targetFilter(pkg) || !shouldProcess(pkg, config)) { + continue; + } const plan = await planMember(config, pkg, formattedPrefix, globalTag); - if (!plan.changed) continue; + if (!plan.changed) { + continue; + } const name = pkg.packageJson.name; const packageJsonPath = path.join(pkg.dir, 'package.json'); diff --git a/packages/version/src/core/prerequisiteScope.ts b/packages/version/src/core/prerequisiteScope.ts index 0bab7370..ec58d854 100644 --- a/packages/version/src/core/prerequisiteScope.ts +++ b/packages/version/src/core/prerequisiteScope.ts @@ -41,14 +41,20 @@ export function resolvePrerequisiteTargets( const overrideSet = new Set(explicitNames); for (const name of explicitNames) { const group = resolution.groupOf(name); - if (group) for (const member of group.members) overrideSet.add(member.packageJson.name); + if (group) { + for (const member of group.members) { + overrideSet.add(member.packageJson.name); + } + } } // Detect which transitive dependencies of the expanded targets actually changed — only those // become prerequisites (an unchanged dependency needs no release). const candidateDeps = new Set(); for (const name of overrideSet) { - for (const dep of graph.transitiveDependencies(name)) candidateDeps.add(dep); + for (const dep of graph.transitiveDependencies(name)) { + candidateDeps.add(dep); + } } const changed = [...candidateDeps].filter((name) => isChanged(name)); diff --git a/packages/version/src/core/versionCalculator.ts b/packages/version/src/core/versionCalculator.ts index e73ff118..d1f70147 100644 --- a/packages/version/src/core/versionCalculator.ts +++ b/packages/version/src/core/versionCalculator.ts @@ -116,7 +116,9 @@ export function applyOverrideScope( * no-tag axis, so they warn like the default.) */ function guardFirstReleaseBump(config: Config, name: string | undefined, currentVer: string, type: ReleaseType): void { - if (config.allowFirstBump) return; + if (config.allowFirstBump) { + return; + } const pkg = name || 'package'; const bumped = bumpVersion(currentVer, type); const message = @@ -126,7 +128,9 @@ function guardFirstReleaseBump(config: Config, name: string | undefined, current if ((config.mismatchStrategy ?? 'warn') === 'error') { throw new Error(`First-release version overshoot: ${message}`); } - if (config.mismatchStrategy === 'ignore') return; + if (config.mismatchStrategy === 'ignore') { + return; + } log(message, 'warning'); } @@ -189,7 +193,9 @@ async function calculateVersionInner(config: Config, options: VersionOptions): P } // Fallback to hardcoded patterns for backward compatibility - if (!packageName) return escapeRegExp(prefix); + if (!packageName) { + return escapeRegExp(prefix); + } const sanitized = sanitizePackageName(packageName); const escapedRaw = escapeRegExp(`${packageName}@${prefix}`); const escapedDash = escapeRegExp(`${sanitized}-${prefix}`); diff --git a/packages/version/src/core/versionEngine.ts b/packages/version/src/core/versionEngine.ts index e775bd4b..c0d2d4b6 100644 --- a/packages/version/src/core/versionEngine.ts +++ b/packages/version/src/core/versionEngine.ts @@ -49,15 +49,23 @@ export class VersionEngine { const effective: Config = { ...config }; if (runOptions) { - if (runOptions.dryRun) effective.dryRun = true; - if (runOptions.sync) effective.sync = true; - if (runOptions.bump) effective.type = runOptions.bump; + if (runOptions.dryRun) { + effective.dryRun = true; + } + if (runOptions.sync) { + effective.sync = true; + } + if (runOptions.bump) { + effective.type = runOptions.bump; + } if (runOptions.prerelease) { effective.prereleaseIdentifier = typeof runOptions.prerelease === 'string' ? runOptions.prerelease : effective.prereleaseIdentifier || 'next'; effective.isPrerelease = true; } - if (runOptions.stable) effective.stableOnly = true; + if (runOptions.stable) { + effective.stableOnly = true; + } // Per-package graduation: graduate only the named packages, leaving other prereleases on // their line. Sets stableOnly + graduateScope; applyOverrideScope clears stableOnly for any // package outside the scope. The global `stable` flag wins — when it's set we graduate ALL @@ -77,12 +85,24 @@ export class VersionEngine { effective.prereleaseIdentifier = effective.prereleaseIdentifier || 'next'; effective.prereleaseScope = runOptions.prereleaseScope; } - if (runOptions.allowFirstBump) effective.allowFirstBump = true; - if (runOptions.targets?.length) this.runtimeTargets = runOptions.targets; - if (runOptions.baseRef) effective.baseRef = runOptions.baseRef; - if (runOptions.overrideScope) effective.overrideScope = runOptions.overrideScope; - if (runOptions.includePrerequisites) this.includePrerequisites = true; - if (runOptions.exclude?.length) this.excludePackages = runOptions.exclude; + if (runOptions.allowFirstBump) { + effective.allowFirstBump = true; + } + if (runOptions.targets?.length) { + this.runtimeTargets = runOptions.targets; + } + if (runOptions.baseRef) { + effective.baseRef = runOptions.baseRef; + } + if (runOptions.overrideScope) { + effective.overrideScope = runOptions.overrideScope; + } + if (runOptions.includePrerequisites) { + this.includePrerequisites = true; + } + if (runOptions.exclude?.length) { + this.excludePackages = runOptions.exclude; + } } // Default values for required properties @@ -387,7 +407,9 @@ export class VersionEngine { tagTemplate: this.config.tagTemplate, packageSpecificTags: this.config.packageSpecificTags, }); - if (!tag) tag = await getLatestTag(formattedPrefix); + if (!tag) { + tag = await getLatestTag(formattedPrefix); + } } catch { // No tag resolvable — getCommitsLength falls back to git describe / full history. } @@ -410,7 +432,9 @@ export class VersionEngine { try { const pkg = parseCargoToml(cargoPath).package; const publish = pkg?.publish; - if (pkg?.name && publish !== false && !(Array.isArray(publish) && publish.length === 0)) return true; + if (pkg?.name && publish !== false && !(Array.isArray(publish) && publish.length === 0)) { + return true; + } } catch { // Unparseable Cargo.toml — fall through; don't claim publishable on a parse failure. } @@ -418,7 +442,9 @@ export class VersionEngine { const pubspecPath = path.join(packageDir, 'pubspec.yaml'); if (fs.existsSync(pubspecPath)) { try { - if (parsePubspec(pubspecPath).publish_to !== 'none') return true; + if (parsePubspec(pubspecPath).publish_to !== 'none') { + return true; + } } catch { // Unparseable pubspec — fall through. } @@ -486,9 +512,15 @@ export class VersionEngine { const namedExactly = new Set(this.config.packages ?? []); const beforeCount = mergedPackages.packages.length; mergedPackages.packages = mergedPackages.packages.filter((pkg) => { - if (!isPrivatePackageJson(pkg.packageJson, path.join(pkg.dir, 'package.json'))) return true; - if (namedExactly.has(pkg.packageJson.name)) return true; - if (this.isNativelyPublishable(pkg.dir)) return true; + if (!isPrivatePackageJson(pkg.packageJson, path.join(pkg.dir, 'package.json'))) { + return true; + } + if (namedExactly.has(pkg.packageJson.name)) { + return true; + } + if (this.isNativelyPublishable(pkg.dir)) { + return true; + } log(`Skipping private npm package ${pkg.packageJson.name}: package.json "private": true`, 'debug'); return false; }); diff --git a/packages/version/src/core/versionStrategies.ts b/packages/version/src/core/versionStrategies.ts index 836c565e..59d90f4c 100644 --- a/packages/version/src/core/versionStrategies.ts +++ b/packages/version/src/core/versionStrategies.ts @@ -95,7 +95,9 @@ function updateCargoFiles( updatePackageVersion(cargoTomlPath, version, dryRun); updatedFiles.push(cargoTomlPath); const lockPath = syncCargoLockfile(cargoTomlPath, dryRun); - if (lockPath && !updatedFiles.includes(lockPath)) updatedFiles.push(lockPath); + if (lockPath && !updatedFiles.includes(lockPath)) { + updatedFiles.push(lockPath); + } }; if (cargoPaths && cargoPaths.length > 0) { @@ -348,7 +350,9 @@ export function createSyncStrategy(config: Config): StrategyFunction { } } catch (error) { // A strictReachable violation must abort the run, not degrade to a minimal entry. - if (error instanceof StrictReachableError) throw error; + if (error instanceof StrictReachableError) { + throw error; + } log(`Error extracting changelog entries: ${error instanceof Error ? error.message : String(error)}`, 'warning'); changelogEntries = [{ type: 'changed', description: `Update version to ${nextVersion}` }]; } @@ -466,7 +470,9 @@ export function createSyncStrategy(config: Config): StrategyFunction { const baselineTag = config.baselineTagTemplate ? formatTag(nextVersion, formattedPrefix, mainPkgName, config.baselineTagTemplate, false) : undefined; - if (baselineTag) addBaselineTag(baselineTag); + if (baselineTag) { + addBaselineTag(baselineTag); + } const allTags = baselineTag ? [...nextTags, baselineTag] : nextTags; // Link per-package tags back to their update records so the publish pipeline // can push each tag immediately after that package publishes. @@ -474,7 +480,9 @@ export function createSyncStrategy(config: Config): StrategyFunction { for (let i = 0; i < workspaceNames.length; i++) { const pkgName = workspaceNames[i]; const pkgTag = nextTags[i]; - if (pkgName && pkgTag) setPackageUpdateTag(pkgName, pkgTag); + if (pkgName && pkgTag) { + setPackageUpdateTag(pkgName, pkgTag); + } } } // Resolved version action. Every package moves in lockstep to the same nextVersion @@ -618,7 +626,9 @@ export function createSingleStrategy(config: Config): StrategyFunction { } } catch (error) { // A strictReachable violation must abort the run, not degrade to a minimal entry. - if (error instanceof StrictReachableError) throw error; + if (error instanceof StrictReachableError) { + throw error; + } log(`Error extracting changelog entries: ${error instanceof Error ? error.message : String(error)}`, 'warning'); // Fall back to minimal entry changelogEntries = [{ type: 'changed', description: `Update version to ${nextVersion}` }]; diff --git a/packages/version/src/core/workspaceGraph.ts b/packages/version/src/core/workspaceGraph.ts index debe2058..407f9291 100644 --- a/packages/version/src/core/workspaceGraph.ts +++ b/packages/version/src/core/workspaceGraph.ts @@ -26,7 +26,9 @@ function cargoDeps(cargoTomlPath: string, pkgDir: string, nameByDir: Map { - if (!ref || ref.trim() === '') return Promise.resolve(false); + if (!ref || ref.trim() === '') { + return Promise.resolve(false); + } // The seam's refExists is the same `rev-parse --verify --quiet ^{commit}` soft lookup. return git.refExists(ref, cwd); } @@ -304,7 +306,9 @@ export async function getLatestStableTag(versionPrefix?: string): Promise { diff --git a/packages/version/src/package/packageProcessor.ts b/packages/version/src/package/packageProcessor.ts index 8d99121e..f327ea5d 100644 --- a/packages/version/src/package/packageProcessor.ts +++ b/packages/version/src/package/packageProcessor.ts @@ -297,7 +297,9 @@ export class PackageProcessor { } } catch (error) { // A strictReachable violation must abort the run, not degrade to a minimal entry. - if (error instanceof StrictReachableError) throw error; + if (error instanceof StrictReachableError) { + throw error; + } log(`Error extracting changelog entries: ${error instanceof Error ? error.message : String(error)}`, 'warning'); // Fall back to minimal entry changelogEntries = [ diff --git a/packages/version/src/utils/formatting.ts b/packages/version/src/utils/formatting.ts index 1d6fc6b6..675e7431 100644 --- a/packages/version/src/utils/formatting.ts +++ b/packages/version/src/utils/formatting.ts @@ -170,7 +170,9 @@ export function deriveBaselineTagPrefix( formattedPrefix: string, packageName?: string, ): string | undefined { - if (!template) return undefined; + if (!template) { + return undefined; + } return template .split('${' + 'version}')[0] .replace(/\$\{prefix\}/g, formattedPrefix) @@ -187,6 +189,8 @@ export function deriveBaselineTagPrefix( * alone — only the display surfaces are cleaned up. */ export function displayTag(tag: string, baselineTagPrefix: string | undefined, formattedPrefix: string): string { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; } diff --git a/packages/version/src/utils/jsonOutput.ts b/packages/version/src/utils/jsonOutput.ts index 5469dcfe..b8aef6b0 100644 --- a/packages/version/src/utils/jsonOutput.ts +++ b/packages/version/src/utils/jsonOutput.ts @@ -57,7 +57,9 @@ export function enableJsonOutput(dryRun = false): void { * Called during dryRun passes in place of writing directly to disk. */ export function recordPendingWrite(path: string, content: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } _pendingWrites.push({ path, content }); } @@ -93,7 +95,9 @@ export function isJsonOutputMode(): boolean { * (preview, standing PR) can render sync releases as a single versioned unit. */ export function setVersioningStrategy(strategy: 'sync' | 'single' | 'async' | 'group'): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } _jsonData.strategy = strategy; } @@ -111,7 +115,9 @@ export function setVersioningStrategy(strategy: 'sync' | 'single' | 'async' | 'g * for the same directory exists. */ export function addPackageUpdate(packageName: string, newVersion: string, filePath: string, isRoot?: boolean): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } // Channel is derived per-package from the resolved version so every consumer (preview, // standing PR) reads a single authoritative value rather than re-deriving it. @@ -145,9 +151,13 @@ export function addPackageUpdate(packageName: string, newVersion: string, filePa * Called after the tag name is computed, to link it back to the update record. */ export function setPackageUpdateTag(packageName: string, tag: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } const update = _jsonData.updates.find((u) => u.packageName === packageName); - if (update) update.tag = tag; + if (update) { + update.tag = tag; + } } /** @@ -156,9 +166,13 @@ export function setPackageUpdateTag(packageName: string, tag: string): void { * package.json update has been recorded. */ export function setPackageUpdateGroup(packageName: string, group: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } const update = _jsonData.updates.find((u) => u.packageName === packageName); - if (update) update.group = group; + if (update) { + update.group = group; + } } /** @@ -168,7 +182,9 @@ export function setPackageUpdateGroup(packageName: string, group: string): void * isn't found (mirrors the other setPackageUpdate* helpers). */ export function setPackageUpdateAction(packageName: string, action: VersionAction, reason: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } const update = _jsonData.updates.find((u) => u.packageName === packageName); if (update) { update.action = action; @@ -184,9 +200,13 @@ export function setPackageUpdateAction(packageName: string, action: VersionActio * setPackageUpdate* helpers). */ export function setPackageUpdatePreviousVersion(packageName: string, previousVersion: string | null): void { - if (!_jsonOutputMode || previousVersion === null) return; + if (!_jsonOutputMode || previousVersion === null) { + return; + } const update = _jsonData.updates.find((u) => u.packageName === packageName); - if (update) update.previousVersion = previousVersion; + if (update) { + update.previousVersion = previousVersion; + } } /** @@ -196,7 +216,9 @@ export function setPackageUpdatePreviousVersion(packageName: string, previousVer * (otherwise-private) update list. */ export function setAllPackageUpdateActions(action: VersionAction, reason: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } for (const update of _jsonData.updates) { update.action = action; update.actionReason = reason; @@ -209,7 +231,9 @@ export function setAllPackageUpdateActions(action: VersionAction, reason: string * the field absent everywhere. Owns the iteration internally, mirroring {@link setAllPackageUpdateActions}. */ export function setAllPackageUpdatePreviousVersions(previousVersion: string | null): void { - if (!_jsonOutputMode || previousVersion === null) return; + if (!_jsonOutputMode || previousVersion === null) { + return; + } for (const update of _jsonData.updates) { update.previousVersion = previousVersion; } @@ -222,7 +246,9 @@ export function setAllPackageUpdatePreviousVersions(previousVersion: string | nu * the engine after the strategy has produced the updates. */ export function tagPrerequisiteRoles(overrideScope: string[], prerequisiteOf: Record): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } const targets = new Set(overrideScope); for (const update of _jsonData.updates) { if (targets.has(update.packageName)) { @@ -238,7 +264,9 @@ export function tagPrerequisiteRoles(overrideScope: string[], prerequisiteOf: Re * Add changelog data for a package to the JSON output */ export function addChangelogData(data: VersionPackageChangelog): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } _jsonData.changelogs.push(data); } @@ -248,7 +276,9 @@ export function addChangelogData(data: VersionPackageChangelog): void { * that don't belong to any specific package. */ export function setSharedEntries(entries: VersionChangelogEntry[]): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } _jsonData.sharedEntries = entries.length > 0 ? entries : undefined; } @@ -256,7 +286,9 @@ export function setSharedEntries(entries: VersionChangelogEntry[]): void { * Add a tag to the JSON output */ export function addTag(tag: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } _jsonData.tags.push(tag); } @@ -266,9 +298,13 @@ export function addTag(tag: string): void { * consumer tags but excluded from GitHub Release creation. */ export function addBaselineTag(tag: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } - if (!_jsonData.baselineTags) _jsonData.baselineTags = []; + if (!_jsonData.baselineTags) { + _jsonData.baselineTags = []; + } _jsonData.baselineTags.push(tag); } @@ -276,7 +312,9 @@ export function addBaselineTag(tag: string): void { * Set the commit message in the JSON output */ export function setCommitMessage(message: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } _jsonData.commitMessage = message; } diff --git a/packages/version/src/utils/logging.ts b/packages/version/src/utils/logging.ts index 82031f6c..110791ab 100644 --- a/packages/version/src/utils/logging.ts +++ b/packages/version/src/utils/logging.ts @@ -10,7 +10,9 @@ import { isJsonOutputMode } from './jsonOutput.js'; * Print a figlet banner */ export function printFiglet(text: string): void { - if (isJsonOutputMode()) return; + if (isJsonOutputMode()) { + return; + } console.log( chalk.yellow( diff --git a/packages/version/src/utils/versionAction.ts b/packages/version/src/utils/versionAction.ts index 9fa6385a..e4a4eb56 100644 --- a/packages/version/src/utils/versionAction.ts +++ b/packages/version/src/utils/versionAction.ts @@ -40,7 +40,9 @@ function versionFromTag(tag: string): string | null { // Bounded quantifiers (not `\d+`) guard against polynomial backtracking on uncontrolled tag input // (CodeQL js/polynomial-redos) — real semver components never approach these lengths. const match = tag.match(/\d{1,16}\.\d{1,16}\.\d{1,16}(?:-[0-9A-Za-z.-]{1,256})?/); - if (!match) return null; + if (!match) { + return null; + } return semver.valid(match[0]); } diff --git a/packages/version/src/workspace.ts b/packages/version/src/workspace.ts index 4567255d..47a278ca 100644 --- a/packages/version/src/workspace.ts +++ b/packages/version/src/workspace.ts @@ -32,7 +32,9 @@ export async function getWorkspacePackageVersions( for (const p of packages) { const name = p.packageJson.name; const version = p.packageJson.version; - if (name && version) versions[name] = version; + if (name && version) { + versions[name] = version; + } } return versions; } diff --git a/packages/version/test/unit/core/groupStrategy.spec.ts b/packages/version/test/unit/core/groupStrategy.spec.ts index 5d0b0b52..5ebd5555 100644 --- a/packages/version/test/unit/core/groupStrategy.spec.ts +++ b/packages/version/test/unit/core/groupStrategy.spec.ts @@ -87,7 +87,9 @@ describe('createGroupStrategy', () => { // Only native-utils has a releasable change (patch). vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-utils') return '2.3.1'; + if (opts.name === '@wdio/native-utils') { + return '2.3.1'; + } return ''; }); @@ -161,8 +163,12 @@ describe('createGroupStrategy', () => { // core gets a minor, utils gets a patch — group should bump minor. vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.4.0'; - if (opts.name === '@wdio/native-utils') return '2.3.1'; + if (opts.name === '@wdio/native-core') { + return '2.4.0'; + } + if (opts.name === '@wdio/native-utils') { + return '2.3.1'; + } return ''; }); @@ -206,7 +212,9 @@ describe('createGroupStrategy', () => { // Only utils changed. vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-utils') return '2.3.1'; + if (opts.name === '@wdio/native-utils') { + return '2.3.1'; + } return ''; }); @@ -237,8 +245,12 @@ describe('createGroupStrategy', () => { // core minor, utils patch, spy unchanged → both changed members align to 2.4.0. vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.4.0'; - if (opts.name === '@wdio/native-utils') return '2.3.1'; + if (opts.name === '@wdio/native-core') { + return '2.4.0'; + } + if (opts.name === '@wdio/native-utils') { + return '2.3.1'; + } return ''; }); @@ -269,7 +281,9 @@ describe('createGroupStrategy', () => { // Only the service earned a change — a premajor prerelease. vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/flutter-service') return '1.0.0-next.0'; + if (opts.name === '@wdio/flutter-service') { + return '1.0.0-next.0'; + } return ''; }); @@ -297,7 +311,9 @@ describe('createGroupStrategy', () => { const contract = mkPackage('wdio_flutter', '0.0.1'); vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/flutter-service') return '1.0.0-next.0'; + if (opts.name === '@wdio/flutter-service') { + return '1.0.0-next.0'; + } return ''; }); @@ -528,7 +544,9 @@ describe('createGroupStrategy', () => { vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { // core earns a patch from its own 2.3.0 baseline. - if (opts.name === '@wdio/native-core') return '2.3.1'; + if (opts.name === '@wdio/native-core') { + return '2.3.1'; + } return ''; }); @@ -558,9 +576,13 @@ describe('createGroupStrategy', () => { const newcomer = mkPackage('@wdio/native-fresh', '1.0.0'); vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.3.1'; + if (opts.name === '@wdio/native-core') { + return '2.3.1'; + } // newcomer also has a change, but its own bump is irrelevant under fixed. - if (opts.name === '@wdio/native-fresh') return '1.0.1'; + if (opts.name === '@wdio/native-fresh') { + return '1.0.1'; + } return ''; }); @@ -586,8 +608,12 @@ describe('createGroupStrategy', () => { const utils = mkPackage('@wdio/native-utils', '2.3.0'); vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.3.1'; - if (opts.name === '@wdio/native-utils') return '2.3.1'; + if (opts.name === '@wdio/native-core') { + return '2.3.1'; + } + if (opts.name === '@wdio/native-utils') { + return '2.3.1'; + } return ''; }); @@ -616,7 +642,9 @@ describe('createGroupStrategy', () => { const b = mkPackage('@app/b', '1.0.0'); vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@app/a') return '1.1.0'; + if (opts.name === '@app/a') { + return '1.1.0'; + } return ''; }); @@ -645,7 +673,9 @@ describe('createGroupStrategy', () => { // Only native-core is in graduateScope → its calculator returns the stable base; native-utils // is out of scope so (per applyOverrideScope, simulated here) it advances its prerelease line. vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '1.0.0'; + if (opts.name === '@wdio/native-core') { + return '1.0.0'; + } return '1.0.0-next.6'; }); @@ -678,7 +708,9 @@ describe('createGroupStrategy', () => { // Both members changed; native-core is the graduate target. Linked releases only changed members, // all at the shared graduated version. vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.0.0'; + if (opts.name === '@wdio/native-core') { + return '2.0.0'; + } return '2.0.0-next.3'; }); @@ -869,8 +901,12 @@ describe('createGroupStrategy', () => { const standalone = mkPackage('@app/standalone', '5.0.0'); vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.3.1'; - if (opts.name === '@app/standalone') return '5.1.0'; + if (opts.name === '@wdio/native-core') { + return '2.3.1'; + } + if (opts.name === '@app/standalone') { + return '5.1.0'; + } return ''; }); @@ -913,8 +949,12 @@ describe('createGroupStrategy', () => { // core earns a minor, utils a patch — independent means each keeps its own commit-driven bump // (a fixed/linked group would write the shared 2.4.0 to both). vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.4.0'; - if (opts.name === '@wdio/native-utils') return '2.3.1'; + if (opts.name === '@wdio/native-core') { + return '2.4.0'; + } + if (opts.name === '@wdio/native-utils') { + return '2.3.1'; + } return ''; }); diff --git a/packages/version/test/unit/core/versionCalculator.spec.ts b/packages/version/test/unit/core/versionCalculator.spec.ts index 94f2c67c..9ab03dc1 100644 --- a/packages/version/test/unit/core/versionCalculator.spec.ts +++ b/packages/version/test/unit/core/versionCalculator.spec.ts @@ -100,43 +100,85 @@ describe('Version Calculator', () => { // Mock normalizePrereleaseIdentifier vi.spyOn(versionUtils, 'normalizePrereleaseIdentifier').mockImplementation((val) => { - if (val === true) return 'next'; + if (val === true) { + return 'next'; + } return val as string; }); // Mock bumpVersion vi.spyOn(versionUtils, 'bumpVersion').mockImplementation((version, releaseType, identifier) => { // Special cases for test expectations - if (version === '1.0.0' && releaseType === 'minor' && !identifier) return '1.1.0'; - if (version === '1.0.0-next.0' && releaseType === 'major' && !identifier) return '2.0.0'; - if (version === '1.0.0-beta.1' && releaseType === 'minor' && !identifier) return '1.1.0'; - if (version === '1.0.0-alpha.2' && releaseType === 'patch' && !identifier) return '1.0.1'; - if (version === '1.0.0' && releaseType === 'prerelease' && identifier === 'alpha') return '1.0.0-alpha.1'; - if (version === '1.3.0' && releaseType === 'major' && identifier === 'next') return '2.0.0-next.0'; - if (version === '1.3.0' && releaseType === 'minor' && identifier === 'next') return '1.4.0-next.0'; - if (version === '1.3.1' && releaseType === 'patch' && identifier === 'next') return '1.3.2-next.0'; - if (version === '1.0.0-beta.1' && releaseType === 'major' && !identifier) return '2.0.0'; - if (version === '1.0.0-test' && releaseType === 'minor' && !identifier) return '1.1.0'; - if (version === '1.0.0' && releaseType === 'patch' && !identifier) return '1.0.1'; + if (version === '1.0.0' && releaseType === 'minor' && !identifier) { + return '1.1.0'; + } + if (version === '1.0.0-next.0' && releaseType === 'major' && !identifier) { + return '2.0.0'; + } + if (version === '1.0.0-beta.1' && releaseType === 'minor' && !identifier) { + return '1.1.0'; + } + if (version === '1.0.0-alpha.2' && releaseType === 'patch' && !identifier) { + return '1.0.1'; + } + if (version === '1.0.0' && releaseType === 'prerelease' && identifier === 'alpha') { + return '1.0.0-alpha.1'; + } + if (version === '1.3.0' && releaseType === 'major' && identifier === 'next') { + return '2.0.0-next.0'; + } + if (version === '1.3.0' && releaseType === 'minor' && identifier === 'next') { + return '1.4.0-next.0'; + } + if (version === '1.3.1' && releaseType === 'patch' && identifier === 'next') { + return '1.3.2-next.0'; + } + if (version === '1.0.0-beta.1' && releaseType === 'major' && !identifier) { + return '2.0.0'; + } + if (version === '1.0.0-test' && releaseType === 'minor' && !identifier) { + return '1.1.0'; + } + if (version === '1.0.0' && releaseType === 'patch' && !identifier) { + return '1.0.1'; + } // For branch pattern tests - if (version === '1.0.0' && releaseType === 'minor' && identifier === undefined) return '1.1.0'; - if (version === '1.0.0' && releaseType === 'patch' && identifier === undefined) return '1.0.1'; + if (version === '1.0.0' && releaseType === 'minor' && identifier === undefined) { + return '1.1.0'; + } + if (version === '1.0.0' && releaseType === 'patch' && identifier === undefined) { + return '1.0.1'; + } // For Package.json fallback tests - if (version === '0.0.0' && releaseType === 'minor') return '0.1.0'; - if (version === '2.0.0' && releaseType === 'minor' && !identifier) return '2.1.0'; + if (version === '0.0.0' && releaseType === 'minor') { + return '0.1.0'; + } + if (version === '2.0.0' && releaseType === 'minor' && !identifier) { + return '2.1.0'; + } // For conventional commits - if (version === '1.0.0' && releaseType === 'patch' && identifier === undefined) return '1.0.1'; - if (version === '0.0.0' && releaseType === 'patch') return '0.0.1'; + if (version === '1.0.0' && releaseType === 'patch' && identifier === undefined) { + return '1.0.1'; + } + if (version === '0.0.0' && releaseType === 'patch') { + return '0.0.1'; + } // Handle specific prerelease cases - if (version === '1.3.0' && releaseType === 'premajor' && identifier === 'next') return '2.0.0-next.0'; + if (version === '1.3.0' && releaseType === 'premajor' && identifier === 'next') { + return '2.0.0-next.0'; + } // Handle smart fallback cases where git tag version is used directly - if (version === '1.1.0' && releaseType === 'patch' && !identifier) return '1.1.1'; - if (version === '1.2.0' && releaseType === 'patch' && !identifier) return '1.2.1'; + if (version === '1.1.0' && releaseType === 'patch' && !identifier) { + return '1.1.1'; + } + if (version === '1.2.0' && releaseType === 'patch' && !identifier) { + return '1.2.1'; + } // Fallback - for unexpected cases, increment patch const semverResult = semver.inc(version, 'patch'); @@ -145,7 +187,9 @@ describe('Version Calculator', () => { // Create a mock for semver.inc vi.spyOn(semver, 'inc').mockImplementation((version, releaseType, identifier) => { - if (!version) return null; + if (!version) { + return null; + } // Convert version to string safely const versionStr = typeof version === 'string' ? version : (version as { version?: string })?.version || '0.0.0'; @@ -153,8 +197,12 @@ describe('Version Calculator', () => { const parts = versionStr.split('.'); // Handle different release types - if (releaseType === 'major') return `${Number(parts[0]) + 1}.0.0`; - if (releaseType === 'minor') return `${parts[0]}.${Number(parts[1]) + 1}.0`; + if (releaseType === 'major') { + return `${Number(parts[0]) + 1}.0.0`; + } + if (releaseType === 'minor') { + return `${parts[0]}.${Number(parts[1]) + 1}.0`; + } if (releaseType === 'patch') { // Handle prerelease versions if (versionStr.includes('-')) { @@ -641,7 +689,9 @@ describe('Version Calculator', () => { // Mock semver.clean to behave like the real library: only pure semver strings // (optionally v-prefixed) are accepted; compound tags like "scope-pkg-v1.2.3" return null. vi.spyOn(semver, 'clean').mockImplementation((version) => { - if (typeof version !== 'string') return null; + if (typeof version !== 'string') { + return null; + } const match = version.match(/^v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)$/); return match ? match[1] : null; }); @@ -682,7 +732,9 @@ describe('Version Calculator', () => { // Mock semver.clean to behave like the real library vi.spyOn(semver, 'clean').mockImplementation((version) => { - if (typeof version !== 'string') return null; + if (typeof version !== 'string') { + return null; + } const match = version.match(/^v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)$/); return match ? match[1] : null; }); @@ -729,7 +781,9 @@ describe('Version Calculator', () => { // Mock semver.clean to behave like the real library vi.spyOn(semver, 'clean').mockImplementation((version) => { - if (typeof version !== 'string') return null; + if (typeof version !== 'string') { + return null; + } const match = version.match(/^v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)$/); return match ? match[1] : null; }); @@ -777,7 +831,9 @@ describe('Version Calculator', () => { // instead of 0.22.0. vi.resetAllMocks(); vi.spyOn(semver, 'clean').mockImplementation((version) => { - if (typeof version !== 'string') return null; + if (typeof version !== 'string') { + return null; + } const match = version.match(/^v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)$/); return match ? match[1] : null; }); @@ -1144,7 +1200,9 @@ describe('Version Calculator', () => { // Implement semver.inc with a more complex behaviour for different scenarios vi.spyOn(semver, 'inc').mockImplementation((version, releaseType, identifier) => { - if (!version) return '1.0.0'; + if (!version) { + return '1.0.0'; + } // For test: should use package.json version when no latestTag exists with explicit bump if (version === '1.0.0-beta.1' && releaseType === 'patch') { @@ -1643,7 +1701,9 @@ describe('Version Calculator', () => { // Mock existing prerelease version vi.spyOn(semver, 'prerelease').mockReturnValue(['next', 0]); vi.spyOn(semver, 'clean').mockImplementation((version) => { - if (version === 'v1.0.0-next.0') return '1.0.0-next.0'; + if (version === 'v1.0.0-next.0') { + return '1.0.0-next.0'; + } return version?.replace(/^[^\d]*/, '') || null; }); vi.spyOn(versionUtils, 'bumpVersion').mockReturnValue('1.0.0'); // Clean to stable diff --git a/packages/version/test/unit/core/versionStrategies.spec.ts b/packages/version/test/unit/core/versionStrategies.spec.ts index 6b88dc8c..108c1c31 100644 --- a/packages/version/test/unit/core/versionStrategies.spec.ts +++ b/packages/version/test/unit/core/versionStrategies.spec.ts @@ -37,14 +37,18 @@ vi.mock('../../../src/utils/formatting.js', () => ({ return template.replace(/\$\{version\}/g, version).replace(/\$\{packageName\}/g, packageName || ''); }), deriveBaselineTagPrefix: vi.fn().mockImplementation((template, formattedPrefix, packageName) => { - if (!template) return undefined; + if (!template) { + return undefined; + } return template .split('${' + 'version}')[0] .replace(/\$\{prefix\}/g, formattedPrefix) .replace(/\$\{packageName\}/g, packageName ?? ''); }), displayTag: vi.fn().mockImplementation((tag, baselineTagPrefix, formattedPrefix) => { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; }), })); @@ -105,7 +109,9 @@ describe('Version Strategies', () => { vi.mocked(formatting.formatCommitMessage, { partial: true }).mockReturnValue('chore: release package-a v1.1.0'); vi.mocked(formatting.deriveBaselineTagPrefix, { partial: true }).mockImplementation( (template, formattedPrefix, packageName) => { - if (!template) return undefined; + if (!template) { + return undefined; + } return template .split('${' + 'version}')[0] .replace(/\$\{prefix\}/g, formattedPrefix) @@ -114,7 +120,9 @@ describe('Version Strategies', () => { ); vi.mocked(formatting.displayTag, { partial: true }).mockImplementation( (tag, baselineTagPrefix, formattedPrefix) => { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; }, ); diff --git a/packages/version/test/unit/package/packageProcessor.spec.ts b/packages/version/test/unit/package/packageProcessor.spec.ts index 7951ea3c..b6ac8d6b 100644 --- a/packages/version/test/unit/package/packageProcessor.spec.ts +++ b/packages/version/test/unit/package/packageProcessor.spec.ts @@ -36,7 +36,9 @@ vi.mock('../../../src/utils/formatting.js', () => ({ escapeRegExp: vi.fn().mockImplementation((str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), deriveBaselineTagPrefix: vi.fn().mockReturnValue(undefined), displayTag: vi.fn().mockImplementation((tag, baselineTagPrefix, formattedPrefix) => { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; }), })); @@ -380,7 +382,9 @@ describe('Package Processor', () => { vi.spyOn(gitTags, 'getLatestTagForPackage').mockResolvedValue('release/v1.0.0'); vi.spyOn(formatting, 'deriveBaselineTagPrefix').mockReturnValue('release/v'); vi.spyOn(formatting, 'displayTag').mockImplementation((tag, baselineTagPrefix, formattedPrefix) => { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; }); @@ -404,7 +408,9 @@ describe('Package Processor', () => { vi.spyOn(gitTags, 'getLatestTagForPackage').mockResolvedValue('release/v1.0.0'); vi.spyOn(formatting, 'deriveBaselineTagPrefix').mockReturnValue('release/v'); vi.spyOn(formatting, 'displayTag').mockImplementation((tag, baselineTagPrefix, formattedPrefix) => { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; }); @@ -427,7 +433,9 @@ describe('Package Processor', () => { vi.spyOn(gitTags, 'getLatestTagForPackage').mockResolvedValue('release/v1.0.0'); vi.spyOn(formatting, 'deriveBaselineTagPrefix').mockReturnValue('release/v'); vi.spyOn(formatting, 'displayTag').mockImplementation((tag, baselineTagPrefix, formattedPrefix) => { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; }); // Baseline resolves as a ref but can't be verified from HEAD (shallow clone / unpushed tag). @@ -700,7 +708,9 @@ describe('Package Processor', () => { const pkgAEntry = { type: 'added', description: 'New feature in pkg-a' }; vi.spyOn(commitParser, 'extractChangelogEntriesFromCommits').mockImplementation((_dir) => { - if (_dir.includes('package-a')) return [pkgAEntry]; + if (_dir.includes('package-a')) { + return [pkgAEntry]; + } return []; }); vi.spyOn(commitParser, 'extractRepoLevelChangelogEntries').mockResolvedValue([repoLevelEntry]); diff --git a/packages/version/test/unit/utils/versionUtils.spec.ts b/packages/version/test/unit/utils/versionUtils.spec.ts index b0ea4d8a..960c1d0e 100644 --- a/packages/version/test/unit/utils/versionUtils.spec.ts +++ b/packages/version/test/unit/utils/versionUtils.spec.ts @@ -171,16 +171,36 @@ describe('Version Utils', () => { beforeEach(() => { // Setup semver mocks vi.mocked(semver.prerelease, { partial: true }).mockImplementation((version) => { - if (version === '1.0.0-beta.1') return ['beta', 1]; - if (version === '1.0.0-beta.2') return ['beta', 2]; - if (version === '1.0.0-alpha.3') return ['alpha', 3]; - if (version === '1.0.0-next.0') return ['next', 0]; - if (version === '2.0.0-alpha.3') return ['alpha', 3]; - if (version === '2.0.0-next.0') return ['next', 0]; - if (version === '3.0.0-rc.1') return ['rc', 1]; - if (version === '2.1.0-next.4') return ['next', 4]; - if (version === '3.5.0-beta.12') return ['beta', 12]; - if (version === '4.0.1-rc.2') return ['rc', 2]; + if (version === '1.0.0-beta.1') { + return ['beta', 1]; + } + if (version === '1.0.0-beta.2') { + return ['beta', 2]; + } + if (version === '1.0.0-alpha.3') { + return ['alpha', 3]; + } + if (version === '1.0.0-next.0') { + return ['next', 0]; + } + if (version === '2.0.0-alpha.3') { + return ['alpha', 3]; + } + if (version === '2.0.0-next.0') { + return ['next', 0]; + } + if (version === '3.0.0-rc.1') { + return ['rc', 1]; + } + if (version === '2.1.0-next.4') { + return ['next', 4]; + } + if (version === '3.5.0-beta.12') { + return ['beta', 12]; + } + if (version === '4.0.1-rc.2') { + return ['rc', 2]; + } return null; }); @@ -270,15 +290,33 @@ describe('Version Utils', () => { vi.mocked(semver.inc, { partial: true }).mockImplementation((version, releaseType, identifier) => { // Stable base versions (after stripping prerelease) - if (version === '1.0.0' && releaseType === 'major') return '2.0.0'; - if (version === '1.0.0' && releaseType === 'minor') return '1.1.0'; - if (version === '1.0.0' && releaseType === 'patch') return '1.0.1'; - if (version === '2.0.0' && releaseType === 'major') return '3.0.0'; - if (version === '3.0.0' && releaseType === 'major') return '4.0.0'; - if (version === '2.1.0' && releaseType === 'minor') return '2.2.0'; - if (version === '3.5.0' && releaseType === 'minor') return '3.6.0'; - if (version === '4.0.1' && releaseType === 'patch') return '4.0.2'; - if (version === '4.0.1' && releaseType === 'minor') return '4.1.0'; + if (version === '1.0.0' && releaseType === 'major') { + return '2.0.0'; + } + if (version === '1.0.0' && releaseType === 'minor') { + return '1.1.0'; + } + if (version === '1.0.0' && releaseType === 'patch') { + return '1.0.1'; + } + if (version === '2.0.0' && releaseType === 'major') { + return '3.0.0'; + } + if (version === '3.0.0' && releaseType === 'major') { + return '4.0.0'; + } + if (version === '2.1.0' && releaseType === 'minor') { + return '2.2.0'; + } + if (version === '3.5.0' && releaseType === 'minor') { + return '3.6.0'; + } + if (version === '4.0.1' && releaseType === 'patch') { + return '4.0.2'; + } + if (version === '4.0.1' && releaseType === 'minor') { + return '4.1.0'; + } if (version === '1.0.0' && releaseType === 'premajor' && identifier === 'alpha') { return '2.0.0-alpha.0'; @@ -298,24 +336,44 @@ describe('Version Utils', () => { // Cases for prerelease increment on existing prerelease versions // Using 'prerelease' release type increments the prerelease counter only if (releaseType === 'prerelease' && identifier === 'next') { - if (version === '1.0.0-next.0') return '1.0.0-next.1'; - if (version === '1.0.0-next.4') return '1.0.0-next.5'; - if (version === '2.0.0-next.0') return '2.0.0-next.1'; + if (version === '1.0.0-next.0') { + return '1.0.0-next.1'; + } + if (version === '1.0.0-next.4') { + return '1.0.0-next.5'; + } + if (version === '2.0.0-next.0') { + return '2.0.0-next.1'; + } } if (releaseType === 'prerelease' && identifier === 'beta') { - if (version === '1.0.0-beta.1') return '1.0.0-beta.2'; - if (version === '1.0.0-beta.2') return '1.0.0-beta.3'; + if (version === '1.0.0-beta.1') { + return '1.0.0-beta.2'; + } + if (version === '1.0.0-beta.2') { + return '1.0.0-beta.3'; + } } if (releaseType === 'prerelease' && identifier === 'alpha') { - if (version === '1.0.0-alpha.3') return '1.0.0-alpha.4'; - if (version === '2.0.0-alpha.3') return '2.0.0-alpha.4'; + if (version === '1.0.0-alpha.3') { + return '1.0.0-alpha.4'; + } + if (version === '2.0.0-alpha.3') { + return '2.0.0-alpha.4'; + } } // Handle prepatch/preminor/premajor calls that fall through (for stable versions with identifiers) if (identifier === 'next' || identifier === 'alpha' || identifier === 'beta') { - if (version === '2.0.0' && releaseType === 'premajor') return '3.0.0-next.0'; - if (version === '1.0.0' && releaseType === 'preminor') return '1.1.0-next.0'; - if (version === '1.0.0' && releaseType === 'prepatch') return '1.0.1-next.0'; + if (version === '2.0.0' && releaseType === 'premajor') { + return '3.0.0-next.0'; + } + if (version === '1.0.0' && releaseType === 'preminor') { + return '1.1.0-next.0'; + } + if (version === '1.0.0' && releaseType === 'prepatch') { + return '1.0.1-next.0'; + } } return `${version}.incremented`; diff --git a/packages/version/test/utils/cli.ts b/packages/version/test/utils/cli.ts index eaadd23e..fbe64eb1 100644 --- a/packages/version/test/utils/cli.ts +++ b/packages/version/test/utils/cli.ts @@ -11,7 +11,9 @@ import { unwrapEnvelope, type VersionOutput } from '@releasekit/core'; export function executeCliCommand(command: string, cwd: string, dryRun = true) { const cliPath = join(process.cwd(), 'dist/cli.js'); const args = command.split(' '); - if (dryRun) args.push('--dry-run'); + if (dryRun) { + args.push('--dry-run'); + } return spawnSync('node', [cliPath, ...args], { cwd, encoding: 'utf-8' }); } diff --git a/scripts/examples-smoke/build-fixture.ts b/scripts/examples-smoke/build-fixture.ts index 8dabae2d..5c6df4d4 100644 --- a/scripts/examples-smoke/build-fixture.ts +++ b/scripts/examples-smoke/build-fixture.ts @@ -38,10 +38,15 @@ function parseArgs(argv: string[]): Args { const args: Partial = { seedLockfile: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === '--scenario') args.scenario = argv[++i]; - else if (a === '--out') args.out = argv[++i]; - else if (a === '--tarball-dir') args.tarballDir = argv[++i]; - else if (a === '--seed-lockfile') args.seedLockfile = true; + if (a === '--scenario') { + args.scenario = argv[++i]; + } else if (a === '--out') { + args.out = argv[++i]; + } else if (a === '--tarball-dir') { + args.tarballDir = argv[++i]; + } else if (a === '--seed-lockfile') { + args.seedLockfile = true; + } } if (!args.scenario || !args.out || !args.tarballDir) { throw new Error('Usage: build-fixture --scenario --out --tarball-dir [--seed-lockfile]'); @@ -51,7 +56,9 @@ function parseArgs(argv: string[]): Args { function findTarball(dir: string, prefix: string): string { const file = readdirSync(dir).find((f) => f.startsWith(prefix) && f.endsWith('.tgz')); - if (!file) throw new Error(`Could not find ${prefix}*.tgz in ${dir}`); + if (!file) { + throw new Error(`Could not find ${prefix}*.tgz in ${dir}`); + } return file; } @@ -158,7 +165,9 @@ function buildNpmCargoMonorepo(out: string, tarballNames: Record function main(): void { const { scenario: scenarioId, out, tarballDir, seedLockfile } = parseArgs(process.argv.slice(2)); const scenario = SCENARIOS.find((s) => s.id === scenarioId); - if (!scenario) throw new Error(`Unknown scenario: ${scenarioId}`); + if (!scenario) { + throw new Error(`Unknown scenario: ${scenarioId}`); + } const outDir = resolve(out); rmSync(outDir, { recursive: true, force: true }); diff --git a/scripts/examples-smoke/generate-smoke-workflow.ts b/scripts/examples-smoke/generate-smoke-workflow.ts index c2a43943..3bf9b1fa 100644 --- a/scripts/examples-smoke/generate-smoke-workflow.ts +++ b/scripts/examples-smoke/generate-smoke-workflow.ts @@ -76,8 +76,12 @@ function extractSetupSteps(scenario: Scenario): Step[] { } const setup: Step[] = []; for (const step of job.steps) { - if (isReleasekitRun(step)) break; // setup is everything before the release call - if (isCheckout(step) || isOutOfScopeApiStep(step)) continue; + if (isReleasekitRun(step)) { + break; // setup is everything before the release call + } + if (isCheckout(step) || isOutOfScopeApiStep(step)) { + continue; + } // Strip only envs that would fail or do harm in a dry run: references to // `secrets.*`. Keep diagnostic toggles (LOG_LEVEL, RUST_BACKTRACE, ...) and // plain literals — the smoke job's value is the missing-tool signal, not @@ -86,7 +90,9 @@ function extractSetupSteps(scenario: Scenario): Step[] { if (cleaned.env && typeof cleaned.env === 'object') { const filtered: Record = {}; for (const [k, v] of Object.entries(cleaned.env as Record)) { - if (!isSecretReferencingEnvValue(v)) filtered[k] = v; + if (!isSecretReferencingEnvValue(v)) { + filtered[k] = v; + } } if (Object.keys(filtered).length > 0) { cleaned.env = filtered; @@ -114,7 +120,9 @@ function runtimeProbe(scenario: Scenario): Step | undefined { publish?: { cargo?: { enabled?: boolean } }; }; const cargoEnabled = cfg.version?.cargo?.enabled === true || cfg.publish?.cargo?.enabled === true; - if (!cargoEnabled) return undefined; + if (!cargoEnabled) { + return undefined; + } return { name: 'Probe: cargo is on PATH (config enables cargo)', // The example's rust-toolchain step must have put cargo here; if it was @@ -149,7 +157,9 @@ function smokeJob(scenario: Scenario): Record { } const probe = runtimeProbe(scenario); - if (probe) steps.push(probe); + if (probe) { + steps.push(probe); + } steps.push({ name: `Dry-run release (${scenario.proves})`, diff --git a/scripts/generate-config-docs.ts b/scripts/generate-config-docs.ts index ad462d7e..a2266b6f 100644 --- a/scripts/generate-config-docs.ts +++ b/scripts/generate-config-docs.ts @@ -39,20 +39,32 @@ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')) as Schema; // --------------------------------------------------------------------------- function ensurePeriod(str: string | undefined): string { - if (!str) return ''; + if (!str) { + return ''; + } const s = str.trim(); return s.endsWith('.') ? s : `${s}.`; } function fmtDefault(val: unknown): string { - if (val === undefined || val === null) return '—'; - if (typeof val === 'boolean') return `\`${val}\``; - if (typeof val === 'string') return `\`"${val}"\``; + if (val === undefined || val === null) { + return '—'; + } + if (typeof val === 'boolean') { + return `\`${val}\``; + } + if (typeof val === 'string') { + return `\`"${val}"\``; + } if (Array.isArray(val)) { - if (val.length === 0) return '`[]`'; + if (val.length === 0) { + return '`[]`'; + } return `\`${JSON.stringify(val)}\``; } - if (typeof val === 'number') return `\`${val}\``; + if (typeof val === 'number') { + return `\`${val}\``; + } return `\`${JSON.stringify(val)}\``; } @@ -63,9 +75,15 @@ function fmtType(prop: SchemaProperty): string { if (prop.oneOf) { return prop.oneOf .map((s) => { - if (s.type === 'boolean' && s.enum) return (s.enum as unknown[]).map((v) => `\`${v}\``).join(' | '); - if (s.type === 'boolean') return 'boolean'; - if (s.type === 'string' && s.enum) return s.enum.map((v) => `\`"${v}"\``).join(' | '); + if (s.type === 'boolean' && s.enum) { + return (s.enum as unknown[]).map((v) => `\`${v}\``).join(' | '); + } + if (s.type === 'boolean') { + return 'boolean'; + } + if (s.type === 'string' && s.enum) { + return s.enum.map((v) => `\`"${v}"\``).join(' | '); + } return s.type ?? 'any'; }) .join(' | '); @@ -74,9 +92,15 @@ function fmtType(prop: SchemaProperty): string { const itemType = prop.items?.type ?? 'any'; return `\`${itemType}[]\``; } - if (prop.type === 'object') return 'object'; - if (prop.type === 'integer') return 'integer'; - if (prop.type === 'number') return 'number'; + if (prop.type === 'object') { + return 'object'; + } + if (prop.type === 'integer') { + return 'integer'; + } + if (prop.type === 'number') { + return 'number'; + } return prop.type ?? '—'; } @@ -85,7 +109,9 @@ function escapePipes(str: string): string { } function propsTable(properties: Record): string { - if (!properties || Object.keys(properties).length === 0) return ''; + if (!properties || Object.keys(properties).length === 0) { + return ''; + } const rows = Object.entries(properties).map(([key, prop]) => { const type = escapePipes(fmtType(prop)); const def = fmtDefault(prop.default); @@ -312,7 +338,9 @@ function renderPublish(prop: SchemaProperty): void { // --------------------------------------------------------------------------- function renderOneOfBooleanObject(key: string, schemaProp: SchemaProperty): void { const objSchema = schemaProp.oneOf?.find((s) => s.type === 'object'); - if (!objSchema) return; + if (!objSchema) { + return; + } emit('Set to `false` to disable.', ''); diff --git a/scripts/generate-schema.ts b/scripts/generate-schema.ts index e064f3e3..a24df33f 100644 --- a/scripts/generate-schema.ts +++ b/scripts/generate-schema.ts @@ -163,9 +163,15 @@ function orderedKeywordKeys(keys: string[]): string[] { return [...keys].sort((a, b) => { const ia = KEY_ORDER.indexOf(a); const ib = KEY_ORDER.indexOf(b); - if (ia === -1 && ib === -1) return a.localeCompare(b); - if (ia === -1) return 1; - if (ib === -1) return -1; + if (ia === -1 && ib === -1) { + return a.localeCompare(b); + } + if (ia === -1) { + return 1; + } + if (ib === -1) { + return -1; + } return ia - ib; }); } @@ -249,8 +255,12 @@ function printUnifiedDiff(before: string, after: string, label: string): void { ['-u', `--label=${label} (committed)`, `--label=${label} (generated)`, beforePath, afterPath], { encoding: 'utf8' }, ); - if (result.stdout) process.stderr.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); + if (result.stdout) { + process.stderr.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } if (result.status === 127 || result.error) { // `diff` not on PATH — fall back to a line-by-line diff. Good enough // for diagnostic output on a (typically small) schema file. @@ -267,8 +277,12 @@ function printLineDiffFallback(before: string, after: string, label: string): vo const afterLines = after.split('\n'); for (let i = 0; i < Math.max(beforeLines.length, afterLines.length); i++) { if (beforeLines[i] !== afterLines[i]) { - if (beforeLines[i] !== undefined) process.stderr.write(`- ${beforeLines[i]}\n`); - if (afterLines[i] !== undefined) process.stderr.write(`+ ${afterLines[i]}\n`); + if (beforeLines[i] !== undefined) { + process.stderr.write(`- ${beforeLines[i]}\n`); + } + if (afterLines[i] !== undefined) { + process.stderr.write(`+ ${afterLines[i]}\n`); + } } } } diff --git a/scripts/run-action.mjs b/scripts/run-action.mjs index 227a295a..5d343648 100644 --- a/scripts/run-action.mjs +++ b/scripts/run-action.mjs @@ -7,7 +7,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; function normalizeBoolean(value, defaultValue = false) { - if (value === undefined || value === '') return defaultValue; + if (value === undefined || value === '') { + return defaultValue; + } return value === 'true'; } @@ -161,7 +163,9 @@ export function writeGateOutputs(stdout, verbose = false) { export function parseReleaseOutput(stdout, verbose = false) { const trimmed = stdout.trim(); - if (!trimmed) return undefined; + if (!trimmed) { + return undefined; + } let parsed; try { parsed = JSON.parse(trimmed); @@ -210,7 +214,9 @@ export function resolveReleaseTags({ parsedTags, gitTags, allowRecovery }) { function setOutput(name, value) { const outputPath = process.env.GITHUB_OUTPUT; - if (!outputPath) return; + if (!outputPath) { + return; + } const text = value === undefined ? '' : String(value); const delimiter = `releasekit_${randomBytes(8).toString('hex')}`; @@ -520,7 +526,9 @@ export function writeStandingPROutputs(stdout, verbose = false) { export function writeSummary(markdown) { const summaryPath = process.env.GITHUB_STEP_SUMMARY; - if (!summaryPath) return; + if (!summaryPath) { + return; + } try { fs.appendFileSync(summaryPath, markdown); } catch (err) { @@ -551,11 +559,21 @@ export function buildReleaseSummary(input, parsed, success) { } const settings = []; - if (input.bump) settings.push(`| Bump | \`${input.bump}\` |`); - if (input.target) settings.push(`| Target | \`${input.target}\` |`); - if (input.scope) settings.push(`| Scope | \`${input.scope}\` |`); - if (input.prerelease) settings.push(`| Prerelease | \`${input.prerelease}\` |`); - if (normalizeBoolean(input.stable)) settings.push(`| Stable | Yes |`); + if (input.bump) { + settings.push(`| Bump | \`${input.bump}\` |`); + } + if (input.target) { + settings.push(`| Target | \`${input.target}\` |`); + } + if (input.scope) { + settings.push(`| Scope | \`${input.scope}\` |`); + } + if (input.prerelease) { + settings.push(`| Prerelease | \`${input.prerelease}\` |`); + } + if (normalizeBoolean(input.stable)) { + settings.push(`| Stable | Yes |`); + } if (settings.length > 0) { lines.push('| Setting | Value |'); @@ -616,9 +634,15 @@ export function buildGateSummary(_input, parsed, success) { lines.push(`| Check | Result |`); lines.push(`|-------|--------|`); lines.push(`| Should release | ${icon} ${shouldRelease ? 'Yes' : 'No'} |`); - if (parsed?.bump) lines.push(`| Bump | \`${parsed.bump}\` |`); - if (parsed?.scope) lines.push(`| Scope | \`${parsed.scope}\` |`); - if (parsed?.target) lines.push(`| Target | \`${parsed.target}\` |`); + if (parsed?.bump) { + lines.push(`| Bump | \`${parsed.bump}\` |`); + } + if (parsed?.scope) { + lines.push(`| Scope | \`${parsed.scope}\` |`); + } + if (parsed?.target) { + lines.push(`| Target | \`${parsed.target}\` |`); + } lines.push(''); if (parsed?.blocked) { diff --git a/scripts/test-harness/index.mjs b/scripts/test-harness/index.mjs index 1a560273..21c18fee 100644 --- a/scripts/test-harness/index.mjs +++ b/scripts/test-harness/index.mjs @@ -140,13 +140,19 @@ try { encoding: 'utf-8', env: process.env, }); - if (result.stdout) console.log(result.stdout); - if (result.stderr) console.log(result.stderr); + if (result.stdout) { + console.log(result.stdout); + } + if (result.stderr) { + console.log(result.stderr); + } return result; }; const assert = (cond, msg) => { - if (!cond) throw new Error(`Backfill assertion failed: ${msg}`); + if (!cond) { + throw new Error(`Backfill assertion failed: ${msg}`); + } }; // 1. --all --apply: reconstruct every workspace package from the global tag series, write files. @@ -166,8 +172,12 @@ try { assert(body.includes(exp.date), `${exp.file} should be dated ${exp.date}`); assert(!body.includes(today), `${exp.file} should not be stamped with today's date (${today})`); // Entries are scoped to the package's own directory. - for (const s of exp.has) assert(body.includes(s), `${exp.file} should mention "${s}"`); - for (const s of exp.hasNot) assert(!body.includes(s), `${exp.file} should NOT mention "${s}" (path scoping)`); + for (const s of exp.has) { + assert(body.includes(s), `${exp.file} should mention "${s}"`); + } + for (const s of exp.hasNot) { + assert(!body.includes(s), `${exp.file} should NOT mention "${s}" (path scoping)`); + } console.log(`✓ ${exp.file}: dated ${exp.date}, scoped correctly`); } diff --git a/scripts/test-harness/run-action-local.mjs b/scripts/test-harness/run-action-local.mjs index 9b4db312..4a01dfc5 100644 --- a/scripts/test-harness/run-action-local.mjs +++ b/scripts/test-harness/run-action-local.mjs @@ -4,7 +4,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; function normalizeBoolean(value, defaultValue = false) { - if (value === undefined || value === '') return defaultValue; + if (value === undefined || value === '') { + return defaultValue; + } return value === 'true'; } @@ -78,7 +80,9 @@ function buildPreviewArgs(input) { export function parseReleaseOutput(stdout) { const trimmed = stdout.trim(); - if (!trimmed) return undefined; + if (!trimmed) { + return undefined; + } try { return JSON.parse(trimmed); } catch { diff --git a/test/integration/examples-smoke.spec.ts b/test/integration/examples-smoke.spec.ts index da3e5c58..46006068 100644 --- a/test/integration/examples-smoke.spec.ts +++ b/test/integration/examples-smoke.spec.ts @@ -67,7 +67,9 @@ describe('examples smoke-test generator', () => { it('should not add a cargo probe to npm-only scenarios', () => { for (const scenario of SCENARIOS) { - if (scenario.fixture === 'npm-cargo-monorepo') continue; + if (scenario.fixture === 'npm-cargo-monorepo') { + continue; + } const hasCargoProbe = stepsOf(`smoke-${scenario.id}`).some((s) => s.run === 'cargo --version'); expect(hasCargoProbe).toBe(false); } From be038d6e2412ce6f4ef519e306162144cafb6682 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:01:08 +0000 Subject: [PATCH 15/19] fix(cli): suppress per line instead of exempting cli.ts; test --output alone The --json print takes a one-line noConsole suppression and the unwritable error-envelope note goes through the logger, so core/src/cli.ts no longer needs a file-wide exemption. A version --output test now fails if --output alone stops collecting the result. Inline releaseDidPublish, drop a guard writeEnvelope already applies, and trim two comments. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- biome.jsonc | 1 - packages/core/src/cli.ts | 5 ++++- packages/core/src/envelope.ts | 2 -- packages/publish/test/unit/output.spec.ts | 2 -- packages/release/src/commands/changed.ts | 13 ------------- packages/release/src/commands/emitResult.ts | 3 --- .../release/src/commands/standing-pr-command.ts | 6 +++--- packages/version/test/unit/cli.spec.ts | 6 ++++++ 8 files changed, 13 insertions(+), 25 deletions(-) delete mode 100644 packages/release/src/commands/changed.ts diff --git a/biome.jsonc b/biome.jsonc index 3c77b594..9a9c82d3 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -145,7 +145,6 @@ "includes": [ "packages/*/src/**", "!packages/core/src/logger.ts", - "!packages/core/src/cli.ts", "!packages/version/src/utils/logging.ts", "!packages/*/src/command.ts", "!packages/*/src/commands/**" diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 130cd034..a4ded61d 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { type Envelope, errorEnvelope, toEnvelopeError } from './envelope.js'; +import { log } from './logger.js'; /** * Write an envelope to the JSON channel: `--output` when given, otherwise stdout under `--json`, and @@ -14,6 +15,7 @@ export function writeEnvelope(envelope: Envelope, opts: { json?: boolean; output } const text = JSON.stringify(envelope, null, 2); if (!opts.output) { + // biome-ignore lint/suspicious/noConsole: prints the --json result console.log(text); return; } @@ -26,8 +28,9 @@ export function writeEnvelope(envelope: Envelope, opts: { json?: boolean; output if (envelope.status !== 'error') { throw err; } - console.error( + log( `Could not write the error envelope to ${opts.output}: ${err instanceof Error ? err.message : String(err)}`, + 'error', ); } } diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 4488a25e..b2f50680 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -87,8 +87,6 @@ const KNOWN_STATUSES = new Set(['success', 'error']); * known: on an error envelope (with the upstream message), on a `schemaVersion` this build is too old * to read, on an unrecognised status, and on a success envelope carrying no payload. Each of those * would otherwise surface further away as a shape error about the payload. - * - * A caller that wants a nullable payload should read `.data` directly instead. */ export function unwrapEnvelope(value: unknown): unknown { if (!isEnvelope(value)) { diff --git a/packages/publish/test/unit/output.spec.ts b/packages/publish/test/unit/output.spec.ts index de6e4105..97bd867f 100644 --- a/packages/publish/test/unit/output.spec.ts +++ b/packages/publish/test/unit/output.spec.ts @@ -57,8 +57,6 @@ describe('publishDidChange', () => { }); it('should be true when the release commit was created, even with no registry publish', () => { - // A GitHub-release-only or --skip-publish run still commits and tags the release. `committed` is - // only set for a commit this run created, so it can't fire on an idempotent re-run. const out = output({ git: { committed: true, tags: ['v2.0.0'], pushed: true } }); expect(publishDidChange(out)).toBe(true); }); diff --git a/packages/release/src/commands/changed.ts b/packages/release/src/commands/changed.ts deleted file mode 100644 index 6eecc198..00000000 --- a/packages/release/src/commands/changed.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { publishDidChange } from '@releasekit/publish'; -import type { ReleaseOutput } from '../types.js'; - -/** - * Did a `standing-pr publish` actually publish anything? - * - * Not answerable from `versionOutput.updates`: the manifest carries versions the merge already landed - * on `main`, so it is populated even on a re-run where nothing published. Only `publishOutput` records - * what this invocation did. - */ -export function releaseDidPublish(result: ReleaseOutput | null | undefined): boolean { - return publishDidChange(result?.publishOutput); -} diff --git a/packages/release/src/commands/emitResult.ts b/packages/release/src/commands/emitResult.ts index 058c7276..f67b1e9b 100644 --- a/packages/release/src/commands/emitResult.ts +++ b/packages/release/src/commands/emitResult.ts @@ -15,9 +15,6 @@ export function emitResult( data: unknown, opts: { json?: boolean; output?: string; changed?: boolean; warnings?: EnvelopeWarning[] }, ): void { - if (!opts.json && !opts.output) { - return; - } writeEnvelope(successEnvelope(data ?? null, { changed: opts.changed, warnings: opts.warnings }), opts); } diff --git a/packages/release/src/commands/standing-pr-command.ts b/packages/release/src/commands/standing-pr-command.ts index 0d856fe2..6eb2db3c 100644 --- a/packages/release/src/commands/standing-pr-command.ts +++ b/packages/release/src/commands/standing-pr-command.ts @@ -1,8 +1,8 @@ import { exitCodeForError } from '@releasekit/core'; +import { publishDidChange } from '@releasekit/publish'; import { Command } from 'commander'; import type { StandingPROptions } from '../standing-pr/standing-pr.js'; import { runStandingPRMerge, runStandingPRPublish, runStandingPRUpdate } from '../standing-pr/standing-pr.js'; -import { releaseDidPublish } from './changed.js'; import { emitError, emitResult, failInput } from './emitResult.js'; export function createStandingPRCommand(): Command { @@ -89,8 +89,8 @@ export function createStandingPRCommand(): Command { try { const result = await runStandingPRPublish(options, prNumber); - // Read the publish effects, not the manifest's versions — see releaseDidPublish. - emitResult(result, { json: opts.json, output: opts.output, changed: releaseDidPublish(result) }); + // The manifest's versions are already on main even when nothing published, so ask the publish output. + emitResult(result, { json: opts.json, output: opts.output, changed: publishDidChange(result?.publishOutput) }); } catch (err) { emitError(err, { json: opts.json, output: opts.output }); console.error(err instanceof Error ? err.message : String(err)); diff --git a/packages/version/test/unit/cli.spec.ts b/packages/version/test/unit/cli.spec.ts index bb465b89..80b0b11d 100644 --- a/packages/version/test/unit/cli.spec.ts +++ b/packages/version/test/unit/cli.spec.ts @@ -163,6 +163,12 @@ describe('createVersionCommand', () => { }); describe('--output', () => { + it('should collect the JSON result for --output without --json', async () => { + await createVersionCommand().parseAsync(['node', 'test', '--output', 'version.json']); + + expect(jsonOutputModule.enableJsonOutput).toHaveBeenCalled(); + }); + it('should resolve a relative --output against the invocation directory, before --project-dir changes it', async () => { // `version --project-dir app --output v.json && publish --input v.json` must find the file where the // caller put it. Model the chdir so a path resolved after it would land inside --project-dir. From 9f32e289da8e8788e8c9fac69a196db733c44eb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:11:45 +0000 Subject: [PATCH 16/19] test(version): write --output through the real JSON writer Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- packages/core/src/types.ts | 4 ++-- packages/version/test/unit/cli.spec.ts | 23 ++++++++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cadfcd5f..b18a1c67 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -82,8 +82,8 @@ export function deriveReleaseChannel(version: string): ReleaseChannel { } /** - * The complete JSON output of @releasekit/version --json. - * This is the primary interchange format between version and notes. + * What @releasekit/version produces: the `data` of its `--json` envelope, and the interchange format + * between version, notes and publish. */ export interface VersionOutput { dryRun: boolean; diff --git a/packages/version/test/unit/cli.spec.ts b/packages/version/test/unit/cli.spec.ts index 80b0b11d..5f362757 100644 --- a/packages/version/test/unit/cli.spec.ts +++ b/packages/version/test/unit/cli.spec.ts @@ -1,3 +1,5 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { EXIT_CODES } from '@releasekit/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -163,10 +165,25 @@ describe('createVersionCommand', () => { }); describe('--output', () => { - it('should collect the JSON result for --output without --json', async () => { - await createVersionCommand().parseAsync(['node', 'test', '--output', 'version.json']); + it('should write the result envelope to the --output file without --json', async () => { + const actual = await vi.importActual('../../src/utils/jsonOutput.js'); + vi.mocked(jsonOutputModule.enableJsonOutput).mockImplementation(actual.enableJsonOutput); + vi.mocked(jsonOutputModule.printJsonOutput).mockImplementation(actual.printJsonOutput); + const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'releasekit-version-output-')); + const file = path.join(dir, 'version.json'); + + try { + await createVersionCommand().parseAsync(['node', 'test', '--output', file]); - expect(jsonOutputModule.enableJsonOutput).toHaveBeenCalled(); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ status: 'success', data: { updates: [] } }); + expect(stdoutSpy).not.toHaveBeenCalled(); + } finally { + vi.mocked(jsonOutputModule.enableJsonOutput).mockReset(); + vi.mocked(jsonOutputModule.printJsonOutput).mockReset(); + stdoutSpy.mockRestore(); + fs.rmSync(dir, { recursive: true, force: true }); + } }); it('should resolve a relative --output against the invocation directory, before --project-dir changes it', async () => { From f5eca4ffd323182619e0fb1b96b0d020ed87defa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:21:39 +0000 Subject: [PATCH 17/19] docs(core): trim the envelope doc comments; drop a stale e2e helper note Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- packages/core/src/envelope.ts | 19 ++++++------------- test/e2e/lib/helpers.sh | 4 ++-- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index b2f50680..cdf0d0e0 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -53,9 +53,8 @@ export function successEnvelope( } /** - * `data` and `changed` exist for commands that fail partway with real progress to report: a publish - * that failed after landing some packages knows what landed, and did change state. Hardcoding - * `null`/`false` here would drop that, and the retry is what needs it. + * `data` and `changed` are for commands that fail partway with progress to report: a publish that + * failed after landing some packages knows what landed, and the retry needs it. */ export function errorEnvelope( errors: EnvelopeError[], @@ -81,12 +80,8 @@ const KNOWN_STATUSES = new Set(['success', 'error']); /** * Unwrap a piped envelope to its payload; a bare payload passes through unchanged, so a - * hand-assembled input or an older releasekit still works. - * - * Throws rather than returning something unusable, so the failure is reported where the cause is still - * known: on an error envelope (with the upstream message), on a `schemaVersion` this build is too old - * to read, on an unrecognised status, and on a success envelope carrying no payload. Each of those - * would otherwise surface further away as a shape error about the payload. + * hand-assembled input or an older releasekit still works. Throws on anything unusable, so the error + * names the cause instead of surfacing later as a shape error about the payload. */ export function unwrapEnvelope(value: unknown): unknown { if (!isEnvelope(value)) { @@ -121,10 +116,8 @@ export function unwrapEnvelope(value: unknown): unknown { } /** - * The documented code → exit-code table (docs/cli.md). Packages throw more specific codes than the - * nine families, so each is mapped to its family here rather than falling through to the generic - * exit 1 — an automation that branches on "this was a git problem" needs the family, and only a - * complete table gives it one. Core owns the map because the exit codes are a cross-package contract. + * The documented code → exit-code table (docs/cli.md): every specific code maps to one of the nine + * families, so automation can branch on the exit code instead of getting the generic 1. */ const CODE_TO_EXIT: Record = { CONFIG_ERROR: EXIT_CODES.CONFIG_ERROR, diff --git a/test/e2e/lib/helpers.sh b/test/e2e/lib/helpers.sh index 159954f8..29487153 100755 --- a/test/e2e/lib/helpers.sh +++ b/test/e2e/lib/helpers.sh @@ -89,8 +89,8 @@ run_cli_json() { fi # Output the JSON (stdout only). Unwrap the uniform CLI envelope ({ schemaVersion, status, data, … }) - # to its data payload so callers read result fields (.versionOutput, …) directly. Output that isn't - # an envelope — non-JSON, or a command that isn't enveloped yet — passes through unchanged. + # to its data payload so callers read result fields (.versionOutput, …) directly. Anything else + # passes through unchanged. if jq -e 'type == "object" and has("schemaVersion") and has("data")' "$tmpfile" >/dev/null 2>&1; then jq '.data' "$tmpfile" else From f7c5d1318315c7ae57181072d980ee0403866b11 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 14:14:17 +0100 Subject: [PATCH 18/19] docs: tighten the envelope docs and comments Fix the failed-upstream wording in docs/cli.md (it's an input error that names the upstream code), say plainly that a partial publish can't be resumed yet, and cut or shorten the comments that restated code or docs. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 2 +- docs/cli.md | 18 +++++++++------- packages/core/src/cli.ts | 12 +++-------- packages/core/src/envelope.ts | 21 ++++++------------- packages/notes/src/input/version-output.ts | 1 - packages/publish/README.md | 2 +- packages/publish/src/command.ts | 3 +-- packages/publish/src/output.ts | 15 +++---------- packages/publish/src/stages/input.ts | 2 +- packages/publish/test/unit/cli.spec.ts | 3 +-- .../src/commands/standing-pr-command.ts | 2 +- packages/version/README.md | 2 +- packages/version/src/command.ts | 3 +-- packages/version/src/utils/jsonOutput.ts | 7 +------ packages/version/test/unit/cli.spec.ts | 3 +-- test/e2e/lib/helpers.sh | 4 +--- 16 files changed, 34 insertions(+), 66 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 8ba11fad..14dee9fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,7 +58,7 @@ A conceptual overview for contributors and users evaluating ReleaseKit. ## Why three separate CLIs -Each package — `@releasekit/version`, `@releasekit/notes`, `@releasekit/publish` — is a standalone CLI that reads from stdin or a file and writes to stdout or disk. They chain through the `VersionOutput` file (`notes` consumes it without passing it on, so it can't sit mid-pipe), with the changelog staged so `publish` includes it in the release commit: +Each package — `@releasekit/version`, `@releasekit/notes`, `@releasekit/publish` — is a standalone CLI that reads from stdin or a file and writes to stdout or disk. They chain through a file rather than a pipe, because `notes` doesn't pass the `VersionOutput` on. Stage the changelog so `publish` commits it: ``` releasekit-version --output version.json diff --git a/docs/cli.md b/docs/cli.md index 5e4d5356..da2953e9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -27,20 +27,24 @@ Every command that produces a JSON result — `release`, `gate`, `standing-pr`, "schemaVersion": 1, // envelope contract version; stable across minor releases "status": "success", // "success" | "error" "changed": true, // did the command change state, or was everything already as desired? - "data": { /* … */ }, // command-specific payload (VersionOutput, gate result, …); null on error, except partial progress + "data": { /* … */ }, // command-specific payload (VersionOutput, gate result, …); null on error, except a partial publish "warnings": [{ "code": "…", "message": "…" }], "errors": [{ "code": "…", "category": "…", "retryable": false, "message": "…" }] } ``` -- **`data`** carries the command's payload verbatim — the envelope wraps it, never replaces it. `releasekit release --json` still exposes its `VersionOutput` at `data.versionOutput`, and `releasekit version --json` exposes it at `data`. It is `null` on error, except for a `publish` pipeline failure, which carries the partial output in `data` — check `changed` for whether anything actually landed (see the chaining section below). -- **`changed`** separates real work from a no-op: a dry run is never `changed`; `publish` is `changed` when it published to a registry or created the release commit; `standing-pr publish` reads its registry results (the merge already made the commit), so a re-run where every version was already published reports `changed: false`; `gate` is read-only and always `changed: false`. +- **`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, except after a partial `publish` (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. + - `standing-pr publish`: registry publishes only, since the merge already made the commit. A re-run where every version was already published reports `false`. + - `gate` is read-only, so always `false`. - **`errors[]`** replaces prose-only failures in JSON mode. Each carries a stable machine `code`, a coarse `category`, a `retryable` flag (only `true` for known-transient failures — a timeout, 429, or 5xx from a provider — so an agent never retries an unknown failure), and a human `message`. - **Stream discipline:** the envelope is the only thing on stdout; all diagnostics (progress, warnings, error text) go to stderr. Parsing stdout as JSON is always safe, and no command prompts interactively on a CI path. ### Chaining `version`, `notes`, and `publish` -`notes` and `publish` unwrap the envelope on the way in and read the `VersionOutput` from `data`. `notes` consumes the `VersionOutput` and writes changelogs and release notes; it never passes the `VersionOutput` on, so it can't sit in the middle of a pipe. Hand the same file to each stage instead, and stage what `notes` wrote — `publish` commits the files `version` changed plus whatever is already staged: +`notes` and `publish` read the `VersionOutput` from the envelope's `data`. `notes` doesn't print it back out, so it can't sit mid-pipe: give each stage the same file. `publish` commits the files `version` changed plus whatever is staged, so stage the changelogs `notes` wrote first: ```sh releasekit version --output version.json @@ -51,9 +55,9 @@ releasekit publish --input version.json `publish` also reads stdin, so `releasekit version --json | releasekit publish` works when no notes step is needed. -A bare `VersionOutput` is still accepted on input, so a hand-assembled file or output from an older releasekit keeps working. Piping a *failed* stage forward fails with that stage's error rather than a schema complaint about missing fields — the message names the upstream code, so the report points at the stage that actually broke. +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`, and reports `changed: true` if anything did (a registry publish or the release commit); the failing stage is named in the error `message`. Registry publishes are idempotent — versions already on the registry are skipped on a later run — but the standalone `publish` also makes the release commit, so re-running it in the same working tree stops at the git-commit stage: there is nothing left to commit. +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`. A re-run skips versions already on the registry, but not the release commit: the git-commit stage runs before the registry publishes and finds nothing to commit, so re-running `publish` on the same tree fails there. Resuming a partial `publish` isn't supported yet. ### Error codes and exit codes @@ -69,7 +73,7 @@ When `publish` fails partway, its error envelope carries what already landed in | `VERSION_ERROR` | `version` | 8 | | `PUBLISH_ERROR` | `publish` | 9 | -Commands throw more specific codes than these nine families — `INPUT_VALIDATION_ERROR`, `PIPELINE_STAGE_ERROR`, and so on. The `code` in `errors[]` is that specific code; the **exit code** is its family's, so a script can branch on the family without enumerating every code — `publish` exits `3` for any input problem (`INPUT_PARSE_ERROR`, `INPUT_VALIDATION_ERROR`, or a failed upstream envelope) and `2` for an invalid config. `publish` reports any failure inside its pipeline as `PIPELINE_STAGE_ERROR` (exit `9`), naming the failing stage in the message. This holds for `version` and `publish`, and for `release` and `standing-pr`, which report an error from their version, notes, or publish stage with that stage's own code. +Commands report more specific codes than these nine (`INPUT_VALIDATION_ERROR`, `PIPELINE_STAGE_ERROR`, …). `errors[].code` is the specific code and the exit code is its family's, so scripts can branch on the family. For example, `publish` exits 3 for any input problem, 2 for an invalid config, and 9 for any pipeline failure (`PIPELINE_STAGE_ERROR`, with the failing stage in the message). `release` and `standing-pr` report a failing stage's own code. `schemaVersion` bumps only on a breaking change to the envelope shape and is stable across minor releases, so agents and CI can pin against it. A consumer reading an envelope from a **newer** producer fails with a version-mismatch error rather than acting on a payload whose shape it may not understand. diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index a4ded61d..57e0be85 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -4,11 +4,7 @@ import { fileURLToPath } from 'node:url'; import { type Envelope, errorEnvelope, toEnvelopeError } from './envelope.js'; import { log } from './logger.js'; -/** - * Write an envelope to the JSON channel: `--output` when given, otherwise stdout under `--json`, and - * nothing at all when neither is set. `--output` is the reliable channel — stdout can be polluted by - * subprocess or log noise, and a single stray byte breaks JSON parsing. - */ +/** Writes to `--output` if given, else to stdout under `--json`, else nothing. */ export function writeEnvelope(envelope: Envelope, opts: { json?: boolean; output?: string }): void { if (!opts.json && !opts.output) { return; @@ -22,9 +18,8 @@ export function writeEnvelope(envelope: Envelope, opts: { json?: boolean; output try { fs.writeFileSync(opts.output, text); } catch (err) { - // An unwritable --output fails the command: rethrow a result write so the caller's error path runs. - // That path writes its error envelope to the same file and fails again — note it on stderr instead of - // throwing out of the error handler, which would crash with a stack and bury the original failure. + // A failed result write rethrows so the command fails; a failed error-envelope write only logs, so the + // original error isn't buried. if (envelope.status !== 'error') { throw err; } @@ -35,7 +30,6 @@ export function writeEnvelope(envelope: Envelope, opts: { json?: boolean; output } } -/** Write a thrown value to the JSON channel as a single-error envelope. */ export function writeErrorEnvelope(error: unknown, opts: { json?: boolean; output?: string }): void { writeEnvelope(errorEnvelope([toEnvelopeError(error)]), opts); } diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 3b8d7c41..ab31efe8 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -52,10 +52,7 @@ export function successEnvelope( }; } -/** - * `data` and `changed` are for commands that fail partway with progress to report: a publish that - * failed after landing some packages knows what landed, and the retry needs it. - */ +/** `data` and `changed` report progress made before the failure, e.g. what a partial publish landed. */ export function errorEnvelope( errors: EnvelopeError[], opts: { warnings?: EnvelopeWarning[]; data?: T; changed?: boolean } = {}, @@ -79,9 +76,8 @@ function isEnvelope(value: unknown): value is Envelope { const KNOWN_STATUSES = new Set(['success', 'error']); /** - * Unwrap a piped envelope to its payload; a bare payload passes through unchanged, so a - * hand-assembled input or an older releasekit still works. Throws on anything unusable, so the error - * names the cause instead of surfacing later as a shape error about the payload. + * Returns an envelope's `data`, or a bare payload unchanged. Throws on an envelope it can't use (newer + * schema, error status, no data), so the failure names its cause. */ export function unwrapEnvelope(value: unknown): unknown { if (!isEnvelope(value)) { @@ -115,10 +111,7 @@ export function unwrapEnvelope(value: unknown): unknown { return value.data; } -/** - * The documented code → exit-code table (docs/cli.md): every specific code maps to one of the nine - * families, so automation can branch on the exit code instead of getting the generic 1. - */ +/** Maps each code to its family's exit code (docs/cli.md); unknown codes exit 1. */ const CODE_TO_EXIT: Record = { CONFIG_ERROR: EXIT_CODES.CONFIG_ERROR, INPUT_ERROR: EXIT_CODES.INPUT_ERROR, @@ -131,8 +124,7 @@ const CODE_TO_EXIT: Record = { VERSION_ERROR: EXIT_CODES.VERSION_ERROR, PUBLISH_ERROR: EXIT_CODES.PUBLISH_ERROR, - // @releasekit/version. Config and workspace discovery are configuration problems (the `packages` - // globs matched nothing, or the config is unreadable); the rest are git or versioning failures. + // @releasekit/version: discovery failures mean bad `packages` globs, so they're config errors. CONFIG_REQUIRED: EXIT_CODES.CONFIG_ERROR, INVALID_CONFIG: EXIT_CODES.CONFIG_ERROR, UNSAFE_CONFIG_PATH: EXIT_CODES.CONFIG_ERROR, @@ -148,8 +140,7 @@ const CODE_TO_EXIT: Record = { NO_FILES: EXIT_CODES.VERSION_ERROR, CARGO_LOCK_SYNC_ERROR: EXIT_CODES.VERSION_ERROR, - // @releasekit/publish. Registry auth and publish failures are publish errors; a malformed package - // manifest is a configuration problem; the git and GitHub stages map to their own families. + // @releasekit/publish: a malformed manifest is a config error. PIPELINE_STAGE_ERROR: EXIT_CODES.PUBLISH_ERROR, NPM_AUTH_ERROR: EXIT_CODES.PUBLISH_ERROR, NPM_PUBLISH_ERROR: EXIT_CODES.PUBLISH_ERROR, diff --git a/packages/notes/src/input/version-output.ts b/packages/notes/src/input/version-output.ts index 79a9f845..062d09e9 100644 --- a/packages/notes/src/input/version-output.ts +++ b/packages/notes/src/input/version-output.ts @@ -65,7 +65,6 @@ export function parseVersionOutput(json: string): ChangelogInput { throw new InputParseError(`Invalid JSON input: ${error instanceof Error ? error.message : String(error)}`); } - // Unwrap here rather than at each call site — this covers the string, file, and stdin entries at once. try { data = unwrapEnvelope(data); } catch (error) { diff --git a/packages/publish/README.md b/packages/publish/README.md index 5b07f1f4..f0df7af2 100644 --- a/packages/publish/README.md +++ b/packages/publish/README.md @@ -83,7 +83,7 @@ Before failing the stage, the npm and cargo publish steps automatically retry ** | `--skip-github-release` | Skip GitHub Release creation | `false` | | `--skip-verification` | Skip post-publish verification | `false` | | `--json` | Output results as a JSON [envelope](../../docs/cli.md#json-output-contract) | `false` | -| `--output ` | Write the JSON envelope to a file instead of stdout | — | +| `--output ` | Write the JSON envelope to a file instead of stdout | stdout | | `--verbose` | Verbose logging | `false` | ## Integration with @releasekit/version diff --git a/packages/publish/src/command.ts b/packages/publish/src/command.ts index 5ee27333..bb32d95a 100644 --- a/packages/publish/src/command.ts +++ b/packages/publish/src/command.ts @@ -68,8 +68,7 @@ export function createPublishCommand(): Command { writeEnvelope(successEnvelope(output, { changed: publishDidChange(output) }), io); } catch (err) { if (err instanceof PipelineError) { - // Carry what landed before the failure, so the caller can see what is already out. `failedStage` - // has no envelope field of its own, so it goes in the message rather than being dropped. + // 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}` }], { diff --git a/packages/publish/src/output.ts b/packages/publish/src/output.ts index 97571dcf..899a2254 100644 --- a/packages/publish/src/output.ts +++ b/packages/publish/src/output.ts @@ -1,18 +1,9 @@ import type { PublishOutput } from './types.js'; /** - * Did this publish change anything? - * - * Two honest signals. A registry result is one: `skipped` and `alreadyPublished` mark the packages - * that were passed over. The release commit is the other: outside a dry run, `git.committed` is only - * set when the git-commit stage created a new commit, so it is never true on an idempotent re-run — - * and never true under `standing-pr publish`, whose merge already made the commit (`skipGitCommit`). - * Release tags, `git.pushed` (set for any push attempt), and the GitHub-release stage (which reports - * `success: true` for an already-existing release) can't distinguish a real publish from a no-op, so - * they don't count. - * - * Answers `changed` for both the `publish` CLI and `standing-pr publish`, which is why it lives here - * rather than in either caller. + * Whether this run changed anything: a registry publish, or a release commit it created (never on a + * re-run or under `standing-pr publish`). Tags, `git.pushed` and GitHub releases report success for a + * no-op, so they don't count. */ export function publishDidChange(output: PublishOutput | null | undefined): boolean { if (!output || output.dryRun) { diff --git a/packages/publish/src/stages/input.ts b/packages/publish/src/stages/input.ts index 43340a06..5d6d1e81 100644 --- a/packages/publish/src/stages/input.ts +++ b/packages/publish/src/stages/input.ts @@ -56,7 +56,7 @@ export async function parseInput(inputPath?: string): Promise { throw createPublishError(PublishErrorCode.INPUT_PARSE_ERROR, 'Input is not valid JSON'); } - // Re-raise an upstream failure as an input error, so it reaches the caller as this stage's own code. + // An unusable or failed upstream envelope is an input error (exit 3). try { parsed = unwrapEnvelope(parsed); } catch (err) { diff --git a/packages/publish/test/unit/cli.spec.ts b/packages/publish/test/unit/cli.spec.ts index e63cb65a..cadf3d67 100644 --- a/packages/publish/test/unit/cli.spec.ts +++ b/packages/publish/test/unit/cli.spec.ts @@ -3,8 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// Partial mock: the envelope builders and writer must stay real, since these tests assert on the -// envelope the command actually emits. Only the logging setters are stubbed, to assert they're wired. +// Keep the real envelope helpers, since the tests assert on what's emitted; stub only the logging setters. vi.mock('@releasekit/core', async (importOriginal) => ({ ...(await importOriginal()), setJsonMode: vi.fn(), diff --git a/packages/release/src/commands/standing-pr-command.ts b/packages/release/src/commands/standing-pr-command.ts index 6eb2db3c..3a879407 100644 --- a/packages/release/src/commands/standing-pr-command.ts +++ b/packages/release/src/commands/standing-pr-command.ts @@ -89,7 +89,7 @@ export function createStandingPRCommand(): Command { try { const result = await runStandingPRPublish(options, prNumber); - // The manifest's versions are already on main even when nothing published, so ask the publish output. + // The manifest is on main whether or not anything published, so `changed` comes from the publish output. emitResult(result, { json: opts.json, output: opts.output, changed: publishDidChange(result?.publishOutput) }); } catch (err) { emitError(err, { json: opts.json, output: opts.output }); diff --git a/packages/version/README.md b/packages/version/README.md index 0c8c9858..77e1712b 100644 --- a/packages/version/README.md +++ b/packages/version/README.md @@ -67,7 +67,7 @@ releasekit-version --prerelease beta | `--project-dir ` | Project directory | cwd | | `-d, --dry-run` | Preview without file changes or git operations | `false` | | `-j, --json` | Output results as a JSON envelope (see below) | `false` | -| `--output ` | Write the JSON envelope to a file instead of stdout (resolved against the directory you run from) | — | +| `--output ` | Write the JSON envelope to a file instead of stdout (relative to where you run it, not `--project-dir`) | stdout | ## JSON Output diff --git a/packages/version/src/command.ts b/packages/version/src/command.ts index 432f2b3e..2c9daf4b 100644 --- a/packages/version/src/command.ts +++ b/packages/version/src/command.ts @@ -31,8 +31,7 @@ export function createVersionCommand(): Command { ) .option('--project-dir ', 'Project directory to run commands in', process.cwd()) .action(async (options) => { - // Resolve --output against the invocation directory before --project-dir changes it, so the file - // lands where the caller asked (as `release --output` does) on every path, success or error. + // Resolve before --project-dir chdirs, so the file lands where the caller asked (as `release --output` does). const output: string | undefined = options.output ? path.resolve(options.output) : undefined; const io = { json: options.json, output }; diff --git a/packages/version/src/utils/jsonOutput.ts b/packages/version/src/utils/jsonOutput.ts index b8aef6b0..a917eca3 100644 --- a/packages/version/src/utils/jsonOutput.ts +++ b/packages/version/src/utils/jsonOutput.ts @@ -326,12 +326,7 @@ export function getJsonData(): VersionOutput { return { ..._jsonData }; } -/** - * Emit the collected `VersionOutput` as the `data` of the uniform CLI envelope, which `notes` and - * `publish` unwrap on the way in. - * - * A dry run writes nothing, so it is never `changed`. - */ +/** Print the collected `VersionOutput` as an envelope's `data`. */ export function printJsonOutput(outputPath?: string): void { if (!_jsonOutputMode) { return; diff --git a/packages/version/test/unit/cli.spec.ts b/packages/version/test/unit/cli.spec.ts index 5f362757..ef680679 100644 --- a/packages/version/test/unit/cli.spec.ts +++ b/packages/version/test/unit/cli.spec.ts @@ -187,8 +187,7 @@ describe('createVersionCommand', () => { }); it('should resolve a relative --output against the invocation directory, before --project-dir changes it', async () => { - // `version --project-dir app --output v.json && publish --input v.json` must find the file where the - // caller put it. Model the chdir so a path resolved after it would land inside --project-dir. + // Model the chdir, so a path resolved after it would land inside --project-dir. const invocationDir = process.cwd(); let cwd = invocationDir; const chdir = vi.spyOn(process, 'chdir').mockImplementation((dir) => { diff --git a/test/e2e/lib/helpers.sh b/test/e2e/lib/helpers.sh index 29487153..104374ce 100755 --- a/test/e2e/lib/helpers.sh +++ b/test/e2e/lib/helpers.sh @@ -88,9 +88,7 @@ run_cli_json() { cat "$stderrfile" >&2 fi - # Output the JSON (stdout only). Unwrap the uniform CLI envelope ({ schemaVersion, status, data, … }) - # to its data payload so callers read result fields (.versionOutput, …) directly. Anything else - # passes through unchanged. + # Print stdout's JSON, unwrapping an envelope to its data so callers read result fields directly. if jq -e 'type == "object" and has("schemaVersion") and has("data")' "$tmpfile" >/dev/null 2>&1; then jq '.data' "$tmpfile" else From 18d68352cce1ef163344d198414f68b68a01c46f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 14:59:23 +0100 Subject: [PATCH 19/19] docs: link to the chaining section from architecture, simplify envelope comments Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 9 +-------- docs/cli.md | 4 ++-- docs/troubleshooting.md | 2 +- packages/core/README.md | 2 +- packages/core/src/cli.ts | 3 +-- packages/core/src/envelope.ts | 7 ++----- packages/core/src/types.ts | 4 ++-- packages/publish/src/output.ts | 5 ++--- 8 files changed, 12 insertions(+), 24 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 14dee9fe..b7497b85 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,14 +58,7 @@ A conceptual overview for contributors and users evaluating ReleaseKit. ## Why three separate CLIs -Each package — `@releasekit/version`, `@releasekit/notes`, `@releasekit/publish` — is a standalone CLI that reads from stdin or a file and writes to stdout or disk. They chain through a file rather than a pipe, because `notes` doesn't pass the `VersionOutput` on. Stage the changelog so `publish` commits it: - -``` -releasekit-version --output version.json -releasekit-notes --input version.json -git add CHANGELOG.md -releasekit-publish --input version.json -``` +Each package — `@releasekit/version`, `@releasekit/notes`, `@releasekit/publish` — is a standalone CLI that reads from stdin or a file and writes to stdout or disk. They chain through a `VersionOutput` file; see [Chaining `version`, `notes`, and `publish`](./cli.md#chaining-version-notes-and-publish). `@releasekit/release` is orchestration sugar: it calls each tool's API directly (no subprocesses) and passes the typed `VersionOutput` through without serialising to JSON. A team that only wants changelog generation can install `@releasekit/notes` alone. diff --git a/docs/cli.md b/docs/cli.md index da2953e9..a54bda75 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -27,13 +27,13 @@ Every command that produces a JSON result — `release`, `gate`, `standing-pr`, "schemaVersion": 1, // envelope contract version; stable across minor releases "status": "success", // "success" | "error" "changed": true, // did the command change state, or was everything already as desired? - "data": { /* … */ }, // command-specific payload (VersionOutput, gate result, …); null on error, except a partial publish + "data": { /* … */ }, // command-specific payload (VersionOutput, gate result, …); null on error, unless publish failed partway "warnings": [{ "code": "…", "message": "…" }], "errors": [{ "code": "…", "category": "…", "retryable": false, "message": "…" }] } ``` -- **`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, except after a partial `publish` (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 `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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7d12141e..376969aa 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -399,6 +399,6 @@ releasekit ships three flags useful for diagnosing issues: **`--verbose`** — emits internal decision logs: which commits were found and classified, which packages were discovered, what version strategy was selected, and what LLM requests/responses look like. Use this when the tool exits cleanly but produces an unexpected result. -**`--json`** — emits structured JSON on stdout. Useful for piping between stages or for scripted inspection. The result is wrapped in an envelope whose `schemaVersion` changes only on a breaking change to its shape (see the [JSON output contract](./cli.md#json-output-contract)); text log output has no such guarantee. +**`--json`** — emits structured JSON on stdout. Useful for piping between stages or for scripted inspection. The result is wrapped in an envelope whose `schemaVersion` changes only on a breaking change to its shape (see the [JSON output contract](./cli.md#json-output-contract)). **In CI logs:** expand the failing step to see the full output. Look for the `[dry-run]` prefix to confirm dry-run mode is active, and check for `[warn]` lines that may indicate soft failures (such as the LLM key warning) that did not abort the run but produced degraded output. diff --git a/packages/core/README.md b/packages/core/README.md index 3e369772..b87c4604 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -15,7 +15,7 @@ This is an internal package that defines the JSON contract between `@releasekit/ | Type | Description | |------|-------------| -| `VersionOutput` | The version stage's result — the `data` of `releasekit-version --json`'s envelope, and what `notes` / `publish` read | +| `VersionOutput` | The `data` of `releasekit-version --json`'s envelope | | `VersionPackageUpdate` | A single package update record | | `VersionPackageChangelog` | Changelog data for one package | | `VersionChangelogEntry` | A single changelog entry | diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 57e0be85..0c28031b 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -18,8 +18,7 @@ export function writeEnvelope(envelope: Envelope, opts: { json?: boolean; output try { fs.writeFileSync(opts.output, text); } catch (err) { - // A failed result write rethrows so the command fails; a failed error-envelope write only logs, so the - // original error isn't buried. + // A failed result write rethrows so the command fails; a failed error-envelope write is only logged. if (envelope.status !== 'error') { throw err; } diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index ab31efe8..33cc6a88 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -52,7 +52,6 @@ export function successEnvelope( }; } -/** `data` and `changed` report progress made before the failure, e.g. what a partial publish landed. */ export function errorEnvelope( errors: EnvelopeError[], opts: { warnings?: EnvelopeWarning[]; data?: T; changed?: boolean } = {}, @@ -60,6 +59,7 @@ export function errorEnvelope( return { schemaVersion: ENVELOPE_SCHEMA_VERSION, status: 'error', + // Progress made before the failure, e.g. what a partial publish landed. changed: opts.changed ?? false, data: opts.data ?? null, warnings: opts.warnings ?? [], @@ -75,10 +75,7 @@ function isEnvelope(value: unknown): value is Envelope { const KNOWN_STATUSES = new Set(['success', 'error']); -/** - * Returns an envelope's `data`, or a bare payload unchanged. Throws on an envelope it can't use (newer - * schema, error status, no data), so the failure names its cause. - */ +/** Returns an envelope's `data`, or a bare payload as is; throws on a newer, failed or empty envelope. */ export function unwrapEnvelope(value: unknown): unknown { if (!isEnvelope(value)) { return value; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b18a1c67..a2d76533 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -82,8 +82,8 @@ export function deriveReleaseChannel(version: string): ReleaseChannel { } /** - * What @releasekit/version produces: the `data` of its `--json` envelope, and the interchange format - * between version, notes and publish. + * The `data` of @releasekit/version's `--json` envelope. + * This is the primary interchange format between version and notes. */ export interface VersionOutput { dryRun: boolean; diff --git a/packages/publish/src/output.ts b/packages/publish/src/output.ts index 899a2254..cd39c052 100644 --- a/packages/publish/src/output.ts +++ b/packages/publish/src/output.ts @@ -1,9 +1,8 @@ import type { PublishOutput } from './types.js'; /** - * Whether this run changed anything: a registry publish, or a release commit it created (never on a - * re-run or under `standing-pr publish`). Tags, `git.pushed` and GitHub releases report success for a - * no-op, so they don't count. + * True if this run published to a registry or created the release commit. Tags, pushes and GitHub + * releases don't count: they report success even when nothing changed. */ export function publishDidChange(output: PublishOutput | null | undefined): boolean { if (!output || output.dryRun) {