Skip to content

Commit 8a925b3

Browse files
fix(release): judge the major-boundary template stamps on the version-pr lane, and stop prerelease cuts re-dating the last GA (#22095)
Part of #22085. This PR carries the half of the card that holds on measurement. The half it does not carry needs a decision first (see "What still stops the refresh"). Clause-②: no ## What this changes 1. **`sync-release-index-currency` no longer re-dates the newest GA on a prerelease cut.** `rewriteStatusField` re-stamped today's date into a `current series:` field that already named the newest GA. The gate it serves (`indexCurrencyFindings`) judges the version only. The date it holds is the release date of the version the run moves the field TO, so a run that moves nothing (every `next` or `rc` cut) is not that version's version commit. A same-version field is now left alone. Battery B's case that pinned the re-date is inverted, and a new battery, `Control H: a prerelease cut never re-dates the newest GA` (7 cases), pins the `next` cut, the `rc` cut, a positive control (a stale entry on the same later day still gets the version and that day), and `syncIndex` end to end on a temp checkout (no write, bytes identical). The roster floor goes from 7 to 8. 2. **`release.yml` `version-pr` › `Validate the post-version tree` validates the blank template's three major-boundary paths instead of refusing them.** It reuses gates the repo already runs, with no build: - `pnpm --filter create-objectstack test`: the template rendered by the scaffolder's own copy and identity rewrite, from a template packed the way npm ships it, plus the ratchets in `template-consistency.test.ts` that judge all three stamps against create-objectstack's NEW major; - `pnpm --filter @objectstack/spec check:template-manifests`. `protocol-version.ts` gets its gates too: - `protocol-version.test.ts` as ONE file (5 s). It does not need the whole spec suite, which is what the step's comment used to claim; - `check:spec-changes` and `check:upgrade-guide`, the two artifacts derived from `PROTOCOL_MAJOR`. It **stays refused**, for the measured reason below. The refusal is now collected instead of exiting first, so a boundary run reports every gate's verdict together. Every pnpm filter carries `--fail-if-no-match`, because a filter that matches nothing exits 0 having run nothing (measured: 0 without it, 1 with it). ## Pin: the boundary train, replayed (throwaway tree, never committed) Tree: this branch at `3079e4aef0` plus PR #22084's `.changeset/pre.json` and `.changeset/22080-v18-line-opens.md`, carried by one local commit that was never pushed. The steps were extracted from this branch's `release.yml` with a YAML parser and run verbatim with `RUNNER_TEMP` / `GITHUB_OUTPUT` / `GITHUB_STEP_SUMMARY` set. | step | exit | reading | |---|---|---| | `Render the post-version tree` (the full root `version` script) | 0 | 69 of 69 `fixed` members at `18.0.0-next.0`, `pre.json` unchanged (`mode: pre`, `tag: next`). `PROTOCOL_VERSION 17.0.0 → 18.0.0`. Template stamps at `^18.0.0` / `'^18'`. 9 docs pins moved to `18.0.0-next.0`. Release index: "already names the newest GA … nothing rewritten" | | release index v17 entry | unchanged | `(current series: 17.7.0, released 2026-10-06)`; `git diff HEAD -- content/docs/releases/index.mdx` empty. Before this PR, the same replay wrote `released 2026-10-07` (reproduced on PR #22084's head `52f7a509c6`) | | shape assertion | pass | 235 paths moved, 0 outside the reviewed surface. It was 236 before this PR; the difference is the release index | | `Validate the post-version tree` | **1** | 11 gates green: the 9 existing content gates except `check:release-notes`, plus the create-objectstack suite, `check:template-manifests` and the lockstep test. 3 gates red: `check:release-notes`, `check:spec-changes`, `check:upgrade-guide`. 1 unvalidated: `protocol-version.ts` | | `Restore the pre-version tree` | 0 | 37 pending changesets, tree clean | **Control on an ordinary train.** This branch without the two opening files is the 17.8.0 refresh #21988 gets today. `Render` exits 0 (194 paths, and the release index stamps `17.8.0, released 2026-10-07`, version and date together). `Validate` exits **0** with all 14 gates green, holding 44 s. `Restore` exits 0. **Negative control for the template gate.** On the boundary tree, `specVersion` in the blank manifest was set back to `^17.0.0`. `template-consistency.test.ts` then exits 1 with 2 failed, and the file was restored by hash (`0956082cfd8e` both sides). **Baseline.** `main`'s own step, replayed on PR #22084's head, exits 1 at the old blanket refusal and names all 4 paths. This reproduces the measurement the card rests on. ## Why `protocol-version.ts` is still refused (measured, not guessed) Moving the protocol major at version time does more than move a constant: - **Two derived artifacts go stale.** On the boundary tree, `check:spec-changes` exits 1 ("spec-changes.json is stale") and `check:upgrade-guide` exits 1. Both pass on the pre-version tree (control). Regenerating them in the throwaway changes `packages/spec/spec-changes.json` by 6342 lines, and adds a 1024-line `Protocol 17 → 18` section to `docs/protocol-upgrade-guide.md`. The version pass regenerates neither. Both gates run in the required `TypeScript Type Check` job, so a version PR let through as things stand would turn `main` red on its next ordinary PR. - **The handshake refuses this repository's own example apps.** `assertProtocolCompat` runs on the app load seam (`packages/runtime/src/app-plugin.ts:421`). Probed with the post-version constant, `checkProtocolCompat` gives `^17` → `incompatible` (`OS_PROTOCOL_INCOMPATIBLE`) and `^18` → `ok`. On the pre-version tree it is the reverse. `examples/app-crm`, `app-showcase` and `app-todo`, plus the two packages in `app-multi-package`, declare `engines: { protocol: '^17' }`. No gate this lane can afford boots them. So the refusal stays, as Done-when 1's last clause provides ("The refusal stays for any major-only path still unvalidated"), with its message rewritten to say exactly this. ## What still stops #21988 refreshing into 18.0.0-next.0 These are three decisions, set out with options and a recommendation in the card's `os-dev-report`. None of them is made here: 1. **How the protocol major moves at the opening.** Either the version pass regenerates the two artifacts and restamps in-repo `engines.protocol` (the version chain grows), or an ordinary PR with CI moves the protocol major ahead of the version PR (the lockstep test's definition changes). 2. **`check:release-notes` is red at the boundary** (it is already in this step). It counts `## 18.0.0-next.0` as "shipped a 18.x release" and asks for `content/docs/releases/v18.mdx` plus its `meta.json` entry. That content is release-owned, and this PR does not write it. 3. **Docs image pins during the `next` line.** These were measured and not edited (Done-when 4). On the first prerelease, `sync-docs-image-tags` moves 9 pins from `17.7.0` to `18.0.0-next.0`: - `docker/README.md`: 3 image tags and 1 build-arg; - `content/docs/deployment/self-hosting.mdx`: 3 image tags and 1 npm pin; - `content/docs/upgrading.mdx`: 1 image tag. Meanwhile `npm view` gives `latest: 17.7.0` (and `rc: 17.0.0-rc.6`, no `next` yet) for `@objectstack/cli`, `@objectstack/spec` and `create-objectstack`. The ghcr `latest` tag does not move for a prerelease (`docker-publish.yml:83`). ## Verification - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `a4cbcfdb16`): 50 commands, all exit 0, exit codes written to disk before any pipe. `--ran`: `50 derived, 50 run, 0 NOT-MEASURED, 0 UNRUN`. The battery `pnpm check:pm-dispatch-gates` passed 1976 cases in 997.8 s. - `node scripts/sync-release-index-currency.mjs --self-test`: exit 0, 42 cases. - **Ablation, committed first.** With `ablation-replace.mjs`, the pre-fix logic (`const rewritten = …; return rewritten === field ? null : rewritten;`) was put back in place of the fix. The self-test exits 1 with exactly 5 failures: battery B's inverted case and 4 of Control H's 7. H's three controls stay green, as they should. The file was restored with blob equal to HEAD and `git diff HEAD` empty. A first, cruder ablation that only deleted the guard line also reddened B and C. That mutation was too strong, so the faithful one above is the reading. - Not run locally: the whole spec suite (the step no longer needs it), and the repo-wide lint farm, which belongs to CI. ## Acceptance notes (noted, not fixed here) - The comment above `Create or update the "chore: version packages" PR` in `release.yml` still says `pnpm run version` is FOUR rewriters. It is five (`sync-release-index-currency.mjs` joined). - `cut-rc.yml` says "On an RC cut this rewriter writes NOTHING". That was false for the date before this PR (an rc cut on a later day re-dated the entry) and is true after it. `cut-rc.yml` is not edited, by the card. - `release.yml`'s step comment said the lockstep test was "reachable only through the whole @objectstack/spec suite". That is corrected in place, because it is the step this card owns. Changeset: none. The diff touches `.github/workflows/release.yml` and `scripts/sync-release-index-currency.mjs`, and neither is in any package's `files[]`. Commits carry this repository's model-free trailer pair (AGENTS.md). --- _Generated by [Claude Code](https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 172be37 commit 8a925b3

2 files changed

Lines changed: 162 additions & 33 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -368,16 +368,23 @@ jobs:
368368
#
369369
# Two more classes exist and did NOT move on that train, because both are
370370
# major-boundary only: packages/spec/src/kernel/protocol-version.ts, and
371-
# per bundled template objectstack.config.ts / objectstack.manifest.json.
372-
# A major cannot reach this lane while check-changeset-no-major.mjs holds
373-
# (pr-automation.yml refuses a PR that introduces one), so they are dormant
374-
# rather than covered — and the shape assertion below says so out loud on
375-
# the first train that wakes them, instead of validating a surface with no
376-
# gate behind it. cut-rc.yml's allowlist block carries the same
377-
# measurement for the RC lane and agrees path-for-path.
371+
# per bundled template objectstack.config.ts / objectstack.manifest.json
372+
# (with the template's own package.json, whose `^N.0.0` ranges move too).
373+
# They stayed dormant while check-changeset-no-major.mjs held; pre mode
374+
# stands that guard aside, and the v18 opening woke them. RE-MEASURED on
375+
# that first boundary train (2026-10-07: 3d9188502e plus the opening's
376+
# `.changeset/pre.json` and `major` changeset, 37 changesets,
377+
# @objectstack/cli 17.7.0 -> 18.0.0-next.0): 236 paths, 0 outside the
378+
# surface, 4 major-only — the three blank-template files and
379+
# protocol-version.ts. The template half is now judged by the gates below;
380+
# protocol-version.ts is still refused, for the measured reason given at
381+
# the refusal. cut-rc.yml's allowlist block carries the same surface for
382+
# the RC lane and agrees path-for-path.
378383
#
379384
# WHAT IS CHECKED, AND WHY EACH ONE (7 s total, measured on the rendered
380-
# tree in this job's own state: install, NO build)
385+
# tree in this job's own state: install, NO build. With the major-boundary
386+
# gates below, the whole step held 44 s on a shared dev container,
387+
# 2026-10-07, on a 17.8.0 train — every gate green)
381388
# ---------------------------------------------------------------------
382389
# Two halves, because the classes above fail in two different ways.
383390
#
@@ -417,6 +424,32 @@ jobs:
417424
# check-release-section-coverage.mjs, check:published-readme-links
418425
# the four gates that READ CHANGELOG.md.
419426
#
427+
# And the gates of the two MAJOR-boundary classes, run on EVERY refresh
428+
# rather than only on the train that moves them: each reads a version
429+
# this step writes on every train, and a gate that runs once a major
430+
# rots unseen between majors. All existing gates; none needs a build.
431+
# create-objectstack's suite the bundled templates RENDERED: the
432+
# scaffolder's own copy and identity
433+
# rewrite, from a template packed the way
434+
# npm ships it, plus the ratchets that
435+
# judge all three stamps against
436+
# create-objectstack's NEW major
437+
# (template-consistency.test.ts).
438+
# Measured on the boundary train: 16
439+
# files, 249 tests, green, 23 s.
440+
# check:template-manifests each stamped manifest still parses as
441+
# TemplateManifestSchema (2 s, green).
442+
# protocol-version.test.ts PROTOCOL_VERSION in lockstep with spec's
443+
# NEW major. ONE file, 5 s — reachable
444+
# without the whole spec suite, which
445+
# this comment used to say it was not.
446+
# 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.
452+
#
420453
# ⛔ WHAT IS DELIBERATELY NOT RUN, AND WHY IT IS NOT AN OMISSION.
421454
# The whole derived farm. Fed this 313-path change set,
422455
# scripts/pm/dispatch-gates.mjs names 49 families; 44 of them run
@@ -595,22 +628,27 @@ jobs:
595628
# check with a diagnosis of their own. Measured: with the pattern
596629
# missing, protocol-version.ts fell into the "wrote outside the
597630
# reviewed surface" branch instead, which is red for the right reason
598-
# and wrong about why. What this lane does not have is a gate for
599-
# either. protocol-version.ts is judged by
600-
# packages/spec/src/kernel/protocol-version.test.ts, reachable only
601-
# through the whole @objectstack/spec suite (measured: 424 files,
602-
# 11273 tests, 5m26s — not a per-refresh cost), and the template stamps
603-
# by a full template render. Unreachable while
604-
# check-changeset-no-major.mjs holds; loud, not silent, the day it
605-
# stops holding.
606-
MAJOR_ONLY="$(grep -xF -f "${TEMPLATE_LIST}" "${MOVED_FILE}" || true)"
631+
# and wrong about why.
632+
#
633+
# The TEMPLATE stamps are validated, not refused: the create-objectstack
634+
# suite and check:template-manifests in the content half judge them
635+
# (see the header). They are counted here only for the summary.
636+
#
637+
# protocol-version.ts is still REFUSED, and not for want of a gate: its
638+
# three gates run below. What moving it does is wider than all three.
639+
# The ADR-0087 D1 handshake (`assertProtocolCompat` on the app LOAD
640+
# seam, packages/runtime/src/app-plugin.ts) refuses every manifest whose
641+
# `engines.protocol` excludes the new major, and this repository's own
642+
# example apps declare the old one. Measured with the boundary train's
643+
# constant: `^17` -> OS_PROTOCOL_INCOMPATIBLE, `^18` -> ok (control on
644+
# the pre-version tree: the reverse). No gate this lane can afford boots
645+
# them, so the refusal stays until a reviewed change says how the
646+
# protocol major moves on this lane. It is collected rather than exited
647+
# on, so one boundary run names every gate verdict as well.
648+
MAJOR_TEMPLATES="$(grep -xF -f "${TEMPLATE_LIST}" "${MOVED_FILE}" || true)"
649+
UNVALIDATED=''
607650
if grep -qxF 'packages/spec/src/kernel/protocol-version.ts' "${MOVED_FILE}"; then
608-
MAJOR_ONLY="${MAJOR_ONLY}"$'\n'"packages/spec/src/kernel/protocol-version.ts"
609-
fi
610-
if [ -n "${MAJOR_ONLY//[[:space:]]/}" ]; then
611-
printf '%s\n' "${MAJOR_ONLY}" | sed '/^$/d; s/^/::error:: unvalidated: /'
612-
echo "::error::this version pass crossed a MAJOR boundary and wrote the surfaces above, which this lane has no gate for. A major was not reachable here when this step was written (pr-automation.yml refuses a PR introducing one), so the gates were left out rather than guessed at. Wire them in before letting this refresh through."
613-
exit 1
651+
UNVALIDATED='packages/spec/src/kernel/protocol-version.ts'
614652
fi
615653
616654
# ── CONTENT ──────────────────────────────────────────────────────
@@ -634,15 +672,31 @@ jobs:
634672
run_gate pnpm check:release-page-status
635673
run_gate node scripts/check-release-section-coverage.mjs
636674
run_gate pnpm check:published-readme-links
637-
675+
# The MAJOR-boundary classes' gates. `--fail-if-no-match`: a filter that
676+
# matches no package otherwise exits 0 having run nothing.
677+
run_gate pnpm --filter create-objectstack --fail-if-no-match test
678+
run_gate pnpm --filter @objectstack/spec --fail-if-no-match check:template-manifests
679+
run_gate pnpm --filter @objectstack/spec --fail-if-no-match exec vitest run --project local src/kernel/protocol-version.test.ts
680+
run_gate pnpm --filter @objectstack/spec --fail-if-no-match check:spec-changes
681+
run_gate pnpm --filter @objectstack/spec --fail-if-no-match check:upgrade-guide
682+
683+
template_moved=$(printf '%s\n' "${MAJOR_TEMPLATES}" | sed '/^$/d' | wc -l | tr -d ' ')
638684
{
639685
echo "- post-version surface: \`${moved}\` path(s), all inside the reviewed shape"
686+
echo "- major-boundary template stamps moved: \`${template_moved}\` (judged by the content gates)"
640687
echo "- content gates failed: \`${#failed[@]}\`"
688+
echo "- major-boundary surfaces left unvalidated: \`$([ -n "${UNVALIDATED}" ] && echo 1 || echo 0)\`"
641689
} >> "$GITHUB_STEP_SUMMARY"
642690
643691
if [ "${#failed[@]}" -ne 0 ]; then
644692
printf '::error::the post-version tree fails %s gate(s) that no CI run would ever have judged: %s\n' \
645693
"${#failed[@]}" "$(printf '%s; ' "${failed[@]}")"
694+
fi
695+
if [ -n "${UNVALIDATED}" ]; then
696+
echo "::error:: unvalidated: ${UNVALIDATED}"
697+
echo "::error::this version pass moved the protocol major. Its lockstep and the two artifacts derived from it are judged above, but the move also makes the protocol handshake refuse every manifest still declaring the old major in engines.protocol — this repository's example apps among them — and no gate on this lane boots them. A reviewed change must decide how the protocol major moves before this refresh goes through."
698+
fi
699+
if [ "${#failed[@]}" -ne 0 ] || [ -n "${UNVALIDATED}" ]; then
646700
exit 1
647701
fi
648702
echo 'Post-version tree validated.'

‎scripts/sync-release-index-currency.mjs‎

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,17 @@
108108
// • An entry with no trailing parenthetical at all. There is no field to stamp, and
109109
// inventing one means choosing between the two wordings above -- the same judgement.
110110
// • An entry whose parenthetical is prose. Same reason.
111+
// • A `current series:` field that already names the newest GA, whatever its date.
112+
// The gate has no finding there, and the date belongs to the version commit that
113+
// moved the field, which a run that moves nothing is not -- every prerelease cut
114+
// is such a run (see `releaseDate`).
111115
//
112116
// An over-eager rewriter is not a lesser failure than an inert one: this file writes
113117
// into curated, reader-facing prose, so `--self-test` asserts a CURRENT index is left
114118
// BYTE-IDENTICAL with no write at all, and that each refused shape survives untouched.
115119

116-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
120+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
121+
import { tmpdir } from 'node:os';
117122
import { dirname, join } from 'node:path';
118123
import { fileURLToPath } from 'node:url';
119124

@@ -175,6 +180,12 @@ export const STATUS_SHAPE =
175180
* version`. UTC, not local: a runner in any timezone must stamp the day the commit is
176181
* dated, and `git` dates the commit in UTC in CI.
177182
*
183+
* ⛔ So the date is TRUE only for the version this run moves the field TO. A run that
184+
* leaves the version where it is -- every prerelease cut, `18.0.0-next.N` and
185+
* `X.Y.Z-rc.N` alike, because a prerelease heading never becomes the newest GA -- is
186+
* not that version's version commit, and today is not its release date. That is why
187+
* `rewriteStatusField` refuses a same-version field outright instead of re-dating it.
188+
*
178189
* A clock this cannot read is a REFUSAL, never a silently wrong date written into
179190
* published prose.
180191
*
@@ -199,6 +210,15 @@ export function releaseDate(now = new Date()) {
199210
* verdict, which is where a refusal becomes loud. See "What it deliberately does NOT
200211
* rewrite".
201212
*
213+
* A field that already names `newestVersion` is left alone, date included. The gate
214+
* judges the VERSION only (`indexCurrencyFindings` has nothing to say about such a
215+
* field), and the date this run holds is the release date of the version it moves the
216+
* field to -- see `releaseDate`. Re-dating a field whose version did not move would
217+
* write the day of an unrelated version pass into published prose: measured on the
218+
* first `18.0.0-next.0` cut, `(current series: 17.7.0, released 2026-10-06)` became
219+
* `released 2026-10-07`, while 17.7.0's version commit `4e4e881427` is dated
220+
* 2026-10-06. In pre mode the newest GA never moves, so every prerelease cut did it.
221+
*
202222
* @param {string} field the inner text of the entry's trailing parenthetical
203223
* @param {string} newestVersion
204224
* @param {string} date `YYYY-MM-DD`
@@ -207,8 +227,8 @@ export function releaseDate(now = new Date()) {
207227
export function rewriteStatusField(field, newestVersion, date) {
208228
const m = STATUS_SHAPE.exec(field);
209229
if (m === null) return null;
210-
const rewritten = `${m[1]}${newestVersion}${m[3]}${date}`;
211-
return rewritten === field ? null : rewritten;
230+
if (m[2] === newestVersion) return null;
231+
return `${m[1]}${newestVersion}${m[3]}${date}`;
212232
}
213233

214234
/**
@@ -445,11 +465,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
445465
'Control E: the verdict is the GATE\'s function, assertion 2 ONLY': 5,
446466
'Control F: the surface and the scope are the gate\'s, not copies': 4,
447467
'Control G: the date refuses an unusable clock': 3,
468+
'Control H: a prerelease cut never re-dates the newest GA': 7,
448469
});
449470

450471
// DELETING an entry silences that battery's floor exactly as effectively as zeroing it,
451472
// so the roster's own size is pinned too.
452-
const SELF_TEST_BATTERY_FLOOR = 7;
473+
const SELF_TEST_BATTERY_FLOOR = 8;
453474

454475
// The key an assertion is filed under when no battery is open. It is not a declared
455476
// battery, so it reds by the same set difference rather than silently inflating
@@ -549,9 +570,9 @@ export function selfTest() {
549570
expect('B — and is byte-identical, so main() never writes it', after.text === before);
550571
expect('B — re-stamping an already-current entry is not a rewrite (the date is not churned '
551572
+ 'on every release either)', rewriteStatusField('current series: 17.3.0, released 2026-09-04', '17.3.0', '2026-09-04') === null);
552-
expect('B — but a same-version entry with a DIFFERENT date still is one',
553-
rewriteStatusField('current series: 17.3.0, released 2026-09-01', '17.3.0', '2026-09-04')
554-
=== 'current series: 17.3.0, released 2026-09-04');
573+
expect('B — and neither is a same-version entry with a DIFFERENT date: the date belongs to the '
574+
+ 'version commit that moved the field, which a run that moves nothing is not',
575+
rewriteStatusField('current series: 17.3.0, released 2026-09-01', '17.3.0', '2026-09-04') === null);
555576
}
556577

557578
// ── Control C ─────────────────────────────────────────────────────────────
@@ -645,6 +666,59 @@ export function selfTest() {
645666
expect('G — and so does a value that is not a Date at all', threwOnNonDate);
646667
}
647668

669+
// ── Control H ─────────────────────────────────────────────────────────────
670+
// The measured defect: the first `18.0.0-next.0` cut re-dated the v17 entry from its
671+
// GA day to the cut's day. A prerelease heading never becomes the newest GA, so a
672+
// prerelease cut leaves the version where it is -- and must leave the date too.
673+
battery('Control H: a prerelease cut never re-dates the newest GA');
674+
{
675+
const CUT_DATE = '2026-10-07';
676+
const withHeading = (heading) => CHANGELOG_FIXTURE.replace('## 17.3.0-rc.1', heading);
677+
const nextCut = withHeading('## 18.0.0-next.0');
678+
const rcCut = withHeading('## 17.4.0-rc.0');
679+
const current = indexOf(CURRENT_V17, CURRENT_V16);
680+
681+
const afterNext = rewriteIndexText({ indexText: current, changelogText: nextCut, date: CUT_DATE });
682+
expect('H — a `next` prerelease cut on a later day rewrites nothing',
683+
afterNext.rewrites.length === 0 && afterNext.text === current);
684+
expect('H — the newest GA keeps its own release date, not the cut\'s',
685+
afterNext.text.includes('current series: 17.3.0, released 2026-09-04')
686+
&& !afterNext.text.includes(`released ${CUT_DATE}`));
687+
expect('H — the prerelease major is not a judged major: no GA of 18 exists yet',
688+
!afterNext.majors.includes(18) && afterNext.majors.includes(17));
689+
expect('H — and refusing the re-date leaves the gate nothing to say',
690+
currencyFindings({ indexText: afterNext.text, changelogText: nextCut }).length === 0);
691+
692+
const afterRc = rewriteIndexText({ indexText: current, changelogText: rcCut, date: CUT_DATE });
693+
expect('H — an `rc` prerelease cut on a later day rewrites nothing either',
694+
afterRc.rewrites.length === 0 && afterRc.text === current);
695+
696+
// Positive control on the same later day: the date still travels WITH a version that
697+
// moves, so the refusal above is about the version standing still, not an inert date.
698+
const staleAfterNext = rewriteIndexText({
699+
indexText: indexOf(STALE_V17, CURRENT_V16), changelogText: nextCut, date: CUT_DATE,
700+
});
701+
expect('H — control: a STALE entry on the same cut is still stamped, version AND that day',
702+
staleAfterNext.rewrites.length === 1
703+
&& staleAfterNext.text.includes(`current series: 17.3.0, released ${CUT_DATE}`));
704+
705+
// End to end through syncIndex, the function main() calls: on a checkout-shaped temp
706+
// tree the index file is not written at all, not rewritten to identical bytes.
707+
const tmp = mkdtempSync(join(tmpdir(), 'sync-release-index-currency-'));
708+
try {
709+
mkdirSync(dirname(join(tmp, INDEX_PATH)), { recursive: true });
710+
mkdirSync(dirname(join(tmp, SPEC_CHANGELOG)), { recursive: true });
711+
writeFileSync(join(tmp, INDEX_PATH), current);
712+
writeFileSync(join(tmp, SPEC_CHANGELOG), nextCut);
713+
const result = syncIndex({ root: tmp, date: CUT_DATE });
714+
expect('H — syncIndex on a next-cut tree reports no write and leaves the index byte-identical',
715+
result.wrote === false && result.rewrites.length === 0
716+
&& readFileSync(join(tmp, INDEX_PATH), 'utf8') === current && result.findings.length === 0);
717+
} finally {
718+
rmSync(tmp, { recursive: true, force: true });
719+
}
720+
}
721+
648722
// ── Floor ─────────────────────────────────────────────────────────────────
649723
const declared = Object.keys(SELF_TEST_BATTERIES);
650724
let floorBreached = false;
@@ -695,8 +769,9 @@ export function selfTest() {
695769
+ 'GA of its major with the version AND the date, a current index is left byte-identical and '
696770
+ 'unwritten, the shapes that need a human sentence ("final release:", a missing parenthetical, '
697771
+ 'prose) are REFUSED and reach the gate\'s own verdict instead, prose parentheticals and '
698-
+ 'out-of-scope majors are never touched, and the surface, the scope floor and the verdict are '
699-
+ 'the gate\'s rather than copies of it.',
772+
+ 'out-of-scope majors are never touched, a prerelease cut (`next` or `rc`) leaves the newest '
773+
+ 'GA\'s release date alone, and the surface, the scope floor and the verdict are the gate\'s '
774+
+ 'rather than copies of it.',
700775
);
701776
selfTestReachedVerdict = true;
702777
return 0;

0 commit comments

Comments
 (0)