build: enforce complete private release workflow
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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) {
|
||||
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}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireCleanAndPushed(repo: string, label: string) {
|
||||
if (output("git", ["status", "--porcelain"], repo)) {
|
||||
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", "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 publish(all: PackageInfo[], version: string) {
|
||||
if (!existsSync(docsRoot)) throw new Error(`Docs repository not found: ${docsRoot}`);
|
||||
requireMigration(version);
|
||||
requireCleanAndPushed(root, "Framework");
|
||||
requireCleanAndPushed(docsRoot, "Docs");
|
||||
|
||||
// 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}`],
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user