Skip to content

Commit 5b12503

Browse files
spec(changes): generate the per-major spec-changes section and the upgrade guide at publish; the pull request generates both in memory and renders the diff (#22449 B′, condition 1) (#22533)
Fixes #22482 Clause-②: no The per-major section of `spec-changes.json` and the protocol upgrade guide are now generated from the ADR-0087 registries at publish, verified there, and shipped in the `@objectstack/spec` tarball and on the GitHub Release, by the lane the `release` section already uses (`scripts/release-spec-changes.sh`). `check:spec-changes` and `check:upgrade-guide` stop comparing a committed copy: they generate both in memory and go red when generation fails. Every pull request renders the generated diff against its base. This is ruling `6078203801` on #22449 (B′), its condition (1), and the pin in triage `6081379232`. The committed copies and their `merge=os-regen` routes stay; deleting them is #22485, which remains open. The guide's public address and the pointer stub are #22483. ADR-0087 D4 is #22484. ## What changed **One generation entry point per projection.** `build-spec-changes.ts` and `build-upgrade-guide.ts` keep their `build()` functions and now share one command-line contract, `packages/spec/scripts/lib/projection-cli.ts`: | Mode | Does | |---|---| | no flag | writes the committed copy, as before (kept until #22485) | | `--check` | generates in memory and writes nothing. ⛔ No committed copy is read. Exit 1 when generation fails, naming the projection and the cause | | `--out FILE` | writes the generated bytes to `FILE`. The publish lane and the diff renderer use it | The publish lane, the pull-request check and the pull-request diff all run these same two scripts, so they cannot disagree. **At publish** (`scripts/release-spec-changes.sh`): - `--prepare` (release.yml) now also writes the guide into the package: `build-upgrade-guide.ts --out packages/spec/protocol-upgrade-guide.md`. It already rewrote all of `spec-changes.json` from the registries, per-major records included. That part is unchanged. - `--generate` (new, for cut-rc.yml) writes both registry projections and **no** per-release section. That is what an rc tarball has always carried. It reads nothing from npm. - `--verify` packs the artifact and runs `check-release-spec-changes.mjs` with a new `--registry DIR`: a fresh generation of both projections. The packed copies must equal it. For the manifest that covers everything except the publish-time fields: the `release` section and the aggregate's one-release export diff. For the guide it is byte equality. After `--prepare` the existing per-release checks run unchanged. After `--generate` the lane declares `--no-release-section`. - `--attach` uploads both files in one `gh release upload` call. - `packages/spec/package.json` `files[]` lists `protocol-upgrade-guide.md`. `scripts/check-published-files.mjs` registers it, as its REGISTERED invariant requires. - `cut-rc.yml` runs `--generate` and `--verify` before its publish. Before this change it packed whatever copy the tree held. **At pull request** (lint.yml, `Type Check · source gates`, a required lane with no paths filter): - The two check steps now generate in memory. - A new step runs `packages/spec/scripts/render-projection-diff.ts --base HEAD^1`. On a pull request, `HEAD^1` is the base tip the merge ref sits on. The step generates both sides with the same `--out` generators: this checkout's, and the base's own generators in a `git archive` of the base. It renders the diff into the job summary and a `::notice` annotation. A per-record headline names the registry ids each record gained or lost. It writes `spec-projections.diff`, and the next step uploads that file as the `spec-projections-diff` artifact, only when the diff is non-empty. A side that cannot be generated is red. A diff, however large, is never red. ## The PM's hypotheses, measured first **H1, the pre-pack hook: confirmed, and the guide half was missing.** Dry run on `9411faa1ba` (the base), `RELEASE_VERSION=17.7.0`, previous `17.6.0` read from npm. `--prepare` exit 0, `--verify` exit 0. - The packed tarball held `CHANGELOG.md LICENSE README.md api-surface liveness llms.txt package.json prompts spec-changes.json src` and **no guide**: `docs/` ships in no package. - The packed `perMajor` equalled the committed one, and so did the aggregate's registry half. `--prepare` already regenerates the whole manifest, per-major records included, at the point the ruling asks for. - In release.yml both `--prepare` and `--verify` run before `Publish to npm + push version tags` (`pnpm run release` = build + build-console + `changeset publish`). `--attach` runs after `Create GitHub Releases`. The spec build (`gen:schema`, `gen:openapi`, tsup) writes neither projection. Same dry run on this branch, `--prepare` exit 0 and then `--verify` exit 0: - The tarball now also carries `protocol-upgrade-guide.md`. - `--verify` prints `✓ registry projections verified against a fresh generation: spec-changes.json (2 per-major record(s), 121 converted / 406 migrated) and protocol-upgrade-guide.md (1590904 bytes) match the artifact.` - Then `✓ release 17.6.0 → 17.7.0 verified against both tarballs: 83 added, 28 removed, 64 converted, 329 migrated. aggregate export diff 17.6.0 → 17.7.0 verified: 83 added, 28 removed.` That line is byte-identical to the base run's. **H2, every reader of the committed copy, at `9411faa1ba`.** For each: what it reads after this PR, before #22485 deletes the copies. | Reader | Reads after this PR | |---|---| | `check:spec-changes`, `check:upgrade-guide` | nothing: generation in memory | | `packages/spec/scripts/check-generated.ts` (GATED rows for both) | runs the two checks by name. They no longer go stale-red, so `--fix` never regenerates the copies. The rows' `artifact` text still names the copies (wording for #22485) | | `scripts/regen-artifacts.mjs` + `.githooks/pre-commit` (`merge=os-regen`) | a merge deferral of either path is discharged by the two checks, with no regeneration. The copy keeps whatever the merge produced | | `scripts/pm/os-regen-merge.sh` and the pm-dispatch hot-file row (`.claude/skills/pm-dispatch/SKILL.md:296`) | its regeneration step (`check:generated --fix`) no longer regenerates the two copies | | `scripts/check-adr-0087-registration.mjs:2184` | still reads the committed `spec-changes.json` at HEAD, as its parser-rot witness (projection ⊆ source). It now reads an aging copy. That copy stays a subset while registration is insertion-only, but a **withdrawn** id would false-red it. #22485 must give it a generated witness before deleting the copy | | `scripts/check-future-spec-major.mjs:351`, `:363` | exemption rows with `covers` counts over both tracked files. The frozen copies keep their counts | | `scripts/check-doc-authoring.mjs:1597` | walks the frozen `docs/protocol-upgrade-guide.md` (#22483 turns it into a stub) | | release.yml `version-pr` (`run_gate … check:spec-changes / check:upgrade-guide`) | generation only. The step comment is corrected | | release.yml publish job and Releases backfill | never read it: `--prepare` overwrites it in the tree. Now also writes the guide | | cut-rc.yml | **packed it as-is** before this PR. Now `--generate` plus `--verify` | | `packages/spec/package.json` `files[]` | ships whatever is at `packages/spec/spec-changes.json` at pack time, which is now always the lane's generation | | `.claude/skills/spec-property-retirement/SKILL.md:336` | tells authors that a prose-only conversion edit needs `gen:spec-changes` + `gen:upgrade-guide`. That is no longer needed. Governed `.claude/**`, not touched here | | comment-only: `pr-automation.yml:905`, `scripts/pm/dispatch-gates.mjs:5667`, and the retirement pins' `EXCLUDED_PREFIXES` | they do not read the content | Cloud was moved by cloud#2750. It runs `gen:spec-changes` in its pinned checkout, and this PR leaves that script's default output path as it was. **H3, the window before #22485: the lanes that ship generate. One lane did not, and now does.** - release.yml publish and backfill overwrite the committed bytes. Measured: a drifted tree copy, with `websocket-durations-unit-in-key` dropped from `perMajor[17 → 18]`, was rewritten by `--prepare`, and `--verify` exited 0. - cut-rc.yml packed the committed bytes with no generation step. In the window that would publish a stale `perMajor`. After #22485 it would publish no `spec-changes.json` at all, because `npm pack` silently skips a missing `files[]` entry. - The lane is dormant today: `.changeset/pre.json` has tag `next`, and cut-rc refuses anything but `rc`. The minimal guard is `--generate` + `--verify` before its publish. Measured on the same drifted tree: `--generate` overwrote it, then `--verify` exit 0 with `✓ no per-release section is owed`. The packed manifest keys are the same as an rc tarball's today: no `release`. - The guard's red was also measured. A stale copy packed after generation gives `--verify` exit 1, naming `perMajor[17 → 18].migrated: 1 id(s) the registries project and the artifact OMITS: websocket-durations-unit-in-key`. A tarball without the guide gives exit 1 `ships no protocol-upgrade-guide.md`. `--verify` with neither `--prepare` nor `--generate` gives exit 1 `Nothing was verified.` **H4, rendering on the pull request: the job summary, a notice annotation and an artifact, with no write path.** Measured in `.github/workflows`: - `$GITHUB_STEP_SUMMARY` is used in 13 workflows. - `actions/upload-artifact@v7` is used in ci.yml (9 steps), cut-rc, merged-branch-reaper, showcase-smoke, test-nightly-tiers and coverage-nightly. - PR comments go through `issues.createComment` under `pull-requests: write` in docs-drift-check.yml (advisory), merge-queue-triage.yml and cross-repo-issue-closer.yml. - The required `typecheck-source-gates` lane holds `contents: read` only. So the diff renders where that lane can write without a token: its summary, an annotation, and an artifact. No new write path is added. ## Pins - `packages/spec/scripts/projection-cli.test.ts` (11 tests): - `--check` neither reads nor writes a stale committed copy, and is green with none; - a generation failure is red in `--check` and in both write modes, and writes nothing; - `--out` writes only there; - usage errors build nothing; - the two real generators route through the runner and are byte-deterministic across runs, which `--verify` depends on. - `packages/spec/scripts/render-projection-diff.test.ts` (9 tests): - an entry-only change renders its id in the headline, the diff and the artifact; - unchanged renders "no change" with no artifact; - a head or base side that fails to generate is red; - a base that predates `--out` is read from the path it writes, and the head never falls back; - plus the id delta, the fence and truncation. - `scripts/check-release-spec-changes.mjs --self-test`: batteries 23 → 31 (P1–P8, the registry half). R1–R17 are untouched and green. - `scripts/release-verify-npm.mjs --self-test` battery 13 floor 11 → 13 (113 → 115 cases). The backfill generates the guide in the version commit's tree and attaches the publish job's bytes. - **`release` section unchanged:** R1–R17 are unchanged, and the H1 verdict line for the section is byte-identical before and after. One-shot proofs, not kept as tests: - **An entry-only change needs no regeneration commit.** I added a step-18 semantic entry file plus `gen:migration-registry` (the registry's generated regions) and regenerated no projection. - `check:spec-changes` exit 0 (407 migrated) and `check:upgrade-guide` exit 0. - Control: the base's `build-spec-changes.ts --check` on the same tree exits 1, `spec-changes.json is stale`. - `render-projection-diff.ts --base HEAD` exits 0: `spec-changes.json +14 −0 (perMajor 17 → 18: +1 migrated (zz-demo-entry-only); aggregate 16 → 18: +1 migrated (zz-demo-entry-only)) · protocol-upgrade-guide.md +3 −0`. - Restored, `git diff HEAD` empty. - **A registry that fails to generate turns the check red.** The same entry with a non-string `replacement`: - `check:spec-changes` exits 1: `✗ spec-changes.json could not be generated from the ADR-0087 registries. Nothing was written.`, then the ZodError. - The renderer exits 1: `NOT rendered — head spec-changes.json: build-spec-changes.ts exited 1`. - Restored. - **Ablation of battery 13's new case.** I dropped the guide from the `gh release upload` line through `scripts/ablation-replace.mjs`: anchor 1 → 0, blob `d9cde2bfbe94` → `516d5f9dc619`. The self-test exited 1 on exactly that case. Restored, blob equals HEAD. ## Verification All readings at `1245a3058c` (this branch's head) unless a line says otherwise. - **Derived gate union.** `node scripts/pm/dispatch-gates.mjs --commands` printed 128 commands. All 128 ran here, each with its exit code recorded, and all exited 0. `--ran` reports: `128 derived, 128 run, 0 NOT-MEASURED, 0 UNRUN`. - An earlier pass at `58d075abdc` recorded exit 3 (`PREREQUISITE NOT MET`, no build) on the four gates that read built `dist/`: `dts-closure`, `dual-build-cjs-loads`, `lean-entry-closure` and `sourcemap-no-sources-content`. - The spec `repo`-project tests then built the workspace's `dist/` in this worktree. The final pass measured all four green. - This covers every command the dispatch named, plus what the re-derivation added: `check-declaration-mirrors` (both), `release-verify-npm --self-test`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:ratchet-remedy-authority`, `check:release-spec-changes` and `check:where-matcher`. - **Spec suites, under `scripts/pm/os-verify-lock.sh`.** - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: VERDICT command-exit 0, 632 files passed, 1 skipped, 18847 tests passed. Taken at `58d075abdc`; `1245a3058c` adds only a three-line comment. - `pnpm --filter @objectstack/spec exec vitest run --project repo --maxWorkers=2`: VERDICT command-exit 0, 54 files, 915 tests passed. - `pnpm --filter @objectstack/spec typecheck`: VERDICT command-exit 0. The scripts program lists all four new script files. - **The two new test files:** 20 of 20 tests pass. - **ESLint, narrowed and measured:** - Population: the 9 changed JS/TS files, all under `eslint.config.mjs`'s `**/*.{ts,…}` object. - `--no-inline-config --format json` reports 9 files: 0 errors, 0 warnings, 0 ignored notices. - The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on a file it does not touch. - The repo-wide `pnpm lint` is CI's. - **Main.** The branch is 6 commits behind `origin/main` `d303b3e7af`. One of those commits (`ee8751d41e`) regenerated the two committed copies, which this diff does not touch. No commit touches a file in this diff, and `git merge-tree` is clean. CI runs on the merge ref. ## File surface Two extensions beyond the claim's list, both forced: - `packages/spec/package.json` `files[]`: the same file as the in-surface check scripts. The ruling ships the guide "in the package", and H1 asks the tarball to carry it. - `scripts/check-published-files.mjs` `EXTRA_ENTRIES`: one entry, which its REGISTERED invariant requires for any new `files[]` entry. Also touched, reading them as the lane's own parts: - `scripts/check-release-spec-changes.mjs`: the gate `--verify` runs. - `scripts/release-verify-npm.mjs`: battery 13 executes the lane's release.yml steps. - `cut-rc.yml`: a release workflow step that calls the script. ## Acceptance notes - `check-generated.ts` GATED rows still call the two committed files their artifacts; the deletion card (#22485) owns that wording. - `check-adr-0087-registration.mjs`'s parser-rot witness reads the committed copy; #22485 must re-point it before deleting the copy (see H2). - The in-package guide `packages/spec/protocol-upgrade-guide.md` is written only by the lane and is not gitignored. A local `--prepare` or `--generate` leaves it untracked, the same way `--prepare` already leaves `spec-changes.json` modified. Neither is ever committed by the lane. --- _Generated by [Claude Code](https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f782f17 commit 5b12503

15 files changed

Lines changed: 1667 additions & 128 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): the protocol upgrade guide ships inside `@objectstack/spec`, and both ADR-0087 projections are generated and verified at publish
6+
7+
Clause-②: no
8+
9+
`@objectstack/spec` now ships `protocol-upgrade-guide.md` beside `spec-changes.json`. An upgrading project reads the guide for the exact version it installed, offline:
10+
11+
```bash
12+
cat node_modules/@objectstack/spec/protocol-upgrade-guide.md
13+
```
14+
15+
The same file is attached to the package's GitHub Release, next to `spec-changes.json`. Its content is unchanged: one section per protocol major from the chain's support floor on, generated from the ADR-0087 conversion table and migration chain.
16+
17+
Both projections are now generated by the release lane, from the registries, right before the tarball is packed. The lane refuses to publish an artifact whose copies differ from a fresh generation. This covers the per-major records and aggregate of `spec-changes.json`, and the whole guide. The rc lane takes the same step, so an rc tarball no longer carries a copy that was committed earlier. The per-release `release` section of `spec-changes.json` is generated and verified exactly as before.
18+
19+
Nothing changes for an author, and nothing is renamed or removed.

