Skip to content

Commit f25f725

Browse files
committed
fix(docs): stamp the release index's "current series" at version time (#15332)
`content/docs/releases/index.mdx` ends each major's entry with a status parenthetical — "(current series: 17.2.0, released 2026-08-23)". That sentence is derived data: the newest GA of a major is already readable from `packages/spec/CHANGELOG.md`, which is where check-release-section-coverage reads it from to produce its finding. Kept by hand, it went stale on three consecutive minors (#10232, #11649, and this one), each caught after the fact. The gate that names it cannot fire on the change that causes it: the version commit is opened by changesets/action with the default GITHUB_TOKEN and gets no CI, and lint.yml runs the gate without `--strict`, where a finding is advisory and the job is green by design. Only release-coverage-patrol.yml passes `--strict`, and a standing patrol is an after-the-fact reader by construction. Adds scripts/sync-release-index-currency.mjs to the root `version` chain, beside the three sync scripts already there — the shape this repo chose for the identical drift class in #2907 and #9064 (e569cac). It imports INDEX_PATH, SPEC_CHANGELOG, the scope predicate, the entry and status lookups and the verdict from check-release-section-coverage.mjs, so the rewriter and the gate read one contract and the rewriter's verdict IS the gate's. It rewrites exactly one shape, `current series: <x.y.z>, released <date>`, and refuses the ones that need a human sentence — a "final release:" claim a newer minor contradicts, an entry with no status field — which then reach the gate's own words and stop the run. Assertion 1 (a published minor has a section) is never consulted here: a rewriter cannot write curated prose, and hard-failing the version lane on unwritten prose would wedge it for a debt the release did not create. release.yml's post-version shape assertion and cut-rc.yml's staging allowlist both resolve the surface from `syncedPaths()` at run time, joining the two lists they already resolve on the same terms — a fourth literal would be a fourth contract. Measured: without that third list the post-version validation rejects content/docs/releases/index.mdx as "outside the reviewed surface", which is the treadmill guard doing its job. The index line itself was fixed by running the stamper, not by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
1 parent c4d1354 commit f25f725

6 files changed

Lines changed: 785 additions & 16 deletions

