Skip to content

ci(release): refresh the version PR on a schedule, publish only on a version-PR merge - #5762

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

ci(release): refresh the version PR on a schedule, publish only on a version-PR merge#5762
os-zhuang merged 1 commit into
mainfrom
claude/issue-10850-version-pr-schedule

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

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
objectui half, mirroring its reasoning where the two files are analogous and diverging where
they are not.

The problem

changeset-release.yml ran its changesets step on every push to main. With pending
changesets 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 daily
landings 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 one
of those ~18 daily runs was re-rendering all 157.

Trigger truth table

Event.changeset/ stateLanePublishes?
push to mainemptyPublish to npm (unchanged)Yes — this is the version-PR merge, i.e. the release act
push to mainchangesets pendingNothing — release job skippedNo
push to mainonly empty changesetsNothing — release job skippedNo (the action would have done nothing anyway)
schedule0 */6 * * *anyRefresh the version PRNo — unreachable by construction
workflow_dispatchwithrefresh_version_pranyRefresh the version PRNo — unreachable by construction
workflow_dispatchwithout the inputanyNothing, with a ::notice:: saying whyNo

The split shape, and why this one

⚠️ This repository is structurally different from objectstack's release.yml. There, a
version-pr job and a publish job already existed and each could simply take its own
trigger. Here there is one changesets step carrying both version: and publish:, and
which 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 two
facts:

case !hasChangesets && !hasPublishScript: -> nothing
case !hasChangesets && hasPublishScript: -> runPublish (npm + tags)
case hasChangesets && !hasNonEmptyChangesets: -> nothing
case hasChangesets: -> runVersion (force-push the PR)

The two consequences are not symmetric, and this asymmetry is the whole design:

  • Omitting publish: makes publishing unreachable by construction. No repository state
    makes the action publish without that input.
  • Omitting version: does not make the refresh unreachable — runVersion falls back to
    plain changeset version. A step invoked on a push that still carries pending changesets
    force-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):

  1. A cheap lane job: sparse checkout of .changeset/ only, permissions: contents: read,
    emits one fact — pending_changesets. It answers the fact; the policy stays in the
    release job's if:, in workflow expression language where one reader can see the whole
    split.
  2. The release job gains needs: lane and one job-level if: carrying the truth table.
  3. The single changesets step becomes two mutually exclusive steps, so each lane's limit is
    structural rather than conditional: the refresh step has no publish: input and no
    NPM_TOKEN/NODE_AUTH_TOKEN in its env
    .

