diff --git a/docs/architecture.md b/docs/architecture.md index cb910424..b7497b85 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,11 +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 can be piped together: - -``` -releasekit-version --json | releasekit-notes | releasekit-publish -``` +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 50278aa4..a54bda75 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,26 +20,45 @@ 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, 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` still exposes its `VersionOutput` at `data.versionOutput`. -- **`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` 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. + - `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` 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 +releasekit notes --input version.json +git add CHANGELOG.md # or each package's changelog in `packages` mode +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. Feeding a failed stage's envelope forward fails as an input error (exit 3) whose message names that stage's code, rather than a schema error about missing fields. + +When `publish` fails partway, its error envelope carries what already landed in `data`, reports `changed: true` if anything did, and names the failing stage in the error `message`. 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 | `code` | `category` | Exit code | @@ -54,7 +73,9 @@ The orchestration commands — `release`, `gate`, and `standing-pr` — emit the | `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 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. --- @@ -275,6 +296,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 +382,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/docs/rust.md b/docs/rust.md index 66d75e2c..8346c3d0 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -211,7 +211,7 @@ jobs: **`Failed to sync Cargo.lock: …`** -releasekit edits each lock's text the way cargo would, and fails the version step (`CARGO_LOCK_SYNC_ERROR`: exit code 8 from `release` and `standing-pr`, 1 from `releasekit version`) rather than leave a lock behind. The message names the lock and the reason: +releasekit edits each lock's text the way cargo would, and fails the version step (`CARGO_LOCK_SYNC_ERROR`, exit code 8) rather than leave a lock behind. The message names the lock and the reason: - `it also has from another source`: the lock also lists the crate from a registry or git source at the old or the new version, so cargo would have to change how it tells the two apart. Sync that lock with cargo yourself (`cargo update` with the new version in `Cargo.toml`) and commit it, or drop the second source of the crate. - `it has path entries named `: the lock holds several local crates of that name, so releasekit can't tell which one is the bumped crate. Sync that lock with cargo yourself. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3f2a2a36..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 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)). **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 0e7cfe73..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` | Complete JSON output of `releasekit-version --json` | +| `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 055aceb9..0c28031b 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -1,6 +1,37 @@ 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'; + +/** 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; + } + const text = JSON.stringify(envelope, null, 2); + if (!opts.output) { + // biome-ignore lint/suspicious/noConsole: prints the --json result + console.log(text); + return; + } + try { + fs.writeFileSync(opts.output, text); + } catch (err) { + // A failed result write rethrows so the command fails; a failed error-envelope write is only logged. + if (envelope.status !== 'error') { + throw err; + } + log( + `Could not write the error envelope to ${opts.output}: ${err instanceof Error ? err.message : String(err)}`, + 'error', + ); + } +} + +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. diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 73b61973..33cc6a88 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -52,28 +52,107 @@ export function successEnvelope( }; } -export function errorEnvelope(errors: EnvelopeError[], opts: { warnings?: EnvelopeWarning[] } = {}): Envelope { +export function errorEnvelope( + errors: EnvelopeError[], + opts: { warnings?: EnvelopeWarning[]; data?: T; changed?: boolean } = {}, +): Envelope { return { schemaVersion: ENVELOPE_SCHEMA_VERSION, status: 'error', - changed: false, - data: null, + // Progress made before the failure, e.g. what a partial publish landed. + changed: opts.changed ?? false, + data: opts.data ?? null, warnings: opts.warnings ?? [], errors, }; } +function isEnvelope(value: unknown): value is Envelope { + return ( + typeof value === 'object' && value !== null && 'schemaVersion' in value && 'status' in value && 'data' in value + ); +} + +const KNOWN_STATUSES = new Set(['success', 'error']); + +/** 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; + } + + 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]; + throw new Error( + first + ? `Upstream releasekit stage failed (${first.code}): ${first.message}` + : 'Upstream releasekit stage failed, but reported no error detail.', + ); + } + + if (value.data === null || value.data === undefined) { + throw new Error('Upstream releasekit stage reported success but produced no data.'); + } + + return value.data; +} + +/** 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, 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: 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, + 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, CARGO_LOCK_SYNC_ERROR: EXIT_CODES.VERSION_ERROR, + + // @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, + 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/src/index.ts b/packages/core/src/index.ts index 2d33c309..fc3535aa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,7 +12,7 @@ export { parseGitHubOwnerRepo, renderIssueRefs, } from './changelogRefs.js'; -export { readPackageVersion } from './cli.js'; +export { readPackageVersion, writeEnvelope, writeErrorEnvelope } from './cli.js'; export { buildDependencyGraph, type Ecosystem, @@ -29,6 +29,7 @@ export { exitCodeForError, successEnvelope, toEnvelopeError, + unwrapEnvelope, } from './envelope.js'; export { EXIT_CODES, type ExitCode, InputError, ReleaseKitError } from './errors.js'; export { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cadfcd5f..a2d76533 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -82,7 +82,7 @@ export function deriveReleaseChannel(version: string): ReleaseChannel { } /** - * The complete JSON output of @releasekit/version --json. + * The `data` of @releasekit/version's `--json` envelope. * This is the primary interchange format between version and notes. */ export interface VersionOutput { diff --git a/packages/core/test/unit/envelope.spec.ts b/packages/core/test/unit/envelope.spec.ts index bed046cc..49da3a15 100644 --- a/packages/core/test/unit/envelope.spec.ts +++ b/packages/core/test/unit/envelope.spec.ts @@ -5,6 +5,7 @@ import { exitCodeForError, successEnvelope, toEnvelopeError, + unwrapEnvelope, } from '../../src/envelope.js'; import { EXIT_CODES, ReleaseKitError } from '../../src/errors.js'; @@ -98,4 +99,53 @@ 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('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); + expect(unwrapEnvelope(null)).toBeNull(); + expect(unwrapEnvelope('a string')).toBe('a string'); + }); + + 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/); + }); + + it('should refuse an envelope from a newer producer', () => { + const future = { ...successEnvelope({ updates: [] }), schemaVersion: ENVELOPE_SCHEMA_VERSION + 1 }; + expect(() => unwrapEnvelope(future)).toThrow(/schemaVersion/); + }); + + 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', () => { + 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 new file mode 100644 index 00000000..f5a38114 --- /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(/ENOENT/); + }); + + it('should not throw out of the error path when --output is unwritable', () => { + 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); + }); +}); diff --git a/packages/notes/src/input/version-output.ts b/packages/notes/src/input/version-output.ts index ab4ebe53..062d09e9 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,13 @@ export function parseVersionOutput(json: string): ChangelogInput { throw new InputParseError(`Invalid JSON input: ${error instanceof Error ? error.message : String(error)}`); } - return versionOutputToChangelogInput(data); + 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/README.md b/packages/publish/README.md index 94d7caa2..f0df7af2 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 | stdout | | `--verbose` | Verbose logging | `false` | ## Integration with @releasekit/version diff --git a/packages/publish/src/command.ts b/packages/publish/src/command.ts index 1d2dbca8..bb32d95a 100644 --- a/packages/publish/src/command.ts +++ b/packages/publish/src/command.ts @@ -1,7 +1,17 @@ -import { EXIT_CODES, setJsonMode, setLogLevel } from '@releasekit/core'; +import { + errorEnvelope, + exitCodeForError, + setJsonMode, + setLogLevel, + successEnvelope, + toEnvelopeError, + writeEnvelope, + writeErrorEnvelope, +} 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 +29,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'); } @@ -52,30 +65,28 @@ 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) { + // The envelope has no stage field, so name the failed stage in the message; `data` keeps what landed. + const error = toEnvelopeError(err); + writeEnvelope( + errorEnvelope([{ ...error, message: `publish failed in the ${err.failedStage} stage: ${error.message}` }], { + data: err.partialOutput, + changed: publishDidChange(err.partialOutput), + }), + io, ); - process.exit(EXIT_CODES.PUBLISH_ERROR); - } - if (BasePublishError.isPublishError(err)) { err.logError(); - process.exit(EXIT_CODES.PUBLISH_ERROR); + } else { + writeErrorEnvelope(err, io); + if (BasePublishError.isPublishError(err)) { + err.logError(); + } else { + console.error(err instanceof Error ? err.message : String(err)); + } } - console.error(err instanceof Error ? err.message : String(err)); - process.exit(EXIT_CODES.GENERAL_ERROR); + process.exit(exitCodeForError(err)); } }); } diff --git a/packages/publish/src/index.ts b/packages/publish/src/index.ts index 6e8cd68a..d322f381 100644 --- a/packages/publish/src/index.ts +++ b/packages/publish/src/index.ts @@ -3,6 +3,8 @@ export { createPublishCommand } from './command.js'; export { getDefaultConfig, loadConfig } from './config.js'; // Errors export { BasePublishError, createPublishError, PipelineError, PublishError, PublishErrorCode } from './errors/index.js'; +// Output +export { publishDidChange } from './output.js'; // Pipeline export { runPipeline } from './pipeline/index.js'; // Input parsing diff --git a/packages/publish/src/output.ts b/packages/publish/src/output.ts new file mode 100644 index 00000000..cd39c052 --- /dev/null +++ b/packages/publish/src/output.ts @@ -0,0 +1,15 @@ +import type { PublishOutput } from './types.js'; + +/** + * 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) { + 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/src/stages/input.ts b/packages/publish/src/stages/input.ts index 31a51ff7..5d6d1e81 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'; @@ -56,6 +56,13 @@ export async function parseInput(inputPath?: string): Promise { throw createPublishError(PublishErrorCode.INPUT_PARSE_ERROR, 'Input is not valid JSON'); } + // An unusable or failed upstream envelope is an input error (exit 3). + 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..cadf3d67 100644 --- a/packages/publish/test/unit/cli.spec.ts +++ b/packages/publish/test/unit/cli.spec.ts @@ -1,14 +1,22 @@ +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'; -vi.mock('@releasekit/core'); +// 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(), + setLogLevel: vi.fn(), +})); 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 } 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'; @@ -46,12 +54,21 @@ const mockOutput: PublishOutput = { git: { committed: false, tags: [], pushed: false }, npm: [], cargo: [], + pub: [], verification: [], githubReleases: [], + publishSucceeded: true, }; 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 } })); @@ -63,6 +80,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', () => { @@ -106,12 +127,27 @@ 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']); + + const envelope = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); + expect(envelope.status).toBe('success'); + expect(envelope.data).toEqual(mockOutput); + 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(consoleSpy).toHaveBeenCalledWith(JSON.stringify(mockOutput, null, 2)); + expect(JSON.parse(consoleSpy.mock.calls[0]?.[0] as string).changed).toBe(true); consoleSpy.mockRestore(); }); @@ -126,29 +162,85 @@ 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'); + 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(); }); - it('should call logError and exit with PUBLISH_ERROR for BasePublishError', async () => { - const publishError = new BasePublishError('publish failed', 'PUBLISH_FAILED'); - 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 () => { + 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 log and exit with GENERAL_ERROR for unknown errors', 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/publish/test/unit/output.spec.ts b/packages/publish/test/unit/output.spec.ts new file mode 100644 index 00000000..97bd867f --- /dev/null +++ b/packages/publish/test/unit/output.spec.ts @@ -0,0 +1,81 @@ +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', () => { + const out = output({ + npm: [result({ skipped: true, alreadyPublished: 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); + }); + + 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 be true when the release commit was created, even with no registry publish', () => { + 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); + }); + + 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 6bfb3a0c..affd5cac 100644 --- a/packages/publish/test/unit/stages/input.spec.ts +++ b/packages/publish/test/unit/stages/input.spec.ts @@ -1,11 +1,13 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import type { - VersionChangelogEntry, - VersionOutput, - VersionPackageChangelog, - VersionPackageUpdate, +import { + errorEnvelope, + successEnvelope, + type VersionChangelogEntry, + type VersionOutput, + type VersionPackageChangelog, + type VersionPackageUpdate, } from '@releasekit/core'; import { afterEach, describe, expect, it } from 'vitest'; import { PublishErrorCode } from '../../../src/errors/index.js'; @@ -45,6 +47,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 deleted file mode 100644 index 3e27ab66..00000000 --- a/packages/release/src/commands/changed.ts +++ /dev/null @@ -1,22 +0,0 @@ -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. - */ -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); -} diff --git a/packages/release/src/commands/emitResult.ts b/packages/release/src/commands/emitResult.ts index 5df7b680..f67b1e9b 100644 --- a/packages/release/src/commands/emitResult.ts +++ b/packages/release/src/commands/emitResult.ts @@ -1,29 +1,12 @@ -import { writeFileSync } from 'node:fs'; import { - type Envelope, type EnvelopeWarning, EXIT_CODES, - errorEnvelope, InputError, successEnvelope, - toEnvelopeError, + writeEnvelope, + writeErrorEnvelope, } 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). @@ -32,18 +15,12 @@ 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); } /** 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/release/src/commands/standing-pr-command.ts b/packages/release/src/commands/standing-pr-command.ts index e6f33595..3a879407 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 { publishDidChange } 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) }); + // 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 }); 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/README.md b/packages/version/README.md index 40a4e54b..77e1712b 100644 --- a/packages/version/README.md +++ b/packages/version/README.md @@ -66,41 +66,49 @@ releasekit-version --prerelease beta | `--include-prerequisites` | Also release the changed internal dependencies of `--target` packages (and the rest of their groups) | `false` | | `--project-dir ` | Project directory | cwd | | `-d, --dry-run` | Preview without file changes or git operations | `false` | -| `-j, --json` | Output results as JSON | `false` | +| `-j, --json` | Output results as a JSON envelope (see below) | `false` | +| `--output ` | Write the JSON envelope to a file instead of stdout (relative to where you run it, not `--project-dir`) | stdout | ## 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 @@ -149,7 +157,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 20f08cf8..2c9daf4b 100644 --- a/packages/version/src/command.ts +++ b/packages/version/src/command.ts @@ -1,4 +1,5 @@ -import { EXIT_CODES } from '@releasekit/core'; +import * as path from 'node:path'; +import { EXIT_CODES, exitCodeForError, InputError, writeErrorEnvelope } from '@releasekit/core'; import { Command } from 'commander'; import { loadConfig } from './config.js'; import { VersionEngine } from './core/versionEngine.js'; @@ -21,6 +22,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 +31,18 @@ export function createVersionCommand(): Command { ) .option('--project-dir ', 'Project directory to run commands in', process.cwd()) .action(async (options) => { + // 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 }; + 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'; + writeErrorEnvelope(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 +118,17 @@ export function createVersionCommand(): Command { log('Versioning process completed.', 'success'); - printJsonOutput(); + printJsonOutput(output); } catch (error) { const { BaseVersionError } = await import('./errors/baseError.js'); + writeErrorEnvelope(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 1410b590..a917eca3 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. */ @@ -324,12 +326,13 @@ export function getJsonData(): VersionOutput { return { ..._jsonData }; } -/** - * Print JSON output at the end of execution - */ -export function printJsonOutput(): void { - if (_jsonOutputMode) { - // biome-ignore lint/suspicious/noConsole: prints the --json result - console.log(JSON.stringify(_jsonData, null, 2)); +/** Print the collected `VersionOutput` as an envelope's `data`. */ +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 03bccb88..37589e02 100644 --- a/packages/version/test/integration/sync-tags.spec.ts +++ b/packages/version/test/integration/sync-tags.spec.ts @@ -13,7 +13,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { VersionOutput } from '@releasekit/core'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { executeCliCommand } 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'; @@ -72,7 +72,7 @@ describe('sync strategy — JSON tag output', () => { const result = executeCliCommand('--json', tempDir); expect(result.status).toBe(0); - const output: VersionOutput = JSON.parse(result.stdout); + const output = readVersionOutput(result.stdout); expect(releasedVersions(output)).toEqual(SYNCED_RELEASE); // @test/pkg-a is sanitized to test-pkg-a (@ stripped, / replaced with -) @@ -93,7 +93,7 @@ describe('sync strategy — JSON tag output', () => { const result = executeCliCommand('--json', tempDir); expect(result.status).toBe(0); - const output: VersionOutput = JSON.parse(result.stdout); + const output = readVersionOutput(result.stdout); expect(releasedVersions(output)).toEqual(SYNCED_RELEASE); expect(output.tags).toEqual(['v0.2.0']); diff --git a/packages/version/test/unit/cli.spec.ts b/packages/version/test/unit/cli.spec.ts index b880094a..ef680679 100644 --- a/packages/version/test/unit/cli.spec.ts +++ b/packages/version/test/unit/cli.spec.ts @@ -1,7 +1,12 @@ +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'; 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'; @@ -159,6 +164,88 @@ describe('createVersionCommand', () => { }); }); + describe('--output', () => { + 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(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 () => { + // 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('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 = [ diff --git a/packages/version/test/unit/utils/jsonOutput.spec.ts b/packages/version/test/unit/utils/jsonOutput.spec.ts index f4ad55c4..e5c6c45c 100644 --- a/packages/version/test/unit/utils/jsonOutput.spec.ts +++ b/packages/version/test/unit/utils/jsonOutput.spec.ts @@ -525,10 +525,30 @@ 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'); + 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 36a3b175..fbe64eb1 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 { unwrapEnvelope, type VersionOutput } from '@releasekit/core'; /** * Execute the CLI command in the given working directory (tempDir) @@ -15,3 +16,8 @@ export function executeCliCommand(command: string, cwd: string, dryRun = true) { } return spawnSync('node', [cliPath, ...args], { cwd, encoding: 'utf-8' }); } + +/** 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; +} diff --git a/test/e2e/lib/helpers.sh b/test/e2e/lib/helpers.sh index 159954f8..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. Output that isn't - # an envelope — non-JSON, or a command that isn't enveloped yet — 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