Skip to content

ci(release): refresh the Version Packages PR on a schedule, not on every push - #11238

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11233-version-pr-schedule
Aug 23, 2026
Merged

ci(release): refresh the Version Packages PR on a schedule, not on every push#11238
os-zhuang merged 1 commit into
mainfrom
claude/issue-11233-version-pr-schedule

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#11233

The defect

release.yml's version-pr job ran on every main push. changesets/action's version path is git reset --hard $GITHUB_SHA → re-version → git push --force origin HEAD:changeset-release/main, so every one of main's ~18 daily landings recomputed and force-pushed the standing Version Packages PR (#10183 / #4935). The PR therefore never held still long enough for its own branch CI to finish — each run was superseded by the next force-push — so it could not converge and was ejected from the merge queue on entry.

This is a property of (action algorithm × trigger frequency), not of the CLI version: this repo is already on @changesets/cli ^3.0.0 and the churn is unchanged. The remedy is the mode the changesets project documents for exactly this case: refresh on a schedule instead of on every push. Between refreshes changeset-release/main is static, its CI converges, and it merges through the ordinary queue like any other PR.

Before / after — the full trigger matrix

eventversion-prrelease-integritypublishdocker
before — push to main✅ runs (force-push every landing)✅ runswhen audit says version absent from npm, held at releaseon outputs
beforeworkflow_dispatch✅ runsrepair lane (force), held at releaseon outputs
after — push to main(the change)✅ runs, unchangedunchangedunchanged
afterschedule (0 */6 * * *)✅ runs
after — dispatch withrefresh_version_pr✅ runs
after — dispatch withoutrefresh_version_pr✅ runsrepair lane (force), unchangedunchanged

Every cell for the publish path is unchanged. The two new rows are the bookkeeping lane, and neither can reach a job that publishes.

The dispatch collision, and why the shape is this one

workflow_dispatch was already taken: it is the publish REPAIR lane (ADR-0125 D4, with the force input). Making an on-demand refresh use the same event naively would mean one event name starting two lanes that must never start each other:

  • a refresh would also queue the release audit, and any refresh finding main's version absent from npm would park a waiting deployment at the release environment — an approval prompt a maintainer must open and dismiss on routine bookkeeping. Approval noise is how an approval stops being read, and that approval is this file's entire barrier;
  • conversely a repair dispatch would also force-push the version PR, which is exactly the churn being removed.

Chosen shape — a new refresh_version_pr boolean input splits the event inside the one file. Each job carries its own half of the split; no job infers its lane from another job's presence:

version-pr: schedule || (workflow_dispatch && inputs.refresh_version_pr)release-integrity: push || (workflow_dispatch && !inputs.refresh_version_pr)publish: success() && (publish-pending || (dispatch && force && !refresh_version_pr))

Three points that were decisions, not defaults:

  1. release-integrity gains an if: it did not have. It previously carried none, 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 this the 6-hourly tick would run a full release audit and queue deployments. The audit mints nothing, so this is a noise guard, not a safety guard, and it is labelled as such in the file.
  2. publish's !inputs.refresh_version_pr is written although it is currently redundant. A refresh dispatch skips release-integrity, and a skipped needs job makes success() false — the belt already holds (the docker job documents that same GitHub behaviour from the other direction). But that would make the force branch's safety a fact about a different job's if:, discoverable only by reading it. This is the one job in the repo that publishes; its guard states its own preconditions.
  3. refresh_version_pr + force is reachable and is refused, not resolved. The two inputs are independent, so the combination can be submitted; publish's guard excludes it, so that dispatch 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.

No second workflow file, per the card: a second lane would duplicate the publish invariants somewhere they can drift apart from these.