‎.github/workflows/cut-rc.yml‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,31 @@ jobs:
876876
echo "::warning::hotcrm@${HOTCRM_REF} is incompatible with the pre-release train — expected for this window's deliberate removals. Ship a migrated hotcrm release, bump it, and set BLOCKING=1 in .github/workflows/release.yml to re-arm this gate."
877877
fi
878878
879+
# ────────────────────────────────────────────────────────────────────────
880+
# ADR-0087 D4 — the two generated projections, under the #22449 B′ ruling.
881+
#
882+
# spec-changes.json and the protocol upgrade guide are generated from the
883+
# registries at publish and nowhere else that ships; no committed copy is
884+
# kept current any more. This lane used to pack whatever copy the tree
885+
# held, so it now generates both into the package (`--generate`: the
886+
# registry projections and NO per-release section — what an rc tarball has
887+
# always carried) and refuses a tarball whose copies differ from a fresh
888+
# generation (`--verify`), before anything reaches npm. Same order, same
889+
# reason as release.yml's publish job: after `changeset publish` the
890+
# tarball is immutable.
891+
# ────────────────────────────────────────────────────────────────────────
892+
- name: Generate the ADR-0087 projections into the package (ADR-0087 D4)
893+
if: ${{ !inputs.dry_run }}
894+
env:
895+
RELEASE_VERSION: ${{ inputs.version }}
896+
run: bash scripts/release-spec-changes.sh --generate
897+
898+
- name: Correctness gate — the packed projections must match the registries
899+
if: ${{ !inputs.dry_run }}
900+
env:
901+
RELEASE_VERSION: ${{ inputs.version }}
902+
run: bash scripts/release-spec-changes.sh --verify
903+
879904
# ────────────────────────────────────────────────────────────────────────
880905
# PUBLISH. `pnpm run release` = build + build-console + release-publish.sh,
881906
# which is `changeset publish` followed by ONE atomic `git push origin --tags`
@@ -928,7 +953,9 @@ jobs:
928953

