Skip to content

Commit 23a6552

Browse files
committed
ci(release): validate the post-version tree before the version PR is opened
The "chore: version packages" PR runs no CI: changesets/action opens and force-pushes it with the default GITHUB_TOKEN, and GitHub does not start workflow runs from events raised by that token. So the largest machine-generated diff of the release cycle is the only commit that reaches main without passing the gates. Two files in this repository already state that mechanism -- lint.yml's docs-image-tag block and scripts/sync-protocol-version.mjs's header -- and each states it while explaining a rewriter built to work around it. Render the post-version tree in the version-pr job, validate it, and put it back before changesets/action reads it. The gate list is derived from the change set by scripts/pm/dispatch-gates.mjs rather than written down, so a fifth version-time rewriter pulls in its surface's gates on the day it lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
1 parent 3689991 commit 23a6552

1 file changed

Lines changed: 322 additions & 2 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 322 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,9 +299,329 @@ jobs:
299299
- name: Verify Changesets "fixed" group covers every public package
300300
run: node scripts/check-changeset-fixed.mjs
301301

302+
# ══════════════════════════════════════════════════════════════════════
303+
# POST-VERSION VALIDATION — THE ONLY GATE THE RELEASE COMMIT EVER PASSES
304+
# (#11945)
305+
# ══════════════════════════════════════════════════════════════════════
306+
# The "chore: version packages" PR runs NO CI and cannot be made to.
307+
# GitHub does not start workflow runs from events raised by the default
308+
# `GITHUB_TOKEN`, and the step below opens and force-pushes that PR with
309+
# exactly that token — so it gets no `pull_request` build, and because the
310+
# branch is force-pushed there is not even a stable head for a human to
311+
# re-run checks against. Two files in this repository already state the
312+
# mechanism in their own words rather than inferring it from a sibling:
313+
#
314+
# lint.yml — "`changeset version` bumps packages/cli on a release PR
315+
# that (per sync-template-versions.mjs's own header) gets NO CI because
316+
# changesets/action opens it with the default GITHUB_TOKEN — so the bump
317+
# merged green and the gate reddened on the NEXT ordinary PR, naming
318+
# files that author never touched."
319+
#
320+
# scripts/sync-protocol-version.mjs — "the lockstep guard
321+
# (protocol-version.test.ts) exists, but release PRs opened by
322+
# changesets/action with the default GITHUB_TOKEN do not trigger CI
323+
# (GitHub's anti-recursion rule), so the guard only fired AFTER the
324+
# merge. Fixing the value at version time is the only spot that cannot
325+
# be skipped."
326+
#
327+
# That last sentence is the treadmill this block exists to end. Each
328+
# version-time output has so far had to buy its own correctness with its
329+
# own rewriter and its own self-test — sync-protocol-version.mjs (#2769),
330+
# sync-template-versions.mjs, sync-docs-image-tags.mjs (#9064) — because
331+
# nothing validated the tree as a whole. Maintainer ruling on the sibling
332+
# card (objectui#5397, 2026-08-22, 「接受所有」) chose option A: validate
333+
# the POST-VERSION tree HERE, after the version step and before the action
334+
# opens or updates the PR. NOT by handing the PR a PAT / GitHub App token
335+
# (widens the supply-chain trust surface, adds rotation obligations), and
336+
# NOT one artifact at a time (per-artifact guarantees do not generalise).
337+
#
338+
# THE SCOPE IS MEASURED IN THIS REPOSITORY, NOT INHERITED (2026-08-25)
339+
# -------------------------------------------------------------------
340+
# The sibling's scope conclusion does NOT transfer. There, the version
341+
# step moves no source byte at all. Here `pnpm run version` is four
342+
# rewriters, not one, and one of them writes TypeScript. Rendered against
343+
# this repository's real tree at 3689991d2 (158 pending changesets,
344+
# @objectstack/cli 17.2.0 -> 17.3.0), `pnpm run version` moved 313 paths:
345+
#
346+
# 158 .changeset/*.md deleted (consumed by the version)
347+
# 76 */package.json 152 changed lines, every one `"version":`
348+
# — no dependency range moved
349+
# 76 */CHANGELOG.md generated from the changeset bodies
350+
# 3 doc surfaces 8 concrete image-tag / npm-pin lines in
351+
# content/docs/deployment/self-hosting.mdx,
352+
# content/docs/upgrading.mdx, docker/README.md
353+
#
354+
# Three more paths can appear at a MAJOR boundary and did not on this
355+
# train — packages/spec/src/kernel/protocol-version.ts, and per bundled
356+
# template objectstack.config.ts and objectstack.manifest.json. The first
357+
# of those is a runtime source file, which is precisely why "the version
358+
# step cannot move source" is false here and the sibling's surface list
359+
# could not simply be copied. cut-rc.yml's allowlist block carries the
360+
# same measurement for the RC lane and agrees path-for-path.
361+
#
362+
# WHY THE GATE LIST IS DERIVED AND NOT WRITTEN DOWN
363+
# -------------------------------------------------
364+
# A hand-listed set is the treadmill again, one level up: the day someone
365+
# adds a fifth rewriter, the list does not know. So the change set is fed
366+
# to scripts/pm/dispatch-gates.mjs, which discovers every check family
367+
# from .github/workflows/** at run time and names the ones whose declared
368+
# path filters cover these paths. A new version-time output pulls in its
369+
# surface's gates on the day it lands, with nothing here to update.
370+
#
371+
# ⚠️ The EXPLICIT-PATHS form is required, not stylistic. By default the
372+
# script derives its change set from the merge base with origin/main, and
373+
# this job checks out `main` itself — measured in a depth-1 clone of main
374+
# with a dirty tree, the bare form prints its usage and exits 2, deriving
375+
# nothing. Passed the paths as argv it works on any checkout (exit 0), and
376+
# on this repository's real 313-path change set both forms name the same
377+
# 49 families. `--repo` asserts this checkout really is this repository,
378+
# so a wrong-tree answer refuses instead of being plausible.
379+
#
380+
# THE PRICE (measured on the rendered tree, same container, same state as
381+
# this job: `pnpm install --frozen-lockfile` and NO build)
382+
# ------------------------------------------------------------------
383+
# 49 families derived; 44 run here in 113 s total, every one green on the
384+
# post-version tree. The control that makes that number mean something:
385+
# the identical sweep on the PRE-version tree gives the identical verdicts
386+
# — 44 green, the same 6 red — so the version step changes no gate's mind
387+
# on this train, and the 6 reds are this environment, not findings.
388+
# At the 6-hourly refresh below that is ~7.5 minutes of runner time a day.
389+
#
390+
# ⛔ WHAT IS DELIBERATELY NOT RUN, AND WHY IT IS NOT AN OMISSION. Five of
391+
# the 49 need a workspace BUILD, and one needs full git history. They are
392+
# excluded by name below rather than by silence:
393+
#
394+
# check:i18n, check:i18n-coverage, check:dev-prereqs,
395+
# check:doc-formula-expressions, check:doc-security-posture
396+
# All five refuse without built dist ("Nothing was measured" /
397+
# "Fix: pnpm build"). A build on this lane is the ~9 minutes the
398+
# comment below records as deliberately moved to the publish job;
399+
# re-adding it to gate a manifest's `"version"` key would undo
400+
# that. What they read is not what the version step moved: the two
401+
# doc gates judge formula fences and security-posture prose, and
402+
# the only content/** lines the version step touches are the 8
403+
# concrete image tags above.
404+
# check:engine-split-ratio
405+
# An ADR-0076 D7 metric over 90 days of git log. It refuses on a
406+
# shallow checkout by design, and this job's checkout is depth-1.
407+
# It reads history, never the tree, so the version step cannot
408+
# change its answer.
409+
#
410+
# A gate that later joins one of those families lands in this list's way
411+
# and reds the lane rather than passing silently — the safe direction.
412+
#
413+
# ⛔ WHY check:nul-bytes IS ADDED BY HAND. It is one of lint.yml's
414+
# UNCONDITIONAL gates, so no path filter can derive it — and the release
415+
# commit is the one commit that never runs lint.yml at all. Its corpus is
416+
# every byte in the tree, which includes the 76 generated CHANGELOGs: the
417+
# only prose the version step writes that it did not author. 1 s.
418+
#
419+
# FAILURE SEMANTICS. A red validation fails the job, so the step below
420+
# never runs and the standing PR keeps its last VALIDATED content instead
421+
# of being force-pushed to a broken one. If the tree stays broken the PR
422+
# goes stale — but loudly, on a red run every six hours, which is the
423+
# opposite of this card's defect. Nothing here can reach the publish lane:
424+
# this whole job is `schedule` / `workflow_dispatch` only.
425+
- name: Render the post-version tree
426+
id: post_version
427+
run: |
428+
set -euo pipefail
429+
shopt -s nullglob
430+
431+
# `/^README\.md$/i` is how `@changesets/read` spells its exclusion, so
432+
# the count is case-folded rather than matching one spelling.
433+
count_pending() {
434+
local n=0 file
435+
for file in .changeset/*.md .changeset/pre/*.md; do
436+
if [ "$(printf '%s' "${file##*/}" | tr '[:upper:]' '[:lower:]')" = 'readme.md' ]; then
437+
continue
438+
fi
439+
n=$((n + 1))
440+
done
441+
printf '%s' "$n"
442+
}
443+
444+
pending_before=$(count_pending)
445+
echo "pending_before=${pending_before}" >> "$GITHUB_OUTPUT"
446+
447+
# The version PR was just merged, or nothing has landed since. The
448+
# action will take its no-changesets branch and do nothing, so there
449+
# is no post-version tree to render and nothing to validate. Said out
450+
# loud because a validation that silently does nothing is this card's
451+
# own failure class.
452+
if [ "${pending_before}" -eq 0 ]; then
453+
echo 'No pending changesets — the version step would move nothing. Skipping the post-version validation.'
454+
{
455+
echo '### Post-version validation'
456+
echo
457+
echo '- pending changesets: `0` — nothing to render, nothing to validate.'
458+
} >> "$GITHUB_STEP_SUMMARY"
459+
exit 0
460+
fi
461+
462+
pnpm run version
463+
464+
# Tracked moves (deletions included) plus new untracked files. Read
465+
# this way rather than by classifying `git status --porcelain` letters:
466+
# a rename arrives as a delete/add pair whose halves are both named
467+
# here, so no status code has to be interpreted.
468+
{
469+
git diff --name-only HEAD
470+
git ls-files --others --exclude-standard
471+
} | sort -u > "${RUNNER_TEMP}/post-version-paths.txt"
472+
moved=$(wc -l < "${RUNNER_TEMP}/post-version-paths.txt" | tr -d ' ')
473+
474+
{
475+
echo '### Post-version validation'
476+
echo
477+
echo "- rendered from \`${GITHUB_SHA}\`"
478+
echo "- pending changesets consumed: \`${pending_before}\`"
479+
echo "- paths the version step moved: \`${moved}\`"
480+
} >> "$GITHUB_STEP_SUMMARY"
481+
482+
- name: Validate the post-version tree
483+
if: steps.post_version.outputs.pending_before != '0'
484+
run: |
485+
set -euo pipefail
486+
487+
mapfile -t MOVED < "${RUNNER_TEMP}/post-version-paths.txt"
488+
if [ "${#MOVED[@]}" -eq 0 ]; then
489+
echo "::error::changesets were pending but the version step moved no path. Either \`pnpm run version\` did nothing, or it wrote outside the working tree — both are a broken version lane, not an empty diff."
490+
exit 1
491+
fi
492+
493+
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack "${MOVED[@]}" \
494+
> "${RUNNER_TEMP}/derived.txt"
495+
496+
# PARSE GUARD. The list is read out of prose the script writes for a
497+
# human, so a format change would otherwise hand this step an empty
498+
# gate list and a green run — a validation that validates nothing,
499+
# which is the defect this whole block is closing. Both the header and
500+
# a non-empty result are therefore asserted before anything runs.
501+
if ! grep -q '^Local gates for this card' "${RUNNER_TEMP}/derived.txt"; then
502+
echo "::error::dispatch-gates.mjs printed no 'Local gates for this card' block. Its output format changed and this step's parser did not — refusing to report a green validation derived from nothing."
503+
exit 1
504+
fi
505+
awk '
506+
/^Local gates for this card/ { on = 1; next }
507+
on && /^[^ ]/ { exit }
508+
on && /^ - / { sub(/^ - /, ""); sub(/ \[.*/, ""); print }
509+
' "${RUNNER_TEMP}/derived.txt" | sort -u > "${RUNNER_TEMP}/gates.txt"
510+
511+
derived=$(wc -l < "${RUNNER_TEMP}/gates.txt" | tr -d ' ')
512+
if [ "${derived}" -eq 0 ]; then
513+
echo "::error::dispatch-gates.mjs derived no gate at all from ${#MOVED[@]} changed path(s). A version pass always moves packages/cli/package.json and the doc surfaces that pin its version, so an empty derivation is a broken parse, not a clean tree."
514+
exit 1
515+
fi
516+
517+
# Written with printf rather than a heredoc: a heredoc body has to
518+
# start at column 0, which ends the YAML block scalar this script
519+
# lives in (caught by parsing this file, not by reading it).
520+
printf '%s\n' \
521+
'node scripts/check-dev-prereqs.mjs' \
522+
'node scripts/check-engine-split-ratio.mjs' \
523+
'pnpm check:i18n' \
524+
'pnpm check:i18n-coverage' \
525+
'pnpm --filter @objectstack/lint run check:doc-formula-expressions' \
526+
'pnpm --filter @objectstack/lint run check:doc-security-posture' \
527+
> "${RUNNER_TEMP}/excluded.txt"
528+
529+
failed=()
530+
skipped=0
531+
ran=0
532+
while IFS= read -r gate; do
533+
[ -z "${gate}" ] && continue
534+
if grep -qxF -- "${gate}" "${RUNNER_TEMP}/excluded.txt"; then
535+
echo "skipped (declared exclusion — needs a build or full history): ${gate}"
536+
skipped=$((skipped + 1))
537+
continue
538+
fi
539+
echo "::group::${gate}"
540+
if bash -c "${gate}"; then
541+
ran=$((ran + 1))
542+
else
543+
failed+=("${gate}")
544+
fi
545+
echo "::endgroup::"
546+
done < "${RUNNER_TEMP}/gates.txt"
547+
548+
echo "::group::pnpm check:nul-bytes"
549+
if pnpm check:nul-bytes; then
550+
ran=$((ran + 1))
551+
else
552+
failed+=('pnpm check:nul-bytes')
553+
fi
554+
echo "::endgroup::"
555+
556+
{
557+
echo "- gate families derived: \`${derived}\` (+1 unconditional: check:nul-bytes)"
558+
echo "- run: \`${ran}\` · declared exclusions skipped: \`${skipped}\` · failed: \`${#failed[@]}\`"
559+
} >> "$GITHUB_STEP_SUMMARY"
560+
561+
if [ "${#failed[@]}" -ne 0 ]; then
562+
printf '::error::the post-version tree fails %s gate(s) that no CI run would ever have judged: %s\n' \
563+
"${#failed[@]}" "$(printf '%s; ' "${failed[@]}")"
564+
exit 1
565+
fi
566+
echo "Post-version tree validated: ${ran} gate(s) green."
567+
568+
# ⚠️ LOAD-BEARING — DO NOT DROP THIS STEP. The steps above render the
569+
# version into the runner's working tree, and changesets/action picks its
570+
# branch from that tree. With `.changeset/` already consumed it finds
571+
# `hasChangesets` false, and this lane deliberately passes no `publish:`
572+
# input, so `hasPublishScript` is false too — v1 then takes
573+
# `case !hasChangesets && !hasPublishScript` (src/index.ts), logs "No
574+
# changesets present or were removed by merging release PR" and RETURNS.
575+
# The refresh becomes a permanent no-op and #4935 fossilises with nothing
576+
# red anywhere: this file's own failure class, one lane over. So the tree
577+
# is put back, and the restoration is ASSERTED rather than assumed.
578+
- name: Restore the pre-version tree
579+
if: always()
580+
env:
581+
PENDING_BEFORE: ${{ steps.post_version.outputs.pending_before }}
582+
run: |
583+
set -euo pipefail
584+
shopt -s nullglob
585+
586+
if [ -z "${PENDING_BEFORE}" ] || [ "${PENDING_BEFORE}" = '0' ]; then
587+
echo 'Nothing was rendered, so there is nothing to restore.'
588+
exit 0
589+
fi
590+
591+
git checkout -- .
592+
# Narrow on purpose, and measured: those are the only roots the
593+
# version pass writes into. A `git clean` on a lane that can push to
594+
# a branch should never be able to reach further than the thing it
595+
# undoes.
596+
git clean -fdq -- .changeset packages examples content docker
597+
598+
count_pending() {
599+
local n=0 file
600+
for file in .changeset/*.md .changeset/pre/*.md; do
601+
if [ "$(printf '%s' "${file##*/}" | tr '[:upper:]' '[:lower:]')" = 'readme.md' ]; then
602+
continue
603+
fi
604+
n=$((n + 1))
605+
done
606+
printf '%s' "$n"
607+
}
608+
609+
pending_after=$(count_pending)
610+
dirty=$(git status --porcelain --untracked-files=no | wc -l | tr -d ' ')
611+
612+
if [ "${pending_after}" != "${PENDING_BEFORE}" ] || [ "${dirty}" -ne 0 ]; then
613+
echo "::error::the pre-version tree was NOT restored (${PENDING_BEFORE} pending changeset(s) before, ${pending_after} after; ${dirty} tracked path(s) still modified). changesets/action would read this tree, find nothing pending and no publish script, and return without refreshing the PR — a silent permanent no-op (#11945)."
614+
exit 1
615+
fi
616+
echo "Pre-version tree restored: ${pending_after} pending changeset(s), working tree clean."
617+
302618
# `pnpm run version` = changeset version + sync-protocol-version +
303-
# sync-template-versions. All three are pure file rewrites; none reads a
304-
# built artifact. The workspace build, the vendored Console build and the
619+
# sync-template-versions + sync-docs-image-tags — FOUR rewriters, read
620+
# from the root `version` script, not three. The fourth joined in #9064
621+
# and this comment did not follow; it is corrected here because the block
622+
# above prices what that script moves, and a stale count of the rewriters
623+
# is a stale statement of the surface being priced. All four are pure file
624+
# rewrites; none reads a built artifact. The workspace build, the vendored Console build and the
305625
# live hotcrm smoke that used to run here were PRE-PUBLISH gates — they
306626
# moved to the `publish` job below, where the publish they gate now lives.
307627
# Leaving them here would gate nothing and cost ~9 minutes of every main

0 commit comments

Comments
 (0)