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.
204 lines
7.7 KiB
JavaScript
204 lines
7.7 KiB
JavaScript
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
/**
|
|
* The one list of owned, publishable @openmaic packages.
|
|
*
|
|
* Several checks and the release workflow each used to carry their own copy of
|
|
* this list, which meant a package missing from one of them was silently exempt
|
|
* from that check rather than failing anything. The scripts now share this
|
|
* module; the workflow cannot import JavaScript into its `on:` trigger, so
|
|
* {@link assertPackageListIsComplete} verifies from the outside that the
|
|
* workflow's own list still agrees with this one.
|
|
*
|
|
* Ordered by dependency: dsl first, since the other three build against it.
|
|
*
|
|
* ── THREAT MODEL FOR EVERY GATE THAT READS THIS ──────────────────────────────
|
|
*
|
|
* This list, and the checks built on it, are configuration held in the same
|
|
* repository as the code they check. Anyone who can merge a change to
|
|
* `scripts/` can also edit the gate itself: shorten this list, relax a rule,
|
|
* delete a workflow step. No amount of additional checking closes that, because
|
|
* every new check arrives with its own editable configuration.
|
|
*
|
|
* That is fine, because it is not the threat these gates are for. They exist to
|
|
* catch MISTAKES — a package added in one place and forgotten in another, a
|
|
* dependency range that publishes differently from how it reads, a test suite
|
|
* that stops running without anyone noticing. Those are silent by default and
|
|
* expensive to find later, which is exactly what a gate is good at. Deliberate
|
|
* subversion is not in scope and cannot be: someone willing to edit these
|
|
* scripts to hide a change can edit the production sources directly, and code
|
|
* review, not tooling, is what stands in the way of that.
|
|
*/
|
|
export const OPENMAIC_PACKAGES = ['dsl', 'storage', 'renderer', 'importer'];
|
|
|
|
/** The packages that depend on another owned package, and on which one. */
|
|
export const INTERNAL_DEPENDENTS = {
|
|
storage: '@openmaic/dsl',
|
|
renderer: '@openmaic/dsl',
|
|
importer: '@openmaic/dsl',
|
|
};
|
|
|
|
export const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
export const PACKAGES_DIRECTORY = join(repositoryRoot, 'packages/@openmaic');
|
|
|
|
export function packageDirectory(name) {
|
|
return `packages/@openmaic/${name}`;
|
|
}
|
|
|
|
export function readManifest(name) {
|
|
return JSON.parse(readFileSync(join(PACKAGES_DIRECTORY, name, 'package.json'), 'utf8'));
|
|
}
|
|
|
|
const PUBLISH_WORKFLOW = '.github/workflows/publish-packages.yml';
|
|
|
|
/**
|
|
* Fail if this list has drifted from what actually exists on disk, or from the
|
|
* release workflow's own hardcoded copies.
|
|
*
|
|
* Being wrong in either direction is silent by nature: a package present on
|
|
* disk but missing here is exempt from every check that reads this module, and
|
|
* a package here but missing from the workflow is never published. Both read as
|
|
* a pass unless something asserts otherwise, which is what this does.
|
|
*
|
|
* Returns the problems rather than exiting, so a caller can report them
|
|
* alongside its own.
|
|
*/
|
|
export function assertPackageListIsComplete() {
|
|
const problems = [];
|
|
|
|
const onDisk = readdirSync(PACKAGES_DIRECTORY, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
const listed = [...OPENMAIC_PACKAGES].sort();
|
|
|
|
for (const name of onDisk) {
|
|
if (!listed.includes(name)) {
|
|
problems.push(
|
|
`packages/@openmaic/${name} exists but is not in OPENMAIC_PACKAGES, so every check ` +
|
|
'that reads this list skips it. Add it there and to publish-packages.yml.',
|
|
);
|
|
}
|
|
}
|
|
for (const name of listed) {
|
|
if (!onDisk.includes(name)) {
|
|
problems.push(`OPENMAIC_PACKAGES lists ${name}, but packages/@openmaic/${name} is gone.`);
|
|
}
|
|
}
|
|
|
|
let workflow;
|
|
try {
|
|
workflow = readFileSync(join(repositoryRoot, PUBLISH_WORKFLOW), 'utf8');
|
|
} catch {
|
|
problems.push(`${PUBLISH_WORKFLOW} is missing, so its package list cannot be cross-checked.`);
|
|
return problems;
|
|
}
|
|
problems.push(...crossCheckPublishWorkflow(workflow));
|
|
|
|
return problems;
|
|
}
|
|
|
|
/** Compare two name sets and describe the difference from `expected`. */
|
|
function describeSetDifference(expected, observed, subject) {
|
|
const problems = [];
|
|
for (const name of expected) {
|
|
if (!observed.includes(name)) {
|
|
problems.push(`${subject} omits ${name}.`);
|
|
}
|
|
}
|
|
for (const name of observed) {
|
|
if (!expected.includes(name)) {
|
|
problems.push(`${subject} names ${name}, which is not in OPENMAIC_PACKAGES.`);
|
|
}
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
/**
|
|
* Check that every place `publish-packages.yml` enumerates packages still names
|
|
* exactly {@link OPENMAIC_PACKAGES}.
|
|
*
|
|
* Textual, because this runs before `pnpm install` and there is no YAML parser
|
|
* on hand. Done honestly rather than by substring search: full-line comments
|
|
* are stripped first, so a commented-out trigger path no longer satisfies the
|
|
* check while quietly disabling a package's release; and each enumeration is
|
|
* compared as a SET, so an unexpected entry fails as well as a missing one.
|
|
*
|
|
* WHAT IT COVERS, and therefore what it does not. Two enumerations are read as
|
|
* exact sets: the `on.push.paths` trigger, and every `for pkg in ...;` loop
|
|
* (the build, pack, publish and tag loops). `--filter "@openmaic/<name>"` is
|
|
* checked more loosely — every package must appear as a filter somewhere, and
|
|
* no filter may name an unknown package — because individual steps legitimately
|
|
* filter subsets, as the typecheck step does by omitting importer. A textual
|
|
* check cannot tell a deliberate subset from an accidental one; converting the
|
|
* workflow to a matrix would, and is a larger change than this.
|
|
*/
|
|
function crossCheckPublishWorkflow(workflow) {
|
|
const problems = [];
|
|
const source = workflow
|
|
.split('\n')
|
|
.filter((line) => !/^\s*#/.test(line))
|
|
.join('\n');
|
|
|
|
// Scoped to the `paths:` list itself. Matching the whole file would also pick
|
|
// up `packages/@openmaic/$pkg/package.json` inside the shell loops, where
|
|
// `$pkg` is the loop variable rather than a package name.
|
|
const pathsBlock = /^\s*paths:\s*$((?:\n\s*-\s*.*)*)/m.exec(source);
|
|
if (!pathsBlock) {
|
|
problems.push(
|
|
`${PUBLISH_WORKFLOW} has no on.push.paths list, so either every push triggers a ` +
|
|
'release or none does; this check expected the trigger to enumerate the packages.',
|
|
);
|
|
} else {
|
|
const triggerPaths = [
|
|
...pathsBlock[1].matchAll(/packages\/@openmaic\/([^/"'\s]+)\/package\.json/g),
|
|
].map((match) => match[1]);
|
|
problems.push(
|
|
...describeSetDifference(
|
|
OPENMAIC_PACKAGES,
|
|
[...new Set(triggerPaths)],
|
|
`${PUBLISH_WORKFLOW} on.push.paths`,
|
|
),
|
|
);
|
|
}
|
|
|
|
const loops = [...source.matchAll(/for pkg in ([^;]+);/g)];
|
|
if (loops.length === 0) {
|
|
problems.push(`${PUBLISH_WORKFLOW} has no \`for pkg in ...\` loop; this check expected one.`);
|
|
}
|
|
for (const loop of loops) {
|
|
const names = loop[1].trim().split(/\s+/).filter(Boolean);
|
|
problems.push(
|
|
...describeSetDifference(
|
|
OPENMAIC_PACKAGES,
|
|
names,
|
|
`${PUBLISH_WORKFLOW} loop \`for pkg in ${loop[1].trim()}\``,
|
|
),
|
|
);
|
|
}
|
|
|
|
const filtered = new Set(
|
|
[...source.matchAll(/--filter\s+"?@openmaic\/([^"\s]+)"?/g)].map((match) => match[1]),
|
|
);
|
|
for (const name of OPENMAIC_PACKAGES) {
|
|
if (!filtered.has(name)) {
|
|
problems.push(
|
|
`${PUBLISH_WORKFLOW} never passes --filter "@openmaic/${name}", so it is never built ` +
|
|
'or tested on the release path.',
|
|
);
|
|
}
|
|
}
|
|
for (const name of filtered) {
|
|
if (!OPENMAIC_PACKAGES.includes(name)) {
|
|
problems.push(
|
|
`${PUBLISH_WORKFLOW} filters @openmaic/${name}, which is not in OPENMAIC_PACKAGES.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return problems;
|
|
}
|