Files
WRNexusJS/scripts/release-changed.ts
T
Clintchiz 613ff7ae5b
Quality / quality (ubuntu-latest) (push) Failing after 9m51s
Quality / quality (windows-latest) (push) Canceled after 0s
fix: invoke npm reliably from Bun on Windows
2026-08-24 11:37:06 +05:30

149 lines
5.6 KiB
TypeScript

/** Independent private releases for changed @wrnexus packages. */
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
const root = resolve(import.meta.dir, "..");
const mode = process.argv[2] ?? "plan";
const requested = process.argv.slice(3).filter((arg) => !arg.startsWith("--"));
interface PackageInfo {
dir: string;
name: string;
file: string;
manifest: Record<string, any>;
}
function run(command: string, args: string[], capture = false, allowFailure = false) {
const result = spawnSync(command, args, {
cwd: root,
encoding: capture ? "utf8" : undefined,
stdio: capture ? "pipe" : "inherit",
});
if (result.error) throw result.error;
if (result.status !== 0 && !allowFailure) {
throw new Error(`${command} ${args.join(" ")} failed with ${result.status}`);
}
return result;
}
function runNpm(args: string[], capture = false, allowFailure = false) {
return process.platform === "win32"
? run(
process.env.ComSpec ?? "cmd.exe",
["/d", "/s", "/c", "npm", ...args],
capture,
allowFailure,
)
: run("npm", args, capture, allowFailure);
}
function allPackages(): PackageInfo[] {
return readdirSync(join(root, "packages"), { withFileTypes: true }).flatMap((entry) => {
const file = join(root, "packages", entry.name, "package.json");
if (!entry.isDirectory() || !existsSync(file)) return [];
const manifest = JSON.parse(readFileSync(file, "utf8"));
return manifest.name?.startsWith("@wrnexus/")
? [{ dir: entry.name, name: manifest.name, file, manifest }]
: [];
});
}
function changedPaths(): string[] {
const tracked = String(run("git", ["diff", "HEAD", "--name-only"], true).stdout ?? "");
const untracked = String(
run("git", ["ls-files", "--others", "--exclude-standard"], true).stdout ?? "",
);
return `${tracked}\n${untracked}`
.split(/\r?\n/)
.filter(Boolean)
.map((path) => path.replace(/\\/g, "/"));
}
function selectChanged(all: PackageInfo[]): PackageInfo[] {
if (requested.length) {
return all.filter((pkg) => requested.includes(pkg.dir) || requested.includes(pkg.name));
}
const paths = changedPaths();
return all.filter((pkg) => paths.some((path) => path.startsWith(`packages/${pkg.dir}/`)));
}
function patch(version: string): string {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
if (!match) throw new Error(`Cannot patch-bump invalid package version ${version}`);
return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
}
function syncWorkspaceLock(pkgs: PackageInfo[]): void {
const file = join(root, "bun.lock");
let source = readFileSync(file, "utf8");
for (const pkg of pkgs) {
const escaped = pkg.dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(
`("packages/${escaped}": \\{\\r?\\n\\s+"name": "[^"]+",\\r?\\n\\s+"version": ")[^"]+(")`,
);
if (!pattern.test(source)) throw new Error(`bun.lock is missing workspace package ${pkg.name}`);
source = source.replace(pattern, `$1${pkg.manifest.version}$2`);
}
writeFileSync(file, source);
}
function registryHas(pkg: PackageInfo): boolean {
return (
runNpm(["view", `${pkg.name}@${pkg.manifest.version}`, "version", "--json"], true, true)
.status === 0
);
}
function unpublished(all: PackageInfo[]): PackageInfo[] {
return all.filter((pkg) => !registryHas(pkg));
}
function print(pkgs: PackageInfo[], action: string): void {
if (!pkgs.length) {
console.log(`No packages need ${action}.`);
return;
}
console.log(`Packages selected for ${action}:`);
for (const pkg of pkgs) console.log(` ${pkg.name}@${pkg.manifest.version}`);
}
const all = allPackages();
if (mode === "plan") {
const selected = selectChanged(all);
print(selected, "a patch release");
for (const pkg of selected) console.log(` -> ${patch(pkg.manifest.version)}`);
} else if (mode === "prepare") {
const selected = selectChanged(all);
if (!selected.length)
throw new Error("No changed packages found. Pass package names explicitly if needed.");
for (const pkg of selected) {
pkg.manifest.version = patch(String(pkg.manifest.version));
writeFileSync(pkg.file, `${JSON.stringify(pkg.manifest, null, 2)}\n`);
}
syncWorkspaceLock(selected);
// Bun currently leaves workspace manifest versions stale when only
// --lockfile-only is used. The workspace entries are synchronized above;
// this install refreshes dependency resolution without changing versions.
run(process.execPath, ["install"]);
run(process.execPath, ["run", "scripts/publish-packages.ts", ...selected.map((pkg) => pkg.name)]);
print(selected, "release preparation");
console.log(
"Review and commit the version and lockfile changes, then run bun run release:changed:publish.",
);
} else if (mode === "publish") {
const selected = requested.length ? selectChanged(all) : unpublished(all);
if (!selected.length) throw new Error("Every local package version is already published.");
run(process.execPath, ["run", "check:component-imports"]);
run(process.execPath, ["test", ...selected.map((pkg) => `packages/${pkg.dir}`)]);
run(process.execPath, ["run", "scripts/publish-packages.ts", ...selected.map((pkg) => pkg.name)]);
runNpm(["whoami"]);
for (const pkg of selected) {
const stage = join(root, ".publish", pkg.dir);
if (!registryHas(pkg)) runNpm(["publish", stage, "--access", "restricted"]);
console.log(` ✓ ${pkg.name}@${pkg.manifest.version}`);
}
} else {
throw new Error("Usage: bun scripts/release-changed.ts plan | prepare | publish [package ...]");
}