fix(scripts): make release change-guard robust to git status prefix (#1198)

The set-version guard parsed `git status --porcelain` and extracted the
path with a fixed `line.slice(3)`. The porcelain "XY <path>" prefix width
can shift, and when it did the slice dropped a leading character —
misreading `.claude-plugin/plugin.json` as `claude-plugin/plugin.json`,
which failed the allowed-paths match and falsely blocked a legitimate
release with "Unexpected uncommitted changes". There was no escape hatch.

Collect changed paths from `git diff --name-only -z HEAD` (tracked) plus
`git ls-files --others --exclude-standard -z` (untracked) instead. Both
emit bare NUL-separated repo-relative paths with no status column to
misparse, so the allowed-paths comparison is exact. Extract the pure
helpers (splitNulList, findUnexpectedChanges) and cover them with tests.

Also document the release flow in CLAUDE.md (the repo had no release docs).
This commit is contained in:
James Russo
2026-06-04 00:58:09 -07:00
committed by GitHub
parent f093ec7a10
commit 028b094db1
3 changed files with 96 additions and 23 deletions
+36
View File
@@ -65,6 +65,42 @@ Never run `bun run --cwd packages/producer test:update` directly from the
host to capture a baseline that will be committed — the resulting output.mp4
will not match CI. Use it only for local-only experimentation.
## Releasing
All eight packages share one version. `.github/workflows/publish.yml` publishes
them when a `v*` tag is pushed (it also accepts a manual `workflow_dispatch` with
a version input). The CLI publishes as the unscoped `hyperframes` package; the
other seven as `@hyperframes/*`.
Cut a release from `main`:
```bash
bun run release:prepare <version> # e.g. 0.6.72
```
Run it twice:
1. **First run** writes `releases/v<version>.md` and prepends an entry to
`docs/changelog.mdx`, then stops with a non-zero exit. Edit both files: write
the 1–2 sentence summary and remove the `<!-- TODO -->` marker.
2. **Second run** (after the TODO is gone) bumps every package and plugin
manifest, creates the `chore: release v<version>` commit, and tags
`v<version>` (lightweight).
Then push to trigger the publish:
```bash
git push origin main --tags
```
Notes:
- Pre-release versions (`0.6.72-alpha.1`) publish to the matching npm dist-tag
(`alpha`) instead of `latest`.
- `--skip-changelog-check` skips the changelog gate for emergency releases.
- The publish step skips any version already on npm, so re-running a failed
workflow run is safe.
## Skills
Composition authoring (not repo development) is guided by skills installed via `npx skills add heygen-com/hyperframes`. See `skills/` for source. Invoke `/hyperframes`, `/hyperframes-cli`, `/hyperframes-registry`, `/tailwind`, or `/gsap` when authoring compositions. Use `/tailwind` for projects created with `hyperframes init --tailwind` so agents follow the pinned Tailwind v4 browser-runtime contract instead of Studio's Tailwind v3 setup. Use `/animejs`, `/css-animations`, `/lottie`, `/three`, or `/waapi` when a composition uses those first-party runtime adapters. Invoke `/hyperframes-media` for asset preprocessing (TTS narration, audio/video transcription, background removal for transparent overlays) — these commands have their own skill so the CLI skill stays focused on the dev loop. When a user provides a website URL and wants a video, invoke `/website-to-hyperframes` — it runs the full 7-step capture-to-video pipeline.
+27 -6
View File
@@ -2,10 +2,12 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
changelogArtifacts,
gitStatusPath,
findUnexpectedChanges,
isPrerelease,
parseReleaseOptions,
releaseAllowedPaths,
releaseRequiresChangelog,
splitNulList,
} from "./set-version.ts";
describe("set-version release options", () => {
@@ -64,12 +66,31 @@ describe("set-version release options", () => {
});
});
describe("git status parsing", () => {
it("extracts unquoted porcelain paths", () => {
assert.equal(gitStatusPath(" M docs/changelog.mdx"), "docs/changelog.mdx");
describe("changed-path guard", () => {
it("splits NUL-separated git output and drops the empty trailing entry", () => {
assert.deepEqual(splitNulList("packages/core/package.json\0releases/v1.2.3.md\0"), [
"packages/core/package.json",
"releases/v1.2.3.md",
]);
assert.deepEqual(splitNulList(""), []);
});
it("extracts quoted porcelain paths", () => {
assert.equal(gitStatusPath('?? "releases/v1.2.3.md"'), "releases/v1.2.3.md");
it("accepts a release whose changes are all allowed paths", () => {
const allowed = releaseAllowedPaths("1.2.3");
assert.deepEqual(
findUnexpectedChanges(
["packages/core/package.json", ".claude-plugin/plugin.json", "releases/v1.2.3.md"],
allowed,
),
[],
);
});
it("flags changes outside the allowed release paths", () => {
const allowed = releaseAllowedPaths("1.2.3");
assert.deepEqual(
findUnexpectedChanges(["packages/core/package.json", "packages/core/src/index.ts"], allowed),
["packages/core/src/index.ts"],
);
});
});
+33 -17
View File
@@ -109,12 +109,8 @@ function updatePluginVersions(version: string) {
}
function createReleaseCommitAndTag(version: string) {
const status = execFileSync("git", ["status", "--porcelain"], {
cwd: ROOT,
encoding: "utf-8",
}).trim();
const allowedPaths = releaseAllowedPaths(version);
assertNoUnexpectedChanges(status, allowedPaths);
assertNoUnexpectedChanges(collectChangedPaths(), allowedPaths);
// Pass git arguments as an array (execFileSync, no shell) so the interpolated
// version and paths can never be interpreted as shell commands.
@@ -208,7 +204,7 @@ export function docsChangelogEntryHasGeneratedTodo(content: string, marker: stri
return hasGeneratedChangelogTodo(entry);
}
function releaseAllowedPaths(version: string) {
export function releaseAllowedPaths(version: string) {
return [
...PACKAGES.map((pkg) => join(pkg, "package.json")),
...PLUGINS.map((plugin) => join(plugin, "plugin.json")),
@@ -217,16 +213,40 @@ function releaseAllowedPaths(version: string) {
];
}
function assertNoUnexpectedChanges(status: string, allowedPaths: string[]) {
const unexpected = status
.split("\n")
.filter(
(line) => line && !allowedPaths.some((allowedPath) => gitStatusPath(line) === allowedPath),
);
// Collect every uncommitted path (modified-tracked + untracked) as clean,
// repo-relative paths. We deliberately use `diff --name-only` and `ls-files`
// with `-z` rather than parsing `git status --porcelain`: the porcelain
// "XY <path>" prefix width shifts with stage state, and a fixed-width slice
// of it once mis-read a legitimate release file (`.claude-plugin/plugin.json`)
// as an unexpected change, falsely blocking a release. These two commands
// emit bare NUL-separated paths with no status column to misparse.
function collectChangedPaths(): string[] {
const tracked = execFileSync("git", ["diff", "--name-only", "-z", "HEAD"], {
cwd: ROOT,
encoding: "utf-8",
});
const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
cwd: ROOT,
encoding: "utf-8",
});
return [...splitNulList(tracked), ...splitNulList(untracked)];
}
export function splitNulList(output: string): string[] {
return output.split("\0").filter(Boolean);
}
export function findUnexpectedChanges(changedPaths: string[], allowedPaths: string[]): string[] {
const allowed = new Set(allowedPaths);
return changedPaths.filter((path) => !allowed.has(path));
}
function assertNoUnexpectedChanges(changedPaths: string[], allowedPaths: string[]) {
const unexpected = findUnexpectedChanges(changedPaths, allowedPaths);
if (unexpected.length > 0) {
console.error("\nUnexpected uncommitted changes:");
unexpected.forEach((line) => console.error(` ${line}`));
unexpected.forEach((path) => console.error(` ${path}`));
console.error("Commit or stash these before releasing.");
process.exit(1);
}
@@ -243,10 +263,6 @@ function printReleaseNextSteps(version: string) {
}
}
export function gitStatusPath(line: string) {
return line.slice(3).replace(/^"|"$/g, "");
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}