Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/_release.reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ jobs:
exit $EXIT_CODE
fi

# Unwrap the uniform CLI envelope ({ schemaVersion, status, data, … }) to its data payload so
# the .versionOutput / .releaseNotes reads below (and in release-output.json) work unchanged.
# Tolerate non-enveloped output.
if echo "$RELEASE_OUTPUT" | jq -e 'type == "object" and has("schemaVersion") and has("data")' > /dev/null 2>&1; then
RELEASE_OUTPUT=$(echo "$RELEASE_OUTPUT" | jq '.data')
fi

# Check if output is valid JSON (releasekit returns null for no changes)
if echo "$RELEASE_OUTPUT" | jq -e '.versionOutput' > /dev/null 2>&1; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -203,6 +210,11 @@ jobs:
exit $EXIT_CODE
fi

# Unwrap the uniform CLI envelope (see the release path above) so the reads below work.
if echo "$RELEASE_OUTPUT" | jq -e 'type == "object" and has("schemaVersion") and has("data")' > /dev/null 2>&1; then
RELEASE_OUTPUT=$(echo "$RELEASE_OUTPUT" | jq '.data')
fi

if echo "$RELEASE_OUTPUT" | jq -e '.versionOutput' > /dev/null 2>&1; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
echo "$RELEASE_OUTPUT" > release-output.json
Expand Down
40 changes: 40 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,46 @@ 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.

```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
"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`.
- **`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.

### Error codes and exit codes

| `code` | `category` | Exit code |
|---|---|---|
| `GENERAL_ERROR` | `general` | 1 |
| `CONFIG_ERROR` | `config` | 2 |
| `INPUT_ERROR` / `INPUT_PARSE_ERROR` | `input` / `input-parse` | 3 |
| `TEMPLATE_ERROR` | `template` | 4 |
| `LLM_ERROR` | `llm` | 5 |
| `GITHUB_ERROR` | `github` | 6 |
| `GIT_ERROR` | `git` | 7 |
| `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.

---

## `releasekit preview`

Post a release preview comment on the current pull request. This is the default command, so `releasekit` with no arguments runs `preview`.
Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/envelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { EXIT_CODES, ReleaseKitError } from './errors.js';

/**
* Version of the `--json` envelope contract. Bumped only on a breaking change to the envelope
* shape, and stable across minor releases, so agents and CI can pin against it.
*/
export const ENVELOPE_SCHEMA_VERSION = 1;

export type EnvelopeStatus = 'success' | 'error';

export interface EnvelopeError {
/** Stable machine code mirroring `ReleaseKitError.code` (e.g. CONFIG_ERROR, LLM_ERROR). */
code: string;
/** Coarse grouping derived from the code (e.g. config, llm, github). */
category: string;
/** Whether the failure is transient and worth retrying. Only true when known-transient. */
retryable: boolean;
message: string;
}

export interface EnvelopeWarning {
code?: string;
message: string;
}

/**
* The uniform result envelope every command emits in `--json` mode. `data` carries the
* command-specific payload (VersionOutput, gate result, standing-PR result) verbatim — the
* envelope wraps it, never replaces it, so the manifest-compat invariant holds.
*/
export interface Envelope<T = unknown> {
schemaVersion: number;
status: EnvelopeStatus;
/** Whether the command changed state, vs. found everything already in the desired state. */
changed: boolean;
data: T;
warnings: EnvelopeWarning[];
errors: EnvelopeError[];
}

export function successEnvelope<T>(
data: T,
opts: { changed?: boolean; warnings?: EnvelopeWarning[] } = {},
): Envelope<T> {
return {
schemaVersion: ENVELOPE_SCHEMA_VERSION,
status: 'success',
changed: opts.changed ?? false,
data,
warnings: opts.warnings ?? [],
errors: [],
};
}

export function errorEnvelope(errors: EnvelopeError[], opts: { warnings?: EnvelopeWarning[] } = {}): Envelope<null> {
return {
schemaVersion: ENVELOPE_SCHEMA_VERSION,
status: 'error',
changed: false,
data: null,
warnings: opts.warnings ?? [],
errors,
};
}

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,
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,
};

/** Map any thrown value to its process exit code, defaulting to GENERAL_ERROR. */
export function exitCodeForError(error: unknown): number {
if (ReleaseKitError.isReleaseKitError(error)) {
return CODE_TO_EXIT[error.code] ?? EXIT_CODES.GENERAL_ERROR;
}
return EXIT_CODES.GENERAL_ERROR;
}

/** Derive a coarse category from a code: CONFIG_ERROR -> config, INPUT_PARSE_ERROR -> input-parse. */
function categoryForCode(code: string): string {
return (
code
.replace(/_ERROR$/, '')
.replace(/_/g, '-')
.toLowerCase() || 'general'
);
}

