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/.github/workflows/lint.yml b/.github/workflows/lint.yml index ea14132ddb1..1818c40c42f 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/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 3ec0727d73d..bf18219095c 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -315,6 +315,71 @@ 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. + +**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 The release pages above are **triaged**, deliberately: a change is written up diff --git a/package.json b/package.json index 1e2955dc715..57ab936beaf 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/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index dd1947b1a97..df77b7d727e 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'; @@ -590,6 +591,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 @@ -696,6 +705,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/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/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; 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)", diff --git a/packages/spec/scripts/build-spec-changes.ts b/packages/spec/scripts/build-spec-changes.ts index a039ee51ef2..12c41353381 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,15 +197,26 @@ function build(): string { const aggregate = SpecChangesSchema.parse( composeSpecChanges(MIGRATION_SUPPORT_FLOOR, PROTOCOL_MAJOR, surfaceDiff), ); + const release = buildReleaseSection(aggregate); const doc = { $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})`, + // 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 +226,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/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)", 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/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'); + }); +}); 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..530185453f8 --- /dev/null +++ b/scripts/check-release-spec-changes.mjs @@ -0,0 +1,684 @@ +#!/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 { tmpdir } from 'node:os'; +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'; +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() { + // `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); + + 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}`); +} + +// `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(); +} 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}"