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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 78 additions & 24 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -368,16 +368,23 @@ jobs:
#
# Two more classes exist and did NOT move on that train, because both are
# major-boundary only: packages/spec/src/kernel/protocol-version.ts, and
# per bundled template objectstack.config.ts / objectstack.manifest.json.
# A major cannot reach this lane while check-changeset-no-major.mjs holds
# (pr-automation.yml refuses a PR that introduces one), so they are dormant
# rather than covered — and the shape assertion below says so out loud on
# the first train that wakes them, instead of validating a surface with no
# gate behind it. cut-rc.yml's allowlist block carries the same
# measurement for the RC lane and agrees path-for-path.
# per bundled template objectstack.config.ts / objectstack.manifest.json
# (with the template's own package.json, whose `^N.0.0` ranges move too).
# They stayed dormant while check-changeset-no-major.mjs held; pre mode
# stands that guard aside, and the v18 opening woke them. RE-MEASURED on
# that first boundary train (2026-10-07: 3d9188502e plus the opening's
# `.changeset/pre.json` and `major` changeset, 37 changesets,
# @objectstack/cli 17.7.0 -> 18.0.0-next.0): 236 paths, 0 outside the
# surface, 4 major-only — the three blank-template files and
# protocol-version.ts. The template half is now judged by the gates below;
# protocol-version.ts is still refused, for the measured reason given at
# the refusal. cut-rc.yml's allowlist block carries the same surface for
# the RC lane and agrees path-for-path.
#
# WHAT IS CHECKED, AND WHY EACH ONE (7 s total, measured on the rendered
# tree in this job's own state: install, NO build)
# tree in this job's own state: install, NO build. With the major-boundary
# gates below, the whole step held 44 s on a shared dev container,
# 2026-10-07, on a 17.8.0 train — every gate green)
# ---------------------------------------------------------------------
# Two halves, because the classes above fail in two different ways.
#
Expand Down Expand Up @@ -417,6 +424,32 @@ jobs:
# check-release-section-coverage.mjs, check:published-readme-links
# the four gates that READ CHANGELOG.md.
#
# And the gates of the two MAJOR-boundary classes, run on EVERY refresh
# rather than only on the train that moves them: each reads a version
# this step writes on every train, and a gate that runs once a major
# rots unseen between majors. All existing gates; none needs a build.
# create-objectstack's suite the bundled templates RENDERED: the
# scaffolder's own copy and identity
# rewrite, from a template packed the way
# npm ships it, plus the ratchets that
# judge all three stamps against
# create-objectstack's NEW major
# (template-consistency.test.ts).
# Measured on the boundary train: 16
# files, 249 tests, green, 23 s.
# check:template-manifests each stamped manifest still parses as
# TemplateManifestSchema (2 s, green).
# protocol-version.test.ts PROTOCOL_VERSION in lockstep with spec's
# NEW major. ONE file, 5 s — reachable
# without the whole spec suite, which
# this comment used to say it was not.
# check:spec-changes, check:upgrade-guide
# the two committed artifacts DERIVED from
# PROTOCOL_MAJOR (2-3 s each). Measured
# RED on the boundary train: the version
# pass moves the constant and regenerates
# neither.
#
# ⛔ WHAT IS DELIBERATELY NOT RUN, AND WHY IT IS NOT AN OMISSION.
# The whole derived farm. Fed this 313-path change set,
# scripts/pm/dispatch-gates.mjs names 49 families; 44 of them run
Expand Down Expand Up @@ -595,22 +628,27 @@ jobs:
# check with a diagnosis of their own. Measured: with the pattern
# missing, protocol-version.ts fell into the "wrote outside the
# reviewed surface" branch instead, which is red for the right reason
# and wrong about why. What this lane does not have is a gate for
# either. protocol-version.ts is judged by
# packages/spec/src/kernel/protocol-version.test.ts, reachable only
# through the whole @objectstack/spec suite (measured: 424 files,
# 11273 tests, 5m26s — not a per-refresh cost), and the template stamps
# by a full template render. Unreachable while
# check-changeset-no-major.mjs holds; loud, not silent, the day it
# stops holding.
MAJOR_ONLY="$(grep -xF -f "${TEMPLATE_LIST}" "${MOVED_FILE}" || true)"
# and wrong about why.
#
# The TEMPLATE stamps are validated, not refused: the create-objectstack
# suite and check:template-manifests in the content half judge them
# (see the header). They are counted here only for the summary.
#
# protocol-version.ts is still REFUSED, and not for want of a gate: its
# three gates run below. What moving it does is wider than all three.
# The ADR-0087 D1 handshake (`assertProtocolCompat` on the app LOAD
# seam, packages/runtime/src/app-plugin.ts) refuses every manifest whose
# `engines.protocol` excludes the new major, and this repository's own
# example apps declare the old one. Measured with the boundary train's
# constant: `^17` -> OS_PROTOCOL_INCOMPATIBLE, `^18` -> ok (control on
# the pre-version tree: the reverse). No gate this lane can afford boots
# them, so the refusal stays until a reviewed change says how the
# protocol major moves on this lane. It is collected rather than exited
# on, so one boundary run names every gate verdict as well.
MAJOR_TEMPLATES="$(grep -xF -f "${TEMPLATE_LIST}" "${MOVED_FILE}" || true)"
UNVALIDATED=''
if grep -qxF 'packages/spec/src/kernel/protocol-version.ts' "${MOVED_FILE}"; then
MAJOR_ONLY="${MAJOR_ONLY}"$'\n'"packages/spec/src/kernel/protocol-version.ts"
fi
if [ -n "${MAJOR_ONLY//[[:space:]]/}" ]; then
printf '%s\n' "${MAJOR_ONLY}" | sed '/^$/d; s/^/::error:: unvalidated: /'
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."
exit 1
UNVALIDATED='packages/spec/src/kernel/protocol-version.ts'
fi