Invariants deliberately untouched

  • publish is still the only job that runs changeset publish or pushes a tag; it is still held whole at environment: release; its steps are unmodified.
  • publish is unreachable from schedule and from a refresh dispatch. A schedule trigger reaching version-pr is not a loosening: that job has no publish capability by construction — the changesets step still takes no publish: script, so the action's publish branch is unreachable from any input state it can observe. That structural guarantee is what this PR does not touch.
  • release-integrity's push behaviour, its predicate, and its github.sha tripwire are unchanged.
  • The 2026-08-07 ruling narrative 「版本发布必须是人工的」 is preserved verbatim; cut-rc.yml, environment config and content/docs/releases/ are not touched.
  • version-pr's per-job concurrency (cancel-in-progress: false) is kept, and its comment is re-justified rather than left standing: the race it covers is now a scheduled tick overlapping a slow previous run, or an on-demand dispatch fired by someone impatient with a tick already in flight.

One note added to the on: block because it argues the split rather than decorating it: scheduled runs are queued, may be delayed or dropped under load, and are disabled after 60 days of repository inactivity. All acceptable for bookkeeping — a late refresh leaves the PR visibly stale and one dispatch fixes it — and a second independent reason the publish lane must never be reachable from schedule.

Prose updated in the same PR (the mechanism changed, so the descriptions must)

.github/workflows/release.yml header:

  • the lane-split table (push → version-pr becomes the schedule/dispatch rows);
  • "WHAT IS DELIBERATELY STILL AUTOMATIC" — version-PR maintenance no longer "stays on push runs";
  • the no-workflow-level-concurrency rationale, whose worked example was "every main push in that window";
  • the docker job's "exactly one of the two upstream jobs runs on any given event" becomes "at most one", since bookkeeping events run neither. No condition change: unset outputs already compare false;
  • two new sections: why version-pr left the push trigger, and the dispatch collision.

docs/releases-maintenance.md:

  • §Why this lane exists — the premise line "The standing Version Packages PR is force-refreshed on every main push" is now past tense, with a marked note that the first half of the premise no longer holds. The cut-rc section's history is not rewritten: the snapshot lane's other reason (the moving objectui pin) is untouched and cut-rc remains the rc route;
  • route (a)/(b) after an rc cut — the credential no longer decides whether version-pr runs, because it does not run on push under either route. The passage now states the stale-until-next-refresh outcome once, and keeps (a)/(b) for what they still decide: whether the cut's push fires release-integrity at all;
  • "regenerates at the next push to main" becomes the next scheduled refresh;
  • §Cutting a GA release — a new first step: check chore: version packages (rc) #4935 is current, and dispatch refresh_version_pr if it is behind, before merging. This is the operational payoff of the card and the one new thing a releaser must know.

Gate verdicts

Families derived with node scripts/pm/dispatch-gates.mjs (no hand-typed path list — the script takes the change set from the merge base itself): 11 matched. All re-run after the final commit, at d3b9dc67f3. Exit codes captured before any pipe; lines below are each gate's own verdict, not $?:

gateverdict line
check:doc-authoring✓ doc authoring guard: 389 files clean — no bare metadata literals.
check:doc-formula-expressions✓ check:doc-formula-expressions: 22 record-scoped formula example(s) across 416 files / 1447 TS blocks judged clean
check:node-versioncheck-node-version: OK (32 setup-node step(s) across 26 workflow(s), all on Node 22).
check:pnpm-filter-targets✓ check:pnpm-filter-targets: 121/149 --filter occurrence(s) across 25 file(s) resolve against 78 workspace package(s)
check:required-contexts✓ check-required-contexts: 6 required context name(s) pinned across 2 workflow(s); 5 instruction surface(s) scanned against 2 retired name(s)
check:shard-attestation✓ check-shard-attestation: 2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s).
check:workflow-status-functionscheck-workflow-status-functions: OK (scanned 26 workflow file(s), 49 job(s), 25 job-level if: expression(s); 10 read needs.*.outputs.*, all naming a status function).
check-aggregator-roster.mjs✓ check-aggregator-roster: 3 aggregator(s) across 2 workflow(s); roster == needs: in both directions
check-required-contexts.mjs (patrol)same verdict as above, run from required-set-patrol.yml
check-shard-attestation.mjs✓ check-shard-attestation --self-test: 103 assertions
check-step-collectors.mjs✓ check-step-collectors: 354 run: steps across 26 workflow(s)
check-ci-filter-parity.mjsOK: all 88 declared cross-package glob(s) (76 unique) are covered by core or crosspkg
check:nul-bytescheck-nul-bytes: OK (scanned 6519 text file(s) ... no raw ASCII control bytes).

