139 lines
5.4 KiB
TypeScript
139 lines
5.4 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 npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
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 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 (
|
|
run(npm, ["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)]);
|
|
run(npm, ["whoami"]);
|
|
for (const pkg of selected) {
|
|
const stage = join(root, ".publish", pkg.dir);
|
|
if (!registryHas(pkg)) run(npm, ["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 ...]");
|
|
}
|