# ── CONTENT ──────────────────────────────────────────────────────
Expand All @@ -634,15 +672,31 @@ jobs:
run_gate pnpm check:release-page-status
run_gate node scripts/check-release-section-coverage.mjs
run_gate pnpm check:published-readme-links

# The MAJOR-boundary classes' gates. `--fail-if-no-match`: a filter that
# matches no package otherwise exits 0 having run nothing.
run_gate pnpm --filter create-objectstack --fail-if-no-match test
run_gate pnpm --filter @objectstack/spec --fail-if-no-match check:template-manifests
run_gate pnpm --filter @objectstack/spec --fail-if-no-match exec vitest run --project local src/kernel/protocol-version.test.ts
run_gate pnpm --filter @objectstack/spec --fail-if-no-match check:spec-changes
run_gate pnpm --filter @objectstack/spec --fail-if-no-match check:upgrade-guide

template_moved=$(printf '%s\n' "${MAJOR_TEMPLATES}" | sed '/^$/d' | wc -l | tr -d ' ')
{
echo "- post-version surface: \`${moved}\` path(s), all inside the reviewed shape"
echo "- major-boundary template stamps moved: \`${template_moved}\` (judged by the content gates)"
echo "- content gates failed: \`${#failed[@]}\`"
echo "- major-boundary surfaces left unvalidated: \`$([ -n "${UNVALIDATED}" ] && echo 1 || echo 0)\`"
} >> "$GITHUB_STEP_SUMMARY"

if [ "${#failed[@]}" -ne 0 ]; then
printf '::error::the post-version tree fails %s gate(s) that no CI run would ever have judged: %s\n' \
"${#failed[@]}" "$(printf '%s; ' "${failed[@]}")"
fi
if [ -n "${UNVALIDATED}" ]; then
echo "::error:: unvalidated: ${UNVALIDATED}"
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."
fi
if [ "${#failed[@]}" -ne 0 ] || [ -n "${UNVALIDATED}" ]; then
exit 1
fi
echo 'Post-version tree validated.'
Expand Down
93 changes: 84 additions & 9 deletions scripts/sync-release-index-currency.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,17 @@
// • An entry with no trailing parenthetical at all. There is no field to stamp, and
// inventing one means choosing between the two wordings above -- the same judgement.
// • An entry whose parenthetical is prose. Same reason.
// • A `current series:` field that already names the newest GA, whatever its date.
// The gate has no finding there, and the date belongs to the version commit that
// moved the field, which a run that moves nothing is not -- every prerelease cut
// is such a run (see `releaseDate`).
//
// An over-eager rewriter is not a lesser failure than an inert one: this file writes
// into curated, reader-facing prose, so `--self-test` asserts a CURRENT index is left
// BYTE-IDENTICAL with no write at all, and that each refused shape survives untouched.

