From d3b9dc67f39cffdd3ef0b6d89cd6da7f4fd65188 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 04:57:54 +0000 Subject: [PATCH] ci(release): refresh the Version Packages PR on a schedule, not on every push 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 --- .github/workflows/release.yml | 183 +++++++++++++++++++++++++++++----- docs/releases-maintenance.md | 62 ++++++++---- 2 files changed, 200 insertions(+), 45 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e657cff1cb..f4d12d808d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,14 +40,15 @@ name: Release # # HOW THE LANES ARE SPLIT NOW # --------------------------- -# push to main → `version-pr` keeps the "chore: version packages" PR -# (#4935) current. Carries NO publish -# capability: the changesets step is +# schedule (6-hourly) → `version-pr` keeps the "chore: version packages" PR +# or a dispatch with (#4935) current. Carries NO publish +# `refresh_version_pr` capability: the changesets step is # invoked WITHOUT a `publish:` script, # so the action's publish branch is # unreachable by construction, not by # an `if:` someone can get wrong. -# → `release-integrity` audits ONLY the version at +# NOT on push — see the section below. +# push to main → `release-integrity` audits ONLY the version at # `github.sha`. Never publishes, never # pushes a tag. May backfill GitHub # Releases / the ADR-0087 D4 asset / @@ -64,8 +65,58 @@ name: Release # at `environment: release` until a # required reviewer approves it. # workflow_dispatch → `publish` the repair lane. Takes no version; -# audits main exactly as the push lane -# does. Same environment gate. +# (WITHOUT audits main exactly as the push lane +# `refresh_version_pr`) does. Same environment gate. +# +# WHY `version-pr` LEFT THE PUSH TRIGGER (#11233, 2026-08-23) +# ---------------------------------------------------------- +# changesets/action's version path is `git reset --hard ` → +# re-version → `git push --force origin HEAD:changeset-release/main` (its source +# is quoted at the top of this file). On push that recomputes and force-pushes +# #4935 on EVERY landing, and main takes ~18 merges a working day. The standing +# Version Packages PR therefore never held still long enough for its own branch +# CI to finish: every run was superseded by the next force-push, so the PR could +# not converge and was ejected from the merge queue on entry. That is a +# structural property of (action algorithm × trigger frequency), not of the CLI +# version — this repo is already on @changesets/cli ^3.0.0 and the churn was +# unchanged. The fix is the one the changesets project documents for exactly +# this: refresh on a SCHEDULE instead of on every push. +# +# Between refreshes `changeset-release/main` is a static branch. Its CI +# converges, and it merges through the ordinary queue like any other PR. A stale +# window of up to six hours is the whole cost, and it is bounded on demand: +# dispatch with `refresh_version_pr` when you want it current NOW (immediately +# before a GA cut, say). The bookkeeping is not time-critical — the changesets +# are already committed on main; #4935 is only their rendering. +# +# ⛔ THE DISPATCH COLLISION, AND WHY THERE IS AN INPUT FOR IT +# ---------------------------------------------------------- +# `workflow_dispatch` was already taken: it is the publish REPAIR lane (D4). +# One event name now has to start two lanes that must never start each other — +# a refresh that also queued the publish audit would park a WAITING DEPLOYMENT +# at the `release` environment on every routine refresh, i.e. an approval prompt +# a maintainer must open and dismiss to keep the real ones meaningful. Approval +# noise is how an approval stops being read, and this file's whole barrier is +# that approval (see below). +# +# So the event is split by an INPUT rather than by a second workflow file (the +# maintainer does not want another lane to maintain, and a second file would +# duplicate the publish invariants where they can drift apart): +# +# dispatch WITH `refresh_version_pr` → version-pr only, no deployment +# dispatch WITHOUT `refresh_version_pr` → the repair lane, exactly as before +# schedule → version-pr only +# push to main → release-integrity (+ publish) +# +# Every job carries the half of that split it needs, in its own `if:`. No job +# infers its lane from another job's presence. +# +# ⚠️ The two inputs are INDEPENDENT, so `refresh_version_pr` + `force` is a +# reachable form, and it is refused rather than resolved: `publish`'s guard +# excludes any dispatch carrying `refresh_version_pr`, so that combination +# refreshes and publishes NOTHING. A dispatch that both refreshes bookkeeping +# and force-publishes is not a thing anyone means; the harmless reading is the +# one that runs. # # WHERE THE HUMAN IS, AFTER ADR-0125 (2026-08-20) # ----------------------------------------------- @@ -98,17 +149,36 @@ name: Release # # WHAT IS DELIBERATELY STILL AUTOMATIC # ------------------------------------ -# Version-PR maintenance (this file's `version-pr` job) stays on push runs — -# harmless bookkeeping, and #4935 must keep regenerating. Release/D4/image -# backfill for an already-published version stays on push runs — it is the -# #4900 repair, and it cannot mint a version. `npm publish` and `git push --tags` -# still live in exactly one job, and that job cannot start without a human -# approving it. +# Version-PR maintenance (this file's `version-pr` job) still runs unattended — +# harmless bookkeeping, and #4935 must keep regenerating — it just runs on a +# 6-hourly schedule instead of on every push (#11233). A `schedule` trigger +# reaching this job is not a loosening: the job has no publish capability by +# construction, so the event that starts it cannot change what it is able to do. +# Release/D4/image backfill for an already-published version stays on push runs — +# it is the #4900 repair, and it cannot mint a version. `npm publish` and +# `git push --tags` still live in exactly one job, that job is reachable from +# `push` and from the repair dispatch ONLY — never from `schedule`, never from a +# refresh dispatch — and it cannot start without a human approving it. on: push: branches: - main + # Version-PR bookkeeping only (#11233). GitHub runs `schedule` on the DEFAULT + # BRANCH exclusively, which is the ref `version-pr` may ever regenerate from, + # so the trigger cannot reach a ref the job is not meant to touch. Six-hourly + # is the stale-window budget: #4935 renders changesets that are already + # committed on main, so lateness costs nothing that cannot be bought back on + # demand with `refresh_version_pr` below. + # + # ⚠️ Scheduled runs are queued, not guaranteed on the minute — GitHub delays + # or drops them under load, and disables them entirely after 60 days of + # repository inactivity. Both are acceptable HERE and would not be on a + # publishing lane: a refresh that arrives late leaves #4935 stale, which is + # visible on the PR and fixable by one dispatch. This is a second reason the + # publish lane must never be reachable from `schedule`. + schedule: + - cron: '0 */6 * * *' # The repair lane (ADR-0125 D4). Takes NO version: both lanes audit main the # same way. `force` exists for the one case the push lane's predicate cannot # see — a publish that died having already shipped the @objectstack/cli canary @@ -126,6 +196,20 @@ on: required: false default: false type: boolean + # The on-demand half of #11233's schedule. Its ONLY effect is to move this + # dispatch onto the bookkeeping lane: `version-pr` requires it, and both + # `release-integrity` and `publish` refuse a dispatch that carries it. It + # therefore cannot widen anything — it is strictly subtractive, the one + # input in this file that can only ever cause LESS to run. + refresh_version_pr: + description: >- + Regenerate the "chore: version packages" PR (#4935) now instead of + waiting for the next 6-hourly refresh. Runs the bookkeeping lane ONLY: + no audit, no publish, no deployment queued at the `release` + environment. Leave unchecked to use the publish repair lane. + required: false + default: false + type: boolean # ⛔ NO workflow-level concurrency — per-JOB groups below, deliberately # (ADR-0125 D5). @@ -139,10 +223,13 @@ on: # # Worse than not separating: a job waiting on the `release` environment approval # holds its run IN PROGRESS for as long as the maintainer takes. Under one -# shared group every main push in that window would queue as pending and evict +# shared group every other run in that window would queue as pending and evict # the one before it, so an hour spent deciding would silently stop the Version # Packages PR from regenerating. Per-job groups keep the waiting publish from -# touching the bookkeeping lane at all. +# touching the bookkeeping lane at all. #11233 sharpened this rather than +# retiring it: the runs that would be evicted are now the 6-hourly refreshes and +# any on-demand one, and a refresh is exactly what someone reaches for while a +# release is mid-approval. jobs: # ══════════════════════════════════════════════════════════════════════════ @@ -150,12 +237,28 @@ jobs: # ══════════════════════════════════════════════════════════════════════════ version-pr: name: Version PR maintenance - if: github.event_name == 'push' + # ⛔ NOT `push` (#11233). On push this job force-pushed #4935 on every one of + # main's ~18 daily landings, so the PR's own CI could never converge and the + # PR could never merge. The scheduled tick is the refresh; the dispatch input + # is the same refresh on demand. + # + # `inputs.refresh_version_pr` is guarded by the event test rather than read + # bare: the `inputs` context exists only on `workflow_dispatch`, so on a + # `schedule` run it is null — and `null` is falsy, which would be the right + # answer by accident. Say which event we are on, so the guard states the lane + # split instead of leaning on a context's emptiness. + if: >- + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && inputs.refresh_version_pr) runs-on: ubuntu-latest - # Serialise against itself so two landings cannot race the force-push to - # `changeset-release/main`; never cancel in progress. An evicted PENDING run - # is harmless here — this job regenerates the PR from scratch, so the newest - # run's result is the one that was wanted anyway. + # Serialise against itself so two refreshes cannot race the force-push to + # `changeset-release/main`; never cancel in progress. The races it covers + # changed with the trigger (#11233) but did not go away: a scheduled tick can + # still overlap the previous one if a refresh runs long, and an on-demand + # dispatch is most likely to be fired precisely when someone is impatient + # with a tick already in flight. An evicted PENDING run is harmless here — + # this job regenerates the PR from scratch, so the newest run's result is the + # one that was wanted anyway. concurrency: group: release-version-pr-${{ github.ref }} cancel-in-progress: false @@ -246,10 +349,23 @@ jobs: # ══════════════════════════════════════════════════════════════════════════ release-integrity: name: Release integrity (audit + no-mint backfill) - # Runs on BOTH events now (ADR-0125 D1): it is the single place that reads - # what main carries and asks npm whether that version exists, so the push - # lane and the repair lane converge on ONE predicate and one guard instead - # of two code paths that can drift. + # Runs on both RELEASE events (ADR-0125 D1): it is the single place that + # reads what main carries and asks npm whether that version exists, so the + # push lane and the repair lane converge on ONE predicate and one guard + # instead of two code paths that can drift. + # + # This `if:` is new with #11233 and is the reason that sentence still holds. + # The job used to carry no `if:` at all, which meant "every event this file + # has" — correct while the file had exactly the two release events, and + # wrong the moment a third arrived. Without it the 6-hourly tick and every + # on-demand refresh would run a full release audit, and each one that found + # main's version absent from npm would queue a deployment at the `release` + # environment for a maintainer to dismiss. The audit mints nothing, so this + # is not a safety guard; it is the noise guard the collision section above + # argues for. Bookkeeping events do not get a release audit. + if: >- + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && !inputs.refresh_version_pr) runs-on: ubuntu-latest # Serialised: its backfills create GitHub Releases and push a runtime image, # and two runs doing that at once is not a state worth reasoning about. @@ -459,10 +575,21 @@ jobs: # than `||`, so `success() && A || B` would let the force branch publish on # top of a FAILED audit — and the audit is what computes the version this # job's name, guard and tag all read. If the audit dies, nothing publishes. + # + # `!inputs.refresh_version_pr` on the force branch is #11233's half, and it + # is written even though it is currently redundant. A refresh dispatch skips + # `release-integrity`, and a SKIPPED `needs` job makes `success()` false — so + # the belt already holds (the `docker` job below documents that same GitHub + # behaviour from the other direction). Redundant is not the same as + # unnecessary: what makes the force branch safe would then be a fact about a + # DIFFERENT job's `if:`, discoverable only by reading it, and the next person + # to touch either guard gets no warning. This is the one job in the + # repository that publishes; its guard states its own preconditions. if: >- success() && (needs.release-integrity.outputs.publish-pending == 'true' || - (github.event_name == 'workflow_dispatch' && inputs.force)) + (github.event_name == 'workflow_dispatch' && inputs.force && + !inputs.refresh_version_pr)) runs-on: ubuntu-latest # One publish at a time per ref, and never cancelled — a run cancelled # mid-`changeset publish` is the state that leaves a fixed group half on npm. @@ -748,11 +875,17 @@ jobs: # Called as a reusable workflow so the same build can be re-run manually via # workflow_dispatch (e.g. base-image CVE rebuilds) — see docker-publish.yml. # - # `!cancelled()` rather than the default implicit success(): exactly one of + # `!cancelled()` rather than the default implicit success(): at most one of # the two upstream jobs runs on any given event, so the other is always # SKIPPED — under the implicit success() this job would then never run at # all. It also survives a publish job that reached npm and then died # (#4900). The outputs are the gate; the jobs' statuses are not. + # + # On #11233's bookkeeping events (`schedule`, or a dispatch carrying + # `refresh_version_pr`) NEITHER upstream job runs, which is why "at most" + # replaced "exactly". Nothing else here changes: both outputs are then unset, + # unset compares false against 'true', and this job stays skipped — the + # outputs were already the gate, so a third event needed no new condition. if: ${{ !cancelled() && (needs.release-integrity.outputs.published == 'true' || needs.publish.outputs.published == 'true') }} permissions: contents: read diff --git a/docs/releases-maintenance.md b/docs/releases-maintenance.md index 0c7d318e42..c60a260c0d 100644 --- a/docs/releases-maintenance.md +++ b/docs/releases-maintenance.md @@ -363,12 +363,20 @@ What it does, in order: commit**. Never the other way round: rc.3 and rc.4 tagged commits that lived only on `changeset-release/main`, which is #6170. -**Why this lane exists.** The standing Version Packages PR is force-refreshed on -every main push, so its CI cannot converge while main is busy — and cutting through -it used to mean chasing a moving objectui pin as well (rc.6 was chased across four -pin-bump laps, every one overtaken before its CI finished, and finishing would have -needed ~40 minutes of coordinated freezes across two repos). A snapshot removes the -race instead of asking people to hold still. +**Why this lane exists.** The standing Version Packages PR *was* force-refreshed on +every main push, so its CI could not converge while main was busy — and cutting +through it used to mean chasing a moving objectui pin as well (rc.6 was chased across +four pin-bump laps, every one overtaken before its CI finished, and finishing would +have needed ~40 minutes of coordinated freezes across two repos). A snapshot removes +the race instead of asking people to hold still. + +⚠️ **The first half of that premise no longer holds** (#11233, 2026-08-23): +`release.yml`'s `version-pr` job moved off `push` onto a 6-hourly `schedule` plus an +on-demand `workflow_dispatch` (`refresh_version_pr`), so between refreshes +`changeset-release/main` is static and its CI does converge. This section is left +standing because the *snapshot* lane was never only about that — the moving objectui +pin is untouched, and `cut-rc` is still the rc route. What it does mean is that "the +version PR can never converge" is no longer a reason to reach for it. **On the pin.** `cut-rc` builds against `.objectui-sha` exactly as committed. It does not resolve objectui `main`, does not compare the pin to anything, and does not @@ -389,22 +397,25 @@ configured the push step fails with that message and **nothing is published**. **What happens to the standing Version Packages PR.** After a cut, the standing `chore: version packages (rc)` PR ([#6208](https://github.com/objectstack-ai/objectstack/pull/6208), -branch `changeset-release/main`) comes to rest in one of two states, and *which* one -depends on the push credential above — so it is written down here rather than -rediscovered at 2am: +branch `changeset-release/main`) comes to rest **stale**, showing an +already-consumed version bump, until the next refresh of the version-PR lane. Since +#11233 that refresh is the 6-hourly `schedule` (or one `workflow_dispatch` with +`refresh_version_pr`, if you want it now) — **not** the cut's own push, under either +push credential, because `version-pr` no longer runs on `push` at all. + +The push credential still decides whether the cut's push fires `release.yml` at all, +which matters for the `release-integrity` lane below — so it is written down here +rather than rediscovered at 2am: - **Route (a), the Actions identity.** GitHub does not create workflow runs from events triggered by the automatic `GITHUB_TOKEN` — the documented recursion guard, whose only exceptions are `workflow_dispatch` and `repository_dispatch`. So the - version-commit push does **not** fire `release.yml`'s `push` lane, `version-pr` - never runs, and #6208 keeps showing a stale, already-consumed version bump until - some later unrelated push to `main` refreshes it. This repo already depends on that - guard elsewhere and says so: see `docker-publish.yml`'s header, which explains that - a `push: tags:` trigger "would never fire" because the release workflow pushes its - tags with `GITHUB_TOKEN`. + version-commit push does **not** fire `release.yml`'s `push` lane at all. This repo + already depends on that guard elsewhere and says so: see `docker-publish.yml`'s + header, which explains that a `push: tags:` trigger "would never fire" because the + release workflow pushes its tags with `GITHUB_TOKEN`. - **Route (b), a PAT in `RELEASE_PUSH_TOKEN`.** A PAT is not the `GITHUB_TOKEN`, so - the push triggers normally, `version-pr` runs, and #6208 regenerates (or closes) - by itself. + the push triggers normally and `release-integrity` audits the pushed commit. **A stale #6208 after an rc cut is EXPECTED AND HARMLESS — do not "fix" it by hand.** Its changesets were consumed by the cut and MOVED into `.changeset/pre/` — under @@ -412,9 +423,10 @@ Its changesets were consumed by the cut and MOVED into `.changeset/pre/` — und `.changeset/pre.json` carries `{"mode","tag"}` and nothing else, so it is not a record of what was consumed (it was, under v2). The PR is bookkeeping, it carries no publish capability by construction (`release.yml` passes the changesets action no -`publish:` script), and it regenerates correctly at the next push to `main` or the -next GA cut. Editing or force-refreshing it manually only risks putting a version -commit somewhere the publish lane can reach. +`publish:` script), and it regenerates correctly at the next scheduled refresh (at +most six hours; sooner if you dispatch one) or the next GA cut. Editing or +force-refreshing it manually only risks putting a version commit somewhere the +publish lane can reach. **The runtime image is not built here.** `release.yml`'s `release-integrity` lane runs on every push to `main` and requests the image once the version is on npm, so it @@ -436,6 +448,16 @@ PR *before* the cut. Merge the `chore: version packages` PR (#4935), then **Actions → Release → Run workflow** with the version `main` now carries. `release.yml`'s three lanes are untouched by the rc lane. +**First, check #4935 is current** (#11233). It is regenerated on a 6-hourly schedule +rather than on every push, so at cut time it can be up to one refresh window behind +main — i.e. it may not yet carry a changeset that landed in the last few hours. +Compare its head against `main` and, if it is behind, **Actions → Release → Run +workflow** with **`refresh_version_pr` checked**, which runs the bookkeeping lane +only: no audit, no publish, and no deployment parked at the `release` environment. +Wait for the refreshed PR's CI, then merge it. That merge is still the decision to +release, and the `release` environment approval is still the authorisation — neither +is changed by where the refresh came from. + ## Drift guard `scripts/check-release-notes.mjs` (run in CI as `pnpm check:release-notes`) fails the