Uh oh!
There was an error while loading. Please reload this page.
ci(release): refresh the version PR on a schedule, publish only on a version-PR merge - #5762
Conversation
…merge The changesets step force-pushed the standing "chore: release packages" PR (#5400) on every landing to main — ~18 a working day, against a weekly release cadence — so the PR's own branch CI never converged and every refresh was a CI run spent on bookkeeping nobody reads until release day. The refresh moves to a 6-hourly `schedule` plus an on-demand `workflow_dispatch` input (`refresh_version_pr`), mirroring the sister change that landed in objectstack as PR #11238. The publish half keeps its trigger exactly as it was: a push to main whose `.changeset/` is empty is the merge of the version PR, and that merge remains the release act. Because this repository publishes from the SAME changesets step that refreshes the PR, the split cannot be an `if:` on the step alone — the action picks its branch from repository state, and omitting `version:` does not disable the refresh (only omitting `publish:` disables publishing). A cheap `lane` job therefore answers "does this commit carry pending changesets?" from a sparse checkout of `.changeset/` before anything installs or builds, and one job-level `if:` states the policy. The per-commit concurrency group and the "Wait for older release runs" queue step are preserved byte-identically; only the prose the trigger change falsifies is updated. Co-Authored-By: Claude <noreply@anthropic.com>
os-zhuang
commented
Aug 23, 2026
CI 判读(派发席): Generated by Claude Code |
os-zhuang
commented
Aug 23, 2026
ACCEPT — session 全量 diff 复核(3 文件 +346/−15,head
Flip ready + auto-merge。 Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixesobjectstack-ai/objectstack#10850
Sister card in this repository: #5571 (same maintainer ruling, this
repo's own workflow). It is left open for the PM to close or retarget — this PR carries the
closing keyword for the dispatched card only.
The objectstack half of the ruling landed as objectstack-ai/objectstack#11238; this is the
objectuihalf, mirroring its reasoning where the two files are analogous and diverging wherethey are not.
The problem
changeset-release.ymlran its changesets step on every push tomain. With pendingchangesets that step's version path is
git reset --hard HEAD-of-the-push→ re-version →git push --force origin HEAD:changeset-release/main, so the standing"chore: release packages" PR (#5400) was force-refreshed on each of
main's ~18 dailylandings while releases are weekly. The PR never held still long enough for its own branch CI
to finish, and every refresh was a CI run spent on bookkeeping nobody reads until release day.
Measured on this branch's merge base: 157 pending changesets in
.changeset/— every oneof those ~18 daily runs was re-rendering all 157.
Trigger truth table
.changeset/statepushtomainpushtomainpushtomainschedule0 */6 * * *workflow_dispatchwithrefresh_version_prworkflow_dispatchwithout the input::notice::saying whyThe split shape, and why this one
release.yml. There, aversion-prjob and apublishjob already existed and each could simply take its owntrigger. Here there is one changesets step carrying both
version:andpublish:, andwhich of the two it performs is not ours to choose per invocation — the action decides from
repository state. Its v1 source (
changesets/action,src/index.ts) dispatches on exactly twofacts:
The two consequences are not symmetric, and this asymmetry is the whole design:
publish:makes publishing unreachable by construction. No repository statemakes the action publish without that input.
version:does not make the refresh unreachable —runVersionfalls back toplain
changeset version. A step invoked on a push that still carries pending changesetsforce-pushes chore: release packages #5400 no matter how it is configured.
So the refresh lane can be closed by construction, but the publish lane can only be closed by
not invoking the action at all on a push that carries pending changesets. That question has
to be answered before install and build, or the saving the ruling asked for is not made: the
job would still install and build on all ~18 daily landings just to decide to do nothing.
Chosen shape — a second job in the same file (a second workflow file was ruled out):
lanejob: sparse checkout of.changeset/only,permissions: contents: read,emits one fact —
pending_changesets. It answers the fact; the policy stays in therelease job's
if:, in workflow expression language where one reader can see the wholesplit.
releasejob gainsneeds: laneand one job-levelif:carrying the truth table.structural rather than conditional: the refresh step has no
publish:input and noNPM_TOKEN/NODE_AUTH_TOKENin its env.Failure modes this shape excludes
publish:input and no npmcredentials. Even a
.changeset/emptied by a merge racing the tick cannot make it publish.This matters because a publish from a tick would be a new capability: the release act here
is a human merging chore: release packages #5400, and a tick reaching npm would publish with nobody having merged.
so the version path is never entered from
push.later push that still finds
.changeset/empty — the path by which@object-ui/*@17.5.0eventually shipped.
deterministic file I/O, so an error means something a default would hide.
lanescript is a deliberate mirror of theaction's
readChangesetState, and it is cross-checked against the real reader below —including the pre-mode branch, which is dormant here but whose failure (a pre-mode release
that silently never publishes) would only surface on release day.
lane; this repository has none (publishing here is not gated on an environment approval, so
there is nothing to re-approve). Rather than invent one under this card, the default-
falseinput is kept for spelling parity and a bare dispatch emits a
::notice::explaining thatnothing ran.
Preserved verbatim
origin/main(2031 bytes,verified mechanically), fail-open design and all. It now serialises across both lanes,
which is wanted: a refresh force-pushing
changeset-release/mainwhile a publish is inflight is exactly the race it exists to prevent.
${{ github.workflow }}-${{ github.sha }},cancel-in-progress: false) — unchanged. Only its header comment gained a qualifier, becausethe trigger change falsifies one sentence in it:
github.shais no longer unique per run(a tick and a dispatch both read
main's head), so same-sha collisions are now reachable.They are provably harmless: a push run at a given sha is always the first run in its group —
the sha did not exist before that push — so a publish run is never the pending one and never
evicted; only refreshes can be evicted, and a refresh regenerates the PR from scratch.
Not done, deliberately
No gate added or removed;
pnpm buildstill runs on both lanes (the refresh arguably does notneed it, but that is an optimisation this card does not authorise). Per-PR changeset
validation —
changeset-guard.yml,changeset-presence.yml— is untouched, as the rulingrequires.
Prose sites updated (only what the trigger change falsifies)
changeset-release.ymlconcurrency headercontent/docs/guide/ci-cd-pipeline.mdworkflow inventory tablemain"content/docs/guide/ci-cd-pipeline.md§Changeset Releasemain" + a 4-step list implying one run does all of itscripts/check-changeset-fixed.mjsheaderchangeset-release.yml, on push tomain"Left alone because they are not falsified:
sync-quick-reference-release.mjsand its test(they say
changeset versionruns "with no human present" — still true on the refresh lane),the pnpm-lock merge-driver table, and the
NPM_TOKENsecrets row.Verification
All at
b38e47f68(the final commit), run as one union after committing.The detection script is the load-bearing new logic, so it was tested rather than assumed.
The script under test was extracted from the shipped YAML, so what ran is what ships.
Oracle = the real
@changesets/read@1.0.0plus the action's ownreadChangesetStatefilteringrule, transcribed from its fetched source:
.changeset/as it stands onmain(157 changesets)truetruefalsefalsefalsefalsetruetruetruetrueThe comparison was proven able to fail. Ablation on the extracted copy (scratchpad only,
never the repo file, with a restore trap): removing the
README.mdexclusion was confirmed ondisk by an anchored observation (
README.mdmentions 1 → 0, 1189 → 1134 bytes), after whichthe "version PR just merged" fixture diverged (mirror
truevs oraclefalse). Therestore leg was confirmed the same way (mentions back to 1) and agreement returned. A first
attempt at this mutation did not land — the
perlanchor missed the extracted script'sindentation and exited 0 — so that reading was discarded as void rather than reported.
Gate verdicts, each quoting the gate's own line:
vitest runon the 4 test files that read this workflowTest Files 4 passed (4)/Tests 72 passed (72)pnpm changeset:check✅ All workspace packages are in the changeset fixed group./✅ No changeset declares a major bump.pnpm check:control-bytes✅ check-control-bytes: OK (scanned 4803 tracked text file(s); skipped 85 binary).pnpm docs:check-linksLinks are valid across 13 scan roots.node scripts/check-changeset-presence.mjs✅ No source of a released package changed in this range, so no changeset is owed.yaml@2.9.0parse errors: 0 warnings: 0;on:parses as the string key, not YAML-1.1trueThe pin test that reads this file specifically —
changeset-release.ymlmust still hand theaction
version: pnpm changeset:version— passes: the regex it uses(
/^\s*version:\s*(.+)$/m) resolves topnpm changeset:versionon the new file, which is whythat input stays on the refresh step verbatim.
Lint was narrowed to the changed files rather than the repo-wide run, and the narrowing is
a measurement, not a skip: eslint's own config reports the
.ymland.mdfiles as "Fileignored because no matching configuration was supplied", leaving exactly one lintable changed
file;
--format jsoncounts 3 files reported, 0 errors; andeslint.config.jsenables notype-aware linting (no
projectService/project:), so this diff cannot move the verdict onany untouched file.
Changeset: none owed. The presence gate decided that itself from the diff (line quoted
above); this repository has no
skip-changesetlabel, and the diff touches no releasedpackage's
src/.Landing class: this diff touches
.github/workflows/,content/docs/andscripts/—not on this repository's governed surface (
AGENTS.md,CLAUDE.md,.claude/**,docs/adr/**per AGENTS.md §受管面), so the ordinary path would apply. Held as draftanyway pending PM review, as dispatched; no ready/queue/auto-merge action taken.
Not verified locally, by design: the acceptance criterion ("a test merge to
mainstartszero Version-Packages refresh runs") is observable only in the Actions run list after this
lands. The trigger truth table above is what stands in for it until then.
Generated by Claude Code