@@ -40,14 +40,15 @@ name: Release
4040#
4141# HOW THE LANES ARE SPLIT NOW
4242# ---------------------------
43- # push to main → `version-pr` keeps the "chore: version packages" PR
44- # (#4935) current. Carries NO publish
45- # capability: the changesets step is
43+ # schedule (6-hourly) → `version-pr` keeps the "chore: version packages" PR
44+ # or a dispatch with (#4935) current. Carries NO publish
45+ # `refresh_version_pr` capability: the changesets step is
4646# 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.
50- # → `release-integrity` audits ONLY the version at
50+ # NOT on push — see the section below.
51+ # push to main → `release-integrity` audits ONLY the version at
5152# `github.sha`. Never publishes, never
5253# pushes a tag. May backfill GitHub
5354# Releases / the ADR-0087 D4 asset /
@@ -64,8 +65,58 @@ name: Release
6465# at `environment: release` until a
6566# required reviewer approves it.
6667# workflow_dispatch → `publish` the repair lane. Takes no version;
67- # audits main exactly as the push lane
68- # does. Same environment gate.
68+ # (WITHOUT audits main exactly as the push lane
69+ # `refresh_version_pr`) does. Same environment gate.
70+ #
71+ # WHY `version-pr` LEFT THE PUSH TRIGGER (#11233, 2026-08-23)
72+ # ----------------------------------------------------------
73+ # changesets/action's version path is `git reset --hard <github.context.sha>` →
74+ # re-version → `git push --force origin HEAD:changeset-release/main` (its source
75+ # is quoted at the top of this file). On push that recomputes and force-pushes
76+ # #4935 on EVERY landing, and main takes ~18 merges a working day. The standing
77+ # Version Packages PR therefore never held still long enough for its own branch
78+ # CI to finish: every run was superseded by the next force-push, so the PR could
79+ # not converge and was ejected from the merge queue on entry. That is a
80+ # structural property of (action algorithm × trigger frequency), not of the CLI
81+ # version — this repo is already on @changesets/cli ^3.0.0 and the churn was
82+ # unchanged. The fix is the one the changesets project documents for exactly
83+ # this: refresh on a SCHEDULE instead of on every push.
84+ #
85+ # Between refreshes `changeset-release/main` is a static branch. Its CI
86+ # converges, and it merges through the ordinary queue like any other PR. A stale
87+ # window of up to six hours is the whole cost, and it is bounded on demand:
88+ # dispatch with `refresh_version_pr` when you want it current NOW (immediately
89+ # before a GA cut, say). The bookkeeping is not time-critical — the changesets
90+ # are already committed on main; #4935 is only their rendering.
91+ #
92+ # ⛔ THE DISPATCH COLLISION, AND WHY THERE IS AN INPUT FOR IT
93+ # ----------------------------------------------------------
94+ # `workflow_dispatch` was already taken: it is the publish REPAIR lane (D4).
95+ # One event name now has to start two lanes that must never start each other —
96+ # a refresh that also queued the publish audit would park a WAITING DEPLOYMENT
97+ # at the `release` environment on every routine refresh, i.e. an approval prompt
98+ # a maintainer must open and dismiss to keep the real ones meaningful. Approval
99+ # noise is how an approval stops being read, and this file's whole barrier is
100+ # that approval (see below).
101+ #
102+ # So the event is split by an INPUT rather than by a second workflow file (the
103+ # maintainer does not want another lane to maintain, and a second file would
104+ # duplicate the publish invariants where they can drift apart):
105+ #
106+ # dispatch WITH `refresh_version_pr` → version-pr only, no deployment
107+ # dispatch WITHOUT `refresh_version_pr` → the repair lane, exactly as before
108+ # schedule → version-pr only
109+ # push to main → release-integrity (+ publish)
110+ #
111+ # Every job carries the half of that split it needs, in its own `if:`. No job
112+ # infers its lane from another job's presence.
113+ #
114+ # ⚠️ The two inputs are INDEPENDENT, so `refresh_version_pr` + `force` is a
115+ # reachable form, and it is refused rather than resolved: `publish`'s guard
116+ # excludes any dispatch carrying `refresh_version_pr`, so that combination
117+ # refreshes and publishes NOTHING. A dispatch that both refreshes bookkeeping
118+ # and force-publishes is not a thing anyone means; the harmless reading is the
119+ # one that runs.
69120#
70121# WHERE THE HUMAN IS, AFTER ADR-0125 (2026-08-20)
71122# -----------------------------------------------
@@ -98,17 +149,36 @@ name: Release
98149#
99150# WHAT IS DELIBERATELY STILL AUTOMATIC
100151# ------------------------------------
101- # Version-PR maintenance (this file's `version-pr` job) stays on push runs —
102- # harmless bookkeeping, and #4935 must keep regenerating. Release/D4/image
103- # backfill for an already-published version stays on push runs — it is the
104- # #4900 repair, and it cannot mint a version. `npm publish` and `git push --tags`
105- # still live in exactly one job, and that job cannot start without a human
106- # approving it.
152+ # Version-PR maintenance (this file's `version-pr` job) still runs unattended —
153+ # harmless bookkeeping, and #4935 must keep regenerating — it just runs on a
154+ # 6-hourly schedule instead of on every push (#11233). A `schedule` trigger
155+ # reaching this job is not a loosening: the job has no publish capability by
156+ # construction, so the event that starts it cannot change what it is able to do.
157+ # Release/D4/image backfill for an already-published version stays on push runs —
158+ # it is the #4900 repair, and it cannot mint a version. `npm publish` and
159+ # `git push --tags` still live in exactly one job, that job is reachable from
160+ # `push` and from the repair dispatch ONLY — never from `schedule`, never from a
161+ # refresh dispatch — and it cannot start without a human approving it.
107162
108163on :
109164 push :
110165 branches :
111166 - main
167+ # Version-PR bookkeeping only (#11233). GitHub runs `schedule` on the DEFAULT
168+ # BRANCH exclusively, which is the ref `version-pr` may ever regenerate from,
169+ # so the trigger cannot reach a ref the job is not meant to touch. Six-hourly
170+ # is the stale-window budget: #4935 renders changesets that are already
171+ # committed on main, so lateness costs nothing that cannot be bought back on
172+ # demand with `refresh_version_pr` below.
173+ #
174+ # ⚠️ Scheduled runs are queued, not guaranteed on the minute — GitHub delays
175+ # or drops them under load, and disables them entirely after 60 days of
176+ # repository inactivity. Both are acceptable HERE and would not be on a
177+ # publishing lane: a refresh that arrives late leaves #4935 stale, which is
178+ # visible on the PR and fixable by one dispatch. This is a second reason the
179+ # publish lane must never be reachable from `schedule`.
180+ schedule :
181+ - cron : ' 0 */6 * * *'
112182 # The repair lane (ADR-0125 D4). Takes NO version: both lanes audit main the
113183 # same way. `force` exists for the one case the push lane's predicate cannot
114184 # see — a publish that died having already shipped the @objectstack/cli canary
126196 required : false
127197 default : false
128198 type : boolean
199+ # The on-demand half of #11233's schedule. Its ONLY effect is to move this
200+ # dispatch onto the bookkeeping lane: `version-pr` requires it, and both
201+ # `release-integrity` and `publish` refuse a dispatch that carries it. It
202+ # therefore cannot widen anything — it is strictly subtractive, the one
203+ # input in this file that can only ever cause LESS to run.
204+ refresh_version_pr :
205+ description : >-
206+ Regenerate the "chore: version packages" PR (#4935) now instead of
207+ waiting for the next 6-hourly refresh. Runs the bookkeeping lane ONLY:
208+ no audit, no publish, no deployment queued at the `release`
209+ environment. Leave unchecked to use the publish repair lane.
210+ required : false
211+ default : false
212+ type : boolean
129213
130214# ⛔ NO workflow-level concurrency — per-JOB groups below, deliberately
131215# (ADR-0125 D5).
@@ -139,23 +223,42 @@ on:
139223#
140224# Worse than not separating: a job waiting on the `release` environment approval
141225# holds its run IN PROGRESS for as long as the maintainer takes. Under one
142- # shared group every main push in that window would queue as pending and evict
226+ # shared group every other run in that window would queue as pending and evict
143227# the one before it, so an hour spent deciding would silently stop the Version
144228# Packages PR from regenerating. Per-job groups keep the waiting publish from
145- # touching the bookkeeping lane at all.
229+ # touching the bookkeeping lane at all. #11233 sharpened this rather than
230+ # retiring it: the runs that would be evicted are now the 6-hourly refreshes and
231+ # any on-demand one, and a refresh is exactly what someone reaches for while a
232+ # release is mid-approval.
146233
147234jobs :
148235 # ══════════════════════════════════════════════════════════════════════════
149236 # PUSH LANE 1 — version-PR bookkeeping. Structurally cannot publish.
150237 # ══════════════════════════════════════════════════════════════════════════
151238 version-pr :
152239 name : Version PR maintenance
153- if : github.event_name == 'push'
240+ # ⛔ NOT `push` (#11233). On push this job force-pushed #4935 on every one of
241+ # main's ~18 daily landings, so the PR's own CI could never converge and the
242+ # PR could never merge. The scheduled tick is the refresh; the dispatch input
243+ # is the same refresh on demand.
244+ #
245+ # `inputs.refresh_version_pr` is guarded by the event test rather than read
246+ # bare: the `inputs` context exists only on `workflow_dispatch`, so on a
247+ # `schedule` run it is null — and `null` is falsy, which would be the right
248+ # answer by accident. Say which event we are on, so the guard states the lane
249+ # split instead of leaning on a context's emptiness.
250+ if : >-
251+ github.event_name == 'schedule' ||
252+ (github.event_name == 'workflow_dispatch' && inputs.refresh_version_pr)
154253 runs-on : ubuntu-latest
155- # Serialise against itself so two landings cannot race the force-push to
156- # `changeset-release/main`; never cancel in progress. An evicted PENDING run
157- # is harmless here — this job regenerates the PR from scratch, so the newest
158- # run's result is the one that was wanted anyway.
254+ # Serialise against itself so two refreshes cannot race the force-push to
255+ # `changeset-release/main`; never cancel in progress. The races it covers
256+ # changed with the trigger (#11233) but did not go away: a scheduled tick can
257+ # still overlap the previous one if a refresh runs long, and an on-demand
258+ # dispatch is most likely to be fired precisely when someone is impatient
259+ # with a tick already in flight. An evicted PENDING run is harmless here —
260+ # this job regenerates the PR from scratch, so the newest run's result is the
261+ # one that was wanted anyway.
159262 concurrency :
160263 group : release-version-pr-${{ github.ref }}
161264 cancel-in-progress : false
@@ -246,10 +349,23 @@ jobs:
246349 # ══════════════════════════════════════════════════════════════════════════
247350 release-integrity :
248351 name : Release integrity (audit + no-mint backfill)
249- # Runs on BOTH events now (ADR-0125 D1): it is the single place that reads
250- # what main carries and asks npm whether that version exists, so the push
251- # lane and the repair lane converge on ONE predicate and one guard instead
252- # of two code paths that can drift.
352+ # Runs on both RELEASE events (ADR-0125 D1): it is the single place that
353+ # reads what main carries and asks npm whether that version exists, so the
354+ # push lane and the repair lane converge on ONE predicate and one guard
355+ # instead of two code paths that can drift.
356+ #
357+ # This `if:` is new with #11233 and is the reason that sentence still holds.
358+ # The job used to carry no `if:` at all, which meant "every event this file
359+ # has" — correct while the file had exactly the two release events, and
360+ # wrong the moment a third arrived. Without it the 6-hourly tick and every
361+ # on-demand refresh would run a full release audit, and each one that found
362+ # main's version absent from npm would queue a deployment at the `release`
363+ # environment for a maintainer to dismiss. The audit mints nothing, so this
364+ # is not a safety guard; it is the noise guard the collision section above
365+ # argues for. Bookkeeping events do not get a release audit.
366+ if : >-
367+ github.event_name == 'push' ||
368+ (github.event_name == 'workflow_dispatch' && !inputs.refresh_version_pr)
253369 runs-on : ubuntu-latest
254370 # Serialised: its backfills create GitHub Releases and push a runtime image,
255371 # and two runs doing that at once is not a state worth reasoning about.
@@ -459,10 +575,21 @@ jobs:
459575 # than `||`, so `success() && A || B` would let the force branch publish on
460576 # top of a FAILED audit — and the audit is what computes the version this
461577 # job's name, guard and tag all read. If the audit dies, nothing publishes.
578+ #
579+ # `!inputs.refresh_version_pr` on the force branch is #11233's half, and it
580+ # is written even though it is currently redundant. A refresh dispatch skips
581+ # `release-integrity`, and a SKIPPED `needs` job makes `success()` false — so
582+ # the belt already holds (the `docker` job below documents that same GitHub
583+ # behaviour from the other direction). Redundant is not the same as
584+ # unnecessary: what makes the force branch safe would then be a fact about a
585+ # DIFFERENT job's `if:`, discoverable only by reading it, and the next person
586+ # to touch either guard gets no warning. This is the one job in the
587+ # repository that publishes; its guard states its own preconditions.
462588 if : >-
463589 success() &&
464590 (needs.release-integrity.outputs.publish-pending == 'true' ||
465- (github.event_name == 'workflow_dispatch' && inputs.force))
591+ (github.event_name == 'workflow_dispatch' && inputs.force &&
592+ !inputs.refresh_version_pr))
466593 runs-on : ubuntu-latest
467594 # One publish at a time per ref, and never cancelled — a run cancelled
468595 # mid-`changeset publish` is the state that leaves a fixed group half on npm.
@@ -748,11 +875,17 @@ jobs:
748875 # Called as a reusable workflow so the same build can be re-run manually via
749876 # workflow_dispatch (e.g. base-image CVE rebuilds) — see docker-publish.yml.
750877 #
751- # `!cancelled()` rather than the default implicit success(): exactly one of
878+ # `!cancelled()` rather than the default implicit success(): at most one of
752879 # the two upstream jobs runs on any given event, so the other is always
753880 # SKIPPED — under the implicit success() this job would then never run at
754881 # all. It also survives a publish job that reached npm and then died
755882 # (#4900). The outputs are the gate; the jobs' statuses are not.
883+ #
884+ # On #11233's bookkeeping events (`schedule`, or a dispatch carrying
885+ # `refresh_version_pr`) NEITHER upstream job runs, which is why "at most"
886+ # replaced "exactly". Nothing else here changes: both outputs are then unset,
887+ # unset compares false against 'true', and this job stays skipped — the
888+ # outputs were already the gate, so a third event needed no new condition.
756889 if : ${{ !cancelled() && (needs.release-integrity.outputs.published == 'true' || needs.publish.outputs.published == 'true') }}
757890 permissions :
758891 contents : read
0 commit comments