/** Convert a thrown value into a structured envelope error. */
export function toEnvelopeError(error: unknown): EnvelopeError {
if (ReleaseKitError.isReleaseKitError(error)) {
return {
code: error.code,
category: categoryForCode(error.code),
// Only ReleaseKitError subclasses that classify transience (LLMError) carry this; a missing or
// undefined flag stays conservatively non-retryable so an agent never retries an unknown failure.
retryable: (error as { retryable?: boolean }).retryable === true,
message: error.message,
};
}
return {
code: 'GENERAL_ERROR',
category: 'general',
retryable: false,
message: error instanceof Error ? error.message : String(error),
};
}
6 changes: 6 additions & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export abstract class ReleaseKitError extends Error {
}
}

/** A CLI input-validation failure — a bad flag combination or a malformed argument. */
export class InputError extends ReleaseKitError {
readonly code = 'INPUT_ERROR';
readonly suggestions: string[] = [];
}

export const EXIT_CODES = {
SUCCESS: 0,
GENERAL_ERROR: 1,
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,18 @@ export {
type GraphPackage,
type WorkspaceDependencyGraph,
} from './dependencyGraph.js';
export { EXIT_CODES, type ExitCode, ReleaseKitError } from './errors.js';
export {
ENVELOPE_SCHEMA_VERSION,
type Envelope,
type EnvelopeError,
type EnvelopeStatus,
type EnvelopeWarning,
errorEnvelope,
exitCodeForError,
successEnvelope,
toEnvelopeError,
} from './envelope.js';
export { EXIT_CODES, type ExitCode, InputError, ReleaseKitError } from './errors.js';
export {
debug,
error,
Expand Down
101 changes: 101 additions & 0 deletions packages/core/test/unit/envelope.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import {
ENVELOPE_SCHEMA_VERSION,
errorEnvelope,
exitCodeForError,
successEnvelope,
toEnvelopeError,
} from '../../src/envelope.js';
import { EXIT_CODES, ReleaseKitError } from '../../src/errors.js';

class TestConfigError extends ReleaseKitError {
readonly code = 'CONFIG_ERROR';
readonly suggestions: string[] = [];
}

class TestLLMError extends ReleaseKitError {
readonly code = 'LLM_ERROR';
readonly suggestions: string[] = [];
readonly retryable: boolean;
constructor(message: string, retryable: boolean) {
super(message);
this.retryable = retryable;
}
}

describe('envelope', () => {
describe('successEnvelope', () => {
it('should wrap data with success status and defaults', () => {
expect(successEnvelope({ tags: ['v1.2.3'] })).toEqual({
schemaVersion: ENVELOPE_SCHEMA_VERSION,
status: 'success',
changed: false,
data: { tags: ['v1.2.3'] },
warnings: [],
errors: [],
});
});

it('should carry changed and warnings when provided', () => {
const env = successEnvelope(null, { changed: true, warnings: [{ message: 'heads up' }] });
expect(env.changed).toBe(true);
expect(env.warnings).toEqual([{ message: 'heads up' }]);
});
});

describe('errorEnvelope', () => {
it('should carry error status, null data, and the given errors', () => {
const errors = [{ code: 'X_ERROR', category: 'x', retryable: false, message: 'boom' }];
expect(errorEnvelope(errors)).toEqual({
schemaVersion: ENVELOPE_SCHEMA_VERSION,
status: 'error',
changed: false,
data: null,
warnings: [],
errors,
});
});
});

describe('toEnvelopeError', () => {
it('should map a ReleaseKitError to its code and derived category', () => {
expect(toEnvelopeError(new TestConfigError('bad config'))).toEqual({
code: 'CONFIG_ERROR',
category: 'config',
retryable: false,
message: 'bad config',
});
});

it('should surface a retryable ReleaseKitError as retryable', () => {
expect(toEnvelopeError(new TestLLMError('timeout', true)).retryable).toBe(true);
});

it('should treat an unclassified error as non-retryable', () => {
expect(toEnvelopeError(new TestLLMError('auth', false)).retryable).toBe(false);
});

it('should map a plain Error to GENERAL_ERROR', () => {
expect(toEnvelopeError(new Error('oops'))).toEqual({
code: 'GENERAL_ERROR',
category: 'general',
retryable: false,
message: 'oops',
});
});

it('should stringify a non-Error thrown value', () => {
expect(toEnvelopeError('just a string').message).toBe('just a string');
});
});

describe('exitCodeForError', () => {
it('should map a ReleaseKitError code to its exit code', () => {
expect(exitCodeForError(new TestConfigError('x'))).toBe(EXIT_CODES.CONFIG_ERROR);
});

it('should default to GENERAL_ERROR for a plain Error', () => {
expect(exitCodeForError(new Error('x'))).toBe(EXIT_CODES.GENERAL_ERROR);
});
});
});
20 changes: 20 additions & 0 deletions packages/release/src/commands/changed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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);
}
Loading
Loading