929954
- name: Attach spec-changes.json to the GitHub Release (ADR-0087 D4)
930955
# Ordering is load-bearing: `gh release upload` needs the Release the step
931-
# above created.
956+
# above created. Uploads BOTH projections `--generate` wrote and `--verify`
957+
# proved — spec-changes.json and protocol-upgrade-guide.md — so the Release
958+
# assets and the tarball are the same bytes.
932959
if: ${{ !cancelled() && steps.publish.outputs.published == 'true' }}
933960
env:
934961
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

‎.github/workflows/lint.yml‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5621,12 +5621,42 @@ jobs:
56215621
- name: Check the meta-url-spelling data module is current and spellings agree
56225622
run: pnpm --filter @objectstack/spec check:meta-url-spelling
56235623

5624-
- name: Check spec-changes.json is regenerated with the ADR-0087 registries
5624+
# The two ADR-0087 D4 projections, under the #22449 B′ ruling. They are
5625+
# generated where they ship — at publish, by scripts/release-spec-changes.sh,
5626+
# which also verifies the packed copies against a fresh generation — so
5627+
# these two steps compare NO committed copy. They generate both from the
5628+
# registries in memory and go red only when generation fails, so a pull
5629+
# request that only adds a registry entry needs no regeneration commit.
5630+
# Like their neighbours they read source via tsx and need no build.
5631+
- name: Check spec-changes.json generates from the ADR-0087 registries
56255632
run: pnpm --filter @objectstack/spec check:spec-changes
56265633