check-ci-filter-parity was named in the dispatch prompt but was not derived for these paths; run anyway, green.

YAML validated with in-repo means, no new dependency — parsed with the workspace's own yaml package and the trigger block and all four job guards dumped; both key spellings (on / YAML-1.1 true) handled as check-required-contexts does. Parse exit 0; guards read back exactly as designed.

Residue examined, not assumed.dispatch-gates flagged 2 silent families whose roster sits under .github/workflows — the shape it warns "reads as a clearance and is not". Both are check:single-claim-paths; its SINGLE_CLAIM_PATHS declares exactly one entry, .objectui-sha. Neither changed path is in it, so the silence is correct.

Repo-wide pnpm lint narrowed, and the narrowing measured.npx eslint --format json over both changed paths reports 2 files, 0 errors, each with File ignored because no matching configuration was supplied — i.e. the population is read from eslint's own config resolution, not from my guess about which files count, and both changed files are outside it entirely. Config invariance: this diff changes no eslint config and no TypeScript, so no untouched file's verdict can move.

Landing class

node scripts/pm/check-governed-merges.mjs --test .github/workflows/release.yml docs/releases-maintenance.md
governed-surface predicate: 0 of 2 path(s) hit the register (5 surfaces, repo-agnostic).
✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list.

Verified on the final file list, not assumed. Opened as DRAFT anyway: release.yml is release-critical, and the PM reviews before flipping ready.

Changeset

None — workflow + docs only, so this PR releases no package. skip-changeset applied as a union with the labels the bots had already set, then read back, which is the exemption pr-automation.yml's changeset-check reads live.

Related / out of scope


Generated by Claude Code

…ery 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 <noreply@anthropic.com>
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

ACCEPT — session 757f0dc0-5dae-51d7-9053-c15c57ebfe3b(派发席,卡 #11233)。

全量 diff 复核(2 文件 +200/−45,head d3b9dc67f3),要点:

  1. 四个守卫逐一读过,与设计一致:version-pr = schedule || (dispatch && refresh_version_pr)(input 读取被事件测试护住);release-integrity 补上此前缺失的 if:(原先无条件=吃所有事件,schedule 会白跑审计并在 environment 门挂待批部署——这条是防噪音的关键修复);publish 的 force 分支显式加 !refresh(自述前置条件而非借他 job 的 if:);refresh+force 矛盾组合被排除为不发布。default: false 保证既有修复道 dispatch 行为逐字不变。
  2. ADR-0125 不变量零触碰:publish 仍是唯一发布 job、仍不可由 schedule/刷新 dispatch 到达、仍整体挂 environment: release;version-pr 结构性无 publish 能力;08-07 裁决叙事逐字保留;cut-rc/环境配置未动。真值表写进头部,新旧叙事(含 releases-maintenance.md 四处前提句)同 PR 同步。
  3. 门禁 11 族派生全绿 + parity/nul-bytes,YAML 用仓内 yaml 包解析读回四个守卫;lint 收窄三证;NOT governed(0/2 命中)判定引用在案。
  4. 越界发现处置正确:docs(releases-maintenance): three sentences still describe the pre-ADR-0125 publish lane — "both lanes are workflow_dispatch" and a typed-version GA dispatch that no longer exists #11239 立卡(pre-ADR-0125 残句,含 08-07 叙事内一处,本 PR 正确地不碰);release: switch the changesets version workflow ("Version Packages" PR refresh) from every-merge-to-main to manual dispatch before a release #10850 同题旧卡不由本 PR 关闭——其范围含「包括 objectui 仓库」,objectstack 半边由本 PR 交付,objectui 半边由 PM 另行重限定派发(见该卡)。

落地:flip ready + auto-merge。合并后维护者的发版流即为:(要最新就 dispatch 勾 refresh_version_pr)→ 合并 Version Packages PR → 批准 release 环境。


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddocumentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-zhuang@claude