Files
WRNexusJS/scripts/release.ts
T
2026-08-01 10:04:42 +05:30

373 lines
14 KiB
TypeScript

/**
* Mandatory WRNexusJS private release workflow.
*
* bun run release:prepare # verify, enforce migration coverage, stage packages
* # review, commit, and push the framework repository
* bun run release:private # publish/resume, update docs, verify llms, commit + push docs
*
* Set WRNEXUS_DOCS_ROOT when the docs repository is not D:\\Company\\wrnexusjs.
*/
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { updateMigrationVersions } from "../packages/cli/src/update.ts";
import { validateAndHashStage } from "./lib/package-integrity.ts";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const docsRoot = resolve(process.env.WRNEXUS_DOCS_ROOT ?? "D:\\Company\\wrnexusjs");
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const mode = process.argv[2];
interface PackageInfo {
dir: string;
name: string;
version: string;
dependencies: Record<string, string>;
}
function run(
command: string,
args: string[],
cwd = root,
options: { capture?: boolean; allowFailure?: boolean } = {},
) {
const result = spawnSync(command, args, {
cwd,
encoding: options.capture ? "utf8" : undefined,
stdio: options.capture ? "pipe" : "inherit",
env: process.env,
});
if (result.error) throw result.error;
if (result.status !== 0 && !options.allowFailure) {
const detail = options.capture ? `\n${result.stderr || result.stdout || ""}` : "";
throw new Error(`${command} ${args.join(" ")} failed with ${result.status}.${detail}`);
}
return result;
}
function output(command: string, args: string[], cwd = root): string {
return String(run(command, args, cwd, { capture: true }).stdout ?? "").trim();
}
function packages(): PackageInfo[] {
return readdirSync(join(root, "packages"), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => {
const file = join(root, "packages", entry.name, "package.json");
if (!existsSync(file)) return null;
const manifest = JSON.parse(readFileSync(file, "utf8"));
if (!manifest.name?.startsWith("@wrnexus/")) return null;
return {
dir: entry.name,
name: manifest.name as string,
version: manifest.version as string,
dependencies: (manifest.dependencies ?? {}) as Record<string, string>,
};
})
.filter((value): value is PackageInfo => value !== null);
}
function alignedVersion(all: PackageInfo[]): string {
const versions = new Set(all.map((pkg) => pkg.version));
if (versions.size !== 1) {
throw new Error(`Package versions are not aligned: ${[...versions].join(", ")}`);
}
const version = all[0]?.version;
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(`Invalid release version: ${version ?? "missing"}`);
}
return version;
}
function sortedPackages(all: PackageInfo[]): PackageInfo[] {
const byName = new Map(all.map((pkg) => [pkg.name, pkg]));
const seen = new Set<string>();
const result: PackageInfo[] = [];
const visit = (pkg: PackageInfo) => {
if (seen.has(pkg.name)) return;
seen.add(pkg.name);
for (const dependency of Object.keys(pkg.dependencies)) {
const internal = byName.get(dependency);
if (internal) visit(internal);
}
result.push(pkg);
};
all.forEach(visit);
return result;
}
function requireMigration(version: string) {
if (!updateMigrationVersions().includes(version)) {
throw new Error(
`Release ${version} has no update migration entry. Add an idempotent migration (a documented no-op is acceptable) to packages/cli/src/update.ts.`,
);
}
}
function validateStaging(all: PackageInfo[], version: string) {
const integrityPath = join(root, ".publish", "PACKAGE-INTEGRITY.json");
if (!existsSync(integrityPath)) {
throw new Error("Missing .publish/PACKAGE-INTEGRITY.json; stage verification did not run.");
}
const integrity = JSON.parse(readFileSync(integrityPath, "utf8"));
const recorded = new Map(
(Array.isArray(integrity.packages) ? integrity.packages : []).map((entry: any) => [
entry.name,
entry,
]),
);
for (const pkg of all) {
const stage = join(root, ".publish", pkg.dir);
const manifestPath = join(stage, "package.json");
const dist = join(stage, "dist", "index.js");
if (!existsSync(manifestPath) || !existsSync(dist)) {
throw new Error(`Missing staged artifact for ${pkg.name}; run release:prepare again.`);
}
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
if (manifest.version !== version || manifest.publishConfig?.access !== "restricted") {
throw new Error(`${pkg.name} staging is not restricted ${version}.`);
}
const actual = validateAndHashStage(stage, manifest);
const expected = recorded.get(pkg.name);
if (!expected || JSON.stringify(expected) !== JSON.stringify(actual)) {
throw new Error(`${pkg.name} staging integrity does not match PACKAGE-INTEGRITY.json.`);
}
}
if (recorded.size !== all.length) {
throw new Error(
`Staging integrity lists ${recorded.size} packages but the workspace contains ${all.length}.`,
);
}
}
function requireCleanAndPushed(repo: string, label: string) {
// `status --porcelain` can report false worktree changes on Windows when a
// generated LF file replaces an autocrlf checkout with identical Git
// content. Compare canonical diffs and untracked files instead.
const worktreeChanged = run("git", ["diff", "--quiet"], repo, {
capture: true,
allowFailure: true,
}).status;
const indexChanged = run("git", ["diff", "--cached", "--quiet"], repo, {
capture: true,
allowFailure: true,
}).status;
const untracked = output("git", ["ls-files", "--others", "--exclude-standard"], repo);
if (worktreeChanged || indexChanged || untracked) {
throw new Error(`${label} repository has uncommitted changes.`);
}
run("git", ["fetch", "origin"], repo);
const branch = output("git", ["branch", "--show-current"], repo);
const head = output("git", ["rev-parse", "HEAD"], repo);
const remote = output("git", ["rev-parse", `origin/${branch}`], repo);
if (head !== remote) throw new Error(`${label} HEAD is not synchronized with origin/${branch}.`);
}
function registryHas(pkg: PackageInfo, version: string): boolean {
const result = run(npm, ["view", `${pkg.name}@${version}`, "version", "--json"], root, {
capture: true,
allowFailure: true,
});
return result.status === 0 && String(result.stdout).includes(version);
}
function verifyPrivate(pkg: PackageInfo, version: string) {
const status = output(npm, ["access", "get", "status", pkg.name]);
if (!status.endsWith(": private")) throw new Error(`${pkg.name}@${version} is not private.`);
}
function syncDocsManifest(all: PackageInfo[], version: string) {
const file = join(docsRoot, "package.json");
const manifest = JSON.parse(readFileSync(file, "utf8"));
manifest.dependencies ??= {};
manifest.devDependencies ??= {};
for (const pkg of all) {
if (pkg.name === "@wrnexus/cli") {
manifest.devDependencies[pkg.name] = `^${version}`;
delete manifest.dependencies[pkg.name];
} else {
manifest.dependencies[pkg.name] = `^${version}`;
delete manifest.devDependencies[pkg.name];
}
}
manifest.wrnexus = { ...(manifest.wrnexus ?? {}), version };
writeFileSync(file, JSON.stringify(manifest, null, 2) + "\n");
}
function verifyDocs(all: PackageInfo[], version: string) {
const manifest = JSON.parse(readFileSync(join(docsRoot, "package.json"), "utf8"));
if (manifest.wrnexus?.version !== version) throw new Error("Docs version marker is stale.");
const llms = readFileSync(join(docsRoot, "public", "llms.txt"), "utf8");
if (!llms.includes(`# WRNexusJS documentation ${version}`)) {
throw new Error("Docs public/llms.txt has a stale or missing release header.");
}
for (const pkg of all) {
if (!llms.includes(`## ${pkg.name}`)) {
throw new Error(`Docs public/llms.txt is missing ${pkg.name}.`);
}
}
const full = readFileSync(join(docsRoot, "public", "llms-full.txt"), "utf8");
if (!full.includes(`WRNexusJS ${version}`) || !full.includes("/packages/helpers")) {
throw new Error("Docs public/llms-full.txt is stale or incomplete.");
}
}
function prepare(all: PackageInfo[], version: string) {
requireMigration(version);
console.log(`\nPreparing WRNexusJS ${version} (${all.length} packages)…\n`);
run(process.execPath, ["run", "scripts/generate-ui-component-reference.mjs"], root);
run(process.execPath, ["run", "format"]);
run(process.execPath, ["run", "check:workspace"]);
run(process.execPath, ["run", "validate:0.7"]);
run(process.execPath, ["run", "security:framework"]);
run(process.execPath, ["run", "sbom"]);
run(process.execPath, ["run", "benchmark:framework"]);
run(process.execPath, ["run", "check"]);
run(process.execPath, ["run", "scripts/publish-packages.ts"]);
validateStaging(all, version);
console.log(
`\n✓ Release ${version} is prepared. Review changes, commit and push the framework repository, then run: bun run release:private\n`,
);
}
function verifyUiComponentReference(): void {
const generatedFiles = [
"packages/ui/component-catalog.json",
"packages/ui/component-reference.json",
"packages/ui/COMPONENTS.md",
];
const result = run(
process.execPath,
["run", "scripts/generate-ui-component-reference.mjs"],
root,
{
capture: true,
allowFailure: true,
},
);
if (result.status !== 0) {
const detail = result.stderr || result.stdout || "";
throw new Error(`Failed to generate the UI component reference.${detail ? `\n${detail}` : ""}`);
}
// The generator intentionally produces stable semantic JSON, while Prettier
// controls repository formatting. Format only the generated files before the
// Git comparison so short arrays and Windows line endings cannot create a
// permanent generate/format release loop.
run(process.execPath, ["x", "prettier", "--write", ...generatedFiles], root);
// Do not use `git status --short` here. On Windows, the generator writes LF
// while an autocrlf checkout may contain CRLF. `git status` can report those
// files as modified even when their canonical Git content is identical.
// `git diff --quiet` applies Git's normal text conversion and only fails for
// a real generated-content difference.
const changedStatus = run("git", ["diff", "--quiet", "--", ...generatedFiles], root, {
capture: true,
allowFailure: true,
}).status;
if (changedStatus === 0) {
return;
}
const changed = output("git", ["diff", "--name-status", "--", ...generatedFiles], root);
throw new Error(
[
"UI component reference is stale.",
"",
changed || generatedFiles.join("\n"),
"",
"Run:",
" bun run scripts/generate-ui-component-reference.mjs",
" bun run format",
" git add packages/ui/component-catalog.json packages/ui/component-reference.json packages/ui/COMPONENTS.md",
' git commit -m "docs(ui): refresh component reference"',
" git push origin main",
].join("\n"),
);
}
function publish(all: PackageInfo[], version: string) {
if (!existsSync(docsRoot)) throw new Error(`Docs repository not found: ${docsRoot}`);
requireMigration(version);
run(process.execPath, ["run", "check:workspace"]);
run(process.execPath, ["run", "validate:0.7"]);
run(process.execPath, ["run", "security:framework"]);
requireCleanAndPushed(root, "Framework");
requireCleanAndPushed(docsRoot, "Docs");
verifyUiComponentReference();
run(process.execPath, ["run", "format"]);
requireCleanAndPushed(root, "Framework after generated references");
run(process.execPath, ["run", "scripts/publish-packages.ts"]);
// Rebuild from the committed source and refuse publication if staging drifts.
run(process.execPath, ["run", "scripts/publish-packages.ts"]);
validateStaging(all, version);
requireCleanAndPushed(root, "Framework after staging");
output(npm, ["whoami"]);
for (const pkg of sortedPackages(all)) {
if (registryHas(pkg, version)) {
console.log(` = ${pkg.name}@${version} already published; verifying`);
} else {
console.log(` + publishing ${pkg.name}@${version}`);
run(npm, ["publish", join(root, ".publish", pkg.dir), "--access", "restricted"]);
}
verifyPrivate(pkg, version);
}
console.log(`\nUpdating docs repository at ${docsRoot}\n`);
run(
process.execPath,
[
"x",
"--package",
`@wrnexus/cli@${version}`,
"wrnexus",
"update",
`--version=${version}`,
"--no-verify",
],
docsRoot,
);
syncDocsManifest(all, version);
run(process.execPath, ["install"], docsRoot);
run(npm, ["install", "--package-lock-only", "--ignore-scripts"], docsRoot);
run(process.execPath, ["run", "docs:generate"], docsRoot);
run(process.execPath, ["run", "docs:generate"], docsRoot);
run(process.execPath, ["run", "check"], docsRoot);
run(process.execPath, ["run", "build"], docsRoot);
verifyDocs(all, version);
run("git", ["add", "--all"], docsRoot);
if (output("git", ["status", "--porcelain"], docsRoot)) {
run("git", ["commit", "-m", `docs: update portal for WRNexusJS ${version}`], docsRoot);
}
run("git", ["push", "origin", "HEAD"], docsRoot);
requireCleanAndPushed(docsRoot, "Docs");
console.log(`\n✓ WRNexusJS ${version} published privately and docs are updated and pushed.\n`);
}
const all = packages();
const version = alignedVersion(all);
if (mode === "prepare") prepare(all, version);
else if (mode === "publish") publish(all, version);
else {
throw new Error("Usage: bun scripts/release.ts prepare | publish");
}