File tree

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

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -673,14 +673,38 @@ jobs:
673673
echo "template surfaces declared by stampedPaths() (${#TEMPLATE_SURFACES[@]}):"
674674
sed 's/^/ /' "$TEMPLATE_LIST"
675675
676+
# The release-index surface `sync-release-index-currency.mjs` stamps, read
677+
# from `syncedPaths()` — which derives it from `INDEX_PATH` in the gate
678+
# whose finding that rewriter clears. Same terms as the two lists above, and
679+
# for the same reason: a third literal here would be a third contract. On an
680+
# RC cut this rewriter writes NOTHING (an `-rc` heading is not a GA version,
681+
# so the newest GA of the major does not move), so this list normally stages
682+
# nothing — it is declared so that the one cut where the index IS stale gets
683+
# a complete commit instead of the refusal below.
684+
INDEX_LIST="${RUNNER_TEMP:-/tmp}/cut-rc-release-index-surfaces.txt"
685+
if ! node --input-type=module \
686+
-e 'import { syncedPaths } from "./scripts/sync-release-index-currency.mjs"; for (const p of syncedPaths()) console.log(p);' \
687+
> "$INDEX_LIST"; then
688+
echo "::error::could not resolve syncedPaths() from scripts/sync-release-index-currency.mjs, so the release-index half of the release file surface is unknown. Refusing to push."
689+
exit 1
690+
fi
691+
if [ ! -s "$INDEX_LIST" ]; then
692+
echo "::error::syncedPaths() in scripts/sync-release-index-currency.mjs resolved EMPTY, so the release index would go unstaged even though the version pass can rewrite it. Refusing to push."
693+
exit 1
694+
fi
695+
mapfile -t INDEX_SURFACES < "$INDEX_LIST"
696+
echo "release-index surfaces declared by syncedPaths() (${#INDEX_SURFACES[@]}):"
697+
sed 's/^/ /' "$INDEX_LIST"
698+
676699
git add -A -- \
677700
'*package.json' \
678701
'*CHANGELOG.md' \
679702
.changeset \
680703
.objectui-sha \
681704
packages/spec/src/kernel/protocol-version.ts \
682705
"${TEMPLATE_SURFACES[@]}" \
683-
"${DOCS_SURFACES[@]}"
706+
"${DOCS_SURFACES[@]}" \
707+
"${INDEX_SURFACES[@]}"
684708
685709
STAGED="$(git diff --cached --name-only)"
686710
if [ -z "$STAGED" ]; then
@@ -689,16 +713,17 @@ jobs:
689713
fi
690714
691715
# Re-check every staged path against the allowlist. The pathspec above is
692-
# convenience; THIS is the guarantee. Three filters, same allowlist the
716+
# convenience; THIS is the guarantee. Four filters, same allowlist the
693717
# pathspec used: the fixed release paths by pattern, then the declared
694-
# template and doc surfaces by WHOLE-LINE EXACT match (`-xF`) against the
695-
# very lists that were staged — so neither derived filter can accept a path
696-
# its declaration does not name, and neither needs regex-escaping of the
697-
# paths to stay exact.
718+
# template, doc and release-index surfaces by WHOLE-LINE EXACT match
719+
# (`-xF`) against the very lists that were staged — so no derived filter
720+
# can accept a path its declaration does not name, and none needs
721+
# regex-escaping of the paths to stay exact.
698722
BAD="$(printf '%s\n' "$STAGED" \
699723
| grep -vE '(^|/)package\.json$|(^|/)CHANGELOG\.md$|^\.changeset/|^\.objectui-sha$|^packages/spec/src/kernel/protocol-version\.ts$' \
700724
| grep -vxF -f "$TEMPLATE_LIST" \
701-
| grep -vxF -f "$SURFACE_LIST" || true)"
725+
| grep -vxF -f "$SURFACE_LIST" \
726+
| grep -vxF -f "$INDEX_LIST" || true)"
702727
if [ -n "$BAD" ]; then
703728
echo "::error::the version commit would carry paths outside the release file surface. Refusing to push. Offending paths follow; if the version pass legitimately grew a new output, widen the allowlist in this workflow deliberately."
704729
printf '%s\n' "$BAD" | sed 's/^/::error:: unexpected: /'

