mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
chore: add release prepare command (#1165)
## What - Add `bun run release:prepare <version>` as the maintainer-facing stable release entrypoint. - Make the first run draft missing changelog artifacts and intentionally exit before tagging; rerunning after manual review delegates to `set-version`. - Tighten the direct `set-version` guard so stable releases also fail when generated TODO changelog copy is still present. - Update maintainer docs to recommend `release:prepare` while keeping `changelog:draft` as the lower-level regeneration tool. ## Why Stable releases should be hard to run without reviewed GitHub release notes and Mintlify changelog copy. This keeps the existing manual rewrite step, but makes the expected path one command that engineers can rerun after review. ## How - Added `scripts/release-prepare.ts` with parsing, draft/review/set-version action selection, and command forwarding. - Added focused script tests for parser behavior, action selection, command forwarding, and TODO detection. - Extracted shared script CLI parsing helpers so `changelog:draft` and `release:prepare` use the same option handling. - Adjusted `changelog:draft --write` so an existing release file is left unchanged unless `--force` is passed, while still allowing a missing docs entry to be added. ## Test plan - [x] Unit tests added/updated: `bun run test:scripts` - [x] Format check: `bun run format:check` - [x] Lint: `bun run lint` - [x] Typecheck: `bun run --filter '*' typecheck` - [x] Fallow audit: `bunx fallow audit --base origin/main --fail-on-issues` - [x] Manual CLI checks: `bun run release:prepare --help`; `bun run set-version 9.9.9` fails before mutation when changelog artifacts are missing - [x] Documentation updated
This commit is contained in:
+2
-2
@@ -139,11 +139,11 @@ All packages use **fixed versioning** — every release bumps all packages to th
|
||||
### Stable releases
|
||||
|
||||
```bash
|
||||
bun run set-version 0.2.0 # bumps all packages, commits, and creates git tag
|
||||
bun run release:prepare 0.2.0 # drafts changelog if needed, then creates the release commit/tag after review
|
||||
git push origin main --tags # triggers the publish workflow
|
||||
```
|
||||
|
||||
The `set-version` script automatically creates a `chore: release v<version>` commit and a `v<version>` git tag. Pushing the tag triggers CI to publish all packages to npm and create a GitHub Release.
|
||||
The `release:prepare` script drafts missing release notes on the first run and stops for manual review. After the generated TODO summary is rewritten, rerun the same command; it delegates to `set-version`, which creates a `chore: release v<version>` commit and a `v<version>` git tag. Pushing the tag triggers CI to publish all packages to npm and create a GitHub Release.
|
||||
|
||||
### Pre-releases (alpha / beta / rc)
|
||||
|
||||
|
||||
@@ -23,19 +23,19 @@ Each reviewed release note lives in `releases/vX.Y.Z.md`.
|
||||
|
||||
The docs changelog lives in `docs/changelog.mdx` and uses Mintlify `<Update>` entries. The draft generator can prepend a docs entry, but maintainers should edit the generated copy before tagging the release. After any manual rewrite, keep `releases/vX.Y.Z.md` and the matching docs `<Update>` entry in sync.
|
||||
|
||||
## Release note workflow
|
||||
## Stable release workflow
|
||||
|
||||
<Steps>
|
||||
<Step title="Draft the release notes">
|
||||
Run the draft command from the repository root:
|
||||
<Step title="Prepare the release">
|
||||
Run the stable release command from the repository root:
|
||||
```bash
|
||||
bun run changelog:draft 0.6.53 --write
|
||||
bun run release:prepare 0.6.53
|
||||
```
|
||||
This creates or updates:
|
||||
On the first run, this creates or updates the changelog draft and then exits before tagging:
|
||||
- `releases/v0.6.53.md`
|
||||
- `docs/changelog.mdx`
|
||||
|
||||
Use `--force` only when regenerating a draft before review. It overwrites `releases/vX.Y.Z.md`; if the docs changelog already has that version, edit the existing docs entry manually.
|
||||
The checkpoint exits non-zero intentionally so chained release commands stop. Review the generated copy, remove the TODO summary marker, and rerun the same command. Once both changelog artifacts are reviewed, `release:prepare` runs `set-version` to create the release commit and tag.
|
||||
</Step>
|
||||
<Step title="Review and rewrite">
|
||||
Read the generated notes and rewrite them for users. Prioritize impact over implementation detail.
|
||||
@@ -47,12 +47,12 @@ The docs changelog lives in `docs/changelog.mdx` and uses Mintlify `<Update>` en
|
||||
- Performance or reliability improvements
|
||||
- Security fixes
|
||||
</Step>
|
||||
<Step title="Create the release commit">
|
||||
Run the existing fixed-version release command:
|
||||
<Step title="Rerun the release command">
|
||||
After review, run the same command again:
|
||||
```bash
|
||||
bun run set-version 0.6.53
|
||||
bun run release:prepare 0.6.53
|
||||
```
|
||||
For stable releases, `set-version` checks that `releases/v0.6.53.md` exists and that `docs/changelog.mdx` has a matching `HyperFrames v0.6.53` entry before it updates package versions or creates the tag. Prereleases and `--no-tag` version bumps skip this check. Use `--skip-changelog-check` only for emergency stable releases.
|
||||
For stable releases, `release:prepare` checks that `releases/v0.6.53.md` exists, that `docs/changelog.mdx` has a matching `HyperFrames v0.6.53` entry, and that neither artifact still contains the generated TODO summary. The lower-level `set-version` command enforces the same reviewed-changelog checkpoint for maintainers who run it directly. Prereleases and `--no-tag` version bumps skip this check. Use `--skip-changelog-check` only for emergency stable releases.
|
||||
|
||||
The release commit can include the version bump, `releases/v0.6.53.md`, and the docs changelog update.
|
||||
</Step>
|
||||
@@ -67,6 +67,16 @@ The docs changelog lives in `docs/changelog.mdx` and uses Mintlify `<Update>` en
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Draft regeneration
|
||||
|
||||
Use the lower-level draft command when you need to regenerate changelog copy before review:
|
||||
|
||||
```bash
|
||||
bun run changelog:draft 0.6.53 --write --force
|
||||
```
|
||||
|
||||
Without `--force`, the draft command leaves an existing `releases/vX.Y.Z.md` file unchanged and still adds the docs changelog entry if it is missing. If the docs changelog already has that version, edit the existing docs entry manually.
|
||||
|
||||
## Writing style
|
||||
|
||||
Use plain, user-facing language. Prefer "Fixed Studio render failures when FFmpeg is missing" over "Added pre-flight check in render activity." Link to relevant docs, migration guides, or pull requests when they help users act.
|
||||
|
||||
@@ -22,16 +22,18 @@ If a feature should ship in alpha only, merge or retarget that PR to a prereleas
|
||||
## Stable release
|
||||
|
||||
Stable releases must be reachable from `origin/main` or `origin/release/v*`.
|
||||
Draft and review release notes before creating the release commit:
|
||||
Prepare and review release notes before creating the release commit:
|
||||
|
||||
```bash
|
||||
bun run changelog:draft <version> --write
|
||||
bun run release:prepare <version>
|
||||
```
|
||||
|
||||
See [Changelog process](/contributing/changelog-process) for the full workflow. For stable releases, `bun run set-version <version>` enforces this checkpoint before creating the release commit and tag.
|
||||
On the first run, `release:prepare` drafts missing changelog artifacts and exits non-zero for review so chained release commands stop before tagging. After the generated TODO summary is rewritten, rerun the same command to create the release commit and tag.
|
||||
|
||||
See [Changelog process](/contributing/changelog-process) for the full workflow. For stable releases, `bun run set-version <version>` still enforces this checkpoint when maintainers run the lower-level release command directly.
|
||||
|
||||
```bash
|
||||
bun run set-version <version>
|
||||
bun run release:prepare <version>
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@
|
||||
"verify:packed-manifests": "node scripts/verify-packed-manifests.mjs",
|
||||
"validate:release-channel": "node scripts/validate-release-channel.mjs",
|
||||
"set-version": "tsx scripts/set-version.ts",
|
||||
"release:prepare": "tsx scripts/release-prepare.ts",
|
||||
"changelog:draft": "tsx scripts/draft-changelog.ts",
|
||||
"sync-schemas": "tsx scripts/sync-schemas.ts",
|
||||
"sync-schemas:check": "tsx scripts/sync-schemas.ts --check",
|
||||
@@ -30,7 +31,7 @@
|
||||
"player:perf": "bun run --filter @hyperframes/player perf",
|
||||
"format:check": "oxfmt --check .",
|
||||
"knip": "knip",
|
||||
"test:scripts": "node --import tsx --test scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts",
|
||||
"test:scripts": "node --import tsx --test scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts",
|
||||
"generate:previews": "tsx scripts/generate-template-previews.ts",
|
||||
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
|
||||
"upload:docs-images": "bash scripts/upload-docs-images.sh",
|
||||
|
||||
+3
-1
@@ -5,7 +5,9 @@ Reviewed GitHub Release bodies live here.
|
||||
Create the next draft with:
|
||||
|
||||
```bash
|
||||
bun run changelog:draft <version> --write
|
||||
bun run release:prepare <version>
|
||||
```
|
||||
|
||||
The first run drafts missing changelog artifacts and exits non-zero for review. After rewriting the generated TODO summary, rerun the same command to create the release commit and tag.
|
||||
|
||||
The publish workflow uses `releases/v<version>.md` as the GitHub Release body when the file exists. Keep these notes user-facing; implementation details can stay in pull requests.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { CLI_SEMVER_PATTERN, validateCliVersion } from "./cli-options.ts";
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
describe("CLI semver validation", () => {
|
||||
it("accepts stable and hyphenated prerelease versions", () => {
|
||||
assert.doesNotThrow(() => validateCliVersion("1.2.3", CLI_SEMVER_PATTERN, fail));
|
||||
assert.doesNotThrow(() => validateCliVersion("1.2.3-alpha.1", CLI_SEMVER_PATTERN, fail));
|
||||
assert.doesNotThrow(() =>
|
||||
validateCliVersion("1.2.3-alpha-feature.2", CLI_SEMVER_PATTERN, fail),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects underscores in prerelease versions", () => {
|
||||
assert.throws(
|
||||
() => validateCliVersion("1.2.3-alpha_1", CLI_SEMVER_PATTERN, fail),
|
||||
/Invalid semver: 1\.2\.3-alpha_1/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
export type InlineValueOption<Key extends string> = {
|
||||
prefix: string;
|
||||
key: Key;
|
||||
};
|
||||
|
||||
export const CLI_SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
type ParserConfig<
|
||||
Parsed extends object,
|
||||
ValueKey extends keyof Parsed & string,
|
||||
BooleanKey extends keyof Parsed & string,
|
||||
> = {
|
||||
inlineValueOptions: Array<InlineValueOption<ValueKey>>;
|
||||
valueOptions: Map<string, ValueKey>;
|
||||
booleanOptions: Map<string, BooleanKey>;
|
||||
parsePositional: (arg: string, index: number) => number;
|
||||
fail: (message: string) => never;
|
||||
};
|
||||
|
||||
export function parseMappedArgument<
|
||||
Parsed extends object,
|
||||
ValueKey extends keyof Parsed & string,
|
||||
BooleanKey extends keyof Parsed & string,
|
||||
>(
|
||||
args: string[],
|
||||
index: number,
|
||||
parsed: Parsed,
|
||||
config: ParserConfig<Parsed, ValueKey, BooleanKey>,
|
||||
) {
|
||||
const arg = args[index];
|
||||
|
||||
if (applyInlineValueOption(arg, parsed, config.inlineValueOptions)) {
|
||||
return index;
|
||||
}
|
||||
if (applyBooleanOption(arg, parsed, config.booleanOptions)) {
|
||||
return index;
|
||||
}
|
||||
|
||||
return applyValueOrPositionalOption(args, index, parsed, config, arg);
|
||||
}
|
||||
|
||||
function applyInlineValueOption<Parsed extends object, ValueKey extends keyof Parsed & string>(
|
||||
arg: string,
|
||||
parsed: Parsed,
|
||||
inlineOptions: Array<InlineValueOption<ValueKey>>,
|
||||
) {
|
||||
const option = inlineOptions.find((candidate) => arg.startsWith(candidate.prefix));
|
||||
if (!option) {
|
||||
return false;
|
||||
}
|
||||
|
||||
parsed[option.key] = arg.slice(option.prefix.length) as Parsed[ValueKey];
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyBooleanOption<Parsed extends object, BooleanKey extends keyof Parsed & string>(
|
||||
arg: string,
|
||||
parsed: Parsed,
|
||||
booleanOptions: Map<string, BooleanKey>,
|
||||
) {
|
||||
const option = booleanOptions.get(arg);
|
||||
if (!option) {
|
||||
return false;
|
||||
}
|
||||
|
||||
parsed[option] = true as Parsed[BooleanKey];
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyValueOrPositionalOption<
|
||||
Parsed extends object,
|
||||
ValueKey extends keyof Parsed & string,
|
||||
BooleanKey extends keyof Parsed & string,
|
||||
>(
|
||||
args: string[],
|
||||
index: number,
|
||||
parsed: Parsed,
|
||||
config: ParserConfig<Parsed, ValueKey, BooleanKey>,
|
||||
arg: string,
|
||||
) {
|
||||
const option = config.valueOptions.get(arg);
|
||||
if (!option) {
|
||||
return config.parsePositional(arg, index);
|
||||
}
|
||||
|
||||
parsed[option] = readNextArg(args, index, arg, config.fail) as Parsed[ValueKey];
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
export function parseVersionOptionArgument<
|
||||
Parsed extends { version?: string },
|
||||
ValueKey extends keyof Parsed & string,
|
||||
BooleanKey extends keyof Parsed & string,
|
||||
>(
|
||||
args: string[],
|
||||
index: number,
|
||||
parsed: Parsed,
|
||||
config: Omit<ParserConfig<Parsed, ValueKey, BooleanKey>, "parsePositional"> & {
|
||||
printUsage: () => void;
|
||||
},
|
||||
) {
|
||||
return parseMappedArgument(args, index, parsed, {
|
||||
...config,
|
||||
parsePositional: (arg, positionalIndex) =>
|
||||
parseVersionOrHelp(arg, positionalIndex, parsed, config),
|
||||
});
|
||||
}
|
||||
|
||||
function parseVersionOrHelp<Parsed extends { version?: string }>(
|
||||
arg: string,
|
||||
index: number,
|
||||
parsed: Parsed,
|
||||
config: { printUsage: () => void; fail: (message: string) => never },
|
||||
) {
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
config.printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
parsed.version = parseVersionPositionalArg(arg, parsed.version, config.fail);
|
||||
return index;
|
||||
}
|
||||
|
||||
export function parseVersionPositionalArg(
|
||||
arg: string,
|
||||
currentVersion: string | undefined,
|
||||
fail: (message: string) => never,
|
||||
) {
|
||||
if (arg.startsWith("--")) {
|
||||
fail(`Unknown option: ${arg}`);
|
||||
}
|
||||
if (currentVersion) {
|
||||
fail(`Unexpected positional argument: ${arg}`);
|
||||
}
|
||||
|
||||
return arg.replace(/^v/, "");
|
||||
}
|
||||
|
||||
export function readNextArg(
|
||||
args: string[],
|
||||
index: number,
|
||||
flag: string,
|
||||
fail: (message: string) => never,
|
||||
) {
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
fail(`Missing value for ${flag}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function validateCliVersion(
|
||||
version: string,
|
||||
pattern: RegExp,
|
||||
fail: (message: string) => never,
|
||||
) {
|
||||
if (!pattern.test(version)) {
|
||||
fail(`Invalid semver: ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCliDate(date: string, fail: (message: string) => never) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
fail(`Invalid date: ${date}. Expected YYYY-MM-DD.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function optionalFlagArg(flag: string, enabled: boolean) {
|
||||
return enabled ? [flag] : [];
|
||||
}
|
||||
|
||||
export function optionalValueArg(flag: string, value: string | undefined) {
|
||||
return value ? [flag, value] : [];
|
||||
}
|
||||
+21
-87
@@ -4,6 +4,13 @@ import { execFileSync } from "child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { pathToFileURL } from "url";
|
||||
import {
|
||||
CLI_SEMVER_PATTERN,
|
||||
parseVersionOptionArgument,
|
||||
validateCliDate,
|
||||
validateCliVersion,
|
||||
type InlineValueOption,
|
||||
} from "./cli-options.ts";
|
||||
|
||||
const ROOT = join(import.meta.dirname, "..");
|
||||
const REPO_URL = "https://github.com/heygen-com/hyperframes";
|
||||
@@ -74,7 +81,7 @@ const INLINE_VALUE_OPTIONS = [
|
||||
{ prefix: "--from=", key: "from" },
|
||||
{ prefix: "--to=", key: "to" },
|
||||
{ prefix: "--date=", key: "date" },
|
||||
] satisfies Array<{ prefix: string; key: ValueOptionKey }>;
|
||||
] satisfies Array<InlineValueOption<ValueOptionKey>>;
|
||||
|
||||
const TYPE_CATEGORIES = new Map([
|
||||
["feat", "Features"],
|
||||
@@ -137,69 +144,13 @@ function createDefaultOptions(): MutableOptions {
|
||||
}
|
||||
|
||||
function parseArgument(args: string[], index: number, parsed: MutableOptions) {
|
||||
const arg = args[index];
|
||||
const inlineOption = findInlineValueOption(arg);
|
||||
|
||||
if (inlineOption) {
|
||||
parsed[inlineOption.key] = arg.slice(inlineOption.prefix.length);
|
||||
return index;
|
||||
}
|
||||
|
||||
return parseNamedOrPositionalArg(args, index, parsed, arg);
|
||||
}
|
||||
|
||||
function findInlineValueOption(arg: string) {
|
||||
return INLINE_VALUE_OPTIONS.find((option) => arg.startsWith(option.prefix));
|
||||
}
|
||||
|
||||
function parseNamedOrPositionalArg(
|
||||
args: string[],
|
||||
index: number,
|
||||
parsed: MutableOptions,
|
||||
arg: string,
|
||||
) {
|
||||
const booleanOption = BOOLEAN_OPTIONS.get(arg);
|
||||
if (booleanOption) {
|
||||
parsed[booleanOption] = true;
|
||||
return index;
|
||||
}
|
||||
|
||||
return parseValueOrPositionalArg(args, index, parsed, arg);
|
||||
}
|
||||
|
||||
function parseValueOrPositionalArg(
|
||||
args: string[],
|
||||
index: number,
|
||||
parsed: MutableOptions,
|
||||
arg: string,
|
||||
) {
|
||||
const valueOption = VALUE_OPTIONS.get(arg);
|
||||
if (valueOption) {
|
||||
parsed[valueOption] = readNextArg(args, index, arg);
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
return parsePositionalArg(arg, parsed, index);
|
||||
}
|
||||
|
||||
function parsePositionalArg(arg: string, parsed: MutableOptions, index: number) {
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
validatePositionalArg(arg, parsed);
|
||||
parsed.version = arg.replace(/^v/, "");
|
||||
return index;
|
||||
}
|
||||
|
||||
function validatePositionalArg(arg: string, parsed: MutableOptions) {
|
||||
if (arg.startsWith("--")) {
|
||||
fail(`Unknown option: ${arg}`);
|
||||
}
|
||||
if (parsed.version) {
|
||||
fail(`Unexpected positional argument: ${arg}`);
|
||||
}
|
||||
return parseVersionOptionArgument(args, index, parsed, {
|
||||
inlineValueOptions: INLINE_VALUE_OPTIONS,
|
||||
valueOptions: VALUE_OPTIONS,
|
||||
booleanOptions: BOOLEAN_OPTIONS,
|
||||
printUsage,
|
||||
fail,
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeOptions(parsed: MutableOptions): Options {
|
||||
@@ -208,8 +159,8 @@ function finalizeOptions(parsed: MutableOptions): Options {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
validateVersion(parsed.version);
|
||||
validateDate(parsed.date);
|
||||
validateCliVersion(parsed.version, CLI_SEMVER_PATTERN, fail);
|
||||
validateCliDate(parsed.date, fail);
|
||||
|
||||
return {
|
||||
version: parsed.version,
|
||||
@@ -221,26 +172,6 @@ function finalizeOptions(parsed: MutableOptions): Options {
|
||||
};
|
||||
}
|
||||
|
||||
function validateVersion(version: string) {
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
|
||||
fail(`Invalid semver: ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateDate(date: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
fail(`Invalid date: ${date}. Expected YYYY-MM-DD.`);
|
||||
}
|
||||
}
|
||||
|
||||
function readNextArg(args: string[], index: number, flag: string) {
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
fail(`Missing value for ${flag}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage:
|
||||
bun run changelog:draft <version> [--write] [--force] [--from <ref>] [--to <ref>] [--date YYYY-MM-DD]
|
||||
@@ -534,7 +465,10 @@ function writeReleaseNotes(version: string, releaseNotes: string, force: boolean
|
||||
writeFileSync(releasePath, `${releaseNotes}\n`, { flag: force ? "w" : "wx" });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
fail(`${releasePath} already exists. Pass --force to overwrite it.`);
|
||||
console.log(
|
||||
`${releasePath} already exists; leaving it unchanged. Pass --force to overwrite it.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
buildDraftCommandArgs,
|
||||
buildSetVersionCommandArgs,
|
||||
parsePrepareOptions,
|
||||
resolveStableReleaseAction,
|
||||
} from "./release-prepare.ts";
|
||||
import {
|
||||
CHANGELOG_REVIEW_TODO,
|
||||
docsChangelogEntryHasGeneratedTodo,
|
||||
hasGeneratedChangelogTodo,
|
||||
} from "./set-version.ts";
|
||||
|
||||
describe("release prepare arguments", () => {
|
||||
it("parses changelog draft and set-version options", () => {
|
||||
assert.deepEqual(
|
||||
parsePrepareOptions([
|
||||
"v1.2.3",
|
||||
"--from",
|
||||
"v1.2.2",
|
||||
"--to=HEAD",
|
||||
"--date",
|
||||
"2026-06-02",
|
||||
"--force",
|
||||
"--no-tag",
|
||||
"--skip-changelog-check",
|
||||
]),
|
||||
{
|
||||
version: "1.2.3",
|
||||
from: "v1.2.2",
|
||||
to: "HEAD",
|
||||
date: "2026-06-02",
|
||||
force: true,
|
||||
skipTag: true,
|
||||
skipChangelogCheck: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("release prepare actions", () => {
|
||||
it("drafts when reviewed changelog artifacts are missing", () => {
|
||||
assert.equal(
|
||||
resolveStableReleaseAction({
|
||||
missingArtifacts: ["releases/v1.2.3.md"],
|
||||
unreviewedArtifacts: [],
|
||||
}),
|
||||
"draft",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks on review when generated TODOs are still present", () => {
|
||||
assert.equal(
|
||||
resolveStableReleaseAction({
|
||||
missingArtifacts: [],
|
||||
unreviewedArtifacts: ["docs/changelog.mdx#HyperFrames v1.2.3"],
|
||||
}),
|
||||
"review",
|
||||
);
|
||||
});
|
||||
|
||||
it("delegates to set-version after artifacts are reviewed", () => {
|
||||
assert.equal(
|
||||
resolveStableReleaseAction({
|
||||
missingArtifacts: [],
|
||||
unreviewedArtifacts: [],
|
||||
}),
|
||||
"set-version",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("release prepare command builders", () => {
|
||||
it("passes only changelog options to changelog:draft", () => {
|
||||
assert.deepEqual(
|
||||
buildDraftCommandArgs({
|
||||
version: "1.2.3",
|
||||
from: "v1.2.2",
|
||||
to: "HEAD",
|
||||
date: "2026-06-02",
|
||||
force: true,
|
||||
skipTag: true,
|
||||
skipChangelogCheck: true,
|
||||
}),
|
||||
[
|
||||
"run",
|
||||
"changelog:draft",
|
||||
"1.2.3",
|
||||
"--write",
|
||||
"--force",
|
||||
"--from",
|
||||
"v1.2.2",
|
||||
"--to",
|
||||
"HEAD",
|
||||
"--date",
|
||||
"2026-06-02",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("passes only release options to set-version", () => {
|
||||
assert.deepEqual(
|
||||
buildSetVersionCommandArgs({
|
||||
version: "1.2.3",
|
||||
from: "v1.2.2",
|
||||
to: "HEAD",
|
||||
date: "2026-06-02",
|
||||
force: true,
|
||||
skipTag: true,
|
||||
skipChangelogCheck: true,
|
||||
}),
|
||||
["run", "set-version", "1.2.3", "--no-tag", "--skip-changelog-check"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reviewed changelog detection", () => {
|
||||
it("detects the generated TODO marker", () => {
|
||||
assert.equal(hasGeneratedChangelogTodo(CHANGELOG_REVIEW_TODO), true);
|
||||
assert.equal(hasGeneratedChangelogTodo("Polished user-facing summary."), false);
|
||||
});
|
||||
|
||||
it("checks only the matching docs changelog entry", () => {
|
||||
const docs = `
|
||||
<Update label="HyperFrames v1.2.4">
|
||||
Reviewed summary.
|
||||
</Update>
|
||||
|
||||
<Update label="HyperFrames v1.2.3">
|
||||
${CHANGELOG_REVIEW_TODO}
|
||||
</Update>
|
||||
`;
|
||||
|
||||
assert.equal(docsChangelogEntryHasGeneratedTodo(docs, "HyperFrames v1.2.3"), true);
|
||||
assert.equal(docsChangelogEntryHasGeneratedTodo(docs, "HyperFrames v1.2.4"), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
import { execFileSync } from "child_process";
|
||||
import { join } from "path";
|
||||
import { pathToFileURL } from "url";
|
||||
import {
|
||||
CLI_SEMVER_PATTERN,
|
||||
optionalFlagArg,
|
||||
optionalValueArg,
|
||||
parseVersionOptionArgument,
|
||||
validateCliDate,
|
||||
validateCliVersion,
|
||||
type InlineValueOption,
|
||||
} from "./cli-options.ts";
|
||||
import {
|
||||
missingChangelogArtifacts,
|
||||
releaseRequiresChangelog,
|
||||
unreviewedChangelogArtifacts,
|
||||
} from "./set-version.ts";
|
||||
|
||||
type PrepareOptions = {
|
||||
version: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
date?: string;
|
||||
force: boolean;
|
||||
skipTag: boolean;
|
||||
skipChangelogCheck: boolean;
|
||||
};
|
||||
|
||||
type MutablePrepareOptions = Omit<PrepareOptions, "version"> & {
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type ValueOptionKey = "from" | "to" | "date";
|
||||
type BooleanOptionKey = "force" | "skipTag" | "skipChangelogCheck";
|
||||
export type StableReleaseAction = "draft" | "review" | "set-version";
|
||||
|
||||
const ROOT = join(import.meta.dirname, "..");
|
||||
|
||||
const VALUE_OPTIONS = new Map<string, ValueOptionKey>([
|
||||
["--from", "from"],
|
||||
["--to", "to"],
|
||||
["--date", "date"],
|
||||
]);
|
||||
|
||||
const BOOLEAN_OPTIONS = new Map<string, BooleanOptionKey>([
|
||||
["--force", "force"],
|
||||
["--no-tag", "skipTag"],
|
||||
["--skip-changelog-check", "skipChangelogCheck"],
|
||||
]);
|
||||
|
||||
const INLINE_VALUE_OPTIONS = [
|
||||
{ prefix: "--from=", key: "from" },
|
||||
{ prefix: "--to=", key: "to" },
|
||||
{ prefix: "--date=", key: "date" },
|
||||
] satisfies Array<InlineValueOption<ValueOptionKey>>;
|
||||
|
||||
function main() {
|
||||
const options = parsePrepareOptions(process.argv.slice(2));
|
||||
|
||||
if (!releaseRequiresChangelog(options)) {
|
||||
runSetVersion(options);
|
||||
return;
|
||||
}
|
||||
|
||||
const missingArtifacts = missingChangelogArtifacts(options.version);
|
||||
const unreviewedArtifacts = unreviewedChangelogArtifacts(options.version);
|
||||
const action = resolveStableReleaseAction({ missingArtifacts, unreviewedArtifacts });
|
||||
|
||||
if (action === "draft") {
|
||||
runDraft(options);
|
||||
printReviewNextSteps(options.version, missingArtifacts);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (action === "review") {
|
||||
printReviewNextSteps(options.version, unreviewedArtifacts);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
runSetVersion(options);
|
||||
}
|
||||
|
||||
export function parsePrepareOptions(args: string[]): PrepareOptions {
|
||||
const parsed = createDefaultOptions();
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
index = parseArgument(args, index, parsed);
|
||||
}
|
||||
|
||||
return finalizeOptions(parsed);
|
||||
}
|
||||
|
||||
function createDefaultOptions(): MutablePrepareOptions {
|
||||
return {
|
||||
force: false,
|
||||
skipTag: false,
|
||||
skipChangelogCheck: false,
|
||||
};
|
||||
}
|
||||
|
||||
function parseArgument(args: string[], index: number, parsed: MutablePrepareOptions) {
|
||||
return parseVersionOptionArgument(args, index, parsed, {
|
||||
inlineValueOptions: INLINE_VALUE_OPTIONS,
|
||||
valueOptions: VALUE_OPTIONS,
|
||||
booleanOptions: BOOLEAN_OPTIONS,
|
||||
printUsage,
|
||||
fail,
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeOptions(parsed: MutablePrepareOptions): PrepareOptions {
|
||||
if (!parsed.version) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
validateCliVersion(parsed.version, CLI_SEMVER_PATTERN, fail);
|
||||
if (parsed.date) {
|
||||
validateCliDate(parsed.date, fail);
|
||||
}
|
||||
|
||||
return {
|
||||
version: parsed.version,
|
||||
from: parsed.from,
|
||||
to: parsed.to,
|
||||
date: parsed.date,
|
||||
force: parsed.force,
|
||||
skipTag: parsed.skipTag,
|
||||
skipChangelogCheck: parsed.skipChangelogCheck,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveStableReleaseAction(state: {
|
||||
missingArtifacts: string[];
|
||||
unreviewedArtifacts: string[];
|
||||
}): StableReleaseAction {
|
||||
if (state.missingArtifacts.length > 0) {
|
||||
return "draft";
|
||||
}
|
||||
if (state.unreviewedArtifacts.length > 0) {
|
||||
return "review";
|
||||
}
|
||||
return "set-version";
|
||||
}
|
||||
|
||||
export function buildDraftCommandArgs(options: PrepareOptions) {
|
||||
return [
|
||||
"run",
|
||||
"changelog:draft",
|
||||
options.version,
|
||||
"--write",
|
||||
...optionalFlagArg("--force", options.force),
|
||||
...optionalValueArg("--from", options.from),
|
||||
...optionalValueArg("--to", options.to),
|
||||
...optionalValueArg("--date", options.date),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildSetVersionCommandArgs(options: PrepareOptions) {
|
||||
return [
|
||||
"run",
|
||||
"set-version",
|
||||
options.version,
|
||||
...optionalFlagArg("--no-tag", options.skipTag),
|
||||
...optionalFlagArg("--skip-changelog-check", options.skipChangelogCheck),
|
||||
];
|
||||
}
|
||||
|
||||
function runDraft(options: PrepareOptions) {
|
||||
runBun(buildDraftCommandArgs(options));
|
||||
}
|
||||
|
||||
function runSetVersion(options: PrepareOptions) {
|
||||
runBun(buildSetVersionCommandArgs(options));
|
||||
}
|
||||
|
||||
function runBun(args: string[]) {
|
||||
try {
|
||||
execFileSync("bun", args, { cwd: ROOT, stdio: "inherit" });
|
||||
} catch (error) {
|
||||
const status =
|
||||
typeof (error as { status?: unknown }).status === "number"
|
||||
? (error as { status: number }).status
|
||||
: 1;
|
||||
process.exit(status);
|
||||
}
|
||||
}
|
||||
|
||||
function printReviewNextSteps(version: string, artifacts: string[]) {
|
||||
console.error(`\nRelease v${version} needs reviewed changelog copy before tagging.`);
|
||||
if (artifacts.length > 0) {
|
||||
console.error("Check:");
|
||||
artifacts.forEach((artifact) => console.error(` ${artifact}`));
|
||||
}
|
||||
console.error("\nReview and rewrite the generated summary, remove the TODO marker, then rerun:");
|
||||
console.error(` bun run release:prepare ${version}`);
|
||||
console.error(
|
||||
"\nThe non-zero exit is intentional so chained release commands stop before tagging.",
|
||||
);
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage:
|
||||
bun run release:prepare <version> [--force] [--from <ref>] [--to <ref>] [--date YYYY-MM-DD]
|
||||
bun run release:prepare <version> [--no-tag] [--skip-changelog-check]
|
||||
|
||||
Examples:
|
||||
bun run release:prepare 0.6.53
|
||||
bun run release:prepare 0.6.53 --from v0.6.52 --to HEAD
|
||||
bun run release:prepare 0.6.53 --force
|
||||
`);
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main();
|
||||
}
|
||||
+47
-5
@@ -18,6 +18,7 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
import { pathToFileURL } from "url";
|
||||
import { CLI_SEMVER_PATTERN } from "./cli-options.ts";
|
||||
|
||||
const PACKAGES = [
|
||||
"packages/core",
|
||||
@@ -33,6 +34,7 @@ const PACKAGES = [
|
||||
const PLUGINS = [".claude-plugin", ".codex-plugin", ".cursor-plugin"];
|
||||
|
||||
const ROOT = join(import.meta.dirname, "..");
|
||||
export const CHANGELOG_REVIEW_TODO = "<!-- TODO: write a 1-2 sentence release summary here. -->";
|
||||
|
||||
type ReleaseOptions = {
|
||||
version: string;
|
||||
@@ -73,7 +75,7 @@ export function parseReleaseOptions(args: string[]): ReleaseOptions {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) {
|
||||
if (!CLI_SEMVER_PATTERN.test(version)) {
|
||||
console.error(`Invalid semver: ${version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -136,13 +138,17 @@ export function isPrerelease(version: string) {
|
||||
|
||||
function assertReviewedChangelog(version: string) {
|
||||
const missing = missingChangelogArtifacts(version);
|
||||
const unreviewed = unreviewedChangelogArtifacts(version);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error("\nMissing reviewed changelog artifacts:");
|
||||
if (missing.length > 0 || unreviewed.length > 0) {
|
||||
console.error("\nChangelog review required:");
|
||||
missing.forEach((artifact) => console.error(` ${artifact}`));
|
||||
console.error(`\nRun: bun run changelog:draft ${version} --write`);
|
||||
unreviewed.forEach((artifact) =>
|
||||
console.error(` ${artifact} still contains the generated TODO summary`),
|
||||
);
|
||||
console.error(`\nRun: bun run release:prepare ${version}`);
|
||||
console.error(
|
||||
"Review and rewrite the generated release notes, then rerun set-version. Use --skip-changelog-check only for emergency releases.",
|
||||
"Review and rewrite the generated release notes, then rerun release:prepare. Use --skip-changelog-check only for emergency releases.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -156,6 +162,12 @@ export function changelogArtifacts(version: string) {
|
||||
return [join("releases", `v${version}.md`), `docs/changelog.mdx#HyperFrames v${version}`];
|
||||
}
|
||||
|
||||
export function unreviewedChangelogArtifacts(version: string) {
|
||||
return changelogArtifacts(version).filter(
|
||||
(artifact) => artifactExists(artifact) && artifactHasGeneratedTodo(artifact),
|
||||
);
|
||||
}
|
||||
|
||||
function artifactExists(artifact: string) {
|
||||
const [path, marker] = artifact.split("#");
|
||||
const absolutePath = join(ROOT, path);
|
||||
@@ -166,6 +178,36 @@ function artifactExists(artifact: string) {
|
||||
return marker ? readFileSync(absolutePath, "utf-8").includes(`label="${marker}"`) : true;
|
||||
}
|
||||
|
||||
function artifactHasGeneratedTodo(artifact: string) {
|
||||
const [path, marker] = artifact.split("#");
|
||||
const content = readFileSync(join(ROOT, path), "utf-8");
|
||||
if (!marker) {
|
||||
return hasGeneratedChangelogTodo(content);
|
||||
}
|
||||
|
||||
return docsChangelogEntryHasGeneratedTodo(content, marker);
|
||||
}
|
||||
|
||||
export function hasGeneratedChangelogTodo(content: string) {
|
||||
return content.includes(CHANGELOG_REVIEW_TODO);
|
||||
}
|
||||
|
||||
export function docsChangelogEntryHasGeneratedTodo(content: string, marker: string) {
|
||||
const labelIndex = content.indexOf(`label="${marker}"`);
|
||||
if (labelIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entryStart = content.lastIndexOf("<Update", labelIndex);
|
||||
const entryEnd = content.indexOf("</Update>", labelIndex);
|
||||
const entry = content.slice(
|
||||
entryStart === -1 ? labelIndex : entryStart,
|
||||
entryEnd === -1 ? undefined : entryEnd + "</Update>".length,
|
||||
);
|
||||
|
||||
return hasGeneratedChangelogTodo(entry);
|
||||
}
|
||||
|
||||
function releaseAllowedPaths(version: string) {
|
||||
return [
|
||||
...PACKAGES.map((pkg) => join(pkg, "package.json")),
|
||||
|
||||
Reference in New Issue
Block a user