mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-18 05:40:25 +08:00
* fix(release): dedupe the published dsl, close two release-path blind spots
Three independent defects on the release path.
1. `@openmaic/storage`, `@openmaic/renderer` and `@openmaic/importer`
declared `"@openmaic/dsl": "workspace:*"`, which pnpm publishes as an
EXACT pin. The tarballs on the registry today say storage@0.1.1 needs
dsl 0.5.0, renderer@0.0.3 needs dsl 0.4.0 and importer@0.1.1 needs dsl
0.4.0, so installing storage together with renderer yields two copies
of the dsl. The dsl carries the schema, the validators and the version
constants, so a document produced against one copy can be validated by
the other's schema revision.
`workspace:^` publishes as `^0.5.0`. Under 0.x that admits 0.5.x, so a
dsl patch reaches dependents without republishing them while a dsl
minor still requires a deliberate dependent release. All three
packages take a patch version increase, both because
check-package-version-bumps.mjs requires one and because the corrected
range only reaches consumers through a new release.
The tarball smoke test now asserts the packed range, so an exact pin
cannot come back unnoticed.
2. The publish workflow's build-integrity guard ran `git diff --quiet --
packages/@openmaic`, which compares the worktree to the INDEX. The
steps before it run third-party package code, which needs no
credential to rewrite a tracked file and `git add` it, after which the
guard reported clean and the rewritten bytes were packed and
published. Both copies of the guard now compare against HEAD, the
commit actually being released.
3. `@openmaic/storage`'s PostgreSQL contract suites refuse to skip when
STORAGE_PG_CONTRACT_REQUIRED=1, but that refusal is a `throw` inside
the test modules, so it never fires if vitest stops collecting them.
Collection is decided by `packages/@openmaic/storage/vitest.config.ts`,
which is on the ignore list of publishable inputs, so excluding
`*.pg.test.ts` there turns both PostgreSQL backends off with a green
run and no version bump.
scripts/assert-pg-contract-suites.mjs reads vitest's json results and
requires both suite files to be present, passed and non-empty, keyed
on the file names rather than on a count. It runs in the publish
workflow's validate job and in the storage PostgreSQL contract
workflow.
Merging this publishes @openmaic/storage@0.1.2, @openmaic/renderer@0.0.4
and @openmaic/importer@0.1.2. That republish is the fix for (1), not a
side effect of it: the corrected dependency range exists only in a
tarball that has not been cut yet.
* fix(release): audit the contract database, pin the format version to a minor
Follow-up on cross-vendor review of the three release-path fixes.
1. The PostgreSQL assertion proved far less than it claimed. Vitest's
`assertionResults` are test CASES, not `expect()` calls, so one
`test('x', () => {})` per required filename satisfied it — and the
whole surface is reachable without a version bump, because `test/` is
on the same ignore list as `vitest.config.ts`. A `vi.mock('pg', ...)`
in the already-wired `test/setup.ts`, or a stubbed suite body, made it
print "ran against a real database" having checked nothing of the
sort.
The script now runs in two phases. Phase 1 keeps the file/status
checks. Phase 2 connects to PG_CONTRACT_URL from outside the vitest
process and requires all five tables the two backends own to exist and
to show inserts in pg_stat_user_tables. Nothing inside `test/` can
forge that. Cumulative insert counters rather than surviving rows,
because the suites clean up after themselves. The success message now
states only what was checked.
2. Validation packed with lifecycle scripts while publishing runs
`--ignore-scripts`, so committing `workspace:*` plus a `prepack` that
rewrites it to `workspace:^` passed the tarball assertion and then
published the exact pin. Both the smoke test and the workflow's
dry-run pack now pass `--config.ignore-scripts=true`, so validation
and publication have identical lifecycle semantics.
3. `workspace:^` unpins the serialized-format version that the exact pin
was silently holding. DSL_VERSION and RUNTIME_DSL_VERSION are
decoupled from the npm version by design, and storage compares them by
value across the package boundary. Under `^0.5.0` a dsl PATCH could
therefore hand a new format to an already-published storage: one
install resolves it and stamps the new version into document_stages,
another install pinned to the older patch reads that row and hard-
fails. Two installs of one published storage version, data-
incompatible.
check-package-version-bumps.mjs now requires a change to either
constant to carry at least a MINOR increase of the dsl package
version, since a caret does not cross a 0.x minor. The rule is
documented next to both constants. That doc edit is itself a
publishable change, so dsl takes a patch bump to 0.5.1.
4. The range and build-integrity guards ran only in the publish workflow,
so a pull request restoring `workspace:*` or leaving a stale generated
file failed at release time, after a version number had been spent.
ci.yml gains a cheap source-level range check
(scripts/check-internal-dependency-ranges.mjs) and the
`git diff --quiet HEAD` integrity check after its install step. The
tarball assertion stays where it is as the release-time proof of
actual published behaviour.
Merging this now publishes @openmaic/dsl@0.5.1 as well as
@openmaic/storage@0.1.2, @openmaic/renderer@0.0.4 and
@openmaic/importer@0.1.2.
* fix(release): fail the gates closed, and state what they actually prove
Confirmation round on the release-path fixes.
1. The format-version rule failed open if version.ts moved. It hard-coded
one source path and returned successfully when either revision lacked
it, so renaming the file, changing DSL_VERSION and bumping dsl by a
patch passed both checks. It now reports an error whenever the file is
absent at either revision while dsl has any publishable change. The
constant regex is anchored to the start of a line, so a commented-out
declaration cannot be read as the value, and a duplicate declaration is
an error rather than a coin flip.
2. "At least a MINOR" was wrong past 1.0.0: `^1.0.0` admits minors, so a
format change shipped as a minor would float into published dependents
exactly as the rule exists to prevent. The rule is now stated as what
it needs to be — an increase the dependents' caret range will NOT admit
— computed from the pre-change version: a minor while dsl is 0.x, a
major once it reaches 1.0.0. The error message and the docs in
version.ts say the same thing.
3. Internal declarations outside `dependencies` bypassed both range
checks. Keeping `dependencies` at `workspace:^` while adding an exact
`peerDependencies` or `optionalDependencies` entry published a second,
tighter constraint and passed. An owned package must now appear exactly
once, in `dependencies`; entries in the peer and optional fields are
rejected outright, in the source check and in the packed-manifest
assertion. The global count test is replaced by naming the three
dependents that must be present.
4. The format rule compared against the base tip, so an un-rebased branch
produced an inverted message. It now uses the merge base, matching the
`base...HEAD` form the rest of diff mode uses. Deliberately not applied
to the version comparison, where the tip is the correct reference.
5. Phase 2 had no baseline, so cumulative counters from an earlier run
satisfied it forever against a non-ephemeral database. It now captures
the counters before the vitest step and requires a positive delta.
Both workflows gained the capture step.
6. Phase 2 overclaimed. It proves rows were inserted into those five
tables during the run, not that the built PgDocumentStore and
PgRuntimeStore inserted them — `test/` is on the publishable-input
ignore list, so test code could write directly. The success message now
says exactly that, and the script documents the threat model: this
catches accidental silencing, which is the defect it was written for;
it is not a defence against someone who can merge changes to `test/`,
who can alter production code just as easily.
Also: scripts/openmaic-packages.mjs is now the single source for the
owned package list, consumed by all three checking scripts, and it
cross-checks itself against both the packages directory and
publish-packages.yml so a package added in one place and forgotten in
another fails rather than going quietly exempt. The workflow's own YAML
lists cannot import it, which is why they are cross-checked instead.
* fix(release): let the format-version source move without opening the gate
Failing closed on a missing version.ts is right, but on its own it made a
rename impossible to merge: the new path does not exist at the base
revision either, so the check refused both before and after the move.
DSL_VERSION_SOURCES is now an ordered candidate list, resolved per
revision against the first path that exists there. A rename lands by
prepending the new path in the same change, and the comparison still
happens across the move. Absent everywhere at either revision, while dsl
has any publishable change, remains an error.
* fix(release): report package-list drift instead of crashing on it
Every check in check-internal-dependency-ranges.mjs reads the shared
package list, so a list naming a package that does not exist threw an
ENOENT out of readManifest before any finding was printed. It failed
closed, but unreadably. The list is now validated first and fatally, with
its own headline.
* fix(release): anchor the caret to the base branch, and check the gates' own inputs
Final confirmation round.
1. The caret escape was computed from the merge base, which is the wrong
reference for it. With merge base dsl 0.5.1, an ordinary minor landing
on main taking the tip to 0.6.0, and HEAD at 0.6.1 with a changed
DSL_VERSION, the rule computed the escape from 0.5.1, got 0.6.0, and
accepted 0.6.1 — but dependents released against 0.6.0 publish
`^0.6.0`, which admits 0.6.1, so the new format reached them anyway.
0.6.1 is also the minimum the ordinary version check allows, making
that the default outcome rather than an unlucky one.
The escape is now computed from the HIGHEST dsl version reachable on
the base branch. Whether the format moved still comes from the merge
base, because that is a question about the branch. The comment on
mergeBaseWithHead now records that it is a no-op in both current CI
invocations (checkout uses the merge ref on pull_request, and the
before-SHA is an ancestor on push) rather than claiming a case that is
not exercised there.
2. The workflow cross-check matched raw text, so a commented-out trigger
path still satisfied it while silently disabling that package's
release. Full-line comments are now stripped first; the `on.push.paths`
list and every `for pkg in ...` loop are compared as exact sets, so an
unexpected entry fails as well as a missing one; and `--filter` usage
is checked more loosely, because individual steps legitimately filter
subsets. The limits of a textual check are stated where it lives.
3. `locate()` returned the first candidate format-version source that
existed, so during a half-finished rename the gate could compare a file
that is no longer the exported one. It now resolves every candidate per
revision and fails unless exactly one declares the constants.
4. INTERNAL_DEPENDENTS was checked in one direction only, so deleting an
entry exempted that package from the source check and from the packed
assertion that iterates the same map. It is now cross-checked both
ways, an owned package in another owned package's devDependencies is
rejected outright, and multiple owned dependencies no longer collapse
to whichever was declared last.
5. assertPackageListIsComplete() now also runs in release mode, which is
the gate that decides what gets published; and the PostgreSQL script
consumes flag values properly instead of treating the first non-flag
argument as the results path.
Also documents, in openmaic-packages.mjs, the threat model these gates
share: they catch mistakes, and anyone who can merge edits to scripts/
can edit production code directly, so deliberate subversion is out of
scope and cannot be closed by adding further checks.
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 four owned packages.
|
|
//
|
|
// KNOWN LIMITATION: this treats "publishable input" as "file under the package
|
|
// directory", which is an under-approximation for all four 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
|
|
// four 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 four
|
|
// 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);
|
|
}
|