‎.github/workflows/release.yml‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,12 @@ jobs:
387387
# nothing to do cannot be observed working.
388388
# check:template-version-sync the template stamper's, for the same
389389
# reason.
390+
# check:release-index-currency-sync
391+
# the release-index stamper's (#15332), for the
392+
# same reason again — on a train whose index is
393+
# already current it rewrites nothing, and a
394+
# rewriter with nothing to do cannot be observed
395+
# working.
390396
# check:nul-bytes lint.yml's one UNCONDITIONAL gate whose
391397
# corpus is every byte in the tree. The 76
392398
# generated CHANGELOGs are the only prose
@@ -539,13 +545,30 @@ jobs:
539545
exit 1
540546
fi
541547
542-
# Same three filters cut-rc.yml applies to the same surface: the fixed
543-
# release paths by pattern, then the two declared lists by WHOLE-LINE
544-
# EXACT match, so neither derived filter can accept a path its
545-
# declaration does not name and neither needs regex-escaping.
548+
# The release-index surface the currency stamper writes, on the same terms
549+
# again: resolved from `syncedPaths()` in the rewriter, which derives it
550+
# from `INDEX_PATH` in the gate whose finding it clears. Third rewriter,
551+
# third resolved list, zero restated literals.
552+
INDEX_LIST="${RUNNER_TEMP}/post-version-release-index-surfaces.txt"
553+
if ! node --input-type=module \
554+
-e 'import { syncedPaths } from "./scripts/sync-release-index-currency.mjs"; for (const p of syncedPaths()) console.log(p);' \
555+
> "${INDEX_LIST}"; then
556+
echo "::error::could not resolve syncedPaths() from scripts/sync-release-index-currency.mjs, so the release-index half of the post-version surface is unknown. Refusing to call this tree validated."
557+
exit 1
558+
fi
559+
if [ ! -s "${INDEX_LIST}" ]; then
560+
echo "::error::syncedPaths() in scripts/sync-release-index-currency.mjs resolved EMPTY, so the release index the version pass rewrites would read as an unexpected path. Refusing to call this tree validated."
561+
exit 1
562+
fi
563+
564+
# Same four filters cut-rc.yml applies to the same surface: the fixed
565+
# release paths by pattern, then the three declared lists by WHOLE-LINE
566+
# EXACT match, so no derived filter can accept a path its declaration
567+
# does not name and none needs regex-escaping.
546568
UNEXPECTED="$(grep -vE '(^|/)package\.json$|(^|/)CHANGELOG\.md$|^\.changeset/|^packages/spec/src/kernel/protocol-version\.ts$' "${MOVED_FILE}" \
547569
| grep -vxF -f "${TEMPLATE_LIST}" \
548-
| grep -vxF -f "${SURFACE_LIST}" || true)"
570+
| grep -vxF -f "${SURFACE_LIST}" \
571+
| grep -vxF -f "${INDEX_LIST}" || true)"
549572
if [ -n "${UNEXPECTED}" ]; then
550573
printf '%s\n' "${UNEXPECTED}" | sed 's/^/::error:: unexpected: /'
551574
echo "::error::the version pass wrote outside the reviewed post-version surface (paths above). This is the treadmill guard: a new version-time output must arrive together with the gate that judges it. Add the surface to the declaration its rewriter reads, and its gate to the content half below — deliberately, in one reviewed diff."
@@ -591,6 +614,7 @@ jobs:
591614
run_gate pnpm check:docs-image-tag
592615
run_gate pnpm check:docs-image-tag-sync
593616
run_gate pnpm check:template-version-sync
617+
run_gate pnpm check:release-index-currency-sync
594618
run_gate pnpm check:nul-bytes
595619
run_gate pnpm check:release-notes
596620
run_gate pnpm check:release-page-status

‎content/docs/releases/index.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ migration steps, then covers new capabilities and notable fixes.
1818

1919
## Versions
2020

