mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
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:
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user