Skip to content

Commit 6c96144

Browse files
hotlongclaude
andauthored
fix(docs): stamp the release index's "current series" at version time (#15332) (#15392)
* 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 * ci(lint): run the release-index rewriter's self-test at PR time (#15332) Third version-time rewriter, third self-test step, beside the two already in this job (check:docs-image-tag-sync, check:template-version-sync). Two gates require a PR-time caller, and both name this family: check-self-test-wired ("a script CI runs that ships a --self-test must have that self-test run by CI"), and dispatch-gates' "a deferred pair defers the LEAD, not the load break" — a family no every-PR workflow runs leaves its import edges unwatched at PR time, so a change breaking the module load of check-release-section-coverage.mjs would not redden the PR that made it. With release.yml as the only caller that self-test was 2-of-1382 RED naming check:release-index-currency-sync. ⚠️ Not the --strict promotion, and it cannot become one: this step runs only --self-test, whose cases are string fixtures. It never reads the live index for currency and never reads a release page, so no corpus state can red it. The advisory, non-strict `Release section-coverage guard` step is untouched — this diff is 44 insertions and 0 deletions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 11f848e commit 6c96144

7 files changed

Lines changed: 829 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/lint.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,50 @@ jobs:
20332033
- name: Template version-time rewriter self-test
20342034
run: pnpm check:template-version-sync
20352035

2036+
# #15332 — the THIRD version-time rewriter, and the third self-test beside
2037+
# the two above. scripts/sync-release-index-currency.mjs joins the root
2038+
# `version` chain and stamps the release index's "current series: X.Y.Z,
2039+
# released YYYY-MM-DD" status field from packages/spec/CHANGELOG.md, so the
2040+
# sentence cannot go stale the way it did on three consecutive minors
2041+
# (#10232, #11649, #15332). The gate that names the staleness,
2042+
# check-release-section-coverage, structurally could not fire on the change
2043+
# that causes it: the version commit is opened by changesets/action with the
2044+
# default GITHUB_TOKEN and gets NO CI, and the step further down in this file
2045+
# runs that gate WITHOUT --strict, where a finding is advisory and the job is
2046+
# green by design.
2047+
#
2048+
# ⚠️ This step is NOT that gate's --strict arm, and must never become it.
2049+
# Promoting --strict here would also red a release page that has no section
2050+
# for a just-published train, i.e. every PR between a version commit and its
2051+
# release-notes PR — a policy question, deliberately not decided by adding a
2052+
# rewriter. What runs here is ONLY --self-test, whose cases are string
2053+
# fixtures: it never reads the live index for currency and never reads a
2054+
# release page at all, so no corpus state can red it. The advisory,
2055+
# non-strict `Release section-coverage guard` step below is untouched.
2056+
#
2057+
# Why it must run at PR time and not only in release.yml's post-version lane,
2058+
# where the rewriter's own corpus is — two gates in this repo say so, and both
2059+
# name a PR-TIME caller:
2060+
# * check-self-test-wired — a script CI runs that ships a --self-test must
2061+
# have that self-test run by CI.
2062+
# * scripts/pm/dispatch-gates.mjs --self-test — "a deferred pair defers the
2063+
# LEAD, not the load break": a family no every-PR workflow runs leaves its
2064+
# import edges unwatched at PR time, so a change breaking the MODULE LOAD
2065+
# of check-release-section-coverage.mjs (which this rewriter imports its
2066+
# surface, scope and verdict from) would not redden the PR that made it.
2067+
# Measured: with release.yml as the only caller, that self-test is
2068+
# 2-of-1382 RED naming this family; with this step, 1382/1382.
2069+
#
2070+
# Only the --self-test runs here, for the same reason the two steps above
2071+
# give: on a corpus that is already current the rewriter has nothing to do, so
2072+
# CI can never observe it working. The self-test is where a STALE entry is
2073+
# observed being stamped through the gate's own verdict, and — the control
2074+
# that matters just as much — where a CURRENT index is observed left
2075+
# byte-identical and UNWRITTEN, because this rewriter writes into curated,
2076+
# reader-facing prose.
2077+
- name: Release-index currency version-time rewriter self-test
2078+
run: pnpm check:release-index-currency-sync
2079+
20362080
# #4851: the docs-accuracy-audit workflow's default scope is a generated list
20372081
# (a workflow script runs in a vm with no filesystem, so it cannot enumerate
20382082
# content/docs/ itself — the caller hands the list in). Hand-kept, that list

.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",
@@ -128,6 +128,7 @@
128128
"check:release-notes": "node scripts/check-release-notes.mjs",
129129
"check:release-page-status": "node scripts/check-release-page-status.mjs --self-test && node scripts/check-release-page-status.mjs",
130130
"check:release-body": "node scripts/release-github-releases.mjs --self-test",
131+
"check:release-index-currency-sync": "node scripts/sync-release-index-currency.mjs --self-test",
131132
"check:node-version": "node scripts/check-node-version.mjs",
132133
"check:pnpm-acquisition": "node scripts/check-pnpm-acquisition.mjs --self-test && node scripts/check-pnpm-acquisition.mjs",
133134
"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)