21-
- [v17.0.0](/docs/releases/v17) — Files become owned `sys_file` records with server-enforced `accept`/`maxSize` and a governed download path, bulk export becomes its own opt-in privilege, the SDK is reconciled against the routes the server actually mounts (21 dead methods out, 40+ real ones in), approval nodes route approvers dynamically via CEL expressions and decision outputs, a datasource that cannot connect fails the boot, and Node 22 becomes the supported floor; 17.1 adds partial field masking, record-view auditing on `sys_audit_log`, and a per-object read-only approval visibility tier — and makes a deactivated permission set or position actually stop granting access, withdraws the bulk-export wildcard from the shipped admin sets, and gives all three flow doors one honest HTTP status table; 17.2 tightens by-id `update`/`delete` against a silently-dropped `where` predicate or a mismatched id, retires `sys_position.permissions` and other dead ADR-0049 surfaces, and stops analytics from answering the wrong number on a cross-object filter (current series: 17.2.0, released 2026-08-23).
21+
- [v17.0.0](/docs/releases/v17) — Files become owned `sys_file` records with server-enforced `accept`/`maxSize` and a governed download path, bulk export becomes its own opt-in privilege, the SDK is reconciled against the routes the server actually mounts (21 dead methods out, 40+ real ones in), approval nodes route approvers dynamically via CEL expressions and decision outputs, a datasource that cannot connect fails the boot, and Node 22 becomes the supported floor; 17.1 adds partial field masking, record-view auditing on `sys_audit_log`, and a per-object read-only approval visibility tier — and makes a deactivated permission set or position actually stop granting access, withdraws the bulk-export wildcard from the shipped admin sets, and gives all three flow doors one honest HTTP status table; 17.2 tightens by-id `update`/`delete` against a silently-dropped `where` predicate or a mismatched id, retires `sys_position.permissions` and other dead ADR-0049 surfaces, and stops analytics from answering the wrong number on a cross-object filter (current series: 17.3.0, released 2026-09-04).
2222
- [v16.0.0](/docs/releases/v16) — One org identifier (`organizationId`) across hooks and actions, quorum + per-group sign-off (会签) approvals with metadata-declared decision actions, time-relative automations, filtered roll-ups, strict dashboard widgets, an identity-scoped MCP stdio transport, and a platform-wide enforce-or-remove sweep that makes dead metadata loud; 16.1 adds a `requires` capability-provider preflight, two more dashboard build gates, and `runAs:'user'` automations that run with the triggering user's real grants (final release: 16.1.0).
2323
- [v15.0.0](/docs/releases/v15) — Explain record access layer by layer, a docked AI workspace in the Console, project-ready Gantt charts, and phone sign-in; 15.1 adds permission-following attachments, no-code third-party connectors, dashboard-wide filters, pinyin search, and whole-record inline editing — with materially safer multi-tenant and write-path defaults (final release: 15.1.1).
2424
- [v14.0.0](/docs/releases/v14) — ADR-0090 vocabulary convergence completed, object `enable.*` flags become real gates, admin user management, phone/SMS auth, book-audience enforcement, data-lifecycle contract, and effective-dated grants (final release: 14.8.0).

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"setup": "pnpm install && pnpm --filter @objectstack/spec build",
1818
"prepare": "node scripts/setup-git-hooks.mjs",
1919
"check:merge-driver": "node scripts/git-merge-regen.mjs --self-test && node scripts/check-regen-pending.mjs --self-test",
20-
"version": "changeset version && node scripts/sync-protocol-version.mjs && node scripts/sync-template-versions.mjs && node scripts/sync-docs-image-tags.mjs",
20+
"version": "changeset version && node scripts/sync-protocol-version.mjs && node scripts/sync-template-versions.mjs && node scripts/sync-docs-image-tags.mjs && node scripts/sync-release-index-currency.mjs",
2121
"release": "pnpm run build && bash scripts/build-console.sh && bash scripts/release-publish.sh",
2222
"docs:dev": "pnpm --filter @objectstack/docs dev",
2323
"docs:build": "pnpm --filter @objectstack/docs build",
@@ -124,6 +124,7 @@
124124
"check:release-notes": "node scripts/check-release-notes.mjs",
125125
"check:release-page-status": "node scripts/check-release-page-status.mjs --self-test && node scripts/check-release-page-status.mjs",
126126
"check:release-body": "node scripts/release-github-releases.mjs --self-test",
127+
"check:release-index-currency-sync": "node scripts/sync-release-index-currency.mjs --self-test",
127128
"check:node-version": "node scripts/check-node-version.mjs",
128129
"check:pnpm-acquisition": "node scripts/check-pnpm-acquisition.mjs --self-test && node scripts/check-pnpm-acquisition.mjs",
129130
"check:workflow-status-functions": "node scripts/check-workflow-status-functions.mjs --self-test && node scripts/check-workflow-status-functions.mjs",

‎scripts/check-release-section-coverage.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,9 @@ import { dirname, isAbsolute, resolve } from 'node:path';
187187
import { fileURLToPath } from 'node:url';
188188
import { isEntrypoint } from './invoked-as.mjs';
189189

190-
const SPEC_CHANGELOG = 'packages/spec/CHANGELOG.md';
190+
export const SPEC_CHANGELOG = 'packages/spec/CHANGELOG.md';
191191
const RELEASES_DIR = 'content/docs/releases';
192-
const INDEX_PATH = `${RELEASES_DIR}/index.mdx`;
192+
export const INDEX_PATH = `${RELEASES_DIR}/index.mdx`;
193193

194194
/**
195195
* The sibling gate, as the repo-relative name every MESSAGE in this file names

0 commit comments

Comments
 (0)