/** * 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; devDependencies?: Record; peerDependencies?: Record; optionalDependencies?: Record; } 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> { // Flattens the default `catalog:` block AND every named `catalogs::` // block into one dep -> version map. Specs are `catalog:` (default) or // `catalog:` (named); dep names are unique across the catalogs this // repo defines, so a flat map resolves both forms. const map = new Map(); 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:` -> `:`. 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(); async function npmLatestVersion(dep: string): Promise { 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 { 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 = {}, ): Promise { 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 // ` = { 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(); 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 { 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 { 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 { 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 { 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 { 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; }