5627-
- name: Check the protocol upgrade guide is regenerated with the ADR-0087 registries
5634+
- name: Check the protocol upgrade guide generates from the ADR-0087 registries
56285635
run: pnpm --filter @objectstack/spec check:upgrade-guide
56295636

5637+
# The ruling's condition (1): the review value the committed copies carried
5638+
# — the generated diff — stays on the pull request. Both sides are generated
5639+
# by the same `--out` generators the publish lane runs: this checkout's, and
5640+
# the base's own in a `git archive` of HEAD^1 (on a pull request, the base
5641+
# tip the merge ref sits on; on every other event, the previous commit). The
5642+
# diff renders into this job's summary and a notice annotation, which need
5643+
# no token that can write, and the step below uploads it as an artifact.
5644+
# Red only when a side cannot be generated; a diff is never red. Measured
5645+
# locally at ~6 s, no build needed.
5646+
- name: Render the generated spec-changes and upgrade-guide diff against the base
5647+
run: >-
5648+
pnpm --filter @objectstack/spec exec tsx scripts/render-projection-diff.ts
5649+
--base HEAD^1 --out-dir "$RUNNER_TEMP/spec-projections" --summary "$GITHUB_STEP_SUMMARY"
5650+
5651+
# Written only when the diff is non-empty, so an unchanged run uploads nothing.
5652+
- name: Upload the generated spec-projections diff
5653+
uses: actions/upload-artifact@v7
5654+
with:
5655+
name: spec-projections-diff
5656+
path: ${{ runner.temp }}/spec-projections/spec-projections.diff
5657+
if-no-files-found: ignore
5658+
retention-days: 14
5659+
56305660
# The #4796 declaration-origin baseline. `export-origins/` records which
56315661
# SOURCE DECLARATION every public entry point exports under every name, and
56325662
# seventeen export-surface pin tests compare against it instead of each

