Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6e29fb6
feat(cli): envelope the version and publish commands, unwrap on the pipe
github-actions[bot] Aug 1, 2026
d2e8a1d
fix(cli): complete the exit-code table and validate envelopes on unwrap
github-actions[bot] Aug 1, 2026
6ae27a5
fix(cli): report publish commits as changes and exit publish by code …
claude Oct 9, 2026
5b15856
fix(cli): correct the chaining, retry and exit-code docs; move the --…
claude Oct 9, 2026
6845599
docs(cli): scope the exit-code contract to the commands it holds for
claude Oct 9, 2026
0f83cda
fix(cli): don't crash the error path on an unwritable --output; scope…
claude Oct 9, 2026
ba3282b
docs(cli): describe notes' parse check precisely; name a lost --outpu…
claude Oct 9, 2026
522cc2c
test: drop low-value review additions
claude Oct 22, 2026
e487331
test: drop the duplicate publish exit test and the notes exit clause
claude Oct 22, 2026
acff673
docs(cli): release and standing-pr now report a stage's own error code
claude Oct 22, 2026
213e550
refactor(cli): share one error-envelope writer and trim review leftovers
claude Oct 22, 2026
d7ede14
test(cli): cover the version and publish failure paths through the co…
claude Oct 22, 2026
2a36120
docs: point --json stability at schemaVersion; keep the version READM…
claude Oct 22, 2026
ffb9272
Merge remote-tracking branch 'origin/main' into feat/544-envelope-pip…
claude Oct 22, 2026
d8888e8
style: add braces to every single-statement body
claude Oct 22, 2026
e4e0897
Merge remote-tracking branch 'origin/main' into feat/544-envelope-pip…
claude Oct 22, 2026
4da9928
Merge remote-tracking branch 'origin/main' into feat/544-envelope-pip…
claude Oct 22, 2026
be038d6
fix(cli): suppress per line instead of exempting cli.ts; test --outpu…
claude Oct 22, 2026
9f32e28
test(version): write --output through the real JSON writer
claude Oct 22, 2026
f5eca4f
docs(core): trim the envelope doc comments; drop a stale e2e helper note
claude Oct 22, 2026
3649663
Merge remote-tracking branch 'origin/main' into feat/544-envelope-pip…
claude Oct 22, 2026
1bf5b07
Merge remote-tracking branch 'origin/main' into feat/544-envelope-pip…
claude Oct 23, 2026
0c32371
Merge remote-tracking branch 'origin/main' into feat/544-envelope-pip…
claude Oct 23, 2026
f7c5d13
docs: tighten the envelope docs and comments
github-actions[bot] Oct 23, 2026
18d6835
docs: link to the chaining section from architecture, simplify envelo…
github-actions[bot] Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

---

Expand Down Expand Up @@ -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 <path>` | string | stdout | Write the JSON result to a file instead of stdout |
| `-t, --target <packages>` | 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 <path>` | string | `cwd` | Project directory to run commands in |
Expand Down Expand Up @@ -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 <path>` | string | stdout | Write the JSON result to a file instead of stdout |
| `--verbose` | boolean | `false` | Verbose logging |

```bash
Expand Down
2 changes: 1 addition & 1 deletion docs/rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <crate> <version> 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 <n> path entries named <crate>`: 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
85 changes: 82 additions & 3 deletions packages/core/src/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,28 +52,107 @@ export function successEnvelope<T>(
};
}

export function errorEnvelope(errors: EnvelopeError[], opts: { warnings?: EnvelopeWarning[] } = {}): Envelope<null> {
export function errorEnvelope<T = null>(
errors: EnvelopeError[],
opts: { warnings?: EnvelopeWarning[]; data?: T; changed?: boolean } = {},
): Envelope<T | null> {
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<EnvelopeStatus>(['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<string, number> = {
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. */
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +29,7 @@ export {
exitCodeForError,
successEnvelope,
toEnvelopeError,
unwrapEnvelope,
} from './envelope.js';
export { EXIT_CODES, type ExitCode, InputError, ReleaseKitError } from './errors.js';
export {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions packages/core/test/unit/envelope.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
exitCodeForError,
successEnvelope,
toEnvelopeError,
unwrapEnvelope,
} from '../../src/envelope.js';
import { EXIT_CODES, ReleaseKitError } from '../../src/errors.js';

Expand Down Expand Up @@ -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/);
});
});
});
Loading
Loading