17b0db1d3e
## 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
224 lines
5.7 KiB
TypeScript
224 lines
5.7 KiB
TypeScript
#!/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();
|
|
}
|