686 lines
27 KiB
JavaScript
686 lines
27 KiB
JavaScript
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 <base-ref> (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 <name> =` 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);
|
|
}
|