Skip to content

Commit 17b0db1

Browse files
authored
chore: add release prepare command (heygen-com#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
1 parent b652c0a commit 17b0db1

11 files changed

Lines changed: 660 additions & 110 deletions

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,11 @@ All packages use **fixed versioning** — every release bumps all packages to th
139139
### Stable releases
140140

141141
```bash
142-
bun run set-version 0.2.0 # bumps all packages, commits, and creates git tag
142+
bun run release:prepare 0.2.0 # drafts changelog if needed, then creates the release commit/tag after review
143143
git push origin main --tags # triggers the publish workflow
144144
```
145145

146-
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.
146+
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.
147147

148148
### Pre-releases (alpha / beta / rc)
149149

docs/contributing/changelog-process.mdx

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,19 @@ Each reviewed release note lives in `releases/vX.Y.Z.md`.
2323

2424
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.
2525

26-
## Release note workflow
26+
## Stable release workflow
2727

2828
<Steps>
29-
<Step title="Draft the release notes">
30-
Run the draft command from the repository root:
29+
<Step title="Prepare the release">
30+
Run the stable release command from the repository root:
3131
```bash
32-
bun run changelog:draft 0.6.53 --write
32+
bun run release:prepare 0.6.53
3333
```
34-
This creates or updates:
34+
On the first run, this creates or updates the changelog draft and then exits before tagging:
3535
- `releases/v0.6.53.md`
3636
- `docs/changelog.mdx`
3737

38-
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.
38+
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.
3939
</Step>
4040
<Step title="Review and rewrite">
4141
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
4747
- Performance or reliability improvements
4848
- Security fixes
4949
</Step>
50-
<Step title="Create the release commit">
51-
Run the existing fixed-version release command:
50+
<Step title="Rerun the release command">
51+
After review, run the same command again:
5252
```bash
53-
bun run set-version 0.6.53
53+
bun run release:prepare 0.6.53
5454
```
55-
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.
55+
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.
5656

5757
The release commit can include the version bump, `releases/v0.6.53.md`, and the docs changelog update.
5858
</Step>
@@ -67,6 +67,16 @@ The docs changelog lives in `docs/changelog.mdx` and uses Mintlify `<Update>` en
6767
</Step>
6868
</Steps>
6969

70+
## Draft regeneration
71+
72+
Use the lower-level draft command when you need to regenerate changelog copy before review:
73+
74+
```bash
75+
bun run changelog:draft 0.6.53 --write --force
76+
```
77+
78+
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.
79+
7080
## Writing style
7181

7282
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.

docs/contributing/release-channels.mdx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,18 @@ If a feature should ship in alpha only, merge or retarget that PR to a prereleas
2222
## Stable release
2323

2424
Stable releases must be reachable from `origin/main` or `origin/release/v*`.
25-
Draft and review release notes before creating the release commit:
25+
Prepare and review release notes before creating the release commit:
2626

2727
```bash
28-
bun run changelog:draft <version> --write
28+
bun run release:prepare <version>
2929
```
3030

31-
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.
31+
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.
32+
33+
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.
3234

3335
```bash
34-
bun run set-version <version>
36+
bun run release:prepare <version>
3537
git push origin main --tags
3638
```
3739

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"verify:packed-manifests": "node scripts/verify-packed-manifests.mjs",
2020
"validate:release-channel": "node scripts/validate-release-channel.mjs",
2121
"set-version": "tsx scripts/set-version.ts",
22+
"release:prepare": "tsx scripts/release-prepare.ts",
2223
"changelog:draft": "tsx scripts/draft-changelog.ts",
2324
"sync-schemas": "tsx scripts/sync-schemas.ts",
2425
"sync-schemas:check": "tsx scripts/sync-schemas.ts --check",
@@ -30,7 +31,7 @@
3031
"player:perf": "bun run --filter @hyperframes/player perf",
3132
"format:check": "oxfmt --check .",
3233
"knip": "knip",
33-
"test:scripts": "node --import tsx --test scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts",
34+
"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",
3435
"generate:previews": "tsx scripts/generate-template-previews.ts",
3536
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
3637
"upload:docs-images": "bash scripts/upload-docs-images.sh",

releases/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ Reviewed GitHub Release bodies live here.
55
Create the next draft with:
66

77
```bash
8-
bun run changelog:draft <version> --write
8+
bun run release:prepare <version>
99
```
1010

11+
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.
12+
1113
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.

scripts/cli-options.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
import { CLI_SEMVER_PATTERN, validateCliVersion } from "./cli-options.ts";
4+
5+
function fail(message: string): never {
6+
throw new Error(message);
7+
}
8+
9+
describe("CLI semver validation", () => {
10+
it("accepts stable and hyphenated prerelease versions", () => {
11+
assert.doesNotThrow(() => validateCliVersion("1.2.3", CLI_SEMVER_PATTERN, fail));
12+
assert.doesNotThrow(() => validateCliVersion("1.2.3-alpha.1", CLI_SEMVER_PATTERN, fail));
13+
assert.doesNotThrow(() =>
14+
validateCliVersion("1.2.3-alpha-feature.2", CLI_SEMVER_PATTERN, fail),
15+
);
16+
});
17+
18+
it("rejects underscores in prerelease versions", () => {
19+
assert.throws(
20+
() => validateCliVersion("1.2.3-alpha_1", CLI_SEMVER_PATTERN, fail),
21+
/Invalid semver: 1\.2\.3-alpha_1/,
22+
);
23+
});
24+
});

scripts/cli-options.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
export type InlineValueOption<Key extends string> = {
2+
prefix: string;
3+
key: Key;
4+
};
5+
6+
export const CLI_SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
7+
8+
type ParserConfig<
9+
Parsed extends object,
10+
ValueKey extends keyof Parsed & string,
11+
BooleanKey extends keyof Parsed & string,
12+
> = {
13+
inlineValueOptions: Array<InlineValueOption<ValueKey>>;
14+
valueOptions: Map<string, ValueKey>;
15+
booleanOptions: Map<string, BooleanKey>;
16+
parsePositional: (arg: string, index: number) => number;
17+
fail: (message: string) => never;
18+
};
19+
20+
export function parseMappedArgument<
21+
Parsed extends object,
22+
ValueKey extends keyof Parsed & string,
23+
BooleanKey extends keyof Parsed & string,
24+
>(
25+
args: string[],
26+
index: number,
27+
parsed: Parsed,
28+
config: ParserConfig<Parsed, ValueKey, BooleanKey>,
29+
) {
30+
const arg = args[index];
31+
32+
if (applyInlineValueOption(arg, parsed, config.inlineValueOptions)) {
33+
return index;
34+
}
35+
if (applyBooleanOption(arg, parsed, config.booleanOptions)) {
36+
return index;
37+
}
38+
39+
return applyValueOrPositionalOption(args, index, parsed, config, arg);
40+
}
41+
42+
function applyInlineValueOption<Parsed extends object, ValueKey extends keyof Parsed & string>(
43+
arg: string,
44+
parsed: Parsed,
45+
inlineOptions: Array<InlineValueOption<ValueKey>>,
46+
) {
47+
const option = inlineOptions.find((candidate) => arg.startsWith(candidate.prefix));
48+
if (!option) {
49+
return false;
50+
}
51+
52+
parsed[option.key] = arg.slice(option.prefix.length) as Parsed[ValueKey];
53+
return true;
54+
}
55+
56+
function applyBooleanOption<Parsed extends object, BooleanKey extends keyof Parsed & string>(
57+
arg: string,
58+
parsed: Parsed,
59+
booleanOptions: Map<string, BooleanKey>,
60+
) {
61+
const option = booleanOptions.get(arg);
62+
if (!option) {
63+
return false;
64+
}
65+
66+
parsed[option] = true as Parsed[BooleanKey];
67+
return true;
68+
}
69+
70+
function applyValueOrPositionalOption<
71+
Parsed extends object,
72+
ValueKey extends keyof Parsed & string,
73+
BooleanKey extends keyof Parsed & string,
74+
>(
75+
args: string[],
76+
index: number,
77+
parsed: Parsed,
78+
config: ParserConfig<Parsed, ValueKey, BooleanKey>,
79+
arg: string,
80+
) {
81+
const option = config.valueOptions.get(arg);
82+
if (!option) {
83+
return config.parsePositional(arg, index);
84+
}
85+
86+
parsed[option] = readNextArg(args, index, arg, config.fail) as Parsed[ValueKey];
87+
return index + 1;
88+
}
89+
90+
export function parseVersionOptionArgument<
91+
Parsed extends { version?: string },
92+
ValueKey extends keyof Parsed & string,
93+
BooleanKey extends keyof Parsed & string,
94+
>(
95+
args: string[],
96+
index: number,
97+
parsed: Parsed,
98+
config: Omit<ParserConfig<Parsed, ValueKey, BooleanKey>, "parsePositional"> & {
99+
printUsage: () => void;
100+
},
101+
) {
102+
return parseMappedArgument(args, index, parsed, {
103+
...config,
104+
parsePositional: (arg, positionalIndex) =>
105+
parseVersionOrHelp(arg, positionalIndex, parsed, config),
106+
});
107+
}
108+
109+
function parseVersionOrHelp<Parsed extends { version?: string }>(
110+
arg: string,
111+
index: number,
112+
parsed: Parsed,
113+
config: { printUsage: () => void; fail: (message: string) => never },
114+
) {
115+
if (arg === "--help" || arg === "-h") {
116+
config.printUsage();
117+
process.exit(0);
118+
}
119+
120+
parsed.version = parseVersionPositionalArg(arg, parsed.version, config.fail);
121+
return index;
122+
}
123+
124+
export function parseVersionPositionalArg(
125+
arg: string,
126+
currentVersion: string | undefined,
127+
fail: (message: string) => never,
128+
) {
129+
if (arg.startsWith("--")) {
130+
fail(`Unknown option: ${arg}`);
131+
}
132+
if (currentVersion) {
133+
fail(`Unexpected positional argument: ${arg}`);
134+
}
135+
136+
return arg.replace(/^v/, "");
137+
}
138+
139+
export function readNextArg(
140+
args: string[],
141+
index: number,
142+
flag: string,
143+
fail: (message: string) => never,
144+
) {
145+
const value = args[index + 1];
146+
if (!value || value.startsWith("--")) {
147+
fail(`Missing value for ${flag}`);
148+
}
149+
return value;
150+
}
151+
152+
export function validateCliVersion(
153+
version: string,
154+
pattern: RegExp,
155+
fail: (message: string) => never,
156+
) {
157+
if (!pattern.test(version)) {
158+
fail(`Invalid semver: ${version}`);
159+
}
160+
}
161+
162+
export function validateCliDate(date: string, fail: (message: string) => never) {
163+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
164+
fail(`Invalid date: ${date}. Expected YYYY-MM-DD.`);
165+
}
166+
}
167+
168+
export function optionalFlagArg(flag: string, enabled: boolean) {
169+
return enabled ? [flag] : [];
170+
}
171+
172+
export function optionalValueArg(flag: string, value: string | undefined) {
173+
return value ? [flag, value] : [];
174+
}

0 commit comments

Comments
 (0)