Skip to content

Commit 3ece770

Browse files
os-zhuangclaude
andauthored
ci(release): refresh the Version Packages PR on a schedule, not on every push (#11238)
The `version-pr` job ran on every main push, so changesets/action recomputed and force-pushed `changeset-release/main` on each of main's ~18 daily landings. The standing Version Packages PR therefore never held still long enough for its own branch CI to converge, and it was ejected from the merge queue on entry. This is (action algorithm x trigger frequency), not a CLI version: the repo is already on @changesets/cli ^3.0.0. Move `version-pr` onto a 6-hourly `schedule` plus an on-demand `workflow_dispatch` input, which is the mode the changesets project documents for exactly this. Between refreshes the branch is static, its CI converges, and it merges through the ordinary queue. `workflow_dispatch` was already the publish repair lane, so the event is split by a new `refresh_version_pr` boolean rather than by a second workflow file: with it, only `version-pr` runs; without it, the repair lane behaves exactly as before. `release-integrity` gains the `if:` that keeps bookkeeping events from queuing a deployment at the `release` environment for a maintainer to dismiss. ADR-0125's invariants are untouched: `publish` is still the only job that publishes, still unreachable from `schedule` and from a refresh dispatch, still held whole at `environment: release`; `version-pr` still carries no `publish:` script, so it cannot publish by construction. The 2026-08-07 ruling narrative is unchanged. Prose that described the old mechanism is updated in the same change: release.yml's own header and the premise line, route (a)/(b) passage and GA cut flow in docs/releases-maintenance.md. Fixes #11233 Co-authored-by: Claude <noreply@anthropic.com>
1 parent df287e6 commit 3ece770

2 files changed

Lines changed: 200 additions & 45 deletions

File tree

.github/workflows/release.yml

Lines changed: 158 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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

108163
on:
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
@@ -126,6 +196,20 @@ on:
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

147234
jobs:
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

Comments
 (0)