‎.github/workflows/release.yml‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -444,11 +444,16 @@ jobs:
444444
# without the whole spec suite, which
445445
# this comment used to say it was not.
446446
# check:spec-changes, check:upgrade-guide
447-
# the two committed artifacts DERIVED from
448-
# PROTOCOL_MAJOR (2-3 s each). Measured
449-
# RED on the boundary train: the version
450-
# pass moves the constant and regenerates
451-
# neither.
447+
# the two projections DERIVED from
448+
# PROTOCOL_MAJOR (2-3 s each). Before the
449+
# #22449 B′ ruling they compared committed
450+
# copies, and were measured RED on the
451+
# boundary train (the version pass moves
452+
# the constant and regenerated neither).
453+
# They now generate both in memory, since
454+
# the publish job generates the shipped
455+
# copies: red only when the post-version
456+
# tree cannot generate them.
452457
#
453458
# ⛔ WHAT IS DELIBERATELY NOT RUN, AND WHY IT IS NOT AN OMISSION.
454459
# The whole derived farm. Fed this 313-path change set,
@@ -1253,7 +1258,9 @@ jobs:
12531258
# `--prepare` regenerates the manifest against the previously published
12541259
# tarball exactly as the publish job does: in the version commit's
12551260
# tree, with that commit's generator. So the asset this repair uploads
1256-
# carries the same per-release section the npm artifact does. It cannot
1261+
# carries the same per-release section the npm artifact does — and, from
1262+
# the version commit that taught `--prepare` the upgrade guide on, the
1263+
# same guide. It cannot
12571264
# repair the npm artifact itself — that tarball is immutable — and this
12581265
# lane never publishes one.
12591266
if: steps.audit.outputs.releases-missing == 'true'
@@ -1783,9 +1790,15 @@ jobs:
17831790
# is immutable and the only remaining repair is another version.
17841791
#
17851792
# `--no-git-checks` is what `changeset publish` passes to `pnpm publish`
1786-
# (its own source), so the working-tree edit `--prepare` makes does not
1787-
# block the publish. The committed copy is untouched: it stays the
1788-
# registry-only projection `check:spec-changes` gates on every PR.
1793+
# (its own source), so the working-tree edits `--prepare` makes do not
1794+
# block the publish.
1795+
#
1796+
# Since the #22449 B′ ruling this is also where BOTH registry projections
1797+
# are generated for the tarball — spec-changes.json's per-major records and
1798+
# aggregate, and packages/spec/protocol-upgrade-guide.md — and `--verify`
1799+
# refuses a packed copy that differs from a fresh generation. No committed
1800+
# copy is read: the pull-request check generates the same projections in
1801+
# memory and renders their diff, and compares nothing.
17891802
# ──────────────────────────────────────────────────────────────────────
17901803
- name: Generate the per-release spec-changes section (ADR-0087 D4)
17911804
env:
@@ -1886,13 +1899,14 @@ jobs:
18861899

