386 lines · 13.5 KB · TypeScript
RawDownload
/** * Version management split across two surfaces: * * - `bumpPackageJsons` — rewrites every discovered publishable package.json *   `version` field and injects `optionalDependencies` on meta packages. *   Safe to call in CI on every run. Uses discovery as the source of truth. *   Does NOT touch Cargo.toml or non-discovered files. * * - `bumpCargoVersions` — rewrites the Rust `[workspace.package]` version, *   lock-step internal dependencies, and excluded AgentOS crate manifests so *   the crates.io publish chain stays consistent. * * - `resolveVersion` / `shouldTagAsLatest` — semver helpers for the local cut. * * All bump functions run ONLY in the CI publish checkout (via the `bump-versions` * subcommand). The committed tree stays pinned at product version `0.0.1`; the * local `cut-release.ts` cutter is a pure trigger and never calls them. */import * as fs from "node:fs/promises";import { join } from "node:path";import { $ } from "execa";import * as semver from "semver";import { scoped } from "./logger.js";import { buildMetaPlatformMap, discoverPackages } from "./packages.js"; const log = scoped("version"); interface PackageJson {	name?: string;	version?: string;	dependencies?: Record<string, string>;	devDependencies?: Record<string, string>;	peerDependencies?: Record<string, string>;	optionalDependencies?: Record<string, string>;} const DEP_FIELDS = [	"dependencies",	"devDependencies",	"peerDependencies",	"optionalDependencies",] as const; /** * Parse the default `catalog:` block from pnpm-workspace.yaml into a * name -> version map. Lightweight line parser (no YAML dep): reads entries * under a top-level `catalog:` key until the next non-indented key. */async function readPnpmCatalog(repoRoot: string): Promise<Map<string, string>> {	// Flattens the default `catalog:` block AND every named `catalogs:<name>:`	// block into one dep -> version map. Specs are `catalog:` (default) or	// `catalog:<name>` (named); dep names are unique across the catalogs this	// repo defines, so a flat map resolves both forms.	const map = new Map<string, string>();	let text: string;	try {		text = await fs.readFile(join(repoRoot, "pnpm-workspace.yaml"), "utf8");	} catch {		return map;	}	const lines = text.split("\n");	// depth 1 entries under `catalog:`; depth 2 under `catalogs:` -> `<name>:`.	let mode: "none" | "catalog" | "catalogs" = "none";	for (const line of lines) {		if (/^catalog:\s*$/.test(line)) {			mode = "catalog";			continue;		}		if (/^catalogs:\s*$/.test(line)) {			mode = "catalogs";			continue;		}		if (mode === "none") continue;		// A non-indented, non-comment, non-blank line ends the block.		if (/^\S/.test(line) && !line.startsWith("#")) {			mode = "none";			continue;		}		const m = line.match(/^\s+'?([^':\s]+)'?\s*:\s*'?([^'\s#]+)'?\s*$/);		if (m) map.set(m[1], m[2]);	}	return map;} export interface BumpOptions {	/** If true, report actions but do not write. */	dryRun?: boolean;	/**	 * When true, only rewrite the `version` field. Does not touch dependency	 * references or inject `optionalDependencies`. Safe to commit to git	 * because it preserves `workspace:*` dep specs that the lockfile expects.	 *	 * When false (default), also rewrites `workspace:*` deps to the literal	 * version and injects `optionalDependencies` on meta packages. This is	 * the publish-time mode used by CI — never committed.	 */	versionOnly?: boolean;} /** * Rewrite every discovered package's `version` to the given string. * * In full mode (default, `versionOnly: false`): also injects * `optionalDependencies` on meta packages and rewrites `workspace:*` * dependency references to the literal version. This is the publish-time * mode used by CI and must NOT be committed — it breaks * `pnpm install --frozen-lockfile` because the lockfile expects * `workspace:*`, not literal versions. * * In version-only mode (`versionOnly: true`): only rewrites the `version` * field, preserving `workspace:*`/`catalog:` dep specs so the lockfile still * resolves. Used by the CI `bump-versions` build step before `turbo build` so * the built JS carries the real version; never committed. * * Returns the number of files written. *//** * Resolve the published `latest` version of an independently-versioned * registry package from npm. Registry software packages (except `common`) * publish from local on their own release track; the main release track pins * `workspace:*` deps on them to whatever was last deliberately released. * Memoized per run. Throws when the package has never been released — a * published manifest carrying `workspace:` protocol is uninstallable by every * consumer, so this must fail the publish, loudly. */const npmLatestCache = new Map<string, string>();async function npmLatestVersion(dep: string): Promise<string> {	const cached = npmLatestCache.get(dep);	if (cached) return cached;	const { execa } = await import("execa");	const { stdout } = await execa("npm", [		"view",		dep,		"dist-tags.latest",	]).catch((error: Error & { shortMessage?: string }) => {		throw new Error(			`cannot resolve npm latest for workspace dep "${dep}": ${error.shortMessage ?? error.message}`,		);	});	const version = stdout.trim();	if (!version) {		throw new Error(			`workspace dep "${dep}" has no published latest on npm; release it from local first (agentos-toolchain publish ... --latest)`,		);	}	npmLatestCache.set(dep, version);	return version;} export async function bumpPackageJsons(	repoRoot: string,	version: string,	opts: BumpOptions = {},): Promise<number> {	const packages = discoverPackages(repoRoot);	const packageNames = new Set(packages.map((p) => p.name));	const metaPlatformMap = buildMetaPlatformMap(packages);	const versionOnly = opts.versionOnly ?? false;	// pnpm `catalog:` specs are a workspace-only protocol; `npm publish` does not	// resolve them, so rewrite them to the literal catalog version pre-publish.	const catalog = await readPnpmCatalog(repoRoot); 	let updated = 0;	for (const pkg of packages) {		const pkgJsonPath = join(pkg.dir, "package.json");		const raw = await fs.readFile(pkgJsonPath, "utf8");		const pkgJson: PackageJson = JSON.parse(raw); 		pkgJson.version = version; 		if (!versionOnly) {			// Inject optionalDependencies on meta packages so end users get the			// correct platform-specific binary via npm's os/cpu/libc resolution.			const platformPkgs = metaPlatformMap.get(pkg.name);			if (platformPkgs && platformPkgs.length > 0) {				pkgJson.optionalDependencies = pkgJson.optionalDependencies ?? {};				for (const platPkg of platformPkgs) {					pkgJson.optionalDependencies[platPkg] = version;				}			} 			for (const field of DEP_FIELDS) {				const deps = pkgJson[field];				if (!deps) continue;				for (const [dep, spec] of Object.entries(deps)) {					if (typeof spec !== "string") continue;					if (spec === "catalog:" || spec.startsWith("catalog:")) {						const resolved = catalog.get(dep);						if (!resolved) {							// A `catalog:` spec npm cannot install must never							// reach a published manifest — fail the publish.							throw new Error(								`cannot resolve catalog spec "${spec}" for dep "${dep}" in ${pkg.name}: not found in pnpm-workspace.yaml catalog(s)`,							);						}						deps[dep] = resolved;						continue;					}					if (!spec.startsWith("workspace:")) continue;					const isOurPkg =						packageNames.has(dep) || dep.startsWith("@rivet-dev/agentos-");					if (isOurPkg) {						deps[dep] = version;						continue;					}					// Independently-versioned registry software packages: pin to					// the npm `latest` the owner last deliberately released from					// local. Never leave `workspace:` in a published manifest.					deps[dep] = await npmLatestVersion(dep);				}			}		} 		// Tab-indented, trailing newline — matches the repo convention.		const newContent = `${JSON.stringify(pkgJson, null, "\t")}\n`;		if (opts.dryRun) {			log.info(`[dry-run] would update ${pkg.name} -> ${version}`);		} else {			await fs.writeFile(pkgJsonPath, newContent);			log.info(`updated ${pkg.name} -> ${version}`);		}		updated++;	} 	log.info(`total: ${updated} package.json files updated to ${version}`);	return updated;} /** * Rewrite the AgentOS Rust workspace version (`[workspace.package]`). AgentOS crates * inherit it via `version.workspace = true`. */export async function bumpCargoVersions(	repoRoot: string,	version: string,	opts: Pick<BumpOptions, "dryRun"> = {},): Promise<void> {	const cargoTomlPath = join(repoRoot, "Cargo.toml");	const cargoToml = await fs.readFile(cargoTomlPath, "utf8");	let next = cargoToml.replace(		/(\[workspace\.package\]\n(?:[^\n]*\n)*?[ \t]*version = )"[^"]+"/,		`$1"${version}"`,	);	// Bump AgentOS-owned crate dep requirements (path = "crates/...").	next = next.replace(		/((?:agentos|agent-os)-[a-z0-9-]+ = \{ path = "crates\/[^"]+", version = ")[^"]+(" \})/g,		`$1${version}$2`,	);	// Also bump aliased AgentOS-owned crate deps declared as	// `<alias> = { package = "agentos-...", path = "crates/...", version = "..." }`	// (e.g. `vfs = { package = "agentos-vfs-core", ... }`), which the pattern	// above misses because the line starts with the alias key, not `agentos-`.	next = next.replace(		/(\{ package = "(?:agentos|agent-os)-[a-z0-9-]+", path = "crates\/[^"]+", version = ")[^"]+(" \})/g,		`$1${version}$2`,	); 	const updates = new Map<string, string>();	if (next !== cargoToml) updates.set(cargoTomlPath, next); 	// Excluded crates cannot inherit workspace values, but Cargo still resolves	// their path dependency graph during a workspace publish dry-run. Rewrite	// their explicit package and AgentOS path-dependency versions transiently.	const cratesDir = join(repoRoot, "crates");	for (const entry of await fs.readdir(cratesDir, { withFileTypes: true })) {		if (!entry.isDirectory()) continue;		const manifestPath = join(cratesDir, entry.name, "Cargo.toml");		let manifest: string;		try {			manifest = await fs.readFile(manifestPath, "utf8");		} catch (error) {			if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;			throw error;		}		if (!/\[package\]\nname = "(?:agentos|agent-os)-[a-z0-9-]+"/.test(manifest)) {			continue;		} 		let updated = manifest.replace(			/(\[package\]\n(?:[^\n]*\n)*?[ \t]*version = )"[^"]+"/,			`$1"${version}"`,		);		updated = updated.replace(			/^((?:agentos|agent-os)-[a-z0-9-]+ = \{[^\n]*\bpath = "\.\.\/[^\"]+"[^\n]*\bversion = ")[^"]+(")/gm,			`$1${version}$2`,		);		updated = updated.replace(			/^(\w[\w-]* = \{[^\n]*\bpackage = "(?:agentos|agent-os)-[a-z0-9-]+"[^\n]*\bpath = "\.\.\/[^\"]+"[^\n]*\bversion = ")[^"]+(")/gm,			`$1${version}$2`,		);		if (updated !== manifest) updates.set(manifestPath, updated);	} 	if (updates.size === 0) {		log.info(`Cargo Rust versions already set to ${version}`);		return;	}	if (opts.dryRun) {		log.info(`[dry-run] would update ${updates.size} Cargo manifests -> ${version}`);		return;	}	for (const [path, contents] of updates) await fs.writeFile(path, contents);	log.info(`updated ${updates.size} Cargo manifests -> ${version}`);} // -----------------------------------------------------------------------------// Local semver helpers — used only by `cut-release.ts`.// ----------------------------------------------------------------------------- async function getAllGitVersions(): Promise<string[]> {	try {		await $`git fetch --tags --force --quiet`;	} catch {		throw new Error(			"could not fetch git tags — refusing to compute latest flag from stale local tags",		);	}	const result = await $`git tag -l v*`;	const tags = result.stdout.trim().split("\n").filter(Boolean);	if (tags.length === 0) return [];	return tags		.map((tag) => tag.replace(/^v/, ""))		.filter((v) => semver.valid(v))		.sort((a, b) => semver.rcompare(a, b));} export async function getLatestGitVersion(): Promise<string | null> {	const versions = await getAllGitVersions();	const stable = versions.filter((v) => {		const p = semver.parse(v);		return p && p.prerelease.length === 0;	});	return stable[0] ?? null;} export async function listRecentVersions(limit = 10): Promise<string[]> {	const all = await getAllGitVersions();	return all.slice(0, limit);} /** * Auto-detect whether a version should be tagged as `latest`. A version is * `latest` only if it has no prerelease identifier AND is greater than any * existing stable git tag. */export async function shouldTagAsLatest(version: string): Promise<boolean> {	const parsed = semver.parse(version);	if (!parsed) throw new Error(`invalid semantic version: ${version}`);	if (parsed.prerelease.length > 0) return false;	const latest = await getLatestGitVersion();	if (!latest) return true;	return semver.gt(version, latest);} export interface ResolveVersionOpts {	version?: string;	major?: boolean;	minor?: boolean;	patch?: boolean;} export async function resolveVersion(	opts: ResolveVersionOpts,): Promise<string> {	if (opts.version) {		if (!semver.valid(opts.version)) {			throw new Error(`invalid semantic version: ${opts.version}`);		}		return opts.version;	}	if (!opts.major && !opts.minor && !opts.patch) {		throw new Error("must provide --version, --major, --minor, or --patch");	}	const latest = await getLatestGitVersion();	if (!latest) {		throw new Error(			"no existing version tags found — use --version to set an explicit version",		);	}	let next: string | null = null;	if (opts.major) next = semver.inc(latest, "major");	else if (opts.minor) next = semver.inc(latest, "minor");	else if (opts.patch) next = semver.inc(latest, "patch");	if (!next) throw new Error("failed to compute next version");	return next;}