Skip to content

Commit 88be631

Browse files
committed
ci(release): the version-PR lane reads the v3 .changeset/pre/ layout with changesets/action v2.1.2, rehearses that read on the post-version tree, and stops counting consumed prerelease changesets as pending
changesets/action@v1 resolved to v1.9.0, whose bundled @changesets/read 0.6.7 takes .changeset/pre/ for a legacy v1 folder changeset and opens .changeset/pre/changes.md: ENOENT on every refresh since the 18.0.0-next.0 cut moved 507 consumed changesets there. - Pin the PR step to changesets/action v2.1.2 by commit (the major upstream pairs with @changesets/cli v3), with v2's input names; no publish-script, push-with-git-cli, create-github-releases and push-git-tags off. - Run the same pinned reader on a copy of the post-version tree outside any git work tree, holding no credential, and require has-changesets false, so a reader that cannot read what the CLI writes fails before the cut. - Count .changeset/pre/*.md as pending only on pre exit, the CLI's own rule; in pre mode a tree with no new changeset read as 539 pending. Claude-Session: https://claude.ai/code/session_012zwsLMxQBwHUYJgPyrpEvx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c7cfbf commit 88be631

1 file changed

Lines changed: 152 additions & 23 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 152 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ name: Release
4343
# schedule (6-hourly) → `version-pr` keeps the "chore: version packages" PR
4444
# or a dispatch with (#4935) current. Carries NO publish
4545
# `refresh_version_pr` capability: the changesets step is
46-
# invoked WITHOUT a `publish:` script,
46+
# invoked WITHOUT a `publish-script:`,
4747
# so the action's publish branch is
4848
# unreachable by construction, not by
4949
# an `if:` someone can get wrong.
@@ -496,11 +496,31 @@ jobs:
496496
set -euo pipefail
497497
shopt -s nullglob
498498
499+
# Whether `.changeset/pre/*.md` is pending depends on the prerelease
500+
# mode. @changesets/cli v3 moves every changeset a prerelease version
501+
# pass consumes into `.changeset/pre/` (the layout the 18.0.0-next.0
502+
# cut created, #23282). In `pre` mode those are CONSUMED: the version
503+
# pass skips them. On `pre exit` the GA version consumes them all
504+
# again. That is the CLI's own rule (`getRelevantChangesets`,
505+
# @changesets/assemble-release-plan), and the PR step's reader applies
506+
# the same split. Counting `pre/` in `pre` mode made a tree with no
507+
# new changeset read as hundreds pending: the version pass then moved
508+
# nothing and the validation below went red on a lane with nothing
509+
# to do. Measured on the post-version tree: 539 counted, 0 moved.
510+
pre_mode=''
511+
if [ -f .changeset/pre.json ]; then
512+
pre_mode="$(node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(".changeset/pre.json", "utf8")).mode ?? ""))')"
513+
fi
514+
499515
# `/^README\.md$/i` is how `@changesets/read` spells its exclusion, so
500516
# the count is case-folded rather than matching one spelling.
501517
count_pending() {
502518
local n=0 file
503-
for file in .changeset/*.md .changeset/pre/*.md; do
519+
local globs=(.changeset/*.md)
520+
if [ "${pre_mode}" = 'exit' ]; then
521+
globs+=(.changeset/pre/*.md)
522+
fi
523+
for file in "${globs[@]}"; do
504524
if [ "$(printf '%s' "${file##*/}" | tr '[:upper:]' '[:lower:]')" = 'readme.md' ]; then
505525
continue
506526
fi
@@ -729,13 +749,89 @@ jobs:
729749
fi
730750
echo 'Post-version tree validated.'
731751
752+
# ══════════════════════════════════════════════════════════════════════
753+
# THE READ REHEARSAL — THE PR STEP'S OWN READER, ON THE TREE THE CUT LANDS
754+
# (#23282)
755+
# ══════════════════════════════════════════════════════════════════════
756+
# changesets/action does not use this repository's @changesets/cli to
757+
# decide whether anything is pending. It bundles its own @changesets/read,
758+
# so two readers of `.changeset/` meet on this lane, released on two
759+
# schedules. They disagreed for the first time at the 18.0.0-next.0 cut:
760+
# that version pass moved 507 consumed changesets into `.changeset/pre/`,
761+
# and the reader bundled in v1.9.0 (what `@v1` resolved to) takes any
762+
# directory under `.changeset/` for a legacy v1 folder changeset, so it
763+
# opened `.changeset/pre/changes.md` and failed with ENOENT on every
764+
# refresh after the cut. Nothing failed BEFORE the cut, because no tree
765+
# main had carried until then held `.changeset/pre/`.
766+
#
767+
# The tree rendered above IS the tree the next cut lands on main. So the
768+
# PR step's reader (same action, same pinned commit) reads it here, every
769+
# refresh, and must answer "nothing left to version". A reader that
770+
# cannot read what the CLI just wrote now fails while the Version
771+
# Packages PR is still open, instead of on the first refresh after it
772+
# merges. Measured against the next.0-shaped tree: v1.9.0 fails here with
773+
# the same ENOENT; v2.1.2 answers `has-changesets` false.
774+
#
775+
# ⛔ WHY IT CANNOT WRITE ANYTHING. The rehearsal runs on a COPY of the
776+
# changeset inputs outside every git work tree, and is handed no
777+
# credential. The only path out of a "pending" read is `runVersion`,
778+
# whose first act is `git checkout` (src/run.ts) and which fails there,
779+
# before any push or API call. Measured on the pre-version tree:
780+
# `has-changesets` true, then exit 1 at that checkout, zero API requests.
781+
# The copy carries the root `package.json` and `node_modules` so the
782+
# action's own Changesets CLI generation check runs against the
783+
# installed CLI too.
784+
- name: Stage the post-version changesets for the read rehearsal
785+
if: steps.post_version.outputs.pending_before != '0'
786+
run: |
787+
set -euo pipefail
788+
REHEARSAL="${RUNNER_TEMP}/changeset-read-rehearsal"
789+
rm -rf "${REHEARSAL}"
790+
mkdir -p "${REHEARSAL}"
791+
cp package.json pnpm-workspace.yaml "${REHEARSAL}/"
792+
cp -R .changeset "${REHEARSAL}/.changeset"
793+
ln -s "${GITHUB_WORKSPACE}/node_modules" "${REHEARSAL}/node_modules"
794+
if git -C "${REHEARSAL}" rev-parse --is-inside-work-tree > /dev/null 2>&1; then
795+
echo "::error::${REHEARSAL} is inside a git work tree, so the read rehearsal could reach a branch. Refusing to run it."
796+
exit 1
797+
fi
798+
echo "Staged $(find "${REHEARSAL}/.changeset" -name '*.md' | wc -l | tr -d ' ') changeset file(s) outside any git work tree: ${REHEARSAL}"
799+
800+
- name: Rehearse the version-PR changeset read on the post-version tree
801+
id: read_rehearsal
802+
if: steps.post_version.outputs.pending_before != '0'
803+
# ⛔ Keep this ref identical to the PR step's below. A rehearsal of a
804+
# different reader proves nothing about the one that runs.
805+
uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2
806+
with:
807+
cwd: ${{ runner.temp }}/changeset-read-rehearsal
808+
# Not a credential. The rehearsal only reads files.
809+
github-token: no-credential-the-read-rehearsal-only-reads
810+
811+
- name: Assert the read rehearsal found nothing left to version
812+
if: steps.post_version.outputs.pending_before != '0'
813+
env:
814+
HAS_CHANGESETS: ${{ steps.read_rehearsal.outputs.has-changesets }}
815+
run: |
816+
set -euo pipefail
817+
# `false` is the only passing answer. `true` on a zero exit is the
818+
# action's "all changesets are empty" branch: after the cut the PR
819+
# step would then refresh nothing, forever, with nothing red. Empty
820+
# means the output was renamed, so the pin no longer reads anything.
821+
if [ "${HAS_CHANGESETS}" != 'false' ]; then
822+
echo "::error::the version PR step's changeset reader, run on the post-version tree, answered has-changesets='${HAS_CHANGESETS}' where only 'false' is right: once this tree lands on main the PR step would misread it. Re-verify the pinned changesets/action against the changeset layout @changesets/cli writes before this version PR merges."
823+
exit 1
824+
fi
825+
echo "The PR step's reader reads the post-version tree as fully versioned: has-changesets=${HAS_CHANGESETS}."
826+
732827
# ⚠️ LOAD-BEARING — DO NOT DROP THIS STEP. The steps above render the
733828
# version into the runner's working tree, and changesets/action picks its
734829
# branch from that tree. With `.changeset/` already consumed it finds
735-
# `hasChangesets` false, and this lane deliberately passes no `publish:`
736-
# input, so `hasPublishScript` is false too — v1 then takes
737-
# `case !hasChangesets && !hasPublishScript` (src/index.ts), logs "No
738-
# changesets present or were removed by merging release PR" and RETURNS.
830+
# `hasChangesets` false, and this lane deliberately passes no
831+
# `publish-script:` input, so `hasPublishScript` is false too — the pinned
832+
# v2.1.2 then takes `case !hasChangesets && !hasPublishScript`
833+
# (src/index.ts), logs "No changesets present or were removed by merging
834+
# version PR" and RETURNS.
739835
# The refresh becomes a permanent no-op and #4935 fossilises with nothing
740836
# red anywhere: this file's own failure class, one lane over. So the tree
741837
# is put back, and the restoration is ASSERTED rather than assumed.
@@ -758,9 +854,20 @@ jobs:
758854
# branch should never be able to reach further than the thing it undoes.
759855
git clean -fdq -- .changeset packages examples content docker
760856
857+
# The render step's count, read the same way and only now: `pre exit`
858+
# versions delete `.changeset/pre.json`, so the mode is read from the
859+
# RESTORED tree.
860+
pre_mode=''
861+
if [ -f .changeset/pre.json ]; then
862+
pre_mode="$(node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(".changeset/pre.json", "utf8")).mode ?? ""))')"
863+
fi
761864
count_pending() {
762865
local n=0 file
763-
for file in .changeset/*.md .changeset/pre/*.md; do
866+
local globs=(.changeset/*.md)
867+
if [ "${pre_mode}" = 'exit' ]; then
868+
globs+=(.changeset/pre/*.md)
869+
fi
870+
for file in "${globs[@]}"; do
764871
if [ "$(printf '%s' "${file##*/}" | tr '[:upper:]' '[:lower:]')" = 'readme.md' ]; then
765872
continue
766873
fi
@@ -793,36 +900,58 @@ jobs:
793900
# nested mapping (caught by check:workflow-status-functions' self-test).
794901
- name: 'Create or update the "chore: version packages" PR'
795902
id: changesets
796-
uses: changesets/action@v1
903+
# PINNED BY COMMIT (#23282). v2 is the major upstream pairs with
904+
# @changesets/cli v3: its bundled reader reads `.changeset/pre/` and, in
905+
# `pre` mode, leaves those consumed changesets out of `hasChangesets`
906+
# (src/readChangesetState.ts), and its CLI generation check refuses a
907+
# CLI v2 project. v1 was held on 2026-08-19 (#9208) because its
908+
# replacement's publish property had not been read, and the hold named
909+
# how to reopen it: read v2's source for that property and for the
910+
# bundled reader. Both were read at this commit, and the bundle was run
911+
# end to end on the next.0-shaped tree. A commit, not `@v2`: `v2` is a
912+
# BRANCH upstream, so a moving ref would change the bundled reader with
913+
# no diff here, and the read rehearsal above runs this same commit.
914+
uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2
797915
with:
798-
# ⛔ THERE IS NO `publish:` INPUT HERE, AND THAT IS THE FIX. ⛔
916+
# ⛔ THERE IS NO `publish-script:` INPUT HERE, AND THAT IS THE FIX. ⛔
799917
#
800918
# Not an oversight and not a style choice — it is what makes this lane
801-
# structurally unable to publish, per #6170. changesets/action@v1
802-
# branches on `hasPublishScript = !!publishScript` (src/index.ts):
919+
# structurally unable to publish, per #6170. The pinned v2.1.2
920+
# branches on `hasPublishScript = !!publishScript` (src/index.ts),
921+
# where `publishScript = core.getInput("publish-script")`:
803922
#
804923
# case !hasChangesets && !hasPublishScript:
805924
# core.info("No changesets present or were removed by merging
806-
# release PR. Not publishing because no publish
807-
# script found.");
925+
# version PR. Not publishing because publish-script
926+
# is not set.");
808927
# return;
928+
# case !hasChangesets && hasPublishScript:
929+
# await runPublish({...}); // unreachable: no publish-script
809930
# case hasChangesets:
810931
# await runVersion({...}); // version PR only
811932
#
812933
# With no publish script, `runPublish` is not reachable from any input
813934
# state the action can observe. Adding one back here re-arms the exact
814-
# lane that minted rc.3 and rc.4 without a human.
815-
version: pnpm run version
816-
commit: 'chore: version packages'
817-
title: 'chore: version packages'
818-
# No-op without a publish script. Kept so that re-adding one can never
819-
# silently resurrect #4900: the action posts each package's raw
935+
# lane that minted rc.3 and rc.4 without a human. v1's input names
936+
# (`publish:`, `version:`, `commit:`, `title:`) are not accepted
937+
# silently by v2: it throws on each one by name.
938+
github-token: ${{ secrets.GITHUB_TOKEN }}
939+
version-script: pnpm run version
940+
commit-message: 'chore: version packages'
941+
pr-title: 'chore: version packages'
942+
# v1's `commitMode: git-cli` default, spelled the v2 way. v2 otherwise
943+
# pushes the version commit through the GitHub API, a different path
944+
# whose commit and size behaviour this lane has never been measured on.
945+
push-with-git-cli: true
946+
# Both no-ops without a publish script. Kept so that re-adding one can
947+
# never silently resurrect #4900: the action posts each package's raw
820948
# CHANGELOG section as the Release body, and @objectstack/spec's
821949
# section for a single v17 RC is ~343k characters against the API's
822-
# 125,000 limit. Releases are created by scripts/release-github-releases.mjs.
823-
createGithubReleases: false
824-
env:
825-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
950+
# 125,000 limit. Releases are created by scripts/release-github-releases.mjs,
951+
# and version tags are pushed only by the publish job below. v2 also
952+
# pushes tags on its own unless told not to, so that one is off too.
953+
create-github-releases: false
954+
push-git-tags: false
826955

827956
# ══════════════════════════════════════════════════════════════════════════
828957
# PUSH LANE 2 — release integrity audit. Never mints. Reads github.sha and the

0 commit comments

Comments
 (0)