374 lines
14 KiB
TypeScript
374 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.8"]);
|
|
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"]);
|
|
run(process.execPath, ["run", "test:staged-consumers"]);
|
|
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.8"]);
|
|
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");
|
|
}
|