From 3d0df5aa17e70b9679867021b92e4ec86bd5d761 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 03:07:04 +0000 Subject: [PATCH 1/7] feat(spec): ship a per-release section in spec-changes.json, gated `spec-changes.json` is keyed to the protocol major while this repo's launch window ships breaking changes in minors, so a consumer crossing 17.3.0 -> 17.4.0 reads `added: 0, removed: 0` over a delta of 225 added and 51 removed exports. Adds the `release` section (from -> to at package-version resolution), generated at publish time only so the committed copy stays the deterministic registry projection, plus the correctness gate that recomputes the delta from the two tarballs and fails the release on a mismatch, naming the disagreeing exports. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude --- .github/workflows/lint.yml | 12 + .github/workflows/release.yml | 44 +- .gitignore | 4 + package.json | 1 + packages/spec/scripts/build-spec-changes.ts | 135 +++- packages/spec/src/migrations/index.ts | 7 + packages/spec/src/migrations/spec-changes.ts | 112 ++++ scripts/check-release-spec-changes.mjs | 668 +++++++++++++++++++ scripts/release-spec-changes.sh | 137 ++-- 9 files changed, 1063 insertions(+), 57 deletions(-) create mode 100755 scripts/check-release-spec-changes.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ee6ca90e2a2..5f19e337970 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -943,6 +943,18 @@ jobs: - name: A declared gate population reaches the tree run: pnpm check:declared-population-live + # ADR-0087 D4's per-release correctness gate (#17080). The REAL run needs + # two published tarballs and npm, so it lives in the release lane and + # cannot run on a PR; what runs here is its `--self-test`, which is + # therefore the only thing standing between an edit to that gate and the + # next release trusting it. Its batteries drive the same `verifyRelease()` + # the release lane calls, over synthetic tarball trees: a section matching + # both artifacts, one omitting a real export, one inventing an export, a + # from/to version pointing at another release, and the two absences that + # are legitimate. Fixture-only, no spawns; well under a second. + - name: Per-release spec-changes gate self-test + run: pnpm check:release-spec-changes + # PM bare-root worklist self-test (#10840). The step above proves the # dispatch derivation still WORKS; this one proves the recorded triage of # the gates that derivation structurally cannot see is still true of the diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1507b803439..254cfba1c1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -913,11 +913,19 @@ jobs: - name: Backfill spec-changes.json on the GitHub Release (ADR-0087 D4) # Ordering is load-bearing: `gh release upload` needs the Release the # step above creates. + # + # `--prepare` regenerates the manifest against the previously published + # tarball exactly as the publish lane does, so the asset this repair + # uploads carries the same per-release section the npm artifact does. + # It cannot repair the npm artifact itself — that tarball is immutable — + # and this lane never publishes one. if: steps.audit.outputs.releases-missing == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_VERSION: ${{ steps.audit.outputs.version }} - run: bash scripts/release-spec-changes.sh + run: | + bash scripts/release-spec-changes.sh --prepare + bash scripts/release-spec-changes.sh --attach # ══════════════════════════════════════════════════════════════════════════ # HUMAN LANE — the ONLY job in this repository that publishes. @@ -1222,6 +1230,35 @@ jobs: echo "::warning::hotcrm@${HOTCRM_REF} is incompatible with the about-to-publish @objectstack/spec. ADVISORY ONLY — the publish continues. HOTCRM_REF is pre-v17; ship a migrated hotcrm release, bump it, and set BLOCKING=1 in .github/workflows/release.yml to re-arm this gate." + # ────────────────────────────────────────────────────────────────────── + # ADR-0087 D4 — the per-release section, and the gate that proves it. + # + # Both steps run BEFORE `changeset publish`, and that ordering is the + # whole design. The section has to be in the tree when the tarball is + # packed (a consumer's tooling reads `node_modules`, not a Release page), + # and a delta that disagrees with the artifacts must stop the release + # while stopping it is still free — after `changeset publish` the tarball + # is immutable and the only remaining repair is another version. + # + # `--no-git-checks` is what `changeset publish` passes to `pnpm publish` + # (its own source), so the working-tree edit `--prepare` makes does not + # block the publish. The committed copy is untouched: it stays the + # registry-only projection `check:spec-changes` gates on every PR. + # ────────────────────────────────────────────────────────────────────── + - name: Generate the per-release spec-changes section (ADR-0087 D4) + env: + RELEASE_VERSION: ${{ steps.guards.outputs.version }} + run: bash scripts/release-spec-changes.sh --prepare + + - name: Correctness gate — the delta must match both tarballs + # ⛔ A failure here fails the release, by design (#17080): a wrong change + # file is worse than none, because a consumer stops looking once it has + # one. The gate names the disagreeing exports and the direction of each + # disagreement, so a held release arrives with its own diagnosis. + env: + RELEASE_VERSION: ${{ steps.guards.outputs.version }} + run: bash scripts/release-spec-changes.sh --verify + # ────────────────────────────────────────────────────────────────────── # The publish itself. `pnpm run release` = build + build-console + # scripts/release-publish.sh, which is `changeset publish` followed by ONE @@ -1312,7 +1349,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_VERSION: ${{ steps.guards.outputs.version }} - run: bash scripts/release-spec-changes.sh + # Uploads the file the two steps above generated and verified — the + # Release asset and the npm artifact are the same bytes by construction, + # not by two runs agreeing. + run: bash scripts/release-spec-changes.sh --attach # ══════════════════════════════════════════════════════════════════════════ # Runtime image — fed by either lane. Building an image for a version that is diff --git a/.gitignore b/.gitignore index 9023b5fbe67..c3ab1257972 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,7 @@ examples/app-crm/storage/ .claude/worktrees/ # an agent worktree is git plumbing, never repo content: a `.git` FILE reading `gitdir:` plus a whole second checkout .worktrees/ + +# The release lane's scratch space: the previously published tarball it unpacks +# and the artifact it packs to verify the ADR-0087 D4 per-release section. +.release-spec-changes/ diff --git a/package.json b/package.json index a50b92c644d..7c6a4ee4cc7 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "check:agent-test-spelling": "node scripts/check-agent-test-spelling.mjs --self-test && node scripts/check-agent-test-spelling.mjs", "check:ratchet-remedy-authority": "node scripts/check-ratchet-remedy-authority.mjs --self-test && node scripts/check-ratchet-remedy-authority.mjs", "check:merged-result": "node scripts/check-merged-result.mjs --self-test", + "check:release-spec-changes": "node scripts/check-release-spec-changes.mjs --self-test", "check:pm-skill-ratchet": "node scripts/pm/check-skill-line-ratchet.mjs --self-test && node scripts/pm/check-skill-line-ratchet.mjs", "check:pm-skill-id-lint": "node scripts/pm/check-skill-id-lint.mjs --self-test && node scripts/pm/check-skill-id-lint.mjs", "check:pm-label-desc-cap": "node scripts/pm/check-label-desc-cap.mjs --self-test && node scripts/pm/check-label-desc-cap.mjs", diff --git a/packages/spec/scripts/build-spec-changes.ts b/packages/spec/scripts/build-spec-changes.ts index a039ee51ef2..4b8e784b03f 100644 --- a/packages/spec/scripts/build-spec-changes.ts +++ b/packages/spec/scripts/build-spec-changes.ts @@ -28,6 +28,22 @@ * both is not consumer leniency — a published tarball is immutable, so there is * no producer to fix. This repo's OWN surface is always the directory. * + * Per-release section: `--previous-package ` points at the UNPACKED + * previous tarball (its `package/` root) and is the publish-time superset of + * `--previous-surface`. From that one directory it reads the previous version + * (its `package.json`), the previous export surface and the previous + * `spec-changes.json`, and writes a `release` section — `from → to` at + * PACKAGE-VERSION resolution — into the manifest that is about to be packed. + * The committed copy never carries it (generating it needs a published tarball, + * so it could not be deterministic), which is exactly how the ruling answers + * the determinism concern: publish time only. + * + * ⛔ The section is OMITTED, loudly, rather than emitted empty when the previous + * tarball lacks either input — an empty `release` is indistinguishable from + * "this release changed nothing", which is the misreading this whole section + * exists to end. `scripts/check-release-spec-changes.mjs` derives the same + * condition from the same artifacts and accepts the absence for the same reason. + * * `spec-changes.json` itself stays a single file, deliberately (#5837), and #8344 * re-measured that call rather than inheriting it. The original reason — "two PRs * append under different majors" — is not what actually holds: in-flight @@ -48,8 +64,11 @@ import { fileURLToPath } from 'node:url'; import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '../src/kernel/protocol-version'; import { MIGRATION_SUPPORT_FLOOR } from '../src/migrations/registry'; import { + composeReleaseChanges, composeSpecChanges, SpecChangesSchema, + SpecReleaseChangesSchema, + type SpecReleaseChanges, type SpecSurfaceAdd, type SpecSurfaceRemove, } from '../src/migrations/spec-changes'; @@ -60,7 +79,30 @@ const SNAPSHOT = resolve(PKG_DIR, 'spec-changes.json'); const SURFACE = resolve(PKG_DIR, API_SURFACE_DIR_NAME); const CHECK = process.argv.includes('--check'); const prevSurfaceIdx = process.argv.indexOf('--previous-surface'); -const PREV_SURFACE = prevSurfaceIdx >= 0 ? process.argv[prevSurfaceIdx + 1] : undefined; +const prevPackageIdx = process.argv.indexOf('--previous-package'); +const PREV_PACKAGE = prevPackageIdx >= 0 ? process.argv[prevPackageIdx + 1] : undefined; +/** The version this tree is about to publish — read, never transcribed. */ +const THIS_VERSION = (JSON.parse(readFileSync(resolve(PKG_DIR, 'package.json'), 'utf8')) as { version: string }) + .version; + +/** + * The export snapshot inside an unpacked published tarball, in whichever of the + * two shapes that release shipped (`api-surface/` from #5837, `api-surface.json` + * before it), or `null` when it shipped neither (pre-protocol-15). + */ +function previousSurfacePath(pkgDir: string): string | null { + const dir = resolve(pkgDir, API_SURFACE_DIR_NAME); + if (existsSync(dir)) return dir; + const monolith = resolve(pkgDir, `${API_SURFACE_DIR_NAME}.json`); + if (existsSync(monolith)) return monolith; + return null; +} + +const PREV_SURFACE = PREV_PACKAGE + ? (previousSurfacePath(PREV_PACKAGE) ?? undefined) + : prevSurfaceIdx >= 0 + ? process.argv[prevSurfaceIdx + 1] + : undefined; /** Flatten an export surface ({ entry: ["name (kind)", …] }) into one set. */ function flattenSurface(path: string): Set { @@ -72,19 +114,75 @@ function flattenSurface(path: string): Set { return out; } -/** Diff two flattened surfaces into the manifest's added/removed arrays. */ -function diffSurfaces(prevPath: string): { added: SpecSurfaceAdd[]; removed: SpecSurfaceRemove[] } { +/** The raw `entry: name` rows a release added and removed, before attribution. */ +function diffSurfaceNames(prevPath: string): { added: string[]; removed: string[] } { const prev = flattenSurface(prevPath); const curr = flattenSurface(SURFACE); - const added: SpecSurfaceAdd[] = [...curr] - .filter((s) => !prev.has(s)) - .sort() - .map((surface) => ({ surface, since: PROTOCOL_MAJOR })); - const removed: SpecSurfaceRemove[] = [...prev] - .filter((s) => !curr.has(s)) - .sort() - .map((surface) => ({ surface, removedIn: PROTOCOL_MAJOR })); - return { added, removed }; + return { + added: [...curr].filter((s) => !prev.has(s)).sort(), + removed: [...prev].filter((s) => !curr.has(s)).sort(), + }; +} + +/** Diff two flattened surfaces into the manifest's added/removed arrays. */ +function diffSurfaces(prevPath: string): { added: SpecSurfaceAdd[]; removed: SpecSurfaceRemove[] } { + const names = diffSurfaceNames(prevPath); + return { + added: names.added.map((surface) => ({ surface, since: PROTOCOL_MAJOR })), + removed: names.removed.map((surface) => ({ surface, removedIn: PROTOCOL_MAJOR })), + }; +} + +/** + * The previous release's registry ids and version, read out of its own unpacked + * tarball. `null` when that tarball carries no `spec-changes.json` (before + * #2897's release side) — the caller then omits the section rather than + * claiming an empty delta. + */ +function previousRelease( + pkgDir: string, +): { version: string; conversionIds: string[]; migrationIds: string[] } | null { + const manifestPath = resolve(pkgDir, 'spec-changes.json'); + if (!existsSync(manifestPath)) return null; + const pkgPath = resolve(pkgDir, 'package.json'); + if (!existsSync(pkgPath)) return null; + const version = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string }).version; + if (!version) return null; + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + aggregate?: { converted?: { conversionId: string }[]; migrated?: { migrationId: string }[] }; + }; + if (!manifest.aggregate) return null; + return { + version, + conversionIds: (manifest.aggregate.converted ?? []).map((c) => c.conversionId), + migrationIds: (manifest.aggregate.migrated ?? []).map((m) => m.migrationId), + }; +} + +/** + * The publish-time `release` section, or `null` with the reason printed. + * + * ⛔ Never returns an empty-but-present section on missing inputs: `release` + * present means "this is the delta", and a consumer cannot tell a true empty + * delta from an uncomputable one. + */ +function buildReleaseSection(current: ReturnType): SpecReleaseChanges | null { + if (!PREV_PACKAGE) return null; + const prevSurface = previousSurfacePath(PREV_PACKAGE); + const prev = previousRelease(PREV_PACKAGE); + if (!prevSurface || !prev) { + console.error( + `No per-release section: the previous tarball at ${PREV_PACKAGE} ships ` + + `${!prevSurface ? 'no api-surface snapshot' : 'no readable spec-changes.json'}, ` + + 'so the delta cannot be computed. Omitting the section — an empty one would read as ' + + '"this release changed nothing".', + ); + return null; + } + const names = diffSurfaceNames(prevSurface); + return SpecReleaseChangesSchema.parse( + composeReleaseChanges(prev.version, THIS_VERSION, current, prev, names), + ); } function build(): string { @@ -99,6 +197,7 @@ function build(): string { const aggregate = SpecChangesSchema.parse( composeSpecChanges(MIGRATION_SUPPORT_FLOOR, PROTOCOL_MAJOR, surfaceDiff), ); + const release = buildReleaseSection(aggregate); const doc = { $comment: @@ -108,6 +207,11 @@ function build(): string { protocolVersion: PROTOCOL_VERSION, supportFloor: MIGRATION_SUPPORT_FLOOR, migrateCommand: `objectstack migrate meta --from (N >= ${MIGRATION_SUPPORT_FLOOR})`, + // Publish-time only, and placed BEFORE the major-keyed records on purpose: + // it is the section a consumer crossing one release needs first, and the + // one whose absence sent the filer of #17080 to a hand diff of two + // `node_modules` trees. + ...(release ? { release } : {}), aggregate, perMajor, }; @@ -117,8 +221,11 @@ function build(): string { const next = build(); if (CHECK) { - if (PREV_SURFACE) { - console.error('check mode compares the committed (registry-only) manifest; drop --previous-surface'); + if (PREV_SURFACE || PREV_PACKAGE) { + console.error( + 'check mode compares the committed (registry-only) manifest; drop ' + + (PREV_PACKAGE ? '--previous-package' : '--previous-surface'), + ); process.exit(2); } const current = existsSync(SNAPSHOT) ? readFileSync(SNAPSHOT, 'utf8') : ''; diff --git a/packages/spec/src/migrations/index.ts b/packages/spec/src/migrations/index.ts index ad4ee847d10..7349c02fd58 100644 --- a/packages/spec/src/migrations/index.ts +++ b/packages/spec/src/migrations/index.ts @@ -30,15 +30,22 @@ export { MigrationFloorError, } from './chain.js'; export { + composeReleaseChanges, composeSpecChanges, SpecChangesSchema, SpecConvertedSchema, SpecMigratedSchema, SpecSurfaceAddSchema, + SpecReleaseChangesSchema, + SpecReleaseSurfaceSchema, SpecSurfaceRemoveSchema, + type PreviousReleaseRegistries, + type ReleaseSurfaceDiff, type SpecChanges, type SpecConverted, type SpecMigrated, + type SpecReleaseChanges, + type SpecReleaseSurface, type SpecSurfaceAdd, type SpecSurfaceRemove, type SurfaceDiff, diff --git a/packages/spec/src/migrations/spec-changes.ts b/packages/spec/src/migrations/spec-changes.ts index 76019cb352d..06f0e5d3762 100644 --- a/packages/spec/src/migrations/spec-changes.ts +++ b/packages/spec/src/migrations/spec-changes.ts @@ -11,6 +11,15 @@ * guide, and the P3 MCP `spec_changes` tool. Prose inverts from primary to * derived — a `rationale` anchor is the only prose, and it lives in the data. * + * **Per-release section (`release`).** Per-major records answer "16 → 17"; the + * launch-window convention ships breaking changes as MINORS, so that is the + * wrong resolution for the consumer who actually has a question. The `release` + * section answers `17.3.0 → 17.4.0` from the two published artifacts and is + * written into the tarball at publish time only — see + * {@link composeReleaseChanges} and `scripts/check-release-spec-changes.mjs`, + * the gate that refuses to publish a release whose section disagrees with the + * two tarballs. + * * Per-major manifests **compose**: because the record is pure data, any tool can * fold a 10→11, 11→12, … series into a single 10→N view, so a cross-major * consumer gets one aggregate answer instead of N documents to reconcile. @@ -60,6 +69,57 @@ export const SpecMigratedSchema = z }) .describe('A semantic migration requiring consumer judgment (D3).'); +/** + * One public export added or removed by a single RELEASE (ADR-0087 D4). + * + * Deliberately narrower than {@link SpecSurfaceAddSchema} / {@link + * SpecSurfaceRemoveSchema}: those carry a protocol MAJOR (`since` / + * `removedIn`), which is the only attribution the aggregate can honestly make. + * Inside {@link SpecReleaseChangesSchema} the attribution is already exact and + * lives on the section — every entry in `added` arrived in `toVersion` and + * every entry in `removed` left in it — so repeating a major here would offer a + * coarser number in the one place a finer one is known, which is the defect + * this section exists to close. It stays an OBJECT rather than a bare string so + * a later field (a replacement pointer) is an additive change. + */ +export const SpecReleaseSurfaceSchema = z + .object({ + surface: z.string().describe('The exported name, e.g. `applyConversions (function)`.'), + }) + .describe('A public export added or removed by one release.'); + +/** + * The `release` section of `spec-changes.json` — the delta between the + * previously published `@objectstack/spec` and the one this tarball ships, at + * PACKAGE-VERSION resolution (ADR-0087 D4). + * + * Why it exists next to `aggregate`/`perMajor`: those are keyed to the protocol + * major, while this repo's launch-window convention ships breaking changes in + * MINORS. A consumer moving 17.3.0 → 17.4.0 therefore reads a manifest whose + * finest question is "16 → 17", answered long ago, with `added`/`removed` + * empty — which reads as "nothing changed" when 218 exports arrived and 51 left. + * + * It is generated at PUBLISH time only, never committed: it is a function of a + * previously published tarball, so a committed copy could not stay + * deterministic from the registries alone. The committed + * `packages/spec/spec-changes.json` carries no `release` key at all, and + * `check:spec-changes` keeps it that way. + */ +export const SpecReleaseChangesSchema = z + .object({ + fromVersion: z.string().describe('The previously published @objectstack/spec version.'), + toVersion: z.string().describe('The @objectstack/spec version this artifact ships.'), + added: z.array(SpecReleaseSurfaceSchema).describe('Exports this release added.'), + converted: z + .array(SpecConvertedSchema) + .describe('D2 conversions first registered in this release.'), + migrated: z + .array(SpecMigratedSchema) + .describe('D3 semantic migrations first registered in this release.'), + removed: z.array(SpecReleaseSurfaceSchema).describe('Exports this release removed.'), + }) + .describe('ADR-0087 D4 per-release change manifest, at package-version resolution.'); + /** The full `spec-changes.json` record for a `from → to` version pair. */ export const SpecChangesSchema = z .object({ @@ -77,6 +137,8 @@ export type SpecSurfaceRemove = z.infer; export type SpecConverted = z.infer; export type SpecMigrated = z.infer; export type SpecChanges = z.infer; +export type SpecReleaseSurface = z.infer; +export type SpecReleaseChanges = z.infer; /** Release-time api-surface diff, supplied to {@link composeSpecChanges}. */ export interface SurfaceDiff { @@ -128,3 +190,53 @@ export function composeSpecChanges( removed: surfaceDiff.removed ?? [], }; } + +/** + * What the PREVIOUSLY published release already carried, read from its own + * `spec-changes.json`. Ids only: the delta below is an id-set difference, and + * reading anything else out of an immutable artifact would make this fold + * depend on a shape we can no longer fix. + */ +export interface PreviousReleaseRegistries { + conversionIds: readonly string[]; + migrationIds: readonly string[]; +} + +/** The release-time export-surface diff, already flattened to `entry: name` rows. */ +export interface ReleaseSurfaceDiff { + added: readonly string[]; + removed: readonly string[]; +} + +/** + * Fold one release's delta into a {@link SpecReleaseChanges} record. + * + * Pure: `current` is this tree's aggregate projection, `previous` is the id set + * the last published tarball carried, and `surfaceDiff` is the export diff of + * the two artifacts. `converted`/`migrated` are the entries that are NEW in + * this release — an id present now and absent then. + * + * ⚠️ An id that disappeared between the two releases (a conversion withdrawn + * from the registry) is deliberately NOT reported here: ADR-0087 D4 names four + * arrays and this record carries exactly those four. A withdrawal is visible by + * comparing two published manifests, and nothing in this section claims + * otherwise. + */ +export function composeReleaseChanges( + fromVersion: string, + toVersion: string, + current: SpecChanges, + previous: PreviousReleaseRegistries, + surfaceDiff: ReleaseSurfaceDiff, +): SpecReleaseChanges { + const priorConversions = new Set(previous.conversionIds); + const priorMigrations = new Set(previous.migrationIds); + return { + fromVersion, + toVersion, + added: [...surfaceDiff.added].sort().map((surface) => ({ surface })), + converted: current.converted.filter((c) => !priorConversions.has(c.conversionId)), + migrated: current.migrated.filter((m) => !priorMigrations.has(m.migrationId)), + removed: [...surfaceDiff.removed].sort().map((surface) => ({ surface })), + }; +} diff --git a/scripts/check-release-spec-changes.mjs b/scripts/check-release-spec-changes.mjs new file mode 100755 index 00000000000..5c644463f8c --- /dev/null +++ b/scripts/check-release-spec-changes.mjs @@ -0,0 +1,668 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-release-spec-changes — the ADR-0087 D4 per-release correctness gate. + * + * node scripts/check-release-spec-changes.mjs --previous --published + * node scripts/check-release-spec-changes.mjs --self-test + * + * Both arguments are the UNPACKED `package/` root of a tarball: `--previous` + * the last published `@objectstack/spec`, `--published` the artifact this + * release is about to publish. The gate recomputes the export-surface delta + * from those two artifacts and refuses the release when the `release` section + * inside the published one disagrees. + * + * ## Why a gate at all + * + * `spec-changes.json` gained a per-release section so a consumer crossing one + * MINOR can read what moved. That makes the file a published contract a + * downstream CI can gate on — and a WRONG change file is worse than none, + * because the consumer stops looking. So the section is not trusted because the + * generator produced it; it is trusted because this gate reproduced it from the + * two artifacts that actually ship. + * + * ## Why this is not a tautology + * + * The generator (`packages/spec/scripts/build-spec-changes.ts`) reads the + * WORKING TREE's `api-surface/` and writes the manifest. This gate reads + * neither: it reads the two TARBALLS, including the manifest as packed. The two + * therefore disagree whenever anything between them is wrong — a stale + * `api-surface/` snapshot in the tree, a `files[]` entry that drops a shard + * from the artifact, a manifest regenerated against the wrong previous version, + * a hand edit, a generator bug. It carries its own flattening rather than + * importing the generator's, on purpose: an instrument that shares the code it + * audits reports agreement with itself. + * + * ## Why it names exports instead of exiting 1 + * + * A gate that can wedge a release without saying why is worse than the defect + * it guards. Every failure prints the disagreeing exports and the DIRECTION of + * each disagreement — claimed-but-not-real, real-but-unclaimed, per array — + * plus the one command that regenerates the section. A release is held only by + * a failure whose remedy is printed with it. + * + * ## The one thing it deliberately does NOT require + * + * A previous tarball that ships no `api-surface` snapshot (before protocol 15) + * or no `spec-changes.json` cannot produce a delta at all. The generator omits + * the section loudly in that case rather than emitting an empty one, and this + * gate derives the same condition from the same artifacts and accepts the + * absence — but it REFUSES a section that is present when it could not have + * been computed, which is the shape that would lie. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const API_SURFACE_DIR = 'api-surface'; +const API_SURFACE_MONOLITH = 'api-surface.json'; +const MANIFEST = 'spec-changes.json'; +const REGENERATE_HINT = + 'Regenerate with: pnpm --filter @objectstack/spec exec tsx scripts/build-spec-changes.ts ' + + '--previous-package '; +/** How many disagreeing names are listed before the rest are counted. */ +const NAME_CAP = 25; + +// Set by `selfTest()` only after its verdict prints, and read at the dispatch: +// a `return` above that line prints nothing and still exits 0 — a self-test +// that never finished, reported as one that passed. +let selfTestReachedVerdict = false; + +// ─── Reading an artifact ────────────────────────────────────────────────── + +/** + * Every `entry: name (kind)` row of an unpacked tarball's export snapshot, or + * `null` when it ships none. + * + * The row spelling is the contract between this gate and the generator, and it + * is the only thing the two share. Both tarball layouts are read because a + * published tarball is immutable: the sharded directory (#5837 on) and the + * single file before it. + */ +function readSurface(pkgDir) { + const dir = path.join(pkgDir, API_SURFACE_DIR); + const rows = new Set(); + if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) { + const shards = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort(); + if (shards.length === 0) return null; + for (const shard of shards) { + const doc = JSON.parse(fs.readFileSync(path.join(dir, shard), 'utf8')); + const entry = doc.entry; + if (typeof entry !== 'string' || !Array.isArray(doc.exports)) { + throw new Error(`${path.join(dir, shard)} is not an api-surface shard (no "entry"/"exports")`); + } + for (const name of doc.exports) rows.add(`${entry}: ${name}`); + } + return rows; + } + const monolith = path.join(pkgDir, API_SURFACE_MONOLITH); + if (fs.existsSync(monolith)) { + const doc = JSON.parse(fs.readFileSync(monolith, 'utf8')); + for (const [entry, names] of Object.entries(doc)) { + for (const name of names) rows.add(`${entry}: ${name}`); + } + return rows; + } + return null; +} + +/** The parsed `spec-changes.json` of an unpacked tarball, or `null` when absent. */ +function readManifest(pkgDir) { + const file = path.join(pkgDir, MANIFEST); + if (!fs.existsSync(file)) return null; + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +/** The `version` of an unpacked tarball's own manifest, or `null`. */ +function readVersion(pkgDir) { + const file = path.join(pkgDir, 'package.json'); + if (!fs.existsSync(file)) return null; + const version = JSON.parse(fs.readFileSync(file, 'utf8')).version; + return typeof version === 'string' ? version : null; +} + +/** Registry ids an artifact's aggregate projection carries. */ +function aggregateIds(manifest) { + const aggregate = manifest?.aggregate; + if (!aggregate) return null; + return { + conversionIds: (aggregate.converted ?? []).map((c) => c.conversionId), + migrationIds: (aggregate.migrated ?? []).map((m) => m.migrationId), + }; +} + +// ─── The comparison ─────────────────────────────────────────────────────── + +function listNames(names) { + const shown = names.slice(0, NAME_CAP).map((n) => ` ${n}`); + if (names.length > NAME_CAP) shown.push(` … and ${names.length - NAME_CAP} more`); + return shown; +} + +/** + * Compare one array of the section against the truth recomputed from the two + * tarballs, in BOTH directions — a section that omits a real removal and one + * that invents a removal are different defects and read differently. + */ +function compareArray(label, claimed, actual, problems) { + const claimedSet = new Set(claimed); + const actualSet = new Set(actual); + const invented = [...claimedSet].filter((n) => !actualSet.has(n)).sort(); + const missed = [...actualSet].filter((n) => !claimedSet.has(n)).sort(); + if (invented.length > 0) { + problems.push( + `release.${label}: ${invented.length} export(s) the section CLAIMS but the two tarballs do not show as ${label}:`, + ...listNames(invented), + ); + } + if (missed.length > 0) { + problems.push( + `release.${label}: ${missed.length} export(s) the two tarballs show as ${label} and the section OMITS:`, + ...listNames(missed), + ); + } + return invented.length === 0 && missed.length === 0; +} + +/** + * The whole verdict for one release pair. + * + * Returns `{ ok, problems, summary }` rather than exiting, so the self-test + * drives the same code path the release lane runs. + */ +export function verifyRelease(previousDir, publishedDir) { + const problems = []; + + const previousVersion = readVersion(previousDir); + const publishedVersion = readVersion(publishedDir); + if (!publishedVersion) { + return { + ok: false, + problems: [`${publishedDir} carries no readable package.json — this is not an unpacked tarball.`], + summary: null, + }; + } + + const publishedManifest = readManifest(publishedDir); + if (!publishedManifest) { + return { + ok: false, + problems: [ + `the artifact about to publish ships no ${MANIFEST}. ADR-0087 D4 requires it inside the tarball; ` + + `check that "${MANIFEST}" is still listed in packages/spec/package.json "files".`, + ], + summary: null, + }; + } + + const previousSurface = readSurface(previousDir); + const previousIds = aggregateIds(readManifest(previousDir)); + const computable = Boolean(previousSurface && previousIds && previousVersion); + const section = publishedManifest.release; + + if (!computable) { + // The one legitimate absence. Naming which input is missing keeps "we could + // not compute it" distinguishable from "nothing changed". + const missing = [ + previousSurface ? null : 'an api-surface snapshot', + previousIds ? null : `a readable ${MANIFEST}`, + previousVersion ? null : 'a readable package.json', + ].filter(Boolean); + if (section) { + return { + ok: false, + problems: [ + `the artifact carries a release section, but the previous tarball ships ${missing.join(' and ')} — ` + + 'so no delta could have been computed from it. A section that could not be derived is exactly the ' + + 'wrong-data case this gate exists to refuse.', + ], + summary: null, + }; + } + return { + ok: true, + problems: [], + summary: + `no release section, and none is owed: the previous tarball ships ${missing.join(' and ')}. ` + + 'This is the pre-protocol-15 shape; nothing is claimed about the delta.', + }; + } + + if (!section) { + return { + ok: false, + problems: [ + `the artifact about to publish ships no release section, but one is owed: the previous release ` + + `(${previousVersion}) carries both an export snapshot and a ${MANIFEST}, so the ` + + `${previousVersion} → ${publishedVersion} delta is computable. ${REGENERATE_HINT}`, + ], + summary: null, + }; + } + + // Versions first: a section computed against the wrong previous release is + // wrong in a way the array comparison below would report as hundreds of + // export disagreements, which buries the one fact that explains them. + let ok = true; + if (section.fromVersion !== previousVersion) { + ok = false; + problems.push( + `release.fromVersion is ${JSON.stringify(section.fromVersion)} but the previous tarball is ` + + `${JSON.stringify(previousVersion)} — the section was generated against a different release.`, + ); + } + if (section.toVersion !== publishedVersion) { + ok = false; + problems.push( + `release.toVersion is ${JSON.stringify(section.toVersion)} but this artifact is ` + + `${JSON.stringify(publishedVersion)} — the section describes a release this tarball is not.`, + ); + } + + const publishedSurface = readSurface(publishedDir); + if (!publishedSurface) { + return { + ok: false, + problems: [ + ...problems, + `the artifact about to publish ships no ${API_SURFACE_DIR}/ snapshot, so its own claim cannot be ` + + 'checked. ADR-0059 §3 ships it in the tarball; check packages/spec/package.json "files".', + ], + summary: null, + }; + } + + const actualAdded = [...publishedSurface].filter((n) => !previousSurface.has(n)); + const actualRemoved = [...previousSurface].filter((n) => !publishedSurface.has(n)); + const claimedAdded = (section.added ?? []).map((e) => e.surface); + const claimedRemoved = (section.removed ?? []).map((e) => e.surface); + ok = compareArray('added', claimedAdded, actualAdded, problems) && ok; + ok = compareArray('removed', claimedRemoved, actualRemoved, problems) && ok; + + // The registry half: entries NEW in this release are the ids the published + // projection carries and the previous one did not. + const publishedIds = aggregateIds(publishedManifest); + if (!publishedIds) { + ok = false; + problems.push(`the artifact's ${MANIFEST} has no aggregate record — its release section cannot be checked.`); + } else { + const priorConversions = new Set(previousIds.conversionIds); + const priorMigrations = new Set(previousIds.migrationIds); + ok = + compareArray( + 'converted', + (section.converted ?? []).map((c) => c.conversionId), + publishedIds.conversionIds.filter((id) => !priorConversions.has(id)), + problems, + ) && ok; + ok = + compareArray( + 'migrated', + (section.migrated ?? []).map((m) => m.migrationId), + publishedIds.migrationIds.filter((id) => !priorMigrations.has(id)), + problems, + ) && ok; + } + + if (!ok) problems.push(REGENERATE_HINT); + + return { + ok, + problems, + summary: ok + ? `release ${section.fromVersion} → ${section.toVersion} verified against both tarballs: ` + + `${claimedAdded.length} added, ${claimedRemoved.length} removed, ` + + `${(section.converted ?? []).length} converted, ${(section.migrated ?? []).length} migrated.` + : null, + }; +} + +// ─── Self-test ──────────────────────────────────────────────────────────── + +const SELF_TEST_BATTERIES = Object.freeze({ + 'a section matching both tarballs → GREEN': 1, + 'the legacy single-file api-surface layout is read → GREEN': 1, + 'a previous tarball with no api-surface → GREEN, no section owed': 1, + 'a section present when the previous tarball could not produce one → RED': 1, + 'R1 — a computable delta with NO release section → RED': 1, + 'R2 — an export that really arrived is missing from added → RED, naming it': 1, + 'R3 — an export the section invents in added → RED, naming it': 1, + 'R4 — a real removal omitted from removed → RED, naming it': 1, + 'R5 — fromVersion pointing at another release → RED': 1, + 'R6 — toVersion disagreeing with the artifact → RED': 1, + 'R7 — a new conversion id omitted from converted → RED': 1, + 'R8 — a conversion id invented in converted → RED': 1, + 'R9 — the published artifact ships no spec-changes.json → RED': 1, + 'R10 — the published artifact ships no api-surface → RED': 1, + 'R11 — an empty export snapshot is not a silent pass → RED': 1, +}); +const SELF_TEST_BATTERY_FLOOR = 15; + +function writeTree(root, files) { + for (const [rel, content] of Object.entries(files)) { + const target = path.join(root, rel); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, typeof content === 'string' ? content : `${JSON.stringify(content, null, 2)}\n`); + } + return root; +} + +function selfTest() { + const tmp = fs.mkdtempSync(path.join(fs.realpathSync('/tmp'), 'release-spec-changes-selftest-')); + let failed = 0; + const batterySeen = new Map(); + const registerCase = (label) => batterySeen.set(label, (batterySeen.get(label) ?? 0) + 1); + + let seq = 0; + const pair = ({ prev = {}, next = {} } = {}) => { + const base = path.join(tmp, `case-${(seq += 1)}`); + return { + previous: writeTree(path.join(base, 'prev'), prev), + published: writeTree(path.join(base, 'next'), next), + }; + }; + + const shard = (entry, exports) => ({ description: 'test shard', entry, exports }); + const manifest = (extra = {}) => ({ + protocolVersion: '17.0.0', + supportFloor: 10, + aggregate: { + from: 10, + to: 17, + added: [], + converted: [{ surface: 's', to: 't', conversionId: 'conv-old', toMajor: 17 }], + migrated: [{ surface: 's', replacement: 'r', migrationId: 'mig-old', toMajor: 17, rationale: 'why' }], + removed: [], + }, + perMajor: [], + ...extra, + }); + const release = (extra = {}) => ({ + fromVersion: '17.3.0', + toVersion: '17.4.0', + added: [{ surface: './ai: NewThing (const)' }], + converted: [], + migrated: [], + removed: [{ surface: './ai: OldThing (const)' }], + ...extra, + }); + /** prev: exports OldThing+Kept; next: exports Kept+NewThing. */ + const PREV = { + 'package.json': { name: '@objectstack/spec', version: '17.3.0' }, + 'api-surface/ai.json': shard('./ai', ['Kept (const)', 'OldThing (const)']), + 'spec-changes.json': manifest(), + }; + const NEXT = (releaseSection = release(), extra = {}) => ({ + 'package.json': { name: '@objectstack/spec', version: '17.4.0' }, + 'api-surface/ai.json': shard('./ai', ['Kept (const)', 'NewThing (const)']), + 'spec-changes.json': manifest(releaseSection === null ? {} : { release: releaseSection }), + ...extra, + }); + + const check = (label, { prev, next }, expectOk, expectText) => { + registerCase(label); + const dirs = pair({ prev, next }); + let verdict; + try { + verdict = verifyRelease(dirs.previous, dirs.published); + } catch (error) { + console.error(`✗ ${label}: threw ${error.message}`); + failed += 1; + return; + } + if (verdict.ok !== expectOk) { + console.error( + `✗ ${label}: expected ${expectOk ? 'GREEN' : 'RED'}, got ${verdict.ok ? 'GREEN' : 'RED'}` + + `${verdict.problems.length > 0 ? `\n ${verdict.problems.join('\n ')}` : ''}`, + ); + failed += 1; + return; + } + if (expectText) { + const haystack = [...verdict.problems, verdict.summary ?? ''].join('\n'); + if (!haystack.includes(expectText)) { + console.error(`✗ ${label}: output never names ${JSON.stringify(expectText)}\n ${haystack}`); + failed += 1; + return; + } + } + console.log(`✓ ${label}`); + }; + + check('a section matching both tarballs → GREEN', { prev: PREV, next: NEXT() }, true, '1 added, 1 removed'); + + check( + 'the legacy single-file api-surface layout is read → GREEN', + { + prev: { + 'package.json': { name: '@objectstack/spec', version: '17.3.0' }, + 'api-surface.json': { './ai': ['Kept (const)', 'OldThing (const)'] }, + 'spec-changes.json': manifest(), + }, + next: NEXT(), + }, + true, + ); + + check( + 'a previous tarball with no api-surface → GREEN, no section owed', + { + prev: { 'package.json': { name: '@objectstack/spec', version: '17.3.0' }, 'spec-changes.json': manifest() }, + next: NEXT(null), + }, + true, + 'none is owed', + ); + + check( + 'a section present when the previous tarball could not produce one → RED', + { + prev: { 'package.json': { name: '@objectstack/spec', version: '17.3.0' }, 'spec-changes.json': manifest() }, + next: NEXT(), + }, + false, + 'could have been computed', + ); + + check('R1 — a computable delta with NO release section → RED', { prev: PREV, next: NEXT(null) }, false, 'is owed'); + + check( + 'R2 — an export that really arrived is missing from added → RED, naming it', + { prev: PREV, next: NEXT(release({ added: [] })) }, + false, + './ai: NewThing (const)', + ); + + check( + 'R3 — an export the section invents in added → RED, naming it', + { prev: PREV, next: NEXT(release({ added: [{ surface: './ai: Ghost (const)' }] })) }, + false, + './ai: Ghost (const)', + ); + + check( + 'R4 — a real removal omitted from removed → RED, naming it', + { prev: PREV, next: NEXT(release({ removed: [] })) }, + false, + './ai: OldThing (const)', + ); + + check( + 'R5 — fromVersion pointing at another release → RED', + { prev: PREV, next: NEXT(release({ fromVersion: '17.2.0' })) }, + false, + 'generated against a different release', + ); + + check( + 'R6 — toVersion disagreeing with the artifact → RED', + { prev: PREV, next: NEXT(release({ toVersion: '17.5.0' })) }, + false, + 'describes a release this tarball is not', + ); + + { + const withNewConversion = manifest({ + release: release({ converted: [] }), + }); + withNewConversion.aggregate.converted.push({ + surface: 's2', + to: 't2', + conversionId: 'conv-new', + toMajor: 17, + }); + check( + 'R7 — a new conversion id omitted from converted → RED', + { + prev: PREV, + next: { + 'package.json': { name: '@objectstack/spec', version: '17.4.0' }, + 'api-surface/ai.json': shard('./ai', ['Kept (const)', 'NewThing (const)']), + 'spec-changes.json': withNewConversion, + }, + }, + false, + 'conv-new', + ); + } + + check( + 'R8 — a conversion id invented in converted → RED', + { + prev: PREV, + next: NEXT(release({ converted: [{ surface: 's', to: 't', conversionId: 'conv-ghost', toMajor: 17 }] })), + }, + false, + 'conv-ghost', + ); + + check( + 'R9 — the published artifact ships no spec-changes.json → RED', + { + prev: PREV, + next: { + 'package.json': { name: '@objectstack/spec', version: '17.4.0' }, + 'api-surface/ai.json': shard('./ai', ['Kept (const)']), + }, + }, + false, + 'ships no spec-changes.json', + ); + + check( + 'R10 — the published artifact ships no api-surface → RED', + { + prev: PREV, + next: { 'package.json': { name: '@objectstack/spec', version: '17.4.0' }, 'spec-changes.json': manifest({ release: release() }) }, + }, + false, + 'ships no api-surface/ snapshot', + ); + + check( + 'R11 — an empty export snapshot is not a silent pass → RED', + { + prev: PREV, + next: { + 'package.json': { name: '@objectstack/spec', version: '17.4.0' }, + 'api-surface/ai.json': shard('./ai', []), + 'spec-changes.json': manifest({ release: release() }), + }, + }, + false, + './ai: Kept (const)', + ); + + // ── Floor: what ran must be what is declared ────────────────────────── + const floorFailure = (message) => { + console.error(`✗ self-test floor: ${message}`); + failed += 1; + }; + const declared = Object.keys(SELF_TEST_BATTERIES); + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + floorFailure( + `SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR} — ` + + 'a battery deleted from the roster takes its own floor with it.', + ); + } + for (const [name, count] of batterySeen) { + if (declared.includes(name)) continue; + floorFailure( + `self-test battery "${name}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES — ` + + 'a case attributed to no declared battery is one nothing floors.', + ); + } + for (const name of declared) { + const count = batterySeen.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + floorFailure( + count === 0 + ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + + 'The verdict below would have claimed that case holds.' + : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + + `${SELF_TEST_BATTERIES[name]}.`, + ); + } + + fs.rmSync(tmp, { recursive: true, force: true }); + + if (failed > 0) { + console.error(`\n✗ check-release-spec-changes self-test: ${failed} failure(s) (cases and floor).`); + process.exit(1); + } + console.log(`\n✓ check-release-spec-changes self-test: ${declared.length} batteries pass.`); + selfTestReachedVerdict = true; +} + +// ─── Dispatch ───────────────────────────────────────────────────────────── + +function argValue(flag) { + const i = process.argv.indexOf(flag); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +function main() { + if (process.argv.includes('--self-test')) { + selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-release-spec-changes self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test that never\n' + + 'finished as a self-test that passed.\n', + ); + process.exit(1); + } + return; + } + + const previous = argValue('--previous'); + const published = argValue('--published'); + if (!previous || !published) { + console.error( + 'usage: node scripts/check-release-spec-changes.mjs --previous ' + + '--published \n' + + ' node scripts/check-release-spec-changes.mjs --self-test', + ); + process.exit(2); + } + for (const [flag, dir] of [['--previous', previous], ['--published', published]]) { + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { + // "Could not run" is a failure, never a skip: a release must not pass this + // gate because its inputs were misspelled. + console.error(`✗ ${flag} ${dir} is not a directory — nothing was checked.`); + process.exit(2); + } + } + + const verdict = verifyRelease(previous, published); + if (!verdict.ok) { + console.error('✗ the per-release section of spec-changes.json disagrees with the two tarballs (ADR-0087 D4).'); + console.error(' A wrong change file is worse than none — a consumer gates its upgrade on this data.\n'); + for (const line of verdict.problems) console.error(` ${line}`); + process.exit(1); + } + console.log(`✓ ${verdict.summary}`); +} + +main(); diff --git a/scripts/release-spec-changes.sh b/scripts/release-spec-changes.sh index 52ae746ccd1..f5559dd58b0 100755 --- a/scripts/release-spec-changes.sh +++ b/scripts/release-spec-changes.sh @@ -1,62 +1,117 @@ #!/usr/bin/env bash -# ADR-0087 D4 — build the release `spec-changes.json` (the registry projection -# joined with the api-surface diff against the PREVIOUSLY PUBLISHED spec — the -# ADR-0059 §3 gate artifact, reused instead of discarded) and attach it to the -# `@objectstack/spec@` GitHub Release. +# ADR-0087 D4 — the release lane's three acts on `spec-changes.json`. # -# The Release itself is created by scripts/release-github-releases.mjs, which -# must run BEFORE this script — `gh release upload` needs something to upload -# onto (#4900). +# bash scripts/release-spec-changes.sh --prepare # BEFORE `changeset publish` +# bash scripts/release-spec-changes.sh --verify # BEFORE `changeset publish` +# bash scripts/release-spec-changes.sh --attach # AFTER the GitHub Release exists +# +# `--prepare` writes the PER-RELEASE section into `packages/spec/spec-changes.json` +# so it ships INSIDE the tarball. Until #17080 the only copy carrying a real +# `added[]`/`removed[]` was the one attached to the GitHub Release: a consumer's +# tooling looks in `node_modules`, where the committed, registry-only copy says +# `added: 0, removed: 0` — which reads as "nothing changed" across a MINOR that +# moved hundreds of exports. The committed copy stays registry-derived and +# deterministic; the section exists only in the published artifact, which is +# what the ruling's "generate at publish time only" means. +# +# `--verify` is the correctness gate, and it is part of acceptance, not a +# nicety: the delta this lane generated is recomputed from the two TARBALLS and +# a mismatch fails the release before anything reaches npm. A wrong change file +# is worse than none. +# +# `--attach` uploads the same file to the GitHub Release. It does NOT regenerate +# — the artifact on the Release page and the one inside the tarball are then the +# same bytes by construction, rather than two runs that happened to agree. The +# Release itself is created by scripts/release-github-releases.mjs, which must +# run BEFORE this mode (`gh release upload` needs something to upload onto). # # Inputs (env): # PUBLISHED — the changesets action's `publishedPackages` JSON array -# RELEASE_VERSION — fallback for the recovery publish path, which produces no -# such JSON; the fixed group releases every package at one -# version, so spec's version is that version -# GH_TOKEN — token for `gh release upload` +# RELEASE_VERSION — the version this run is publishing; the fallback for the +# recovery publish path, which produces no such JSON, and +# the only source on the pre-publish modes (nothing has been +# published yet when they run) +# GH_TOKEN — token for `gh release upload` (`--attach` only) set -euo pipefail +MODE="${1:---attach}" +case "${MODE}" in + --prepare|--verify|--attach) ;; + *) echo "::error::unknown mode '${MODE}' (expected --prepare, --verify or --attach)"; exit 2 ;; +esac + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +workdir="${repo_root}/.release-spec-changes" +prev_dir="${workdir}/previous/package" +packed_dir="${workdir}/packed/package" + new_version=$(jq -r '.[] | select(.name=="@objectstack/spec") | .version' <<<"${PUBLISHED:-[]}") if [ -z "${new_version}" ] || [ "${new_version}" = "null" ]; then new_version="${RELEASE_VERSION:-}" fi if [ -z "${new_version}" ]; then - echo "::error::@objectstack/spec version unknown (neither publishedPackages nor RELEASE_VERSION) — cannot attach spec-changes.json" + echo "::error::@objectstack/spec version unknown (neither publishedPackages nor RELEASE_VERSION) — cannot ${MODE#--} spec-changes.json" exit 1 fi -# Previous published version = newest on npm that isn't the one just published. -prev_version=$(npm view @objectstack/spec versions --json \ - | jq -r --arg v "${new_version}" '[.[] | select(. != $v)] | last // empty') - -workdir=$(mktemp -d) -prev_surface="" -if [ -n "${prev_version}" ]; then - echo "Diffing the export surface against previously published @objectstack/spec@${prev_version}" - tarball=$(cd "${workdir}" && npm pack "@objectstack/spec@${prev_version}" --silent) - # Three shapes, because a published tarball is immutable and we unpack whichever - # one that release shipped: - # - `package/api-surface/` — sharded by entry point, #5837 onward; - # - `package/api-surface.json` — the single file, protocol 15 .. #5837; - # - neither — before protocol 15. - tar -xzf "${workdir}/${tarball}" -C "${workdir}" package/api-surface 2>/dev/null || true - tar -xzf "${workdir}/${tarball}" -C "${workdir}" package/api-surface.json 2>/dev/null || true - if [ -d "${workdir}/package/api-surface" ]; then - prev_surface="${workdir}/package/api-surface" - elif [ -f "${workdir}/package/api-surface.json" ]; then - prev_surface="${workdir}/package/api-surface.json" - else - # Releases before protocol 15 did not ship an export snapshot in the npm - # artifact; the manifest is still attached, with empty added[]/removed[]. - echo "@objectstack/spec@${prev_version} ships no api-surface snapshot — added/removed stay empty" +# ── --prepare ───────────────────────────────────────────────────────────── +if [ "${MODE}" = "--prepare" ]; then + rm -rf "${workdir}/previous" + mkdir -p "${workdir}/previous" + + # Previous published version = newest on npm that isn't the one we are about + # to publish. Before the publish that number is simply absent from the list, + # and the filter keeps this correct on the repair path, where it is not. + prev_version=$(npm view @objectstack/spec versions --json \ + | jq -r --arg v "${new_version}" '[.[] | select(. != $v)] | last // empty') + + if [ -z "${prev_version}" ]; then + # The first publish ever. Nothing to diff against, and the generator is run + # without the flag so the tarball carries the registry-only manifest. + echo "::notice::no previously published @objectstack/spec — no per-release section for ${new_version}" + pnpm --filter @objectstack/spec exec tsx scripts/build-spec-changes.ts + exit 0 fi + + echo "Diffing @objectstack/spec@${new_version} against previously published ${prev_version}" + tarball=$(cd "${workdir}/previous" && npm pack "@objectstack/spec@${prev_version}" --silent) + # The whole `package/` root is unpacked, not just the surface: the generator + # reads the previous VERSION from its package.json and the previous registry + # ids from its spec-changes.json, so nothing about the previous release is + # transcribed by hand. Three surface shapes exist across history (the + # `api-surface/` directory from #5837, the single `api-surface.json` from + # protocol 15, neither before that) and the generator reads whichever this one + # shipped — a published tarball is immutable, so there is no producer to fix. + tar -xzf "${workdir}/previous/${tarball}" -C "${workdir}/previous" + + pnpm --filter @objectstack/spec exec tsx scripts/build-spec-changes.ts --previous-package "${prev_dir}" + echo "Wrote the ${prev_version} → ${new_version} section into packages/spec/spec-changes.json" + exit 0 fi -if [ -n "${prev_surface}" ]; then - pnpm --filter @objectstack/spec exec tsx scripts/build-spec-changes.ts --previous-surface "${prev_surface}" -else - pnpm --filter @objectstack/spec exec tsx scripts/build-spec-changes.ts +# ── --verify ────────────────────────────────────────────────────────────── +if [ "${MODE}" = "--verify" ]; then + if [ ! -d "${prev_dir}" ]; then + # The prepare step is what unpacks it. Missing means the lane skipped that + # step or it failed — either way nothing was measured, so this refuses + # rather than passing a release nobody checked. + echo "::error::${prev_dir} is missing — run 'bash scripts/release-spec-changes.sh --prepare' first. Nothing was verified." + exit 1 + fi + rm -rf "${workdir}/packed" + mkdir -p "${workdir}/packed" + # The artifact this release would publish, produced by the same packer + # `changeset publish` uses, so `files[]` applies exactly as it will. + tarball=$(cd "${repo_root}/packages/spec" && pnpm pack --pack-destination "${workdir}/packed" --silent | tail -1) + tar -xzf "${tarball}" -C "${workdir}/packed" + node "${repo_root}/scripts/check-release-spec-changes.mjs" --previous "${prev_dir}" --published "${packed_dir}" + exit 0 fi -gh release upload "@objectstack/spec@${new_version}" packages/spec/spec-changes.json --clobber +# ── --attach ────────────────────────────────────────────────────────────── +if [ ! -f "${repo_root}/packages/spec/spec-changes.json" ]; then + echo "::error::packages/spec/spec-changes.json is missing — nothing to attach" + exit 1 +fi +gh release upload "@objectstack/spec@${new_version}" "${repo_root}/packages/spec/spec-changes.json" --clobber echo "Attached spec-changes.json to release @objectstack/spec@${new_version}" From ac1024dcce775d1f0dc32ae89e4ccc8e899dcfb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 03:10:48 +0000 Subject: [PATCH 2/7] feat(cli): report the installed release delta in `os validate --json` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `protocolVersionGap` answers the major-resolution question and is null for an app on `^17` running spec 17.4.0 — correct, and silent about a release that narrowed accept-sets under the launch-window convention. `specReleaseChanges` reads the installed artifact's own per-release section (ADR-0087 D4) so a CI job can ask what moved in the release it has. A sibling key, not a widening of the one above: one key, one question. Adds the consumer docs section, the changeset, and the unit tests for both the fold and the reader. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude --- .changeset/17080-per-release-spec-changes.md | 48 +++++++ content/docs/upgrading.mdx | 55 ++++++++ packages/cli/src/commands/validate.ts | 15 +++ .../src/utils/spec-release-changes.test.ts | 78 +++++++++++ .../cli/src/utils/spec-release-changes.ts | 121 ++++++++++++++++++ .../spec/src/migrations/migrations.test.ts | 75 ++++++++++- 6 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 .changeset/17080-per-release-spec-changes.md create mode 100644 packages/cli/src/utils/spec-release-changes.test.ts create mode 100644 packages/cli/src/utils/spec-release-changes.ts diff --git a/.changeset/17080-per-release-spec-changes.md b/.changeset/17080-per-release-spec-changes.md new file mode 100644 index 00000000000..a0cb92d2999 --- /dev/null +++ b/.changeset/17080-per-release-spec-changes.md @@ -0,0 +1,48 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +--- + +feat(spec): `spec-changes.json` ships a per-release section, verified against both tarballs (#17080) + +Clause-②: yes (widening) — one new OPTIONAL section on a published artifact plus one new +`os validate --json` key. Nothing previously present is renamed, retired or reshaped: the +`aggregate` and `perMajor` records and every existing key keep their spelling and meaning. +Contract-review tier. + +`spec-changes.json` (ADR-0087 D4) is keyed to the **protocol major**, while this repo's +launch-window convention ships BREAKING entries as **minors**. A consumer crossing one minor +therefore reads a file whose finest question is "16 → 17" — answered long ago — with +`added: 0, removed: 0`, which reads as *nothing changed*. Measured on the published tarballs: +between `@objectstack/spec@17.3.0` and `17.4.0` the export surface gained **225** exports and +lost **51**, and the shipped manifest reported zero of each. + +**What ships now.** The published artifact carries a `release` section — `fromVersion` → +`toVersion` at package-version resolution, with `added` / `removed` (the exports that arrived +and left, each named `": ()"`) and `converted` / `migrated` (the ADR-0087 +D2/D3 entries first registered in that release): + +```bash +jq '.release | {fromVersion, toVersion, added: (.added | length), removed: (.removed | length)}' \ + node_modules/@objectstack/spec/spec-changes.json +os validate --json | jq .specReleaseChanges # the same data, via the CLI +``` + +**The committed copy is unchanged and stays deterministic.** The section is a function of a +previously *published* tarball, so it is generated at publish time only; `check:spec-changes` +keeps the registry-only projection in the tree exactly as it was. + +**A wrong change file is worse than none, so it is gated.** Before anything reaches npm the +release lane recomputes the delta from the two tarballs — the previously published one and the +one about to be published — and refuses to publish when the section disagrees, naming the +disagreeing exports and the direction of each disagreement. A release whose data would mislead +does not ship. + +**Absence stays distinguishable from zero.** When the previous tarball carries no export +snapshot the section is omitted rather than emitted empty, and `specReleaseChanges` is `null` +in exactly that case: a consumer must never read "could not be computed" as "nothing changed", +which is the defect this closes. + +New public exports on `@objectstack/spec`: `SpecReleaseChangesSchema`, +`SpecReleaseSurfaceSchema`, `composeReleaseChanges`, and the types `SpecReleaseChanges`, +`SpecReleaseSurface`, `PreviousReleaseRegistries`, `ReleaseSurfaceDiff`. diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 3ec0727d73d..ee8f6a16fab 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -315,6 +315,61 @@ one checklist per major, which is what those rows link to. The [release notes overview](/docs/releases) summarizes what each major changed. +### What the installed artifact tells you, without a second worktree + +The release pages are written for a human. The same delta ships **inside the +package**, for your tooling: `@objectstack/spec` carries a `spec-changes.json`, +and from the release that follows 17.4.0 on it carries a **`release`** section +describing the release you actually installed — `from` → `to` at the package version, not at the protocol +major. + +```bash +# What did the release I just installed change? +jq '.release | {fromVersion, toVersion, + added: (.added | length), removed: (.removed | length), + converted: (.converted | length), migrated: (.migrated | length)}' \ + node_modules/@objectstack/spec/spec-changes.json +``` + +`added` and `removed` are the public exports that arrived and left, each one +named — `"./ai: AgentSchema (const)"`, the entry point followed by the export +and its kind. `converted` and `migrated` are the ADR-0087 conversions and +semantic migrations first registered in that release. The same file's +`aggregate` and `perMajor` records are unchanged and still answer the +major-boundary question. + +The `os` CLI reads the same section, so a CI job does not have to know the file +exists: + +```bash +os validate --json | jq .specReleaseChanges +``` + +Three properties worth relying on: + +- **The section describes a release, not a major.** Every entry in `added` + arrived in `toVersion` and every entry in `removed` left in it. That is the + one question the `perMajor` records cannot answer, and it is the question a + minor upgrade asks. +- **A missing section is not an empty one.** The key is absent when the delta + could not be computed — a release published before this section existed, or + one whose predecessor shipped no export snapshot. `os validate --json` reports + `null` in exactly those cases. ⛔ Do not read an absent section as "nothing + changed"; read it as "this artifact does not say". +- **It is verified against the artifacts before it ships.** The release lane + recomputes the delta from the previously published tarball and the one about + to be published, and a release whose section disagrees with them does not + publish. The numbers are as true as the two tarballs are. + + +**What it does not tell you.** The export surface is the shape of the API, not +its behaviour: a release can narrow what a value is allowed to be, or start +enforcing a constraint that was declared and inert, without adding or removing a +single export. A delta of `0 added, 0 removed` is a real measurement of the +export surface and says nothing about the accept-sets behind it — the release +checklist above is still the record for those. + + ### The per-package changelogs are the exhaustive record The release pages above are **triaged**, deliberately: a change is written up diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 340cec62539..2d955bc2c79 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -44,6 +44,7 @@ import { isReportedError, } from '../utils/format.js'; import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js'; +import { readSpecReleaseChanges } from '../utils/spec-release-changes.js'; // [#14553] The navigation-contribution group check, shared with `os compile`. // Reports; never refuses — the runtime still relocates, deliberately. import { findNavGroupDiagnostics } from '../utils/nav-contribution-groups.js'; @@ -580,6 +581,14 @@ export default class Validate extends Command { // point at the migration guide. const protocolGap = checkProtocolVersionGap(config.manifest); + // The minor-resolution half of the same question. `protocolGap` is null + // for an app on `^17` running spec 17.4.0 — compatible at the major, and + // silent about a release that narrowed accept-sets under the launch-window + // convention. This reads the installed artifact's own per-release delta + // (ADR-0087 D4), so `--json` answers "what moved in the release I have" + // without a second worktree and a hand diff of two node_modules trees. + const specReleaseChanges = readSpecReleaseChanges(); + // 4b. Structural advisories (non-blocking) — computed HERE, above the // `if (flags.json)` branch, for exactly the reason `unknownKeyWarnings` // is computed up beside `normalized`: everything below that branch only @@ -686,6 +695,12 @@ export default class Validate extends Command { // rename is one stroke, no alias, no dual-key window; its value // shape is unchanged. protocolVersionGap: protocolGap, + // A sibling key, deliberately, rather than a widening of the one + // above: `protocolVersionGap` non-null means "the platform on disk + // is outside the range you declared", and a consumer gating CI on + // that must not start failing because an ordinary minor shipped + // exports. One key, one question. + specReleaseChanges, duration: timer.elapsed(), }, // `--strict` means one thing — "treat warnings as errors" — and it now diff --git a/packages/cli/src/utils/spec-release-changes.test.ts b/packages/cli/src/utils/spec-release-changes.test.ts new file mode 100644 index 00000000000..a0a78402992 --- /dev/null +++ b/packages/cli/src/utils/spec-release-changes.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, describe, expect, it } from 'vitest'; + +import { readSpecReleaseChanges } from './spec-release-changes.js'; + +const dir = mkdtempSync(join(tmpdir(), 'spec-release-changes-')); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +let seq = 0; +/** Write a `spec-changes.json` the reader will be pointed at, and return its path. */ +function manifest(doc: unknown): string { + const path = join(dir, `spec-changes-${(seq += 1)}.json`); + writeFileSync(path, typeof doc === 'string' ? doc : JSON.stringify(doc, null, 2)); + return path; +} + +const RELEASE = { + fromVersion: '17.3.0', + toVersion: '17.4.0', + added: [{ surface: './ai: NewThing (const)' }, { surface: './ui: Other (type)' }], + converted: [{ surface: 's', to: 't', conversionId: 'conv-new', toMajor: 17 }], + migrated: [], + removed: [{ surface: './integration: Gone (type)' }], +}; + +describe('readSpecReleaseChanges (ADR-0087 D4 per-release section)', () => { + it('reports the installed release delta at package-version resolution', () => { + const result = readSpecReleaseChanges(manifest({ protocolVersion: '17.0.0', release: RELEASE })); + expect(result).toMatchObject({ + fromVersion: '17.3.0', + toVersion: '17.4.0', + added: 2, + removed: 1, + converted: 1, + migrated: 0, + }); + }); + + it('⛔ reports NOTHING, never zeros, for a manifest with no release section', () => { + // This is the pre-#17080 artifact, and the whole defect it fixes: a + // consumer that reads `added: 0` from a release which moved 225 exports + // concludes the upgrade is safe. Absence must stay distinguishable from a + // measured empty delta. + expect(readSpecReleaseChanges(manifest({ protocolVersion: '17.0.0', aggregate: {} }))).toBeNull(); + }); + + it('a measured EMPTY delta is reported, with zeros', () => { + const empty = { ...RELEASE, added: [], converted: [], migrated: [], removed: [] }; + expect(readSpecReleaseChanges(manifest({ release: empty }))).toMatchObject({ + added: 0, + removed: 0, + converted: 0, + migrated: 0, + }); + }); + + it('a half-readable section is no section — never a delta with a hole in it', () => { + expect(readSpecReleaseChanges(manifest({ release: { ...RELEASE, added: undefined } }))).toBeNull(); + expect(readSpecReleaseChanges(manifest({ release: { ...RELEASE, fromVersion: 17 } }))).toBeNull(); + expect(readSpecReleaseChanges(manifest({ release: { ...RELEASE, removed: 'three' } }))).toBeNull(); + }); + + it('an unreadable or absent file is silence, not a throw', () => { + expect(readSpecReleaseChanges(join(dir, 'does-not-exist.json'))).toBeNull(); + expect(readSpecReleaseChanges(manifest('{ not json'))).toBeNull(); + expect(readSpecReleaseChanges(null)).toBeNull(); + }); + + it('names the file it read, so a consumer can read the entries themselves', () => { + const path = manifest({ release: RELEASE }); + expect(readSpecReleaseChanges(path)?.source).toBe(path); + }); +}); diff --git a/packages/cli/src/utils/spec-release-changes.ts b/packages/cli/src/utils/spec-release-changes.ts new file mode 100644 index 00000000000..d058087e9b7 --- /dev/null +++ b/packages/cli/src/utils/spec-release-changes.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +/** + * Minor-resolution upgrade data, read from the installed artifact. + * + * ## The question this answers, and the one `protocolVersionGap` answers + * + * `protocolVersionGap` (`utils/protocol-version-gap.ts`) is a MAJOR-resolution + * advisory: it fires when the app's declared `engines.protocol` range excludes + * the installed platform, which by construction is a major boundary. An app + * declaring `^17` on spec 17.4.0 is compatible, so that advisory is `null` — + * correctly, and silently, even when the release it just installed narrowed + * several accept-sets. This repo ships BREAKING changes as minors under the + * launch-window convention, so "compatible at the major" is not "nothing to + * read". + * + * This reader answers the other half: what the installed RELEASE changed + * relative to the one published before it, at package-version resolution. It is + * not a second opinion about compatibility — it makes no compatibility judgment + * at all — so the two never disagree. + * + * ## Why it is a read and not a computation + * + * The delta is computed once, at publish time, from the two tarballs, and is + * verified there against them (`scripts/check-release-spec-changes.mjs`); it + * ships as the `release` section of `spec-changes.json` inside + * `@objectstack/spec` (ADR-0087 D4). Recomputing anything here would need the + * previous tarball, which the consumer does not have — and a second producer of + * the same fact is exactly the "two opinions" defect the protocol-gap advisory's + * own header names. Absent section ⇒ nothing is reported, never a zero: a + * release published before this section existed is not a release that changed + * nothing. + */ +export interface SpecReleaseChanges { + /** The previously published `@objectstack/spec` version this delta starts at. */ + fromVersion: string; + /** The installed `@objectstack/spec` version. */ + toVersion: string; + /** Public exports the installed release added. */ + added: number; + /** Public exports the installed release removed. */ + removed: number; + /** ADR-0087 D2 conversions first registered in the installed release. */ + converted: number; + /** ADR-0087 D3 semantic migrations first registered in the installed release. */ + migrated: number; + /** The file this was read from, so a consumer can read the named entries itself. */ + source: string; +} + +/** The `release` section shape, as far as this reader cares. */ +interface ReleaseSection { + fromVersion?: unknown; + toVersion?: unknown; + added?: unknown; + converted?: unknown; + migrated?: unknown; + removed?: unknown; +} + +function count(value: unknown): number | null { + return Array.isArray(value) ? value.length : null; +} + +/** + * Locate the installed `@objectstack/spec`'s `spec-changes.json`. + * + * Resolved from the CWD (the app) first and the CLI second, the same order and + * for the same reason as `resolveInstalledSpecVersion`: a globally linked CLI + * must report the platform the APP installed. + */ +function resolveManifestPath(): string | null { + for (const from of [`${process.cwd()}/package.json`, import.meta.url]) { + try { + const pkgJson = createRequire(from).resolve('@objectstack/spec/package.json'); + return join(dirname(pkgJson), 'spec-changes.json'); + } catch { + // not resolvable from here — try the next origin + } + } + return null; +} + +/** + * The installed release's own delta, or `null` when the artifact does not carry + * one (unresolvable spec, no manifest, a release published before the section + * existed, or a section this reader cannot fully trust). + * + * Every field is required: a partially readable section is reported as no + * section at all rather than as a delta with a hole in it, because the one + * failure mode that matters here is a consumer reading a number that is not + * true of the release it has. + */ +export function readSpecReleaseChanges( + /** Injectable for tests; defaults to the spec resolved from the app on disk. */ + manifestPath: string | null = resolveManifestPath(), +): SpecReleaseChanges | null { + if (!manifestPath) return null; + let section: ReleaseSection | undefined; + try { + const doc = JSON.parse(readFileSync(manifestPath, 'utf8')) as { release?: ReleaseSection }; + section = doc.release; + } catch { + return null; + } + if (!section || typeof section !== 'object') return null; + + const { fromVersion, toVersion } = section; + if (typeof fromVersion !== 'string' || typeof toVersion !== 'string') return null; + const added = count(section.added); + const removed = count(section.removed); + const converted = count(section.converted); + const migrated = count(section.migrated); + if (added === null || removed === null || converted === null || migrated === null) return null; + + return { fromVersion, toVersion, added, removed, converted, migrated, source: manifestPath }; +} diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index 67e6dde0fdf..68f64fd42f4 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -14,7 +14,12 @@ import { MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, } from './registry.js'; -import { composeSpecChanges, SpecChangesSchema } from './spec-changes.js'; +import { + composeReleaseChanges, + composeSpecChanges, + SpecChangesSchema, + SpecReleaseChangesSchema, +} from './spec-changes.js'; const CONVERSION_IDS = new Set(ALL_CONVERSIONS.map((c) => c.id)); @@ -483,3 +488,71 @@ describe('spec-changes.json manifest (ADR-0087 D4)', () => { expect(SpecChangesSchema.safeParse(changes).success).toBe(true); }); }); + +describe('per-release section (ADR-0087 D4, package-version resolution)', () => { + const aggregate = composeSpecChanges(MIGRATION_SUPPORT_FLOOR, PROTOCOL_MAJOR); + const allConversionIds = aggregate.converted.map((c) => c.conversionId); + const allMigrationIds = aggregate.migrated.map((m) => m.migrationId); + + it('reports only the registry entries this release added', () => { + // The previous release carried everything but the first conversion, so that + // one — and nothing else — is new in this release. + const [firstNew, ...alreadyPublished] = allConversionIds; + const release = composeReleaseChanges( + '17.3.0', + '17.4.0', + aggregate, + { conversionIds: alreadyPublished, migrationIds: allMigrationIds }, + { added: [], removed: [] }, + ); + expect(release.converted.map((c) => c.conversionId)).toEqual([firstNew]); + expect(release.migrated).toHaveLength(0); + expect(SpecReleaseChangesSchema.safeParse(release).success).toBe(true); + }); + + it('carries the export delta the two artifacts show, sorted', () => { + const release = composeReleaseChanges( + '17.3.0', + '17.4.0', + aggregate, + { conversionIds: allConversionIds, migrationIds: allMigrationIds }, + { added: ['./ui: Zed (const)', './ai: Alpha (const)'], removed: ['./integration: Gone (type)'] }, + ); + expect(release.added.map((a) => a.surface)).toEqual(['./ai: Alpha (const)', './ui: Zed (const)']); + expect(release.removed.map((r) => r.surface)).toEqual(['./integration: Gone (type)']); + expect(release.fromVersion).toBe('17.3.0'); + expect(release.toVersion).toBe('17.4.0'); + }); + + it('a release that moved nothing is four empty arrays, not a missing section', () => { + // The section is OMITTED when the delta cannot be computed; when it CAN be + // and is empty, the emptiness is the answer and must survive the schema. + const release = composeReleaseChanges( + '17.4.0', + '17.4.1', + aggregate, + { conversionIds: allConversionIds, migrationIds: allMigrationIds }, + { added: [], removed: [] }, + ); + expect(release.added).toHaveLength(0); + expect(release.removed).toHaveLength(0); + expect(release.converted).toHaveLength(0); + expect(release.migrated).toHaveLength(0); + expect(SpecReleaseChangesSchema.safeParse(release).success).toBe(true); + }); + + it('⛔ never re-attributes a release entry to a protocol MAJOR', () => { + // The section's own from/to is the exact attribution; a `since: 17` beside + // it would offer a coarser number in the one place a finer one is known — + // the defect the section exists to close. The schema refuses the old shape. + const release = composeReleaseChanges( + '17.3.0', + '17.4.0', + aggregate, + { conversionIds: allConversionIds, migrationIds: allMigrationIds }, + { added: ['./ai: Alpha (const)'], removed: [] }, + ); + expect(release.added[0]).toEqual({ surface: './ai: Alpha (const)' }); + expect(Object.keys(release.added[0])).not.toContain('since'); + }); +}); From c3ba6336c19cef1dec7edefd67906467be75ec1f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 03:32:58 +0000 Subject: [PATCH 3/7] chore(spec): regenerate api-surface and export-origins for the new exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven new declarations reach the root entry — the per-release schema, its surface-entry schema, the fold and their types. No export is removed or narrowed. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude --- packages/spec/api-surface/root.json | 7 +++++++ packages/spec/export-origins/root.json | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index dbcd4ad08b0..d0a072af865 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -127,8 +127,10 @@ "PredicateInput (type)", "PredicateInputSchema (const)", "PredicateSchema (const)", + "PreviousReleaseRegistries (interface)", "RETIRED_DEFS_BY_MAJOR (const)", "RETIRED_KEYS_BY_MAJOR (const)", + "ReleaseSurfaceDiff (interface)", "STACK_DEFINITION_KEYS (const)", "STACK_KEY_GUIDANCE (const)", "STACK_RUNTIME_MEMBERS (const)", @@ -140,6 +142,10 @@ "SpecConvertedSchema (const)", "SpecMigrated (type)", "SpecMigratedSchema (const)", + "SpecReleaseChanges (type)", + "SpecReleaseChangesSchema (const)", + "SpecReleaseSurface (type)", + "SpecReleaseSurfaceSchema (const)", "SpecSurfaceAdd (type)", "SpecSurfaceAddSchema (const)", "SpecSurfaceRemove (type)", @@ -160,6 +166,7 @@ "classifyRequiredCapability (function)", "collectConversionNotices (function)", "composeMigrationChain (function)", + "composeReleaseChanges (function)", "composeSpecChanges (function)", "composeStacks (function)", "createEvalUser (function)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index f5f450a556f..aae573e5662 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -126,8 +126,10 @@ "PredicateInput": "src/shared/expression.zod.ts#PredicateInput (type)", "PredicateInputSchema": "src/shared/expression.zod.ts#PredicateInputSchema (const)", "PredicateSchema": "src/shared/expression.zod.ts#PredicateSchema (const)", + "PreviousReleaseRegistries": "src/migrations/spec-changes.ts#PreviousReleaseRegistries (interface)", "RETIRED_DEFS_BY_MAJOR": "src/migrations/registry.ts#RETIRED_DEFS_BY_MAJOR (const)", "RETIRED_KEYS_BY_MAJOR": "src/migrations/registry.ts#RETIRED_KEYS_BY_MAJOR (const)", + "ReleaseSurfaceDiff": "src/migrations/spec-changes.ts#ReleaseSurfaceDiff (interface)", "STACK_DEFINITION_KEYS": "src/stack.zod.ts#STACK_DEFINITION_KEYS (const)", "STACK_KEY_GUIDANCE": "src/data/authoring-key-lint.ts#STACK_KEY_GUIDANCE (const)", "STACK_RUNTIME_MEMBERS": "src/data/authoring-key-lint.ts#STACK_RUNTIME_MEMBERS (const)", @@ -139,6 +141,10 @@ "SpecConvertedSchema": "src/migrations/spec-changes.ts#SpecConvertedSchema (const)", "SpecMigrated": "src/migrations/spec-changes.ts#SpecMigrated (type)", "SpecMigratedSchema": "src/migrations/spec-changes.ts#SpecMigratedSchema (const)", + "SpecReleaseChanges": "src/migrations/spec-changes.ts#SpecReleaseChanges (type)", + "SpecReleaseChangesSchema": "src/migrations/spec-changes.ts#SpecReleaseChangesSchema (const)", + "SpecReleaseSurface": "src/migrations/spec-changes.ts#SpecReleaseSurface (type)", + "SpecReleaseSurfaceSchema": "src/migrations/spec-changes.ts#SpecReleaseSurfaceSchema (const)", "SpecSurfaceAdd": "src/migrations/spec-changes.ts#SpecSurfaceAdd (type)", "SpecSurfaceAddSchema": "src/migrations/spec-changes.ts#SpecSurfaceAddSchema (const)", "SpecSurfaceRemove": "src/migrations/spec-changes.ts#SpecSurfaceRemove (type)", @@ -159,6 +165,7 @@ "classifyRequiredCapability": "src/kernel/platform-capabilities.ts#classifyRequiredCapability (function)", "collectConversionNotices": "src/conversions/apply.ts#collectConversionNotices (function)", "composeMigrationChain": "src/migrations/chain.ts#composeMigrationChain (function)", + "composeReleaseChanges": "src/migrations/spec-changes.ts#composeReleaseChanges (function)", "composeSpecChanges": "src/migrations/spec-changes.ts#composeSpecChanges (function)", "composeStacks": "src/stack.zod.ts#composeStacks (function)", "createEvalUser": "src/identity/eval-user.zod.ts#createEvalUser (function)", From fe3c7522e4ce169cb7af110ff07fc45f710fc4a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 03:35:31 +0000 Subject: [PATCH 4/7] fix(scripts): put the release gate's dispatch behind the entrypoint guard The gate exports `verifyRelease` so its self-test drives the same function the release lane calls; a `scripts/**` module that exports a binding and dispatches at the top level runs inside whoever imports it (`pnpm check:entry-guard`). Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude --- scripts/check-release-spec-changes.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/check-release-spec-changes.mjs b/scripts/check-release-spec-changes.mjs index 5c644463f8c..bf2d944d473 100755 --- a/scripts/check-release-spec-changes.mjs +++ b/scripts/check-release-spec-changes.mjs @@ -55,6 +55,8 @@ import fs from 'node:fs'; import path from 'node:path'; +import { isEntrypoint } from './invoked-as.mjs'; + const API_SURFACE_DIR = 'api-surface'; const API_SURFACE_MONOLITH = 'api-surface.json'; const MANIFEST = 'spec-changes.json'; @@ -665,4 +667,10 @@ function main() { console.log(`✓ ${verdict.summary}`); } -main(); +// `verifyRelease` is exported so the self-test drives the same function the +// release lane calls. An exported module whose top level also DISPATCHES ends +// its importer's import instead — so the dispatch is behind the guard +// (`pnpm check:entry-guard`). +if (isEntrypoint(import.meta.url)) { + main(); +} From 681e1abea06b3320764f2905ba4f418af1782015 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 06:00:43 +0000 Subject: [PATCH 5/7] fix(scripts,cli): resolve the gate's scratch base statically, and classify the release-delta reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI reds at the pushed head, both this branch's. `scripts/check-release-spec-changes.mjs` seeded its self-test tree from `fs.realpathSync('/tmp')`. The exposed-scratch-dir sweep in `scripts/pm/dispatch-gates.mjs` resolves every `mkdtempSync`/`mkdirSync` base statically to prove no gate writes its scratch tree into the repo, and reports a base it cannot read as UNRESOLVED rather than assuming it is fine — so the sweep reddened on a call it does not model. `tmpdir()` is the spelling every other gate in `scripts/` uses, it is what the sweep reads as outside the tree, and it honours TMPDIR/RUNNER_TEMP where the literal did not. `packages/cli/test/validate-build-gate-parity.test.ts` holds a CLOSED roster: every bare identifier `compile.ts` or `validate.ts` calls must land in exactly one of its three ledgers. `readSpecReleaseChanges` arrived in `validate.ts` unclassified. It goes in NOT_A_GATE, with the reason: it reads the installed `@objectstack/spec`'s own published per-release delta and takes nothing from the stack, so it reaches no verdict and can refuse nothing. The row states the distinction from `checkProtocolVersionGap` next to it, which does judge the input and is therefore a shared gate. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude --- .../test/validate-build-gate-parity.test.ts | 20 +++++++++++++++++++ scripts/check-release-spec-changes.mjs | 10 +++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 6b77b8565b5..450962497a9 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -221,6 +221,26 @@ const NOT_A_GATE: Readonly> = { 'cleanupOldRuntimeBundles', 'warningsSoFar', ], + // [#17080] Reads a fact about the TOOLCHAIN, not about the input. The + // ADR-0087 D4 `release` section is computed at publish time from the two + // tarballs and shipped inside the installed `@objectstack/spec`; this reader + // opens that file and counts its entries. It takes nothing from the stack, so + // its answer is the same for every stack on the machine — it can reach no + // verdict, raise no finding and refuse nothing, and `os validate` exits + // identically whether it returns a delta or `null`. + // + // ⛔ NOT the `checkProtocolVersionGap` row in SHARED_NON_REGISTRY_GATES + // above, which this sits next to in `validate.ts` and is easy to mistake for + // a sibling. That one JUDGES THE INPUT — it reads `engines.protocol` off the + // stack manifest and reports when the installed platform falls outside the + // range the author declared — which is why it is a gate, why it must be wired + // into both commands, and why parity applies to it. Nothing here reads the + // stack at all. If this ever grows a comparison against something the author + // wrote, it becomes a gate that day and moves up to + // SHARED_NON_REGISTRY_GATES, wired into `compile.ts` too. + 'Reports a property of the installed platform, derived from no part of the stack — judges no input': [ + 'readSpecReleaseChanges', + ], 'Not ours — a Node builtin, a global, an oclif base or a third-party namespace': [ 'dirname', 'String', diff --git a/scripts/check-release-spec-changes.mjs b/scripts/check-release-spec-changes.mjs index bf2d944d473..530185453f8 100755 --- a/scripts/check-release-spec-changes.mjs +++ b/scripts/check-release-spec-changes.mjs @@ -53,6 +53,7 @@ */ import fs from 'node:fs'; +import { tmpdir } from 'node:os'; import path from 'node:path'; import { isEntrypoint } from './invoked-as.mjs'; @@ -351,7 +352,14 @@ function writeTree(root, files) { } function selfTest() { - const tmp = fs.mkdtempSync(path.join(fs.realpathSync('/tmp'), 'release-spec-changes-selftest-')); + // `tmpdir()`, never a `/tmp` literal and never a `realpathSync` around one: + // the scratch-dir sweep in `scripts/pm/dispatch-gates.mjs` resolves this base + // statically to prove no gate writes its scratch tree INTO the repo, and a + // base it cannot read is reported UNRESOLVED rather than assumed fine. A call + // it does not model hides an in-tree scratch dir just as effectively as one + // that really is in-tree. `tmpdir()` also honours TMPDIR/RUNNER_TEMP, which a + // hardcoded `/tmp` does not. + const tmp = fs.mkdtempSync(path.join(tmpdir(), 'release-spec-changes-selftest-')); let failed = 0; const batterySeen = new Map(); const registerCase = (label) => batterySeen.set(label, (batterySeen.get(label) ?? 0) + 1); From 1737a24b510f1086af658c1351ad53ec85e1be7e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 08:32:39 +0000 Subject: [PATCH 6/7] docs(spec): say in the shipped artifact that a withdrawal is not reported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-release section carries ADR-0087 D4's four arrays, and the boundary they draw — an id that disappeared from the published chain is deliberately NOT reported — lived only in `composeReleaseChanges`'s JSDoc and the PR body. A consumer reads `release.converted: []` as "no conversion change" while an id really did leave the chain between the two releases. Say it where a consumer reads it: the shipped manifest's `$comment` and the upgrading page's "what it does not tell you" callout, with the two-manifest comparison that does answer the question. Documentation of an existing boundary — no fifth array, no shape change, and the generator and the release gate are untouched. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude --- content/docs/upgrading.mdx | 10 ++++++++++ packages/spec/scripts/build-spec-changes.ts | 7 ++++++- packages/spec/spec-changes.json | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index ee8f6a16fab..bf18219095c 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -368,6 +368,16 @@ enforcing a constraint that was declared and inert, without adding or removing a single export. A delta of `0 added, 0 removed` is a real measurement of the export surface and says nothing about the accept-sets behind it — the release checklist above is still the record for those. + +**And it does not report withdrawals.** All four arrays report what a release +**added**: `converted` and `migrated` list the ADR-0087 ids *first registered* +in it. An id that **left** the published chain between the two releases is in +none of them — `converted: []` means "this release registered none", never +"none was withdrawn". ADR-0087 D4 names these four arrays and this section +carries exactly those four; to see a withdrawal, compare the `aggregate` +records of the two installed manifests (`.aggregate.converted[].conversionId` +and `.aggregate.migrated[].migrationId`) — an id present in the older one and +absent from the newer one was withdrawn. ### The per-package changelogs are the exhaustive record diff --git a/packages/spec/scripts/build-spec-changes.ts b/packages/spec/scripts/build-spec-changes.ts index 4b8e784b03f..12c41353381 100644 --- a/packages/spec/scripts/build-spec-changes.ts +++ b/packages/spec/scripts/build-spec-changes.ts @@ -203,7 +203,12 @@ function build(): string { $comment: 'GENERATED (ADR-0087 D4) — do not edit. Regenerate with: pnpm --filter @objectstack/spec gen:spec-changes. ' + 'A projection of the D2 conversion table + D3 migration chain; the upgrade guide and the MCP spec_changes ' + - 'tool derive from this same data.', + 'tool derive from this same data. ' + + 'When a `release` section is present, its four ADR-0087 D4 arrays report what that release ADDED: ' + + '`added`/`removed` are the export-surface diff of the two published tarballs, and `converted`/`migrated` ' + + 'are the D2/D3 ids FIRST REGISTERED in it. An id that LEFT the published chain between the two releases ' + + 'is reported in none of them — `converted: []` means "this release registered none", never "none was ' + + 'withdrawn"; a withdrawal is visible only by comparing two published manifests.', protocolVersion: PROTOCOL_VERSION, supportFloor: MIGRATION_SUPPORT_FLOOR, migrateCommand: `objectstack migrate meta --from (N >= ${MIGRATION_SUPPORT_FLOOR})`, diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 1182ae68748..9dbc98682bf 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -1,5 +1,5 @@ { - "$comment": "GENERATED (ADR-0087 D4) — do not edit. Regenerate with: pnpm --filter @objectstack/spec gen:spec-changes. A projection of the D2 conversion table + D3 migration chain; the upgrade guide and the MCP spec_changes tool derive from this same data.", + "$comment": "GENERATED (ADR-0087 D4) — do not edit. Regenerate with: pnpm --filter @objectstack/spec gen:spec-changes. A projection of the D2 conversion table + D3 migration chain; the upgrade guide and the MCP spec_changes tool derive from this same data. When a `release` section is present, its four ADR-0087 D4 arrays report what that release ADDED: `added`/`removed` are the export-surface diff of the two published tarballs, and `converted`/`migrated` are the D2/D3 ids FIRST REGISTERED in it. An id that LEFT the published chain between the two releases is reported in none of them — `converted: []` means \"this release registered none\", never \"none was withdrawn\"; a withdrawal is visible only by comparing two published manifests.", "protocolVersion": "17.0.0", "supportFloor": 10, "migrateCommand": "objectstack migrate meta --from (N >= 10)", From 2733f3f2495146867eba94dff1d0fce6b7dc14df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 10:36:28 +0000 Subject: [PATCH 7/7] chore(spec): regenerate the declaration-text shard for this PR's 7 new exports `main` grew `packages/spec/api-surface-declarations/**` after this branch was enqueued, so the queue build's `check:api-surface-declarations` saw this PR's 7 root exports as undeclared text. Regenerated with the remedy the gate prints; no hand edit to a shard. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho --- .../spec/api-surface-declarations/root.txt | 55 ++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/spec/api-surface-declarations/root.txt b/packages/spec/api-surface-declarations/root.txt index 7ebf656f6da..6c83181913b 100644 --- a/packages/spec/api-surface-declarations/root.txt +++ b/packages/spec/api-surface-declarations/root.txt @@ -12,8 +12,8 @@ # excluded: documentation drift is `check:docs`'s axis, not this one. # # entry: . -# exported names: 212 -# declarations: 213 +# exported names: 219 +# declarations: 220 # # GENERATED — ⛔ never hand-edited. Regenerate after a real build: # pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec gen:api-surface-declarations @@ -44865,12 +44865,24 @@ declare const PredicateSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$strip>; +// ── PreviousReleaseRegistries (interface) ── +interface PreviousReleaseRegistries { + conversionIds: readonly string[]; + migrationIds: readonly string[]; +} + // ── RETIRED_DEFS_BY_MAJOR (const) ── declare const RETIRED_DEFS_BY_MAJOR: Readonly>; // ── RETIRED_KEYS_BY_MAJOR (const) ── declare const RETIRED_KEYS_BY_MAJOR: Readonly>; +// ── ReleaseSurfaceDiff (interface) ── +interface ReleaseSurfaceDiff { + added: readonly string[]; + removed: readonly string[]; +} + // ── STACK_DEFINITION_KEYS (const) ── declare const STACK_DEFINITION_KEYS: readonly StackDefinitionKey[]; @@ -44954,6 +44966,42 @@ declare const SpecMigratedSchema: z.ZodObject<{ rationale: z.ZodString; }, z.core.$strip>; +// ── SpecReleaseChanges (type) ── +type SpecReleaseChanges = z.infer; + +// ── SpecReleaseChangesSchema (const) ── +declare const SpecReleaseChangesSchema: z.ZodObject<{ + fromVersion: z.ZodString; + toVersion: z.ZodString; + added: z.ZodArray>; + converted: z.ZodArray>; + migrated: z.ZodArray>; + removed: z.ZodArray>; +}, z.core.$strip>; + +// ── SpecReleaseSurface (type) ── +type SpecReleaseSurface = z.infer; + +// ── SpecReleaseSurfaceSchema (const) ── +declare const SpecReleaseSurfaceSchema: z.ZodObject<{ + surface: z.ZodString; +}, z.core.$strip>; + // ── SpecSurfaceAdd (type) ── type SpecSurfaceAdd = z.infer; @@ -45078,6 +45126,9 @@ declare function collectConversionNotices(stack: Record, option // ── composeMigrationChain (function) ── declare function composeMigrationChain(fromMajor: number, toMajor?: number): MigrationStep[]; +// ── composeReleaseChanges (function) ── +declare function composeReleaseChanges(fromVersion: string, toVersion: string, current: SpecChanges, previous: PreviousReleaseRegistries, surfaceDiff: ReleaseSurfaceDiff): SpecReleaseChanges; + // ── composeSpecChanges (function) ── declare function composeSpecChanges(fromMajor: number, toMajor: number, surfaceDiff?: SurfaceDiff): SpecChanges;