diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..1147a432b --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,43 @@ +changelog: + exclude: + labels: + - skip-changelog + - dependencies + authors: + - dependabot + - dependabot[bot] + categories: + - title: Breaking Changes + labels: + - breaking-change + - breaking + - title: Features + labels: + - enhancement + - feature + - title: Fixes + labels: + - bug + - fix + - title: Docs & Examples + labels: + - documentation + - docs + - examples + - title: Catalog + labels: + - catalog + - registry + - title: Performance + labels: + - performance + - perf + - title: Internal + labels: + - internal + - refactor + - tests + - ci + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 805228230..4f40a28af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,6 +208,7 @@ jobs: with: node-version: 22 - run: bun install --frozen-lockfile + - run: bun run test:scripts - run: bun run --cwd packages/core build:hyperframes-runtime - run: bun run --filter '!@hyperframes/producer' test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9f9fd2a82..dc17b8d01 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -166,11 +166,19 @@ jobs: PRERELEASE: ${{ steps.version.outputs.prerelease }} REPO: ${{ github.repository }} run: | + NOTES_FILE="releases/v${VERSION}.md" + # Skip if release already exists (idempotent re-runs) if gh release view "v${VERSION}" --repo "$REPO" >/dev/null 2>&1; then echo "Release v${VERSION} already exists — skipping" else - FLAGS=(--repo "$REPO" --title "v${VERSION}" --generate-notes) + FLAGS=(--repo "$REPO" --title "v${VERSION}") + if [ -f "$NOTES_FILE" ]; then + FLAGS+=(--notes-file "$NOTES_FILE") + else + echo "No reviewed release notes found at $NOTES_FILE — using GitHub generated notes" + FLAGS+=(--generate-notes) + fi if [ "$PRERELEASE" = "true" ]; then FLAGS+=(--prerelease) fi diff --git a/docs/changelog.mdx b/docs/changelog.mdx new file mode 100644 index 000000000..fa614e357 --- /dev/null +++ b/docs/changelog.mdx @@ -0,0 +1,26 @@ +--- +title: "Changelog" +description: "Release notes for HyperFrames." +rss: true +--- + +Recent HyperFrames releases, including user-facing features, fixes, and migration notes. + +{/* New release entries are prepended by `bun run changelog:draft --write`. */} + + +This release focuses on stability improvements across Studio rendering, producer telemetry, and core timeline handling. + +## Fixed + +- **Studio:** Added an FFmpeg pre-flight check before rendering so setup issues fail earlier with a clearer path to resolution. +- **Producer:** Normalized structured error messages so telemetry and logs no longer collapse details into `[object Object]`. +- **Core:** Guarded timeline method calls for non-conformant timeline-like objects. +- **Release:** Removed a stale marketplace version field from packaged release metadata. + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.6.51...v0.6.52). + diff --git a/docs/contributing/changelog-process.mdx b/docs/contributing/changelog-process.mdx new file mode 100644 index 000000000..4da49e5b7 --- /dev/null +++ b/docs/contributing/changelog-process.mdx @@ -0,0 +1,84 @@ +--- +title: Changelog process +description: How HyperFrames drafts, reviews, and publishes release notes. +--- + +HyperFrames changelogs have two audiences: + +- Developers reading the docs changelog for user-facing changes, migration notes, and reasons to upgrade. +- Maintainers publishing GitHub Releases during the npm release process. + +The release workflow keeps both audiences in sync while preserving a human editing step. + +## Goals + +- Make every stable release easy to scan from the docs site. +- Publish useful GitHub Release notes without relying only on raw commit logs. +- Keep release notes editable before publishing. +- Avoid over-documenting internal-only commits that do not change user behavior. + +## Source of truth + +Each reviewed release note lives in `releases/vX.Y.Z.md`. + +The docs changelog lives in `docs/changelog.mdx` and uses Mintlify `` 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 `` entry in sync. + +## Release note workflow + + + + Run the draft command from the repository root: + ```bash + bun run changelog:draft 0.6.53 --write + ``` + This creates or updates: + - `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. + + + Read the generated notes and rewrite them for users. Prioritize impact over implementation detail. + + Call out: + - Breaking changes and required migration steps + - New capabilities + - Important bug fixes + - Performance or reliability improvements + - Security fixes + + + Run the existing fixed-version release command: + ```bash + bun run set-version 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. + + The release commit can include the version bump, `releases/v0.6.53.md`, and the docs changelog update. + + + Push the release tag: + ```bash + git push origin main --tags + ``` + The publish workflow uses `releases/v0.6.53.md` as the GitHub Release body when the file exists. If no reviewed release file is present, it falls back to GitHub-generated notes. + + The generated compare link points to the future `v0.6.53` tag. It may not resolve between the PR merge and the final tag push. + + + +## 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. + +Group changes in this order when applicable: + +1. Breaking Changes +2. Features +3. Fixes +4. Performance +5. Docs & Examples +6. Catalog +7. Internal + +Avoid listing release commits, dependency-only updates, generated file churn, and changes labeled `skip-changelog`. diff --git a/docs/contributing/release-channels.mdx b/docs/contributing/release-channels.mdx index 1758c67ef..6aafb2b60 100644 --- a/docs/contributing/release-channels.mdx +++ b/docs/contributing/release-channels.mdx @@ -22,9 +22,16 @@ 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: ```bash -bun run set-version 0.4.24 +bun run changelog:draft --write +``` + +See [Changelog process](/contributing/changelog-process) for the full workflow. For stable releases, `bun run set-version ` enforces this checkpoint before creating the release commit and tag. + +```bash +bun run set-version git push origin main --tags ``` @@ -33,6 +40,7 @@ For hotfixes, branch from the last stable tag, cherry-pick only the fix, publish ## Alpha release Alpha releases must be reachable from a prerelease branch such as `origin/next` or `origin/alpha`. +Use the same changelog draft workflow when the prerelease contains changes that users should know about. ```bash git checkout next diff --git a/docs/docs.json b/docs/docs.json index 0449793d6..2e504e9ec 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -53,6 +53,7 @@ "group": "Getting Started", "pages": [ "introduction", + "changelog", "quickstart", "showcase", "examples", @@ -300,6 +301,7 @@ "contributing", "contributing/catalog", "contributing/release-channels", + "contributing/changelog-process", "contributing/testing-local-changes", "contributing/studio-manual-dom-editing" ] diff --git a/package.json b/package.json index 4c8c81423..5038933db 100644 --- a/package.json +++ b/package.json @@ -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", + "changelog:draft": "tsx scripts/draft-changelog.ts", "sync-schemas": "tsx scripts/sync-schemas.ts", "sync-schemas:check": "tsx scripts/sync-schemas.ts --check", "lint": "oxlint . && tsx scripts/lint-skills.ts", @@ -29,6 +30,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", "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", diff --git a/releases/README.md b/releases/README.md new file mode 100644 index 000000000..1c330d9c1 --- /dev/null +++ b/releases/README.md @@ -0,0 +1,11 @@ +# Release notes + +Reviewed GitHub Release bodies live here. + +Create the next draft with: + +```bash +bun run changelog:draft --write +``` + +The publish workflow uses `releases/v.md` as the GitHub Release body when the file exists. Keep these notes user-facing; implementation details can stay in pull requests. diff --git a/releases/v0.6.52.md b/releases/v0.6.52.md new file mode 100644 index 000000000..a2b36cc59 --- /dev/null +++ b/releases/v0.6.52.md @@ -0,0 +1,16 @@ +# HyperFrames v0.6.52 + +Released on 2026-05-27. + +This release focuses on stability improvements across Studio rendering, producer telemetry, and core timeline handling. + +## Fixed + +- **Studio:** Added an FFmpeg pre-flight check before rendering so setup issues fail earlier with a clearer path to resolution. +- **Producer:** Normalized structured error messages so telemetry and logs no longer collapse details into `[object Object]`. +- **Core:** Guarded timeline method calls for non-conformant timeline-like objects. +- **Release:** Removed a stale marketplace version field from packaged release metadata. + +## Full changelog + +https://github.com/heygen-com/hyperframes/compare/v0.6.51...v0.6.52 diff --git a/scripts/draft-changelog.test.ts b/scripts/draft-changelog.test.ts new file mode 100644 index 000000000..16af3060a --- /dev/null +++ b/scripts/draft-changelog.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + escapeForMdx, + parseArgs, + parseCommit, + renderCommitBullet, + renderMdxCommitBullet, + shouldSkipCommit, + type RawCommit, +} from "./draft-changelog.ts"; + +const REPO_URL = "https://github.com/heygen-com/hyperframes"; + +function commit(subject: string): RawCommit { + return { + sha: "1234567890abcdef1234567890abcdef12345678", + shortSha: "1234567", + author: "Test Author", + subject, + }; +} + +describe("draft changelog arguments", () => { + it("parses positional, value, inline, and boolean options", () => { + assert.deepEqual( + parseArgs([ + "v1.2.3", + "--from", + "v1.2.2", + "--to=HEAD", + "--date", + "2026-06-02", + "--write", + "--force", + ]), + { + version: "1.2.3", + from: "v1.2.2", + to: "HEAD", + date: "2026-06-02", + write: true, + force: true, + }, + ); + }); +}); + +describe("draft changelog commit parsing", () => { + it("categorizes conventional commit types", () => { + assert.equal(parseCommit(commit("feat: add timeline markers")).category, "Features"); + assert.equal(parseCommit(commit("fix: repair audio sync")).category, "Fixes"); + assert.equal(parseCommit(commit("perf: reduce render startup")).category, "Performance"); + assert.equal(parseCommit(commit("docs: update quickstart")).category, "Docs & Examples"); + assert.equal(parseCommit(commit("test: cover frame capture")).category, "Internal"); + assert.equal(parseCommit(commit("move the preview panel")).category, "Other Changes"); + }); + + it("detects catalog changes from scope or summary", () => { + assert.equal(parseCommit(commit("feat(catalog): add kinetic title")).category, "Catalog"); + assert.equal(parseCommit(commit("fix: repair registry preview metadata")).category, "Catalog"); + }); + + it("lets breaking changes override the normal category", () => { + const parsed = parseCommit(commit("fix(cli)!: remove legacy render flag")); + + assert.equal(parsed.breaking, true); + assert.equal(parsed.category, "Breaking Changes"); + }); + + it("skips release, bump, and explicit skip commits", () => { + assert.equal(shouldSkipCommit(commit("chore: release v1.2.3")), true); + assert.equal(shouldSkipCommit(commit("chore: bump version to v1.2.3")), true); + assert.equal(shouldSkipCommit(commit("fix: internal cleanup [skip changelog]")), true); + assert.equal(shouldSkipCommit(commit("fix: real user-facing bug")), false); + }); +}); + +describe("draft changelog rendering", () => { + it("renders commit bullets with scope and pull request links", () => { + const parsed = parseCommit(commit("feat(cli): add render hints (#42)")); + + assert.equal( + renderCommitBullet(parsed), + `- **CLI:** Add render hints ([1234567](${REPO_URL}/commit/1234567890abcdef1234567890abcdef12345678), [#42](${REPO_URL}/pull/42))`, + ); + }); + + it("renders commit bullets without scope or pull request links", () => { + const parsed = parseCommit(commit("fix: repair playback")); + + assert.equal( + renderCommitBullet(parsed), + `- Repair playback ([1234567](${REPO_URL}/commit/1234567890abcdef1234567890abcdef12345678))`, + ); + }); + + it("escapes MDX-sensitive characters only in docs bullets", () => { + const parsed = parseCommit(commit("feat(docs): support blocks with {tags} (#7)")); + + assert.equal( + escapeForMdx("\\{tags}"), + "\\\\\\\\{tags\\}\\", + ); + assert.ok( + renderMdxCommitBullet(parsed).includes("Support \\ blocks with \\{tags\\}"), + ); + assert.ok(renderCommitBullet(parsed).includes("Support blocks with {tags}")); + }); +}); diff --git a/scripts/draft-changelog.ts b/scripts/draft-changelog.ts new file mode 100644 index 000000000..e9ad34deb --- /dev/null +++ b/scripts/draft-changelog.ts @@ -0,0 +1,564 @@ +#!/usr/bin/env tsx + +import { execFileSync } from "child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import { pathToFileURL } from "url"; + +const ROOT = join(import.meta.dirname, ".."); +const REPO_URL = "https://github.com/heygen-com/hyperframes"; +const DOCS_MARKER = + "{/* New release entries are prepended by `bun run changelog:draft --write`. */}"; + +const CATEGORY_ORDER = [ + "Breaking Changes", + "Features", + "Fixes", + "Performance", + "Docs & Examples", + "Catalog", + "Internal", + "Other Changes", +]; + +type Options = { + version: string; + from?: string; + to?: string; + date: string; + write: boolean; + force: boolean; +}; + +export type RawCommit = { + sha: string; + shortSha: string; + author: string; + subject: string; +}; + +export type ParsedCommit = RawCommit & { + type: string; + scope?: string; + summary: string; + breaking: boolean; + category: string; + prNumber?: string; +}; + +type DraftOutput = { + releaseNotes: string; + docsUpdate: string; +}; + +type MutableOptions = Omit & { + version?: string; +}; + +type ValueOptionKey = "from" | "to" | "date"; +type BooleanOptionKey = "write" | "force"; +type ParsedSubject = Pick; + +const VALUE_OPTIONS = new Map([ + ["--from", "from"], + ["--to", "to"], + ["--date", "date"], +]); + +const BOOLEAN_OPTIONS = new Map([ + ["--write", "write"], + ["--force", "force"], +]); + +const INLINE_VALUE_OPTIONS = [ + { prefix: "--from=", key: "from" }, + { prefix: "--to=", key: "to" }, + { prefix: "--date=", key: "date" }, +] satisfies Array<{ prefix: string; key: ValueOptionKey }>; + +const TYPE_CATEGORIES = new Map([ + ["feat", "Features"], + ["fix", "Fixes"], + ["perf", "Performance"], +]); + +const INTERNAL_TYPES = new Set(["build", "chore", "ci", "refactor", "test"]); + +function main() { + const options = parseArgs(process.argv.slice(2)); + const draft = createDraft(options); + outputDraft(options, draft); +} + +function createDraft(options: Options): DraftOutput { + const versionTag = `v${options.version}`; + const to = options.to ?? (tagExists(versionTag) ? versionTag : "HEAD"); + const from = options.from ?? resolvePreviousTag(versionTag, to); + const commits = getCommits(from, to).filter((commit) => !shouldSkipCommit(commit)); + const parsedCommits = commits.map(parseCommit); + + const releaseNotes = renderReleaseNotes(options.version, options.date, from, parsedCommits); + const docsUpdate = renderDocsUpdate(options.version, options.date, from, parsedCommits); + + return { releaseNotes, docsUpdate }; +} + +function outputDraft(options: Options, draft: DraftOutput) { + if (!options.write) { + console.log(draft.releaseNotes); + console.log("\n--- Mintlify update block ---\n"); + console.log(draft.docsUpdate); + console.log( + "\nRun with --write to create the release file and prepend the docs changelog entry.", + ); + return; + } + + writeReleaseNotes(options.version, draft.releaseNotes, options.force); + prependDocsUpdate(options.version, draft.docsUpdate); +} + +export function parseArgs(args: string[]): Options { + const parsed = createDefaultOptions(); + + for (let index = 0; index < args.length; index += 1) { + index = parseArgument(args, index, parsed); + } + + return finalizeOptions(parsed); +} + +function createDefaultOptions(): MutableOptions { + return { + date: new Date().toISOString().slice(0, 10), + write: false, + force: false, + }; +} + +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}`); + } +} + +function finalizeOptions(parsed: MutableOptions): Options { + if (!parsed.version) { + printUsage(); + process.exit(1); + } + + validateVersion(parsed.version); + validateDate(parsed.date); + + return { + version: parsed.version, + from: parsed.from, + to: parsed.to, + date: parsed.date, + write: parsed.write, + force: parsed.force, + }; +} + +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 [--write] [--force] [--from ] [--to ] [--date YYYY-MM-DD] + +Examples: + bun run changelog:draft 0.6.53 + bun run changelog:draft 0.6.53 --write + bun run changelog:draft 0.6.53 --from v0.6.52 --to HEAD --write +`); +} + +function fail(message: string): never { + console.error(message); + process.exit(1); +} + +function git(args: string[]) { + return execFileSync("git", args, { + cwd: ROOT, + encoding: "utf-8", + }).trim(); +} + +function tagExists(tag: string) { + try { + git(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}`]); + return true; + } catch { + return false; + } +} + +function resolvePreviousTag(versionTag: string, to: string) { + try { + if (tagExists(versionTag)) { + return git(["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*", `${versionTag}^`]); + } + return git(["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*", to]); + } catch { + fail("Could not resolve the previous release tag. Pass --from explicitly."); + } +} + +function getCommits(from: string, to: string): RawCommit[] { + const output = git(["log", "--format=%H%x09%h%x09%an%x09%s", "--no-merges", `${from}..${to}`]); + if (!output) { + return []; + } + + return output.split("\n").map((line) => { + const [sha = "", shortSha = "", author = "", ...subjectParts] = line.split("\t"); + return { + sha, + shortSha, + author, + subject: subjectParts.join("\t"), + }; + }); +} + +export function shouldSkipCommit(commit: RawCommit) { + const subject = commit.subject.toLowerCase(); + return ( + subject.includes("[skip changelog]") || + /^chore: release v\d+\.\d+\.\d+/.test(subject) || + /^chore: bump version/.test(subject) + ); +} + +export function parseCommit(commit: RawCommit): ParsedCommit { + const prNumber = extractPrNumber(commit.subject); + const subjectWithoutPr = commit.subject.replace(/\s+\(#\d+\)$/, ""); + const parsedSubject = parseConventionalSubject(subjectWithoutPr); + const category = categorizeCommit(parsedSubject); + + return { + ...commit, + ...parsedSubject, + category, + prNumber, + }; +} + +export function parseConventionalSubject(subject: string): ParsedSubject { + const match = /^([a-z]+)(?:\(([^)]+)\))?(!)?:\s+(.+)$/.exec(subject); + if (!match) { + return { + type: "other", + summary: subject, + breaking: false, + }; + } + + return { + type: match[1], + scope: match[2], + summary: match[4], + breaking: match[3] === "!", + }; +} + +function extractPrNumber(subject: string) { + return /\(#(\d+)\)$/.exec(subject)?.[1]; +} + +export function categorizeCommit(subject: ParsedSubject) { + if (subject.breaking) { + return "Breaking Changes"; + } + + if (isCatalogChange(subject)) { + return "Catalog"; + } + + return knownCategoryFor(subject) ?? "Other Changes"; +} + +function isCatalogChange(subject: ParsedSubject) { + const normalizedScope = subject.scope?.toLowerCase() ?? ""; + const normalizedSummary = subject.summary.toLowerCase(); + return ( + ["catalog", "registry"].includes(normalizedScope) || /catalog|registry/.test(normalizedSummary) + ); +} + +function knownCategoryFor(subject: ParsedSubject) { + return ( + TYPE_CATEGORIES.get(subject.type) ?? + docsCategoryFor(subject) ?? + internalCategoryFor(subject.type) + ); +} + +function docsCategoryFor(subject: ParsedSubject) { + return isDocsChange(subject) ? "Docs & Examples" : undefined; +} + +function isDocsChange(subject: ParsedSubject) { + const normalizedFields = [subject.type, subject.scope?.toLowerCase()]; + return normalizedFields.includes("docs") || subject.summary.toLowerCase().includes("example"); +} + +function internalCategoryFor(type: string) { + return INTERNAL_TYPES.has(type) ? "Internal" : undefined; +} + +function renderReleaseNotes(version: string, date: string, from: string, commits: ParsedCommit[]) { + const sections = renderSections(commits, renderCommitBullet); + const compareUrl = `${REPO_URL}/compare/${from}...v${version}`; + + return [ + `# HyperFrames v${version}`, + "", + `Released on ${date}.`, + "", + "", + "", + sections, + "", + "## Full changelog", + "", + compareUrl, + ].join("\n"); +} + +function renderDocsUpdate(version: string, date: string, from: string, commits: ParsedCommit[]) { + const sections = renderSections(commits, renderMdxCommitBullet); + const compareUrl = `${REPO_URL}/compare/${from}...v${version}`; + const tags = renderTags(commits); + + return [ + "", + "", + "", + sections, + "", + `[View the full commit range](${compareUrl}).`, + "", + ].join("\n"); +} + +function renderSections(commits: ParsedCommit[], renderBullet: (commit: ParsedCommit) => string) { + if (commits.length === 0) { + return "No notable changes were found in the selected commit range."; + } + + return CATEGORY_ORDER.flatMap((category) => { + const commitsInCategory = commits.filter((commit) => commit.category === category); + if (commitsInCategory.length === 0) { + return []; + } + + return [`## ${category}`, "", ...commitsInCategory.map(renderBullet), ""]; + }) + .join("\n") + .trim(); +} + +export function renderCommitBullet(commit: ParsedCommit) { + const scope = commit.scope ? `**${formatScope(commit.scope)}:** ` : ""; + const links = [`[${commit.shortSha}](${REPO_URL}/commit/${commit.sha})`]; + if (commit.prNumber) { + links.push(`[#${commit.prNumber}](${REPO_URL}/pull/${commit.prNumber})`); + } + + return `- ${scope}${capitalize(commit.summary)} (${links.join(", ")})`; +} + +export function renderMdxCommitBullet(commit: ParsedCommit) { + const scope = commit.scope ? `**${escapeForMdx(formatScope(commit.scope))}:** ` : ""; + const links = [`[${commit.shortSha}](${REPO_URL}/commit/${commit.sha})`]; + if (commit.prNumber) { + links.push(`[#${commit.prNumber}](${REPO_URL}/pull/${commit.prNumber})`); + } + + return `- ${scope}${escapeForMdx(capitalize(commit.summary))} (${links.join(", ")})`; +} + +function renderTags(commits: ParsedCommit[]) { + return ["Release", ...uniqueScopeTags(commits).slice(0, 3)]; +} + +function uniqueScopeTags(commits: ParsedCommit[]) { + return Array.from(new Set(commits.flatMap(scopeTagsForCommit))); +} + +function scopeTagsForCommit(commit: ParsedCommit) { + return commit.scope ? [formatScope(commit.scope)] : []; +} + +export function formatScope(scope: string) { + const knownScopes = new Map([ + ["api", "API"], + ["aws", "AWS"], + ["aws-lambda", "AWS Lambda"], + ["cli", "CLI"], + ["core", "Core"], + ["docs", "Docs"], + ["engine", "Engine"], + ["ffmpeg", "FFmpeg"], + ["producer", "Producer"], + ["readme", "README"], + ["studio", "Studio"], + ]); + + const known = knownScopes.get(scope.toLowerCase()); + if (known) { + return known; + } + + return scope + .split(/[-_]/) + .map((part) => capitalize(part)) + .join(" "); +} + +function capitalize(value: string) { + if (!value) { + return value; + } + return value[0].toUpperCase() + value.slice(1); +} + +function renderTagsLiteral(tags: string[]) { + return `[${tags.map((tag) => JSON.stringify(tag)).join(", ")}]`; +} + +export function escapeForMdx(text: string) { + return text + .replace(/\\/g, "\\\\") + .replace(//g, "\\>") + .replace(/\{/g, "\\{") + .replace(/\}/g, "\\}"); +} + +function writeReleaseNotes(version: string, releaseNotes: string, force: boolean) { + const releasesDir = join(ROOT, "releases"); + const releasePath = join(releasesDir, `v${version}.md`); + mkdirSync(releasesDir, { recursive: true }); + + // Use an exclusive-write flag rather than a separate existsSync check so the + // "already exists" guard is atomic with the write (no TOCTOU race). + // EEXIST is only reachable when force is false (the "wx" flag); with force the + // "w" flag overwrites and never throws it, so no separate force check is needed. + try { + 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.`); + } + throw error; + } + console.log(`Wrote ${releasePath}`); +} + +function prependDocsUpdate(version: string, docsUpdate: string) { + const changelogPath = join(ROOT, "docs", "changelog.mdx"); + const changelog = readFileSync(changelogPath, "utf-8"); + + if (changelog.includes(`label="HyperFrames v${version}"`)) { + console.log(`docs/changelog.mdx already has a v${version} entry; leaving it unchanged.`); + return; + } + + if (!changelog.includes(DOCS_MARKER)) { + fail(`Could not find insertion marker in ${changelogPath}`); + } + + const updated = changelog.replace(DOCS_MARKER, `${DOCS_MARKER}\n\n${docsUpdate}`); + writeFileSync(changelogPath, updated); + console.log(`Prepended v${version} to ${changelogPath}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/scripts/set-version.test.ts b/scripts/set-version.test.ts new file mode 100644 index 000000000..9e6f220ce --- /dev/null +++ b/scripts/set-version.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + changelogArtifacts, + gitStatusPath, + isPrerelease, + parseReleaseOptions, + releaseRequiresChangelog, +} from "./set-version.ts"; + +describe("set-version release options", () => { + it("parses stable release flags", () => { + assert.deepEqual(parseReleaseOptions(["1.2.3", "--no-tag", "--skip-changelog-check"]), { + version: "1.2.3", + skipTag: true, + skipChangelogCheck: true, + }); + }); + + it("requires reviewed changelog artifacts for stable tagged releases", () => { + assert.equal( + releaseRequiresChangelog({ + version: "1.2.3", + skipTag: false, + skipChangelogCheck: false, + }), + true, + ); + }); + + it("does not require changelog artifacts for prereleases, no-tag bumps, or emergency skips", () => { + assert.equal(isPrerelease("1.2.3-alpha.1"), true); + assert.equal( + releaseRequiresChangelog({ + version: "1.2.3-alpha.1", + skipTag: false, + skipChangelogCheck: false, + }), + false, + ); + assert.equal( + releaseRequiresChangelog({ + version: "1.2.3", + skipTag: true, + skipChangelogCheck: false, + }), + false, + ); + assert.equal( + releaseRequiresChangelog({ + version: "1.2.3", + skipTag: false, + skipChangelogCheck: true, + }), + false, + ); + }); + + it("tracks both GitHub release and docs changelog artifacts", () => { + assert.deepEqual(changelogArtifacts("1.2.3"), [ + "releases/v1.2.3.md", + "docs/changelog.mdx#HyperFrames v1.2.3", + ]); + }); +}); + +describe("git status parsing", () => { + it("extracts unquoted porcelain paths", () => { + assert.equal(gitStatusPath(" M docs/changelog.mdx"), "docs/changelog.mdx"); + }); + + it("extracts quoted porcelain paths", () => { + assert.equal(gitStatusPath('?? "releases/v1.2.3.md"'), "releases/v1.2.3.md"); + }); +}); diff --git a/scripts/set-version.ts b/scripts/set-version.ts index d78af2eb2..ddb6b497d 100644 --- a/scripts/set-version.ts +++ b/scripts/set-version.ts @@ -7,15 +7,17 @@ * bun run set-version 0.1.1 # stable release → npm "latest" tag * bun run set-version 0.1.1-alpha.1 # pre-release → npm "alpha" tag * bun run set-version 0.1.1 --no-tag # bump only (no commit or tag) + * bun run set-version 0.1.1 --skip-changelog-check # emergency stable release * * All packages and plugins share a single version number (fixed versioning). * Pre-release suffixes (-alpha, -beta, -rc, etc.) are detected by the * publish workflow and published to the corresponding npm dist-tag. */ -import { readFileSync, writeFileSync } from "fs"; +import { existsSync, readFileSync, writeFileSync } from "fs"; import { join } from "path"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; +import { pathToFileURL } from "url"; const PACKAGES = [ "packages/core", @@ -32,13 +34,41 @@ const PLUGINS = [".claude-plugin", ".codex-plugin", ".cursor-plugin"]; const ROOT = join(import.meta.dirname, ".."); +type ReleaseOptions = { + version: string; + skipTag: boolean; + skipChangelogCheck: boolean; +}; + function main() { - const args = process.argv.slice(2); + const options = parseReleaseOptions(process.argv.slice(2)); + if (releaseRequiresChangelog(options)) { + assertReviewedChangelog(options.version); + } + + updatePackageVersions(options.version); + updatePluginVersions(options.version); + + console.log( + `\nSet ${PACKAGES.length} packages and ${PLUGINS.length} plugin manifests to v${options.version}`, + ); + + if (options.skipTag) { + console.log(`\nSkipped commit and tag (--no-tag). Remember to commit and tag manually.`); + return; + } + + createReleaseCommitAndTag(options.version); + printReleaseNextSteps(options.version); +} + +export function parseReleaseOptions(args: string[]): ReleaseOptions { const version = args.find((a) => !a.startsWith("--")); const skipTag = args.includes("--no-tag"); + const skipChangelogCheck = args.includes("--skip-changelog-check"); if (!version) { - console.error("Usage: bun run set-version [--no-tag]"); + console.error("Usage: bun run set-version [--no-tag] [--skip-changelog-check]"); console.error("Example: bun run set-version 0.1.1"); process.exit(1); } @@ -48,7 +78,10 @@ function main() { process.exit(1); } - // Update each package.json + return { version, skipTag, skipChangelogCheck }; +} + +function updatePackageVersions(version: string) { for (const pkg of PACKAGES) { const pkgPath = join(ROOT, pkg, "package.json"); const content = JSON.parse(readFileSync(pkgPath, "utf-8")); @@ -57,7 +90,9 @@ function main() { writeFileSync(pkgPath, JSON.stringify(content, null, 2) + "\n"); console.log(` ${content.name}: ${oldVersion} -> ${version}`); } +} +function updatePluginVersions(version: string) { // Update each plugin.json. Replace just the version string rather than // round-tripping through JSON.parse/stringify: oxfmt keeps these manifests' // short arrays inline, but JSON.stringify expands them, which would fail the @@ -69,52 +104,94 @@ function main() { writeFileSync(pluginPath, text.replace(/("version"\s*:\s*)"[^"]*"/, `$1"${version}"`)); console.log(` ${plugin}: ${oldVersion} -> ${version}`); } +} - console.log( - `\nSet ${PACKAGES.length} packages and ${PLUGINS.length} plugin manifests to v${version}`, - ); - - if (skipTag) { - console.log(`\nSkipped commit and tag (--no-tag). Remember to commit and tag manually.`); - return; - } - - // Verify working tree is clean (aside from the version bumps we just made) - const status = execSync("git status --porcelain", { +function createReleaseCommitAndTag(version: string) { + const status = execFileSync("git", ["status", "--porcelain"], { cwd: ROOT, encoding: "utf-8", }).trim(); + const allowedPaths = releaseAllowedPaths(version); + assertNoUnexpectedChanges(status, allowedPaths); + + // Pass git arguments as an array (execFileSync, no shell) so the interpolated + // version and paths can never be interpreted as shell commands. + const pathsToAdd = allowedPaths.filter((path) => existsSync(join(ROOT, path))); + execFileSync("git", ["add", ...pathsToAdd], { cwd: ROOT, stdio: "inherit" }); + execFileSync("git", ["commit", "-m", `chore: release v${version}`], { + cwd: ROOT, + stdio: "inherit", + }); + execFileSync("git", ["tag", `v${version}`], { cwd: ROOT, stdio: "inherit" }); + console.log(`\nCreated commit and tag v${version}`); +} + +export function releaseRequiresChangelog(options: ReleaseOptions) { + return !options.skipTag && !options.skipChangelogCheck && !isPrerelease(options.version); +} + +export function isPrerelease(version: string) { + return version.includes("-"); +} + +function assertReviewedChangelog(version: string) { + const missing = missingChangelogArtifacts(version); + + if (missing.length > 0) { + console.error("\nMissing reviewed changelog artifacts:"); + missing.forEach((artifact) => console.error(` ${artifact}`)); + console.error(`\nRun: bun run changelog:draft ${version} --write`); + console.error( + "Review and rewrite the generated release notes, then rerun set-version. Use --skip-changelog-check only for emergency releases.", + ); + process.exit(1); + } +} + +export function missingChangelogArtifacts(version: string) { + return changelogArtifacts(version).filter((artifact) => !artifactExists(artifact)); +} + +export function changelogArtifacts(version: string) { + return [join("releases", `v${version}.md`), `docs/changelog.mdx#HyperFrames v${version}`]; +} + +function artifactExists(artifact: string) { + const [path, marker] = artifact.split("#"); + const absolutePath = join(ROOT, path); + + if (!existsSync(absolutePath)) { + return false; + } + return marker ? readFileSync(absolutePath, "utf-8").includes(`label="${marker}"`) : true; +} + +function releaseAllowedPaths(version: string) { + return [ + ...PACKAGES.map((pkg) => join(pkg, "package.json")), + ...PLUGINS.map((plugin) => join(plugin, "plugin.json")), + "docs/changelog.mdx", + join("releases", `v${version}.md`), + ]; +} + +function assertNoUnexpectedChanges(status: string, allowedPaths: string[]) { const unexpected = status .split("\n") .filter( - (line) => - line && - !PACKAGES.some((pkg) => line.includes(pkg)) && - !PLUGINS.some((plugin) => line.includes(plugin)), + (line) => line && !allowedPaths.some((allowedPath) => gitStatusPath(line) === allowedPath), ); + if (unexpected.length > 0) { console.error("\nUnexpected uncommitted changes:"); unexpected.forEach((line) => console.error(` ${line}`)); console.error("Commit or stash these before releasing."); process.exit(1); } +} - execSync( - `git add ${[...PACKAGES.map((p) => join(p, "package.json")), ...PLUGINS.map((p) => join(p, "plugin.json"))].join(" ")}`, - { - cwd: ROOT, - stdio: "inherit", - }, - ); - execSync(`git commit -m "chore: release v${version}"`, { - cwd: ROOT, - stdio: "inherit", - }); - execSync(`git tag v${version}`, { cwd: ROOT, stdio: "inherit" }); - console.log(`\nCreated commit and tag v${version}`); - - const isPrerelease = version.includes("-"); - if (isPrerelease) { +function printReleaseNextSteps(version: string) { + if (isPrerelease(version)) { const distTag = version.replace(/^.*-([a-zA-Z]+).*$/, "$1"); console.log(`\nThis is a pre-release — npm dist-tag will be "${distTag}" (not "latest").`); console.log(`Consumers install with: npm install @hyperframes/core@${distTag}`); @@ -124,4 +201,10 @@ function main() { } } -main(); +export function gitStatusPath(line: string) { + return line.slice(3).replace(/^"|"$/g, ""); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +}