Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/17080-per-release-spec-changes.md
Original file line number Diff line number Diff line change
@@ -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 `"<entry>: <name> (<kind>)"`) 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`.
12 changes: 12 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 42 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
65 changes: 65 additions & 0 deletions content/docs/upgrading.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="warn">
**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.
</Callout>

### The per-package changelogs are the exhaustive record

The release pages above are **triaged**, deliberately: a change is written up
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions packages/cli/src/utils/spec-release-changes.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading