import { execFileSync } from 'node:child_process'; import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { OPENMAIC_PACKAGES, assertPackageListIsComplete } from './openmaic-packages.mjs'; const REGISTRY = 'https://registry.npmjs.org'; const commonIgnoredInputs = { files: ['.gitignore', 'vitest.config.ts'], directories: ['docs/', 'test/'], }; // Keep this package set in lockstep with publish-packages.yml. The release // workflow intentionally publishes only these five owned packages. // // KNOWN LIMITATION: this treats "publishable input" as "file under the package // directory", which is an under-approximation for all five packages. // // Renderer and importer inline their dependency graph through Rollup, so a // lockfile-only resolution change rewrites their published bundles. Even dsl // and storage are not exempt: their dist is whatever the lockfile's TypeScript // emits, and dsl's shipped JSON schema is generated by the lockfile's // ts-json-schema-generator. A toolchain bump can therefore change any of the // five tarballs with no diff under the package directory. // // Closing this would mean treating the lockfile and toolchain configuration as // an input of every package, which makes every dependency bump demand five // version bumps, or externalising the bundled dependencies. Both are decisions // about how the packages are built rather than about this check. Diff mode is a // merge-time guard against the common case, not a proof of byte equality. const ignoredInputOverrides = { importer: { // These are local tooling, demo assets, or the legacy reference // implementation. The importer build and package files exclude all of them. files: [ ...commonIgnoredInputs.files, '.babelrc.cjs', '.eslintignore', '.eslintrc.cjs', 'DESIGN.md', 'SKILL.md', 'favicon.ico', 'index.html', ], directories: [...commonIgnoredInputs.directories, 'scripts/', 'src1/'], }, }; // Built from the shared package list rather than spelled out again, so a new // package cannot be silently exempt from this gate by being absent here. const ignoredPackageInputs = Object.fromEntries( OPENMAIC_PACKAGES.map((name) => [name, ignoredInputOverrides[name] ?? commonIgnoredInputs]), ); const usage = [ 'Usage:', ' check-package-version-bumps.mjs (diff mode, merge-time gate)', ' check-package-version-bumps.mjs --release (release mode, pre-publish gate)', ].join('\n'); let repositoryRoot; try { repositoryRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', }).trim(); } catch { console.error('Package version checks must run inside a Git worktree.'); process.exit(2); } function git(args, { quiet = false } = {}) { return execFileSync('git', args, { cwd: repositoryRoot, encoding: 'utf8', // execFileSync forwards the child's stderr to ours by default, which turns // an expected lookup miss into a scary "fatal:" line in the job log. stdio: quiet ? ['ignore', 'pipe', 'pipe'] : undefined, }).trim(); } function gitFileAt(ref, file) { const path = git(['ls-tree', '--full-tree', '--name-only', ref, '--', file]); if (path === '') return undefined; return git(['show', `${ref}:${file}`]); } function resolveCommit(ref) { if (!ref) return undefined; try { return git(['rev-parse', '--verify', `${ref}^{commit}`], { quiet: true }); } catch { return undefined; } } /** * The commit `base` and HEAD diverged from — what `base...HEAD` compares * against. Falls back to `base` itself if there is no common ancestor, which * only happens for unrelated histories. * * NOTE: in both CI invocations this currently returns `base` unchanged. * `actions/checkout@v4` on a `pull_request` event checks out the merge ref, so * `merge-base(base.sha, HEAD)` is `base.sha`; on a push to main the before-SHA * is already an ancestor of HEAD. It is used anyway because it is the correct * reference for a "did THIS branch change it" question, and because the local * invocations that people actually run by hand are not on a merge ref. */ function mergeBaseWithHead(base) { try { return git(['merge-base', base, 'HEAD'], { quiet: true }); } catch { return base; } } /** * Parse a semver version. Prerelease identifiers are kept because the registry * holds whatever was ever published: refusing to order them would let one * historical `x.y.z-beta.1` block every future release of every package. */ function parseVersion(raw) { const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(raw); if (!match) return undefined; return { raw, parts: match.slice(1, 4).map(Number), prerelease: match[4] === undefined ? undefined : match[4].split('.'), }; } function isStable(version) { return version.prerelease === undefined; } /** Semver precedence for prerelease identifiers. */ function comparePrerelease(left, right) { if (left === undefined && right === undefined) return 0; // A version without a prerelease outranks one with it. if (left === undefined) return 1; if (right === undefined) return -1; for (let i = 0; i < Math.max(left.length, right.length); i += 1) { const a = left[i]; const b = right[i]; if (a === undefined) return -1; if (b === undefined) return 1; const aNumeric = /^\d+$/.test(a); const bNumeric = /^\d+$/.test(b); if (aNumeric && bNumeric) { if (Number(a) !== Number(b)) return Number(a) - Number(b); } else if (aNumeric !== bNumeric) { // Numeric identifiers always have lower precedence than alphanumeric. return aNumeric ? -1 : 1; } else if (a !== b) { return a < b ? -1 : 1; } } return 0; } function readVersion(contents, source) { const raw = JSON.parse(contents).version; const version = parseVersion(raw); if (!version || !isStable(version)) { throw new Error(`${source} must use a stable x.y.z version, got ${JSON.stringify(raw)}`); } return version; } function compareVersions(left, right) { for (let i = 0; i < left.parts.length; i += 1) { if (left.parts[i] !== right.parts[i]) return left.parts[i] - right.parts[i]; } return comparePrerelease(left.prerelease, right.prerelease); } function packageDirectory(name) { return `packages/@openmaic/${name}`; } /** Whether any publishable input of `name` differs between `base` and HEAD. */ function publishableInputsChanged(name, base) { const directory = packageDirectory(name); const ignored = ignoredPackageInputs[name]; const changed = git([ 'diff', '--name-only', '--no-renames', '--diff-filter=ACDMRT', `${base}...HEAD`, '--', directory, ]) .split('\n') .filter(Boolean); return changed.some((file) => { const relative = file.slice(directory.length + 1); const isIgnored = ignored.files.includes(relative) || ignored.directories.some((prefix) => relative.startsWith(prefix)); return !isIgnored; }); } function failIfAny(failures, headline) { if (failures.length === 0) return; console.error([headline, ...failures.map((failure) => `- ${failure}`)].join('\n')); process.exit(1); } /** * Where the serialized-format constants live, newest first. * * A list rather than one path so that moving the constants stays LANDABLE * without ever letting the rule stop comparing. Failing closed on a missing * file is right, but on its own it makes a rename impossible to merge: the new * path does not exist at the base revision either, so the check would refuse * both before and after. Resolving each revision against the first candidate * that exists there means a rename lands by PREPENDING the new path here, in * the same change, and the comparison still happens across it. * * Retired paths stay until the base branch no longer reaches a revision that * used them. */ const DSL_VERSION_SOURCES = ['packages/@openmaic/dsl/src/version.ts']; /** * The two SERIALIZED-FORMAT versions the dsl owns. They are deliberately * decoupled from the npm version (see the module docstring in version.ts), and * storage compares them by value across the package boundary, refusing to read * a document or session written at a version it does not know. */ const DSL_FORMAT_CONSTANTS = ['DSL_VERSION', 'RUNTIME_DSL_VERSION']; function readFormatConstants(contents, source) { const found = {}; for (const name of DSL_FORMAT_CONSTANTS) { // Anchored to the start of a line (allowing only indentation) so that a // commented-out `// export const DSL_VERSION = '0.1.0'` cannot be read as // the live value. `export const =` also keeps the longer identifiers // that merely contain these names (RUNTIME_DSL_VERSION_KEY, // INITIAL_DSL_VERSION, UNVERSIONED_DSL_VERSION) from matching. const pattern = new RegExp(`^[ \\t]*export const ${name}\\s*=\\s*'([^']*)'`, 'gm'); const matches = [...contents.matchAll(pattern)]; if (matches.length === 0) { throw new Error( `${source} no longer declares ${name} as a top-level string literal; this check ` + 'cannot read it, and must not guess. Update the gate together with the change.', ); } if (matches.length > 1) { throw new Error( `${source} declares ${name} ${matches.length} times; this check cannot tell which ` + 'one is authoritative.', ); } found[name] = matches[0][1]; } return found; } /** * The lowest version that `^before` does NOT admit — the smallest increase that * actually stops an already-published dependent from resolving it. * * ^1.4.2 admits >=1.4.2 <2.0.0 so the escape is a MAJOR: 2.0.0 * ^0.5.1 admits >=0.5.1 <0.6.0 so the escape is a MINOR: 0.6.0 * ^0.0.3 admits only 0.0.3 so the escape is a PATCH: 0.0.4 * * Expressing the rule this way rather than naming a fixed level keeps it * correct across the 1.0 boundary: once dsl is 1.x a caret admits minors, so * "minor" would stop being enough exactly when it started to matter. */ function caretEscapeVersion(version) { const [major, minor, patch] = version.parts; if (major > 0) return { raw: `${major + 1}.0.0`, level: 'MAJOR' }; if (minor > 0) return { raw: `0.${minor + 1}.0`, level: 'MINOR' }; return { raw: `0.0.${patch + 1}`, level: 'PATCH' }; } /** * A change to a serialized-format version requires a dsl package version * increase that the dependents' caret range will NOT admit. * * The dependents declare `@openmaic/dsl` as `workspace:^`, published as a * caret. That range is what stops a consumer installing two copies of the dsl, * but it also means any version the caret admits reaches them without a release * of their own — so an admitted bump is free to change what they can read. * * Without this rule: dsl ships 0.5.1 moving DSL_VERSION '0.1.0' -> '0.2.0' with * a migration, which version.ts explicitly calls legitimate. Installation A * resolves 0.5.1, and the very same published storage version stamps * `dslVersion: '0.2.0'` into `document_stages`. Installation B, lockfile-pinned * to dsl 0.5.0, reads that row and hard-fails as "newer than this client". * Two installs of one published storage version, silently data-incompatible. * * The required increase is therefore whatever escapes the caret — a minor while * dsl is 0.x, a major once it reaches 1.0.0 — so a dependent only ever picks up * a new serialized format through a deliberate release of its own. The old * exact pin was holding this invariant by accident; making the range useful * means stating it out loud. * * FAILS CLOSED. If the constants cannot be located at either revision while dsl * changed at all, this reports an error rather than passing. Renaming or * splitting that file is exactly the case where the rule would otherwise stop * comparing and a format change could ride out inside the caret. */ function checkDslFormatVersionRule(baseTip, mergeBase, failures) { const manifest = `${packageDirectory('dsl')}/package.json`; const beforeManifest = gitFileAt(mergeBase, manifest); const afterManifest = gitFileAt('HEAD', manifest); // dsl did not exist at the merge base, or has been removed: there is no // package for the rule to constrain, and the ordinary checks cover both. if (beforeManifest === undefined || afterManifest === undefined) return; /** * Every candidate that exists at `ref` AND declares the constants. * * All of them, not the first: during a half-finished rename both files can * exist, the new one still carrying the old values while the old one — which * is what is actually exported — carries the new. Taking the first match * would compare the wrong file and pass. Ambiguity is an error. */ const locate = (ref) => { const found = []; const unreadable = []; for (const path of DSL_VERSION_SOURCES) { const contents = gitFileAt(ref, path); if (contents === undefined) continue; try { found.push({ path, constants: readFormatConstants(contents, `${path} at ${ref}`) }); } catch (error) { unreadable.push(error instanceof Error ? error.message : String(error)); } } return { found, unreadable }; }; const before = locate(mergeBase); const after = locate('HEAD'); for (const side of [before, after]) { if (side.found.length > 1) { failures.push( `dsl: ${side.found.map((entry) => entry.path).join(' and ')} both declare the ` + 'serialized-format constants, so this check cannot tell which one is authoritative. ' + 'Finish the move and drop the retired file, or narrow DSL_VERSION_SOURCES.', ); return; } } if (before.found.length === 0 || after.found.length === 0) { // Nothing about dsl moved, so nothing can have moved the format either. if (!publishableInputsChanged('dsl', mergeBase)) return; const side = before.found.length === 0 ? before : after; const missingAt = before.found.length === 0 ? mergeBase : 'HEAD'; failures.push( [ `dsl: none of ${DSL_VERSION_SOURCES.join(', ')} declares the serialized-format `, `constants at ${missingAt}, so the rule cannot be evaluated for a change that does `, 'touch dsl. This check must not pass by default. If the constants moved, prepend ', 'their new path to DSL_VERSION_SOURCES in scripts/check-package-version-bumps.mjs ', 'in the same change; the old path stays listed so the comparison still works ', `across the move.${side.unreadable.length > 0 ? ` (${side.unreadable.join('; ')})` : ''}`, ].join(''), ); return; } const beforeConstants = before.found[0].constants; const afterConstants = after.found[0].constants; const moved = DSL_FORMAT_CONSTANTS.filter( (name) => beforeConstants[name] !== afterConstants[name], ); if (moved.length === 0) return; let beforePackage; let afterPackage; try { beforePackage = readVersion(beforeManifest, `${manifest} at ${mergeBase}`); afterPackage = readVersion(afterManifest, `${manifest} at HEAD`); } catch (error) { failures.push(error instanceof Error ? error.message : String(error)); return; } // The caret to escape is the one that ALREADY-PUBLISHED dependents carry, so // it comes from the highest dsl version reachable on the base branch, not // from the merge base. An ordinary minor landing on main while this branch is // open moves that reference: with merge base 0.5.1, base tip 0.6.0 and HEAD // 0.6.1, escaping `^0.5.1` needs only 0.6.0 — but dependents released against // 0.6.0 publish `^0.6.0`, which admits 0.6.1, and the new format reaches them // anyway. 0.6.1 is also the minimum the ordinary version check allows, so // that is the default outcome rather than an unlucky one. // // `moved` still comes from the merge base: whether THIS branch changed the // format is a question about the branch, not about the base branch. const baseTipManifest = baseTip === mergeBase ? beforeManifest : gitFileAt(baseTip, manifest); let reference = beforePackage; if (baseTipManifest !== undefined) { try { const tipPackage = readVersion(baseTipManifest, `${manifest} at ${baseTip}`); if (compareVersions(tipPackage, reference) > 0) reference = tipPackage; } catch (error) { failures.push(error instanceof Error ? error.message : String(error)); return; } } const escape = caretEscapeVersion(reference); if (compareVersions(afterPackage, parseVersion(escape.raw)) >= 0) { console.log( `dsl: ${moved.join(', ')} changed and the package version escaped the dependents ` + `caret range (${beforePackage.raw} -> ${afterPackage.raw}, highest on the base ` + `branch ${reference.raw}, needed >= ${escape.raw}).`, ); return; } failures.push( `dsl: ${moved .map((name) => `${name} ${beforeConstants[name]} -> ${afterConstants[name]}`) .join(', ')}, but the package version only moved ${beforePackage.raw} -> ` + `${afterPackage.raw}. A serialized-format change needs an increase that the dependents ` + `caret range will NOT admit: here at least ${escape.raw} (a ${escape.level}), because ` + `the highest dsl on the base branch is ${reference.raw}, dependents released against it ` + `declare \`^${reference.raw}\`, and that admits everything below ${escape.raw}. Anything ` + 'it admits hands the new format to already-published dependents, so two installs of the ' + 'same dependent version could write and then refuse to read data written by the other.', ); } /** * Diff mode: every publishable change between `base` and HEAD must carry a * version increase. * * This is where drift is actually prevented. It runs on pull requests and on * pushes to main, where a real push range always exists, so it sees every * commit that can change a package before that change is reachable by a * release. */ function runDiffMode(base) { if (!resolveCommit(base)) { console.error(`Base ref ${JSON.stringify(base)} is not an available commit.`); process.exit(2); } // The package list this whole gate iterates. A package missing from it is // exempt from every check below, so validate it before trusting any of them. const failures = [...assertPackageListIsComplete()]; for (const name of Object.keys(ignoredPackageInputs)) { if (!publishableInputsChanged(name, base)) continue; const manifest = `${packageDirectory(name)}/package.json`; const beforeContents = gitFileAt(base, manifest); const afterContents = gitFileAt('HEAD', manifest); if (afterContents === undefined) { failures.push(`${name}: ${manifest} was removed`); continue; } if (beforeContents === undefined) { console.log(`${name}: new package at ${base}, nothing to compare.`); continue; } let before; let after; try { before = readVersion(beforeContents, `${manifest} at ${base}`); after = readVersion(afterContents, `${manifest} at HEAD`); } catch (error) { failures.push(`${name}: ${error instanceof Error ? error.message : String(error)}`); continue; } if (compareVersions(after, before) <= 0) { failures.push( `${name}: publishable package inputs changed but version did not increase ` + `(${before.raw} -> ${after.raw})`, ); } else { console.log(`${name}: ${before.raw} -> ${after.raw}`); } } // Both references, because the rule asks two different questions. "Did THIS // branch move a format constant" is about the branch, so it is answered // against the merge base — the base tip may already carry someone else's // format change, and comparing against it reports the difference backwards. // "Which caret must the bump escape" is about what already-published // dependents carry, so it is answered against the highest dsl version on the // base branch. // // Neither is applied to the version comparison above. That one asks "does // this version beat what is already on the base branch", where the tip alone // is right — the merge base there would let a branch reuse a version another // branch published in the meantime. checkDslFormatVersionRule(base, mergeBaseWithHead(base), failures); failIfAny( failures, 'Every @openmaic publishable package change must ship with a new package version:', ); console.log('Package version check passed.'); } /** * Versions of `name` already on the registry. * * Returns `undefined` only for a definitive "this package does not exist" * answer. Every other outcome — a transient error, an auth or proxy failure, an * unparseable body, a registry that is not the one we publish to — exits * non-zero, because reading "unknown" as "never published" would skip the * checks below entirely. */ function registryVersions(name) { let stdout = ''; let stderr = ''; let failed = false; try { stdout = execFileSync('npm', ['view', name, 'versions', '--json', '--registry', REGISTRY], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }); } catch (error) { failed = true; stdout = String(error.stdout ?? ''); stderr = String(error.stderr ?? ''); } const body = stdout.trim(); if (body !== '') { let parsed; try { parsed = JSON.parse(body); } catch { console.error(`Registry response for ${name} was not JSON: ${body.slice(0, 200)}`); process.exit(2); } // npm --json reports errors as an object with an `error` member. if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.error) { if (parsed.error.code === 'E404') return undefined; console.error( `Registry error for ${name}: ${parsed.error.code} ${parsed.error.summary ?? ''}`, ); process.exit(2); } if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) return parsed; if (typeof parsed === 'string') return [parsed]; console.error(`Unexpected registry payload for ${name}: ${body.slice(0, 200)}`); process.exit(2); } if (failed && /E404|404 Not Found/.test(stderr)) return undefined; console.error( `Unable to determine the published versions of ${name}: ${stderr.trim() || 'empty response'}`, ); process.exit(2); } /** * Release mode: runs inside the publish job, before publishing, for every * trigger. * * Scope note. An earlier revision of this gate also tried to prove, at publish * time, that a package whose version is already on the registry still matches * the source that produced it. There is no trustworthy record to prove that * against: git tags are mutable and can be created or moved by hand, pnpm does * not record `gitHead`, and a push range describes one push rather than the * origin of a release. Every anchor available here is either forgeable or * missing for packages released before the scheme existed, and treating a * forgeable anchor as authoritative is worse than not checking, because it * turns "unproven" into "proven". * * So drift is prevented where it is provable — diff mode, at merge time, on * every push to main — and this gate is limited to the claims a release can * actually establish: * * 1. every version about to be published is new and moves forward, so a * release can never quietly reuse or downgrade a published version; * 2. a package whose version is already published is reported and left * alone, so `pnpm publish` skipping it is a stated outcome rather than a * silent one; * 3. anything the registry cannot answer definitively stops the release. * * The workflow adds the two guarantees that do not belong in a script: real * publishes only happen from a commit contained in `main`, and each package is * published and tagged individually so a partial failure stays retryable. */ function runReleaseMode() { // Also here, not only in diff mode: this is the gate that decides what gets // published, so it must not iterate a list it has not checked. const failures = [...assertPackageListIsComplete()]; const releases = []; for (const name of Object.keys(ignoredPackageInputs)) { const packageName = `@openmaic/${name}`; const manifest = join(repositoryRoot, packageDirectory(name), 'package.json'); let local; try { local = readVersion(readFileSync(manifest, 'utf8'), `${packageName} package.json`); } catch (error) { failures.push(`${name}: ${error instanceof Error ? error.message : String(error)}`); continue; } const published = registryVersions(packageName); if (published === undefined) { console.log(`${packageName}: not on the registry yet, ${local.raw} is the first release.`); releases.push({ package: packageName, version: local.raw }); continue; } // Order against every published version, prereleases included: each one // occupies its number on the registry, so discarding them could let a // release move backwards. const unparsable = published.filter((version) => parseVersion(version) === undefined); if (unparsable.length > 0) { failures.push( `${packageName}: the registry holds versions that are not semver ` + `(${unparsable.slice(0, 5).join(', ')}); refusing to order ${local.raw} against them.`, ); continue; } const parsed = published.map(parseVersion).filter((version) => version !== undefined); if (parsed.length === 0) { failures.push(`${packageName}: the registry reports no usable versions; refusing to guess.`); continue; } const highest = [...parsed].sort(compareVersions).pop(); if (published.includes(local.raw)) { // Being behind the registry is not a harmless no-op. `pnpm publish` // rewrites each `workspace:*` dependency to the version in this tree, so // a sibling released alongside a rolled-back package would be published // declaring a dependency on the older one. if (compareVersions(local, highest) < 0) { failures.push( `${packageName}: the tree is at ${local.raw} while ${highest.raw} is published. ` + 'Refusing to release from a tree that is behind the registry: any sibling ' + `published from it would declare a dependency on ${local.raw}.`, ); continue; } console.log( `${packageName}: ${local.raw} is already published; this run will not republish it.`, ); continue; } if (compareVersions(local, highest) <= 0) { failures.push( `${packageName}: ${local.raw} is not greater than the published ${highest.raw}; ` + 'refusing to release from a stale tree', ); continue; } console.log(`${packageName}: releasing ${local.raw} (published: ${highest.raw}).`); releases.push({ package: packageName, version: local.raw }); } failIfAny(failures, 'Refusing to publish @openmaic packages:'); const planPath = process.env.RELEASE_PLAN_PATH; if (planPath) { writeFileSync(planPath, `${JSON.stringify(releases, null, 2)}\n`); console.log(`Wrote the release plan for ${releases.length} package(s) to ${planPath}.`); } if (releases.length === 0) { console.log('Nothing to release: every package version is already on the registry.'); } console.log('Release version check passed.'); } const args = process.argv.slice(2); if (args[0] === '--release') { runReleaseMode(); } else if (args.length > 0 && !args[0].startsWith('--')) { runDiffMode(args[0]); } else { console.error(usage); process.exit(2); }