18871900
- name: Attach spec-changes.json to the GitHub Release (ADR-0087 D4)
18881901
# Ordering is load-bearing: `gh release upload` needs the Release the
1889-
# step above created.
1902+
# step above created. Uploads both projections: spec-changes.json and
1903+
# protocol-upgrade-guide.md.
18901904
if: ${{ !cancelled() && steps.publish.outputs.published == 'true' }}
18911905
env:
18921906
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
18931907
RELEASE_VERSION: ${{ steps.guards.outputs.version }}
1894-
# Uploads the file the two steps above generated and verified — the
1895-
# Release asset and the npm artifact are the same bytes by construction,
1908+
# Uploads the files the two steps above generated and verified — the
1909+
# Release assets and the npm artifact are the same bytes by construction,
18961910
# not by two runs agreeing.
18971911
run: bash scripts/release-spec-changes.sh --attach
18981912

‎packages/spec/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,8 @@
260260
"src/**/*.zod.ts",
261261
"CHANGELOG.md",
262262
"api-surface",
263-
"spec-changes.json"
263+
"spec-changes.json",
264+
"protocol-upgrade-guide.md"
264265
],
265266
"scripts": {
266267
"build": "pnpm gen:schema && pnpm gen:openapi && tsup && if [ -z \"$OS_SKIP_DTS\" ]; then NODE_OPTIONS=\"--max-old-space-size=6144\" BUILD_DTS=true tsup; fi && node ../../scripts/check-dts-emitted.mjs && node ../../scripts/check-dts-references.mjs && node ../../scripts/check-dev-prereqs.mjs --stamp",

‎packages/spec/scripts/build-spec-changes.ts‎

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,27 @@
88
* conversion table and the D3 migration chain — folded per major from the
99
* support floor to the current protocol major, plus one aggregate record for
1010
* the whole range. Because it is generated, it can never drift from what the
11-
* loader and `migrate meta` actually do; a CI `--check` enforces that the
12-
* committed copy is regenerated with any registry change (the ADR-0049
13-
* enforce-or-remove discipline applied to release artifacts).
11+
* loader and `migrate meta` actually do.
1412
*
15-
* pnpm --filter @objectstack/spec gen:spec-changes # regenerate + write
16-
* pnpm --filter @objectstack/spec check:spec-changes # CI: fail on drift
13+
* WHERE it is generated (#22449 B′): at publish, by
14+
* `scripts/release-spec-changes.sh`, which writes it into the package before
15+
* `npm pack` and verifies the packed copy against a fresh generation. The
16+
* pull-request stage generates it IN MEMORY (`--check`) and compares no
17+
* committed copy, so a pull request that only adds a registry entry needs no
18+
* regeneration commit; `render-projection-diff.ts` renders the generated diff
19+
* against the base on the pull request instead. The command-line contract is
20+
* `lib/projection-cli.ts`.
21+
*
22+
* pnpm --filter @objectstack/spec gen:spec-changes # write the committed copy (until it is deleted)
23+
* pnpm --filter @objectstack/spec check:spec-changes # CI: generate in memory, red when generation fails
24+
* tsx scripts/build-spec-changes.ts --out <file> # write the generated bytes anywhere
1725
*
1826
* Release-time surface join: `--previous-surface <path>` diffs the current
1927
* committed export surface against a previously *published* one (both ship in
2028
* the npm artifact from protocol 15 on) and fills the `added[]`/`removed[]`
2129
* arrays of the aggregate record. The Release workflow runs this against the
2230
* last published spec tarball and attaches the result to the GitHub Release; the
23-
* committed copy keeps `added`/`removed` empty (registry-derived content only)
31+
* registry-only projection (no previous artifact) keeps `added`/`removed` empty
2432
* so it stays deterministic.
2533
*
2634
* ⚠️ That diff is ONE RELEASE wide while the aggregate record is keyed by
@@ -43,9 +51,9 @@
4351
* (its `package.json`), the previous export surface and the previous
4452
* `spec-changes.json`, and writes a `release` section — `from → to` at
4553
* PACKAGE-VERSION resolution — into the manifest that is about to be packed.
46-
* The committed copy never carries it (generating it needs a published tarball,
47-
* so it could not be deterministic), which is exactly how the ruling answers
48-
* the determinism concern: publish time only.
54+
* The registry-only projection never carries it (generating it needs a
55+
* published tarball, so it could not be deterministic), which is exactly how the
56+
* ruling answers the determinism concern: publish time only.
4957
*
5058
* ⛔ The section is OMITTED, loudly, rather than emitted empty when the previous
5159
* tarball lacks either input — an empty `release` is indistinguishable from
@@ -66,7 +74,7 @@
6674
* the PR. Measurement table: `../src/migrations/entries/README.md`.
6775
*/
6876

69-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
77+
import { readFileSync, existsSync } from 'node:fs';
7078
import { resolve } from 'node:path';
7179
import { fileURLToPath } from 'node:url';
7280

@@ -83,6 +91,7 @@ import {
8391
type SpecSurfaceRemove,
8492
type SpecSurfaceScope,
8593
} from '../src/migrations/spec-changes';
94+
import { exitWith, runProjectionCli } from './lib/projection-cli';
8695
import { API_SURFACE_DIR_NAME, readApiSurfaceFrom } from './lib/sharded-artifacts';
8796

8897
const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
@@ -294,26 +303,35 @@ function build(): string {
294303
return `${JSON.stringify(doc, null, 2)}\n`;
295304
}
296305

297-
const next = build();
306+
if (CHECK && (PREV_SURFACE || PREV_PACKAGE)) {
307+
// The pull-request check is the REGISTRY-ONLY projection; the per-release
308+
// section and the export diff need a published tarball and exist only at publish.
309+
console.error(
310+
'check mode generates the registry-only manifest in memory; drop ' +
311+
(PREV_PACKAGE ? '--previous-package' : '--previous-surface'),
312+
);
313+
process.exit(2);
314+
}
298315

299-
if (CHECK) {
300-
if (PREV_SURFACE || PREV_PACKAGE) {
301-
console.error(
302-
'check mode compares the committed (registry-only) manifest; drop ' +
303-
(PREV_PACKAGE ? '--previous-package' : '--previous-surface'),
304-
);
305-
process.exit(2);
306-
}
307-
const current = existsSync(SNAPSHOT) ? readFileSync(SNAPSHOT, 'utf8') : '';
308-
if (current !== next) {
309-
console.error(
310-
'spec-changes.json is stale — the ADR-0087 registries changed without regenerating the manifest.\n' +
311-
'Run: pnpm --filter @objectstack/spec gen:spec-changes (and commit the result)',
312-
);
313-
process.exit(1);
314-
}
315-
console.log('spec-changes.json is up to date.');
316-
} else {
317-
writeFileSync(SNAPSHOT, next);
318-
console.log(`Wrote ${SNAPSHOT}`);
316+
/** One clause naming what a generated manifest holds, for `--check`'s success line. */
317+
function describeManifest(bytes: string): string {
318+
const doc = JSON.parse(bytes) as {
319+
protocolVersion: string;
320+
perMajor: unknown[];
321+
aggregate: { converted: unknown[]; migrated: unknown[] };
322+
};
323+
return (
324+
`protocol ${doc.protocolVersion}, ${doc.perMajor.length} per-major record(s), aggregate ` +
325+
`${doc.aggregate.converted.length} converted / ${doc.aggregate.migrated.length} migrated, ${Buffer.byteLength(bytes)} bytes`
326+
);
319327
}
328+
329+
exitWith(
330+
runProjectionCli({
331+
name: 'spec-changes.json',
332+
committedPath: SNAPSHOT,
333+
build,
334+
describe: describeManifest,
335+
argv: process.argv,
336+
}),
337+
);

0 commit comments

Comments
 (0)