/** * 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; } 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, }; }) .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(); 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"); }