Failure modes this shape excludes

  • A scheduled tick publishing to npm. Denied twice over — no publish: input and no npm
    credentials. 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.
  • A push force-pushing chore: release packages #5400. The whole release job is skipped when changesets are pending,
    so the version path is never entered from push.
  • The version-PR merge failing to publish. Preserved exactly, including the retry on a
    later push that still finds .changeset/ empty — the path by which @object-ui/*@17.5.0
    eventually shipped.
  • A wrong lane chosen silently. The detection fails loudly rather than defaulting; it is
    deterministic file I/O, so an error means something a default would hide.
  • Detection drifting from the action. The lane script is a deliberate mirror of the
    action'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.
  • A bare "Run workflow" vanishing. objectstack's bare dispatch is its publish repair
    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-false
    input is kept for spelling parity and a bare dispatch emits a ::notice:: explaining that
    nothing ran.

Preserved verbatim

  • The "Wait for older release runs" step — byte-identical to 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/main while a publish is in
    flight is exactly the race it exists to prevent.
  • The per-commit concurrency group (${{ github.workflow }}-${{ github.sha }},
    cancel-in-progress: false) — unchanged. Only its header comment gained a qualifier, because
    the trigger change falsifies one sentence in it: github.sha is 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 build still runs on both lanes (the refresh arguably does not
need it, but that is an optimisation this card does not authorise). Per-PR changeset
validationchangeset-guard.yml, changeset-presence.yml — is untouched, as the ruling
requires.

Prose sites updated (only what the trigger change falsifies)

SiteWasNow
changeset-release.yml concurrency header"The last step of this workflow publishes to npm, so a discarded run is a discarded publish""A push run … can publish", plus the same-sha collision analysis
content/docs/guide/ci-cd-pipeline.md workflow inventory table"Push to main"both halves named
content/docs/guide/ci-cd-pipeline.md §Changeset Release"Trigger: Push to main" + a 4-step list implying one run does all of ittwo-lane trigger + a lane table
scripts/check-changeset-fixed.mjs header"the only invocation is inside changeset-release.yml, on push to main"names the refresh lane; the gate's point (only it can say so on a PR) is sharpened, not weakened

Left alone because they are not falsified: sync-quick-reference-release.mjs and its test
(they say changeset version runs "with no human present" — still true on the refresh lane),
the pnpm-lock merge-driver table, and the NPM_TOKEN secrets 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.0 plus the action's own readChangesetState filtering
rule, transcribed from its fetched source:

FixtureOracleShipped mirrorAgree
.changeset/ as it stands on main (157 changesets)truetrueyes
version PR just merged (README only)falsefalseyes
pre mode, all ids consumedfalsefalseyes
pre exited, same filestruetrueyes
only an empty changeset (docs-only declaration)truetrueyes

The comparison was proven able to fail. Ablation on the extracted copy (scratchpad only,
never the repo file, with a restore trap): removing the README.md exclusion was confirmed on
disk by an anchored observation (README.md mentions 1 → 0, 1189 → 1134 bytes), after which
the "version PR just merged" fixture diverged (mirror true vs oracle false). The
restore leg was confirmed the same way (mentions back to 1) and agreement returned. A first
attempt at this mutation did not land — the perl anchor missed the extracted script's
indentation and exited 0 — so that reading was discarded as void rather than reported.

Gate verdicts, each quoting the gate's own line:

CheckVerdict
vitest run on 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 validity, via the workspace's own yaml@2.9.0parse errors: 0 warnings: 0; on: parses as the string key, not YAML-1.1 true

The pin test that reads this file specifically — changeset-release.yml must still hand the
action version: pnpm changeset:version — passes: the regex it uses
(/^\s*version:\s*(.+)$/m) resolves to pnpm changeset:version on the new file, which is why
that 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 .yml and .md files as "File
ignored because no matching configuration was supplied", leaving exactly one lintable changed
file; --format json counts 3 files reported, 0 errors; and eslint.config.js enables no
type-aware linting (no projectService/project:), so this diff cannot move the verdict on
any untouched file.

Changeset: none owed. The presence gate decided that itself from the diff (line quoted
above); this repository has no skip-changeset label, and the diff touches no released
package's src/.

Landing class: this diff touches .github/workflows/, content/docs/ and scripts/
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 draft
anyway pending PM review, as dispatched; no ready/queue/auto-merge action taken.

Not verified locally, by design: the acceptance criterion ("a test merge to main starts
zero 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

…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-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

CI 判读(派发席):Build Docs不是本 PR 的——objectui main tip(9850c6e4e,05:09,早于本 PR 存在)同 check 同样红。根因:npm 上的 @objectstack/spec@17.1.0 dist 经 formula → core → components 把 pg-connection-string(require('fs'))拽进文档站浏览器包,Turbopack 拒绝。修复已在 objectstack 侧落地(#11072,spec 浏览器条件),等 objectstack 17.2.0 发布 + objectui 升依赖即愈——正是维护者当前在推进的发版。本 PR 是 workflow 触发器 diff,不可能影响该依赖图;不为此红推任何改动,base 恢复后随队重跑即绿。


Generated by Claude Code

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

ACCEPT — session 757f0dc0-5dae-51d7-9053-c15c57ebfe3b(派发席,卡 objectstack#10850 的 objectui 半边)。

全量 diff 复核(3 文件 +346/−15,head b38e47f68),要点:

  1. 政策中枢一处、能力拒绝两重:job 级 if: 完整陈述车道分割(push∧无 pending = version-PR 刚合并的发布场景 / schedule / dispatch∧refresh);发布步仅 push 可达且不带 version:;刷新步publish: 输入 + 无 NPM_TOKEN/NODE_AUTH_TOKEN——结构性不可发布,不靠 if: 说话。发布步旁那段 ⚠️(「省略 version: 不禁用刷新分支,护住它的是 needs.lane,松了守卫它就会重新刷 PR」)是对后来编辑者的关键警示,写得诚实。
  2. lane 检测有真验证:镜像脚本从已交付的 YAML 中提取执行,oracle 是真 @changesets/read + action 自身过滤规则,5 个夹具一致;消融证明比较器能红(去掉 README 排除后夹具分歧,恢复后回归),且第一次未落盘的变异被诚实作废重做。72 个读本工作流的既有测试全绿。
  3. 保留项核实:per-commit concurrency 与「Wait for older release runs」排队器逐字节保留(2031 字节对账);无 changeset 应欠(check-changeset-presence 判定引用)。
  4. 两个 open question 的裁定:footer 用会话 URL 形式,采纳(本次运行实证 bare 形式会被后续编辑吞掉);objectui#5571(同题姊妹卡)由 PM 在本 PR 合并后关闭并指回,#10850 为记录卡。

Flip ready + auto-merge。⚠️ 已知前置:base 的 Build Docs 红(@objectstack/spec@17.1.0 拽入 pg-connection-string,见前一条判读)——本 PR 会在 objectstack 17.2.0 发布 + objectui 升依赖使 main 回绿后自动合并,期间挂着是正确状态,不是本 PR 的红。


Generated by Claude Code

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

release: switch the changesets version workflow ("Version Packages" PR refresh) from every-merge-to-main to manual dispatch before a release

2 participants

@os-zhuang@claude