import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

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

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

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

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

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

// ── Control H ─────────────────────────────────────────────────────────────
// The measured defect: the first `18.0.0-next.0` cut re-dated the v17 entry from its
// GA day to the cut's day. A prerelease heading never becomes the newest GA, so a
// prerelease cut leaves the version where it is -- and must leave the date too.
battery('Control H: a prerelease cut never re-dates the newest GA');
{
const CUT_DATE = '2026-10-07';
const withHeading = (heading) => CHANGELOG_FIXTURE.replace('## 17.3.0-rc.1', heading);
const nextCut = withHeading('## 18.0.0-next.0');
const rcCut = withHeading('## 17.4.0-rc.0');
const current = indexOf(CURRENT_V17, CURRENT_V16);

const afterNext = rewriteIndexText({ indexText: current, changelogText: nextCut, date: CUT_DATE });
expect('H — a `next` prerelease cut on a later day rewrites nothing',
afterNext.rewrites.length === 0 && afterNext.text === current);
expect('H — the newest GA keeps its own release date, not the cut\'s',
afterNext.text.includes('current series: 17.3.0, released 2026-09-04')
&& !afterNext.text.includes(`released ${CUT_DATE}`));
expect('H — the prerelease major is not a judged major: no GA of 18 exists yet',
!afterNext.majors.includes(18) && afterNext.majors.includes(17));
expect('H — and refusing the re-date leaves the gate nothing to say',
currencyFindings({ indexText: afterNext.text, changelogText: nextCut }).length === 0);

const afterRc = rewriteIndexText({ indexText: current, changelogText: rcCut, date: CUT_DATE });
expect('H — an `rc` prerelease cut on a later day rewrites nothing either',
afterRc.rewrites.length === 0 && afterRc.text === current);

// Positive control on the same later day: the date still travels WITH a version that
// moves, so the refusal above is about the version standing still, not an inert date.
const staleAfterNext = rewriteIndexText({
indexText: indexOf(STALE_V17, CURRENT_V16), changelogText: nextCut, date: CUT_DATE,
});
expect('H — control: a STALE entry on the same cut is still stamped, version AND that day',
staleAfterNext.rewrites.length === 1
&& staleAfterNext.text.includes(`current series: 17.3.0, released ${CUT_DATE}`));

// End to end through syncIndex, the function main() calls: on a checkout-shaped temp
// tree the index file is not written at all, not rewritten to identical bytes.
const tmp = mkdtempSync(join(tmpdir(), 'sync-release-index-currency-'));
try {
mkdirSync(dirname(join(tmp, INDEX_PATH)), { recursive: true });
mkdirSync(dirname(join(tmp, SPEC_CHANGELOG)), { recursive: true });
writeFileSync(join(tmp, INDEX_PATH), current);
writeFileSync(join(tmp, SPEC_CHANGELOG), nextCut);
const result = syncIndex({ root: tmp, date: CUT_DATE });
expect('H — syncIndex on a next-cut tree reports no write and leaves the index byte-identical',
result.wrote === false && result.rewrites.length === 0
&& readFileSync(join(tmp, INDEX_PATH), 'utf8') === current && result.findings.length === 0);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}

// ── Floor ─────────────────────────────────────────────────────────────────
const declared = Object.keys(SELF_TEST_BATTERIES);
let floorBreached = false;
Expand Down Expand Up @@ -695,8 +769,9 @@ export function selfTest() {
+ 'GA of its major with the version AND the date, a current index is left byte-identical and '
+ 'unwritten, the shapes that need a human sentence ("final release:", a missing parenthetical, '
+ 'prose) are REFUSED and reach the gate\'s own verdict instead, prose parentheticals and '
+ 'out-of-scope majors are never touched, and the surface, the scope floor and the verdict are '
+ 'the gate\'s rather than copies of it.',
+ 'out-of-scope majors are never touched, a prerelease cut (`next` or `rc`) leaves the newest '
+ 'GA\'s release date alone, and the surface, the scope floor and the verdict are the gate\'s '
+ 'rather than copies of it.',
);
selfTestReachedVerdict = true;
return 0;
Expand Down
Loading