fix(types): SchemaRegistry's kanban entry stops describing a componen… #4836
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Changeset Release | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # TWO LANES, ONE FILE: THE VERSION PR REFRESHES ON A CLOCK, npm PUBLISHES ON A | |
| # MERGE. (objectstack#10850) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # | |
| # Maintainer ruling, 2026-08-21 (verbatim, do not translate): | |
| # | |
| # 「我每周才发一次版本,changeset 每个pr 都跑有必要吗」 | |
| # 「立卡吧,version 工作流改成发版前手动触发」 | |
| # 「包括 objectui 仓库」 | |
| # | |
| # The sister half landed first in `../objectstack` as its `release.yml` | |
| # `version-pr` job (objectstack#11233 / PR #11238, maintainer 2026-08-23): a | |
| # 6-hourly `schedule` plus an on-demand dispatch input, which is the later and | |
| # more specific spelling of "手动触发" and the one mirrored here. Per-PR changeset | |
| # VALIDATION is explicitly untouched by the ruling — `changeset-guard.yml` and | |
| # `changeset-presence.yml` still run on every pull request. | |
| # | |
| # WHY THE REFRESH LEFT THE PUSH TRIGGER | |
| # ------------------------------------- | |
| # With pending changesets this file's changesets step recomputed and FORCE-PUSHED | |
| # the standing "chore: release packages" PR (objectui#5400) on every landing, and | |
| # `main` takes ~18 merges a working day (the same cadence the concurrency note | |
| # below measures). The PR therefore never held still long enough for its own | |
| # branch CI to finish. Releases are weekly, so every one of those refreshes was a | |
| # CI run spent on bookkeeping nobody reads until release day. Between refreshes | |
| # `changeset-release/main` is now a static branch whose CI can converge. | |
| # | |
| # The staleness window is the whole cost, and it is bounded on demand: dispatch | |
| # with `refresh_version_pr` when you want the PR current NOW (immediately before | |
| # a release, say). The bookkeeping is not time-critical — the changesets are | |
| # already committed on `main`; objectui#5400 is only their rendering. | |
| # | |
| # WHY THE SPLIT NEEDS A SEPARATE `lane` JOB, AND NOT JUST AN `if:` ON THE STEP | |
| # --------------------------------------------------------------------------- | |
| # ⚠️ Unlike objectstack's `release.yml` — which has a version job and a publish | |
| # job and could simply give each one its own trigger — this repository publishes | |
| # from THE SAME changesets step that refreshes the PR. Which of the two it does | |
| # 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, `hasChangesets` and whether a `publish:` input was given: | |
| # | |
| # case !hasChangesets && !hasPublishScript: -> nothing | |
| # case !hasChangesets && hasPublishScript: -> runPublish (npm + tags) | |
| # case hasChangesets && !hasNonEmptyChangesets: -> nothing | |
| # case hasChangesets: -> runVersion (force-push PR) | |
| # | |
| # Two consequences drive everything below, and they are NOT symmetric: | |
| # | |
| # - Omitting `publish:` makes publishing UNREACHABLE BY CONSTRUCTION. There is | |
| # no state in which the action publishes without that input. | |
| # - Omitting `version:` does NOT make the refresh unreachable. `runVersion` | |
| # falls back to plain `changeset version` when the input is absent, so a step | |
| # invoked on a push that still carries pending changesets force-pushes | |
| # objectui#5400 no matter how it is configured. | |
| # | |
| # So the refresh lane is closed by construction, and the publish lane can only be | |
| # closed by NOT INVOKING THE ACTION on a push that carries pending changesets. | |
| # That question — "does `main` carry pending changesets?" — has to be answered | |
| # before the expensive steps, 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. Hence one cheap job that answers it from a sparse checkout, and one | |
| # job-level `if:` that states the policy. A second workflow FILE was ruled out | |
| # (no new lanes to maintain, and the publish invariants would be duplicated where | |
| # they can drift apart). | |
| # | |
| # THE LANES | |
| # --------- | |
| # push to main, version NOT on npm -> PUBLISH what this commit declares. | |
| # Normally that is the version-PR merge | |
| # itself. It is also the retry: any | |
| # later push re-attempts a release whose | |
| # own run failed, which is how | |
| # @object-ui/*@17.5.0 eventually shipped | |
| # 6h50m late (see the `pnpm test` note | |
| # further down) — except that it is no | |
| # longer luck, because it no longer | |
| # depends on `.changeset/` happening to | |
| # still be empty (objectui#5442). | |
| # push to main, version already on npm-> NOTHING. This is every ordinary | |
| # landing: a merge that is not a release | |
| # does not move the manifest version, so | |
| # there is nothing to publish and the | |
| # expensive job is skipped. | |
| # schedule (6-hourly) -> REFRESH ONLY. The changesets step is | |
| # or a dispatch with invoked WITHOUT a `publish:` script | |
| # `refresh_version_pr` and WITHOUT npm credentials, so the | |
| # publish branch is unreachable by | |
| # construction rather than by an `if:` | |
| # someone can get wrong. | |
| # dispatch without the input -> NOTHING, loudly. See the input's note. | |
| # | |
| # THE REFRESH LANE VALIDATES WHAT IT IS ABOUT TO RENDER (objectui#5397) | |
| # --------------------------------------------------------------------- | |
| # The release PR runs no CI and cannot be made to — 849 `ci.yml` runs on | |
| # `changeset-release/main`, every recent one `action_required`, created and | |
| # parked with nothing executed — so the release commit is the only commit that | |
| # reaches `main` without passing the merge queue. Before the refresh step | |
| # invokes the action, this job therefore renders the post-version tree itself, | |
| # validates the surfaces the version step can move, and puts the tree back. The | |
| # ruling, the measurements that chose that scope over a 40-minute `pnpm test`, | |
| # and why the restore is load-bearing are at "POST-VERSION VALIDATION" below. | |
| # | |
| # WHY THE PUBLISH LANE IS KEYED ON npm AND NOT ON `.changeset/` | |
| # ------------------------------------------------------------- | |
| # It used to be keyed on "does this commit carry pending changesets?", and that | |
| # predicate answers a question about BOOKKEEPING while being read as if it | |
| # answered a question about the REGISTRY. The two come apart exactly where it | |
| # costs a release (objectui#5442, maintainer ruling 2026-08-22, Option B): | |
| # | |
| # The version PR is cut from `main` at T and merged at T+n. `main` takes ~18 | |
| # merges a working day, so by T+n it has almost always accumulated changesets | |
| # that the merge does not remove. Those changesets belong to the NEXT version; | |
| # the version this commit declares is finished and unpublished. Keyed on | |
| # changesets, that release is skipped, and the next version PR bumps straight | |
| # past it. | |
| # | |
| # Measured, and still true at the commit this paragraph was written on: of the 90 | |
| # versions `packages/core/CHANGELOG.md` declares, 16 never reached npm, and | |
| # `packages/core/package.json` says 17.6.0 while `dist-tags.latest` says 17.5.0. | |
| # `@object-ui/react` and `@object-ui/types` are missing the exact same 16, which | |
| # is the counter-probe that matters: whole RELEASES never published, rather than | |
| # some package failing to. | |
| # | |
| # So the predicate is now "is the version this commit declares already on npm?". | |
| # It is answered in the `lane` job by one read of `registry.npmjs.org`, and it is | |
| # better than its predecessor on both axes at once: | |
| # | |
| # - CORRECTNESS. Pending changesets stop being the publish lane's business. | |
| # What this commit declares gets published, whatever the next version is | |
| # accumulating. | |
| # - COST. It is STRICTLY cheaper than "no pending changesets", not a trade | |
| # against it. An ordinary landing does not move the manifest version, so it | |
| # answers "already on npm" and skips the expensive job — where the old | |
| # predicate ran the job in full on every landing that happened to find | |
| # `.changeset/` empty, only to publish nothing. | |
| # | |
| # The saving objectstack#10850 asked for is therefore kept, and kept for a | |
| # sounder reason: a landing pays for the release job when it has a release to | |
| # make, rather than when a directory happens to be empty. | |
| # | |
| # ⚠️ The predicate cannot reach the action on its own. `changesets/action@v1` | |
| # does not take a "publish" instruction — it picks a branch from repository | |
| # state, and `hasChangesets` is the discriminator (algorithm quoted above). On a | |
| # release-merge commit carrying the next version's changesets it takes | |
| # `runVersion` and publishes nothing. So the publish lane hands it a tree with | |
| # nothing pending; see the step that does it, which explains why that is safe. | |
| # | |
| # ⛔ A refresh lane that could publish would be a NEW capability, not a moved | |
| # one: this repository's release act is the version-PR MERGE, a human action. A | |
| # scheduled tick that reached npm would publish with nobody having merged | |
| # anything. That is why the publish half is denied to it twice over — no | |
| # `publish:` input and no `NPM_TOKEN`/`NODE_AUTH_TOKEN` in its env — and why the | |
| # publish half keeps its trigger exactly as it was. This card changed WHEN the | |
| # version PR refreshes and nothing else: no gate was added, and none removed. | |
| # | |
| # ⚠️ 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 objectui#5400 stale, which is visible on the | |
| # PR and fixable with one dispatch. It is a second reason the publish lane must | |
| # never be reachable from `schedule`. | |
| on: | |
| # The PUBLISH lane (and only it — see the header). Kept exactly as it was: the | |
| # merge of the version PR is the release act in this repository. | |
| push: | |
| branches: | |
| - main | |
| # The REFRESH lane. GitHub runs `schedule` on the DEFAULT BRANCH exclusively, | |
| # which is the only ref this workflow may regenerate the version PR from, so | |
| # the trigger cannot reach a ref the lane is not meant to touch. | |
| schedule: | |
| - cron: '0 */6 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| # The on-demand half of the schedule. Its ONLY effect is to select the | |
| # refresh lane; it can start nothing the 6-hourly tick does not start, so | |
| # it cannot widen anything. | |
| # | |
| # ⚠️ It defaults to FALSE to match objectstack's spelling of the same | |
| # input, which leaves "Run workflow" with default inputs doing NOTHING in | |
| # this repository — objectstack's bare dispatch is its publish REPAIR lane, | |
| # and this repository has no such lane (publishing here is not gated on an | |
| # environment approval, so there is nothing to re-approve). Rather than | |
| # invent one under this card, the `lane` job below prints a `::notice::` | |
| # saying why nothing ran, so a bare dispatch reports instead of vanishing. | |
| refresh_version_pr: | |
| description: >- | |
| Regenerate the "chore: release packages" PR (objectui#5400) now instead | |
| of waiting for the next 6-hourly refresh. Runs the bookkeeping lane | |
| ONLY: nothing is published to npm. Leave unchecked and this workflow | |
| does nothing at all. | |
| required: false | |
| default: false | |
| type: boolean | |
| # One concurrency group per COMMIT — never one shared by every push to `main`. | |
| # | |
| # A shared group does not queue. GitHub keeps at most ONE pending run per group and | |
| # cancels the previous pending run whenever a newer one arrives, so on a branch that | |
| # takes ~18 merges a day a long-running release job discards most of what follows it. | |
| # Measured over runs #3712-#3911 (2026-08-17 .. 2026-08-20, 200 runs): 93 were | |
| # `conclusion: cancelled` with an EMPTY jobs array — not one step ever ran — while the | |
| # 106 that did execute held the group for a median of 33m22s. Median wait for a run | |
| # that survived: 5m16s (objectui#5404, method and figures from objectui#5395). | |
| # | |
| # A push run of this workflow can publish to npm, so a discarded run is a discarded | |
| # publish. `cancel-in-progress: true` is NOT the fix: it keeps one pending slot by | |
| # killing the run that may be mid-`changeset publish`, which is strictly worse than | |
| # any wait. | |
| # | |
| # ⚠️ "A push run" is where that sentence used to say "this workflow", and the | |
| # distinction is new with objectstack#10850: since the refresh lane arrived, `push` | |
| # is no longer the only event here, and `github.sha` is no longer unique per run. | |
| # A `schedule` tick and a `workflow_dispatch` both read the head of `main`, so two | |
| # of them between merges land in the SAME group and the older pending one is | |
| # evicted. That is harmless, and provably so rather than by luck: | |
| # | |
| # - Only refresh runs can be evicted. A push run at `<sha>` is always the FIRST | |
| # run in its group — the sha did not exist before that push — so it is never | |
| # the pending one, and `cancel-in-progress: false` means it is never cancelled | |
| # once started. The publish lane keeps exactly the protection this key was | |
| # added for. | |
| # - An evicted refresh is nothing lost. The refresh regenerates objectui#5400 | |
| # from scratch, so the newest run's result is the one that was wanted anyway — | |
| # the same reasoning `../objectstack`'s `version-pr` job records for its own | |
| # concurrency group. | |
| # | |
| # Keyed by `github.sha` the group holds exactly one run, so nothing is ever discarded. | |
| # Serialisation is not given up — it moves into the "Wait for older release runs" step | |
| # below, which queues runs for real and in order, and which can only ever delay a run, | |
| # never drop one. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.sha }} | |
| cancel-in-progress: false | |
| permissions: | |
| # `actions: read` lets the queue step below list this workflow's own runs. Declaring | |
| # any `permissions:` block sets every unlisted scope to `none`, so it has to be named. | |
| actions: read | |
| contents: write | |
| pull-requests: write | |
| id-token: write | |
| jobs: | |
| # ══════════════════════════════════════════════════════════════════════════ | |
| # Which lane is this run in? (objectstack#10850) | |
| # ══════════════════════════════════════════════════════════════════════════ | |
| # The header explains why this cannot be an `if:` on the changesets step: the | |
| # publish lane is only safe on a commit where `.changeset/` is empty, and that | |
| # fact has to be known BEFORE install and build, or every one of `main`'s ~18 | |
| # daily landings still pays for a job that will decide to do nothing. | |
| # | |
| # This job answers the FACT ("does this commit carry pending changesets?") and | |
| # nothing else. The POLICY lives in the release job's `if:` below, in workflow | |
| # expression language where it can be read in one place — a shell script that | |
| # emitted "the lane" would move the split somewhere no reviewer looks. | |
| lane: | |
| name: Decide lane | |
| runs-on: ubuntu-latest | |
| # Narrower than the workflow-level block deliberately: this job reads files | |
| # and writes nothing. Declaring any `permissions:` sets every unlisted scope | |
| # to `none`, which is the point — a new job on a workflow that can publish | |
| # should not inherit the publish credentials' scopes. | |
| permissions: | |
| contents: read | |
| outputs: | |
| manifest_version: ${{ steps.npm.outputs.manifest_version }} | |
| version_on_npm: ${{ steps.npm.outputs.version_on_npm }} | |
| steps: | |
| # Two directories, because the lane now turns on two facts: `.changeset/` | |
| # answers "are changesets pending?" and `packages/core/` carries the fixed | |
| # group's version anchor, which answers "is that version already on npm?". | |
| # Still far short of a full checkout of this repository — ~15s that every | |
| # landing would otherwise pay. | |
| - name: Check out the lane inputs | |
| uses: actions/checkout@v7 | |
| with: | |
| sparse-checkout: | | |
| .changeset | |
| packages/core | |
| # This step no longer PREDICTS anything: it reports the lane's inputs and | |
| # says why a bare "Run workflow" did nothing. That is all it does. | |
| # | |
| # It used to also mirror, in bash and before any install, the file scan | |
| # that `changesets/action` performs through `readChangesetState`, and | |
| # publish the result as a `pending_changesets` output gating the release | |
| # job's "Clear pending changesets" step. objectui#6081 DELETED that mirror. | |
| # | |
| # ⛔ Do not reintroduce it. The thing it mirrored has no stable referent, | |
| # which is why no amount of care could keep it honest: `changesets/action` | |
| # is used at a MOVING TAG, its bundled `@changesets/read@^0.6.7` is a | |
| # different major from the `1.0.0` this repository installs, the two | |
| # disagree in OPPOSITE directions (`AGENTS.md` / `CLAUDE.md` / `GEMINI.md` | |
| # count for one and not the other; `.changeset/pre/*.md` the other way), | |
| # and the bundled copy is a minified chunk that nothing here installs — so | |
| # no test could execute it and no lockfile recorded it. A retag of `v1` in | |
| # place changed what the mirror was mirroring and NOTHING in this | |
| # repository failed. On the release lane, where objectui#5442 already | |
| # produced one silent lost release, that is the worst available property. | |
| # | |
| # What replaced it is not a better prediction but the absence of one: the | |
| # clear step is IDEMPOTENT and a measured no-op on the very input where | |
| # this output used to skip it — `Removed 0 pending changeset file(s)`, | |
| # exit 0, byte-identical tree, and a rerun after a real clear also removes | |
| # 0. A step that does nothing when there is nothing to do does not need | |
| # something else to predict whether there is anything to do. | |
| # | |
| # ⛔ Nor should anyone reintroduce it to "save" the clear step a run. It | |
| # costs a directory glob and some `rm -f` on a job whose other steps take | |
| # ~90s, and the price of buying that back is a hand-maintained copy of | |
| # another project's minified file scan. | |
| # | |
| # ⚠️ `.changeset` stays in the sparse-checkout above: the `npm` step below | |
| # reads `.changeset/config.json` for the `fixed` group. It is no longer | |
| # read for changeset FILES. | |
| - name: Report the lane | |
| env: | |
| REFRESH_VERSION_PR: ${{ inputs.refresh_version_pr }} | |
| run: | | |
| set -euo pipefail | |
| { | |
| echo '### Changeset Release lane' | |
| echo | |
| echo "- event: \`${GITHUB_EVENT_NAME}\`" | |
| echo "- commit: \`${GITHUB_SHA}\`" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # A bare "Run workflow" reports instead of vanishing — see the input's | |
| # note in the `on:` block above. | |
| if [ "${GITHUB_EVENT_NAME}" = 'workflow_dispatch' ] && [ "${REFRESH_VERSION_PR}" != 'true' ]; then | |
| echo "::notice::Nothing to do. Check 'refresh_version_pr' to regenerate the version PR; publishing to npm happens when that PR is merged, not from a dispatch." | |
| fi | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # THE PUBLISH PREDICATE: is the version this commit declares on npm? | |
| # (objectui#5442 — the ruled Option B, argued in this file's header) | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # `@object-ui/core` is an ANCHOR, not a special case: every package in the | |
| # `fixed` group of `.changeset/config.json` moves as one version, so any | |
| # member answers for the whole release. That membership is ASSERTED rather | |
| # than assumed — if `@object-ui/core` ever leaves the group it stops | |
| # answering for the release, and the publish lane must fail rather than key | |
| # itself on a package that no longer represents one. | |
| # | |
| # LOUD on every answer that is not a clean yes or no. 200 is present, 404 | |
| # is absent, and anything else — 5xx, a proxy, no network — is neither. | |
| # Guessing "absent" publishes on a registry hiccup; guessing "present" | |
| # skips a real release. Neither is worth making silently, which is the same | |
| # reason the detector above refuses to guess a lane. | |
| - name: Is this commit's version already on npm? | |
| id: npm | |
| env: | |
| ANCHOR_PKG: '@object-ui/core' | |
| ANCHOR_MANIFEST: packages/core/package.json | |
| ATTEMPTS: '5' | |
| BACKOFF_SECONDS: '5' | |
| run: | | |
| set -euo pipefail | |
| if ! jq -e --arg p "$ANCHOR_PKG" '[.fixed[]?[]?] | index($p)' \ | |
| .changeset/config.json > /dev/null; then | |
| echo "::error::${ANCHOR_PKG} is not in the 'fixed' group of .changeset/config.json, so it no longer answers for the release version. Point this step at a package that is still in the group (objectui#5442)." | |
| exit 1 | |
| fi | |
| manifest_version=$(jq -r '.version // empty' "$ANCHOR_MANIFEST") | |
| if [ -z "$manifest_version" ]; then | |
| echo "::error::Could not read a version from ${ANCHOR_MANIFEST} (objectui#5442)." | |
| exit 1 | |
| fi | |
| # The registry's per-version endpoint, which answers 200 for a version | |
| # it has published and 404 for one it has never seen. `/` in the scope | |
| # has to be percent-encoded. | |
| encoded=$(printf '%s' "$ANCHOR_PKG" | sed 's|/|%2F|') | |
| url="https://registry.npmjs.org/${encoded}/${manifest_version}" | |
| on_npm='' | |
| for attempt in $(seq 1 "$ATTEMPTS"); do | |
| code=$(curl -sS -o /dev/null -w '%{http_code}' \ | |
| -H 'Cache-Control: no-cache' --max-time 20 "$url" 2>/dev/null) || code='000' | |
| case "$code" in | |
| 200) on_npm=true; break ;; | |
| 404) on_npm=false; break ;; | |
| *) echo "attempt ${attempt}/${ATTEMPTS}: registry answered ${code} for ${url}" ;; | |
| esac | |
| # An explicit `if`, not `[ ... ] && sleep`: bash exempts AND-lists | |
| # from `set -e`, so the short form works, but it works for a reason | |
| # a reader has to already know. | |
| if [ "${attempt}" -lt "${ATTEMPTS}" ]; then sleep "$BACKOFF_SECONDS"; fi | |
| done | |
| if [ -z "$on_npm" ]; then | |
| echo "::error::Could not establish whether ${ANCHOR_PKG}@${manifest_version} is on npm after ${ATTEMPTS} attempts, so the publish lane has no predicate to run on. Refusing to guess (objectui#5442)." | |
| exit 1 | |
| fi | |
| echo "manifest_version=${manifest_version}" >> "$GITHUB_OUTPUT" | |
| echo "version_on_npm=${on_npm}" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "- declared version: \`${ANCHOR_PKG}@${manifest_version}\`" | |
| echo "- already on npm: \`${on_npm}\`" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| if [ "$on_npm" = false ] && [ "${GITHUB_EVENT_NAME}" != 'push' ]; then | |
| # Visible, but not a failure here: this lane cannot publish, and the | |
| # next push to `main` closes the gap. The failure lives on the push | |
| # lane, where it is actionable — see "Verify the release reached npm". | |
| echo "::notice::${ANCHOR_PKG}@${manifest_version} is not on npm. The next push to main will publish it (objectui#5442)." | |
| fi | |
| release: | |
| name: Changeset Release | |
| needs: lane | |
| # The whole lane split, in one place (objectstack#10850). Each half names its | |
| # own event rather than leaning on the complement of the other: | |
| # | |
| # push + version not yet on npm -> PUBLISH what this commit declares. | |
| # push + version already on npm -> skipped; nothing to release. | |
| # schedule -> REFRESH. | |
| # dispatch WITH the input -> the same refresh, on demand. | |
| # dispatch WITHOUT it -> skipped; the `lane` job says why. | |
| # | |
| # ⚠️ The push half reads `version_on_npm`, NOT a count of pending | |
| # changesets. That is the whole of objectui#5442: pending changesets belong | |
| # to the next version and say nothing about whether THIS one shipped. Since | |
| # objectui#6081 nothing counts them at all — `.changeset/` reaches the | |
| # release only through the step that CLEARS it for the action, never through | |
| # a decision about whether there is a release to make. | |
| # | |
| # `inputs.refresh_version_pr` is guarded by its event test rather than read | |
| # bare: the `inputs` context exists only on `workflow_dispatch`, so on a | |
| # `schedule` run it is null — falsy, and therefore 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 == 'push' && needs.lane.outputs.version_on_npm == 'false') || | |
| github.event_name == 'schedule' || | |
| (github.event_name == 'workflow_dispatch' && inputs.refresh_version_pr) | |
| runs-on: ubuntu-latest | |
| steps: | |
| # The queue the `concurrency:` key above cannot provide. GitHub offers exactly two | |
| # behaviours for a contended group — cancel the running one, or discard the pending | |
| # ones — and neither is acceptable on a workflow that publishes. So the ordering is | |
| # done here: a run waits until every release run older than itself has finished, then | |
| # proceeds. Ordering is by `run_number`, a total order, so the oldest run is always | |
| # free to proceed and this cannot deadlock. | |
| # | |
| # It is deliberately FAIL-OPEN. Every exit path is `exit 0`, and the step additionally | |
| # carries `continue-on-error: true`: if the API cannot be read, or an older run is | |
| # wedged past the cap, this step gives up waiting and lets the release proceed. A run | |
| # that never runs is the defect this queue exists to remove — degrading to today's | |
| # unserialised behaviour is the acceptable failure, blocking a publish is not. | |
| - name: Wait for older release runs to finish | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| RUN_NUMBER: ${{ github.run_number }} | |
| # A release job is ~90s of work; this cap is a wedged-run escape hatch, not a | |
| # normal wait. | |
| MAX_WAIT_SECONDS: '900' | |
| POLL_SECONDS: '20' | |
| run: | | |
| set -uo pipefail | |
| # A rename of this file makes the path 404, `gh` exits non-zero, and the step | |
| # fails open below — it does not silently stop serialising while looking green. | |
| runs_path="repos/${GITHUB_REPOSITORY}/actions/workflows/changeset-release.yml/runs?per_page=100" | |
| older_jq='[.workflow_runs[] | select(.status != "completed" and .run_number < '"${RUN_NUMBER}"')] | |
| | .[] | " waiting on run #\(.run_number) (\(.status)) \(.html_url)"' | |
| deadline=$(( $(date +%s) + MAX_WAIT_SECONDS )) | |
| while : ; do | |
| if ! older=$(gh api "${runs_path}" --jq "${older_jq}"); then | |
| echo "::warning::Could not list this workflow's runs, so the release lane is not serialised for this run. Proceeding — this step may delay a release, never block one (objectui#5404)." | |
| exit 0 | |
| fi | |
| count=$(printf '%s\n' "${older}" | grep -c 'waiting on run' || true) | |
| if [ "${count}" -eq 0 ]; then | |
| echo "No older release run is still in flight. Proceeding." | |
| exit 0 | |
| fi | |
| if [ "$(date +%s)" -ge "${deadline}" ]; then | |
| echo "::warning::${count} older release run(s) still unfinished after ${MAX_WAIT_SECONDS}s. Proceeding without waiting further — a release run that never executes is the defect this queue exists to prevent (objectui#5404)." | |
| exit 0 | |
| fi | |
| echo "${count} older release run(s) still in flight:" | |
| printf '%s\n' "${older}" | |
| sleep "${POLL_SECONDS}" | |
| done | |
| - name: Checkout Repo | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| submodules: true | |
| - name: Enable Corepack | |
| run: corepack enable | |
| - name: Verify pnpm version | |
| run: pnpm --version | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: '22.x' | |
| cache: 'pnpm' | |
| registry-url: 'https://registry.npmjs.org' | |
| # ⛔ Do NOT add a lockfile merge-driver `git config` step back here. | |
| # | |
| # There used to be one, and it could never fire. Removed by objectui#6436 | |
| # under the maintainer's ruling of 2026-08-27 ("dead in CI, live for | |
| # contributors"): this job performs NO local merge, so a driver configured | |
| # here had no occasion to run. | |
| # | |
| # ⭐ The MECHANISM is not retired — only this file's dead half is. | |
| # `.gitattributes` still routes `pnpm-lock.yaml` through `merge=pnpm-merge`, | |
| # and `CONTRIBUTING.md` still tells contributors to define that driver | |
| # locally before `git merge upstream/main`. Measured both ways in a scratch | |
| # repo, one variable changed: WITH the attribute the driver fires and the | |
| # lockfile is regenerated; WITHOUT it, nothing else altered, the identical | |
| # merge ends in `CONFLICT (content)` with conflict markers left inside | |
| # `pnpm-lock.yaml`. The mechanism is contributor-facing with zero CI | |
| # consumers, and this file is where the last CI consumer used to be. | |
| # | |
| # The sweep, so nobody re-derives it a fourth time (objectui#6358 and | |
| # objectui#6369 each re-derived it for a different workflow): | |
| # | |
| # Every `git` in this file, enumerated rather than grepped-for-absence: | |
| # `git status --porcelain` (x2), `git checkout -- .` and `git clean -fdq` | |
| # in "Restore the pre-version tree". No `merge`, `rebase`, `pull`, | |
| # `cherry-pick`, `am`, `apply` or `revert` anywhere in the file — each of | |
| # those zero-hits was taken with a control term that DID hit the same | |
| # file, because a zero-hit with no control is not a reading. | |
| # | |
| # `git checkout -- .` (below, in the restore step) takes tracked paths | |
| # from the INDEX to undo what `pnpm changeset:version` wrote. That is a | |
| # discard, not a merge; no merge driver participates. | |
| # | |
| # ⭐ The load-bearing part is not in this file at all — it is what | |
| # `changesets/action@v1` does, and it has to be read to be known. Read at | |
| # v1.9.0 (`a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d`), `src/git.ts` and the | |
| # executed `dist/` bundle agreeing: the action's COMPLETE git surface is | |
| # `checkout <branch>` / `checkout -b`, `reset --hard <sha>`, `add .`, | |
| # `commit -m`, `push origin HEAD:<branch> --force`, `push origin <tag>` | |
| # and `config user.*`. The version-branch update is `prepareBranch` | |
| # (checkout, then `reset --hard github.context.sha`) followed by a commit | |
| # and a FORCE-PUSH. It never merges, rebases or three-ways anything, so on | |
| # this side too the driver had no occasion to fire. (Its `commitMode: | |
| # github-api` path — not selected here; the default is `git-cli` — builds | |
| # the commit through the GitHub API instead, and the bundle reads no | |
| # `.gitattributes` and implements no merge driver at all.) | |
| # | |
| # ⚠️ Re-adding the step reddens `ci-cd-pipeline-doc.test.ts`, which now | |
| # asserts that ZERO workflows configure the driver — in both directions. If | |
| # some future job really does merge locally on the runner, it needs the step | |
| # AND a row in the "Lockfile Merge Driver" table of | |
| # `content/docs/guide/ci-cd-pipeline.md`; the pin fails otherwise. Note that | |
| # pin greps workflow files for the config key, so even NAMING the key in a | |
| # comment here trips it — deliberately the safe direction (a false red, never | |
| # a false green), which is why this comment spells the attribute form. | |
| # ── Cache bookkeeping cannot void a recorded verdict (objectui#7048) ── | |
| # THE ORDERING, carried here from the type-check split in `ci.yml` | |
| # (objectui#6577, PR #7047) because it is what a future reader needs in | |
| # order to judge whether these steps may be touched: the verdict is | |
| # recorded by the checking steps; everything after them is bookkeeping, | |
| # and bookkeeping must never discard an answer the gate already produced. | |
| # In this job the answer at stake is the publish/refresh outcome and the | |
| # post-version tree validation below it. | |
| # | |
| # Why the SPLIT rather than a timeout on one step: combined | |
| # `actions/cache` declares `main: dist/restore/index.js` plus | |
| # `post: dist/save/index.js`, so its save is a step the RUNNER generates | |
| # at job end (`Post Turbo Cache`). No workflow syntax attaches | |
| # `timeout-minutes` or `continue-on-error` to a generated post step. | |
| # Measured once, on `ci.yml`'s type-check cache: a 1-second save took | |
| # 789s and ran that job into its ceiling (objectui#6577). | |
| # `actions/cache`'s own `save-always` deprecation text points at this | |
| # same split. | |
| # | |
| # ⚠️ This job declares NO `timeout-minutes`, so its ceiling is GitHub's | |
| # default of 360 minutes — and this is the PUBLISH lane, where a job that | |
| # hangs after `Publish to npm` has already shipped and the concurrency | |
| # note above records what a long-running release job does to the pushes | |
| # behind it. ⛔ Adding a ceiling is NOT the fix and is not attempted here: | |
| # the release act must never be truncated by a clock. The bound below is | |
| # on the bookkeeping step alone, which runs after every release step. | |
| # | |
| # The restore half is deliberately left UNBOUNDED: a restore stall fails | |
| # BEFORE anything is published, which is the honest failure. | |
| - name: Restore Turbo Cache | |
| id: turbo-cache | |
| uses: actions/cache/restore@v6 | |
| with: | |
| path: .turbo/cache | |
| key: turbo-${{ runner.os }}-${{ github.sha }} | |
| restore-keys: | | |
| turbo-${{ runner.os }}- | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| # ⛔ Do not add a `pnpm test` step back here. | |
| # | |
| # There used to be one, and it was 31m19s of a 33m22s job (medians over the 106 runs | |
| # of #3712-#3911 that executed; everything else combined is ~90s — install 6s, build | |
| # 35s, the changesets action 24s). It re-tested a commit that is ALREADY on `main`, so | |
| # it could not keep anything out; the only thing it could do was stop the release | |
| # afterwards, and it did: 2 of the last 27 release-PR merges (runs #3606 and #3901) | |
| # failed here and skipped the changesets step entirely. #3606 was the run that would | |
| # have published @object-ui/*@17.5.0, which reached npm 6h50m late, on a later push | |
| # that happened to still find `.changeset/` empty. | |
| # | |
| # The backstop for `main` is `ci.yml`'s push lane, which runs the whole suite under | |
| # coverage across four shards on every push and enforces the thresholds on the merged | |
| # report (objectui#5403). That is the copy that tests this commit; this one only ever | |
| # duplicated it, ~34 minutes later. | |
| - name: Build packages | |
| run: pnpm build | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # One changesets step per lane, and each one CANNOT do the other's job. | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # These were a single step carrying both `version:` and `publish:`, which | |
| # let repository state pick the behaviour (the algorithm is quoted in this | |
| # file's header). Splitting them is what makes each lane's limit structural | |
| # instead of conditional: the refresh step has no `publish:` input, so no | |
| # state — not even a `.changeset/` that has just been emptied by a merge | |
| # racing the tick — can make a scheduled run publish. An `if:` cannot say | |
| # that; an absent input can. | |
| # | |
| # The `id:`s are for the log, nothing reads them. | |
| # | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # ⛔ THE `@v1` PIN IS LOAD-BEARING. READ THIS BEFORE BUMPING IT. | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # `changesets/action@v1` is pinned because two lanes in this file are | |
| # built on properties asserted against **v1's source**, not because anyone | |
| # prefers an old version. Dependabot has proposed the v1 -> v2 bump in both | |
| # repositories and it was declined in both, deliberately and on the same | |
| # reasoning: objectstack#9208 (closed unmerged 2026-08-19) and | |
| # objectui#4945 (closed unmerged 2026-08-23). Neither was merged; both | |
| # repositories stay on v1. | |
| # | |
| # A bump is not forbidden — it is UNVERIFIED. What follows is what a future | |
| # upgrade has to re-establish, written here because the next reader stands | |
| # here and not in a closed PR. | |
| # | |
| # THE TWO PROPERTIES AN UPGRADE MUST RE-VERIFY | |
| # -------------------------------------------- | |
| # (1) THE DISPATCH TABLE. `!hasChangesets && hasPublishScript -> | |
| # runPublish` (quoted in full in this file's header, from v1's | |
| # `src/index.ts`). BOTH the `lane` job's predicate and the "Clear | |
| # pending changesets" step exist only because the action chooses its | |
| # branch from repository state rather than from an input. If v-next | |
| # takes an explicit instruction instead, the clear step is no longer a | |
| # workaround to keep — it is dead weight to delete, and keeping it | |
| # would be the bug. | |
| # | |
| # (2) `runPublish` NEVER COMMITS AND NEVER PUSHES A BRANCH. Verified in | |
| # v1's `src/run.ts`: `runPublish` spans lines 83-198 and the only git | |
| # operation inside it is `git.pushTag()` (lines 124 and 146); | |
| # `git.prepareBranch()` (line 290) and `git.pushChanges()` (line 362) | |
| # occur solely inside `runVersion`. THIS is what makes the clear | |
| # step's runner-local deletion safe. ⚠️ If that ever stops holding, | |
| # that step stops being a local trick and becomes a commit that | |
| # DELETES EVERY PENDING CHANGESET IN THE REPOSITORY — ~161 files at | |
| # the time of writing. Re-verify it by reading the source, not by | |
| # reading release notes. | |
| # | |
| # WHAT v2 ACTUALLY CHANGES (read from its CHANGELOG, not from compiled | |
| # source; every item below was checked against the v2.0.0 entry) | |
| # --------------------------------------------------------------------- | |
| # - #681 / #668 — INPUTS RENAMED AND KEBAB-CASED: `version` -> | |
| # `version-script`, `publish` -> `publish-script`, `commit` -> | |
| # `commit-message`, `title` -> `pr-title` (also `branch` -> | |
| # `pr-base-branch`, which this file does not use). This file passes all | |
| # four of the old names. Under v2 they are simply unknown inputs, and | |
| # the two failures are both SILENT: the publish lane loses its | |
| # `publish:` and stops publishing, and the refresh lane loses its | |
| # `version:` and falls back to bare `changeset version` — which skips | |
| # `scripts/sync-quick-reference-release.mjs` and breaks objectui#5394. | |
| # (`scripts/__tests__/sync-quick-reference-release.test.ts` pins the | |
| # `version:` line and would catch the rename, so that half fails loudly | |
| # at least in CI.) | |
| # - #692 — RELEASE COMMITS AND TAGS ARE PUSHED VIA THE GitHub API BY | |
| # DEFAULT (`commit-mode` replaced by a boolean `push-with-git-cli`). | |
| # ⚠️ This is the one that undercuts property (2): that verification is | |
| # about v1's git-CLI path. An API-based push is a different code path | |
| # and property (2) must be re-established against it from scratch. | |
| # - #674 — the `GITHUB_TOKEN` ENVIRONMENT VARIABLE IS NO LONGER ACCEPTED; | |
| # a custom token must go through the `github-token` input. Both steps | |
| # below pass it via `env:`. | |
| # - #695 — `.npmrc` HANDLING REMOVED when `NPM_TOKEN` is set; npm auth is | |
| # expected via Trusted Publishing or `actions/setup-node`'s | |
| # `registry-url`. This job does run `actions/setup-node` with | |
| # `registry-url`, so that half may already be satisfied — but it is | |
| # `NODE_AUTH_TOKEN` that such a setup consumes, and the interaction has | |
| # to be checked rather than assumed. | |
| # - #678 — PUBLISHED-PACKAGE DETECTION MOVED off stdout parsing onto a | |
| # shared output file named by a `CHANGESETS_OUTPUT` environment | |
| # variable, which custom scripts must forward to the Changesets CLI. | |
| # `pnpm changeset:publish` is a custom script | |
| # (`check-published-dist-tooling.mjs && changeset publish`), so it would | |
| # have to forward it or git tags and GitHub releases stop being created | |
| # (#697 downgrades that to a warning rather than an error — i.e. it | |
| # fails QUIETLY, which is this card's whole subject matter). | |
| # | |
| # ⛔ DO NOT WRITE "we are held back by the Changesets CLI generation." | |
| # That is the plausible wrong answer. v2's #699 validates that projects use | |
| # Changesets CLI v3 and directs CLI **v2** users to `changesets/action@v1` | |
| # — and this repository is already on v3: `package.json` declares | |
| # `@changesets/cli: ^3.0.0` and `pnpm-lock.yaml` resolves it to 3.0.1 | |
| # (`../objectstack` declares `^3.0.0` too). Measured, not assumed. The CLI | |
| # generation is NOT what pins us; the two properties above are. | |
| # | |
| # Worth knowing rather than acting on: v2's #656 adds `/select-mode`, | |
| # `/version` and `/publish` SUB-ACTIONS. A dedicated publish sub-action is | |
| # plausibly the shape that makes the clear step unnecessary — which is an | |
| # argument for revisiting the pin eventually, not for bumping it blind. | |
| # | |
| # ⛔ WHAT NOTICES A BUMP. This `@v1` ref is pinned by exactly one thing: | |
| # `scripts/__tests__/changeset-release-action-ref-pin.test.ts`. It fails on | |
| # a bump of EITHER `uses: changesets/action@v1` line in this file and names | |
| # the two properties above as what a bump must re-establish. There is no | |
| # second guard — the type checker and the linters do not read this file at | |
| # all — so ⛔ do not delete that test as collateral of some other cleanup. | |
| # | |
| # It is deliberately a pin on the REF and not a copy of the action's | |
| # behaviour. objectui#5775 tried the other shape: the `lane` job carried a | |
| # bash mirror of the action's file scan, measured against BOTH readers and | |
| # found at different majors (this action bundles `@changesets/read@^0.6.7`, | |
| # the repository installs `1.0.0`, and they disagree on `AGENTS.md` / | |
| # `CLAUDE.md` / `GEMINI.md` and on `.changeset/pre/`). objectui#6081 | |
| # deleted that mirror, because the bundled copy is a minified chunk behind | |
| # a MOVING tag and cannot be pinned from here at any effort — a retag of | |
| # `v1` in place changed it and nothing here failed. A ref pin cannot catch | |
| # a retag either, but it does not pretend to: it catches the DELIBERATE | |
| # bump, which is the only moment anyone can act on. | |
| # ⚠️ THE STEP THAT LETS THE PREDICATE REACH THE ACTION (objectui#5442). | |
| # | |
| # The job guard above no longer says anything about `.changeset/` — it says | |
| # the declared version is not on npm. But `changesets/action@v1` still | |
| # picks its own branch from `hasChangesets`, so on a release-merge commit | |
| # carrying the NEXT version's changesets it would take `runVersion` and | |
| # publish nothing. That is objectui#5442's defect arriving through the | |
| # action instead of through the job guard, and no `if:` can talk the action | |
| # out of it. A tree with nothing pending can. | |
| # | |
| # ⛔ Nothing is committed and nothing is pushed. That is verified against | |
| # the action's source rather than assumed: `runPublish` (`src/run.ts`) runs | |
| # the publish script, then for each package it published pushes a TAG and | |
| # creates a GitHub release. It never commits, and never pushes a branch. So | |
| # this deletion cannot leave the runner, and `.changeset/` on `main` is | |
| # untouched — those changesets are still owed to the next version PR, which | |
| # the 6-hourly refresh lane will render as usual. | |
| # | |
| # This is deliberately a SUPERSET of what the action counts, and it is NOT | |
| # a second copy of the `lane` job's mirror. The mirror's job is to PREDICT | |
| # the action's branch, and predicting requires agreeing exactly. This step | |
| # has a one-directional obligation — leave the count at zero — so removing | |
| # more than the action would count is safe, and it is checked directly | |
| # below instead of being reasoned about. Keeping only `README.md` covers | |
| # every name the reader ignores (`README.md` case-insensitively, plus | |
| # `AGENTS.md` / `CLAUDE.md` / `GEMINI.md`) as well as `.changeset/pre/*.md`, | |
| # without owning a list that has to track theirs. | |
| # | |
| # `.changeset/config.json` is NOT touched: `changeset publish` reads the | |
| # `fixed` group from it, and so does `check-published-dist-tooling.mjs`, | |
| # which `pnpm changeset:publish` runs first. | |
| # | |
| # ⛔ THE `github.event_name == 'push'` TEST IS LOAD-BEARING. DO NOT DROP IT. | |
| # | |
| # This guard used to carry a second half that read the `lane` job's | |
| # `pending_changesets` output. objectui#6081 deleted that half — the | |
| # step is idempotent and a measured no-op on an empty tree, so predicting | |
| # whether it has work was buying nothing at the price of a mirror of | |
| # another project's file scan (see the `lane` job's report step). | |
| # | |
| # ⚠️ The EVENT half is a different thing entirely and dropping it kills the | |
| # release SILENTLY. On the refresh lane this step would empty the tree | |
| # before the action runs, so `hasChangesets` is false — and the refresh | |
| # step passes no `publish:` input, so `hasPublishScript` is false too. | |
| # v1 then takes `case !hasChangesets && !hasPublishScript` (`src/index.ts` | |
| # line 68), logs "No changesets present or were removed by merging release | |
| # PR", and RETURNS. The 6-hourly refresh becomes a permanent no-op and the | |
| # standing "chore: release packages" PR fossilises forever, with nothing | |
| # failing anywhere. That is this file's own failure class, on the other | |
| # lane. What objectui#6081 deleted is the PREDICTION, not the event scoping. | |
| # | |
| # ⚠️ ONE CASE WHERE THIS STEP IS NOT LITERALLY A NO-OP — dormant here, real | |
| # later. In PRE mode, the ids already listed in `pre.json.changesets` are | |
| # consumed records; the deleted mirror filtered them out and this step does | |
| # not, so it removes them (measured: `Removed 2`) while `pre.json` itself | |
| # SURVIVES, so pre mode is still detected. Harmless on three independent | |
| # grounds — the deletion is runner-local (property (2) above), the next | |
| # `changeset version` deletes those records anyway, and this repository has | |
| # no `pre.json` and no `.changeset/pre/` directory today. Written down | |
| # because a pre-release is precisely when nobody wants to rediscover it. | |
| - name: Clear pending changesets for the publish branch | |
| if: github.event_name == 'push' | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| # `/^README\.md$/i` is how `@changesets/read` spells its exclusion, so | |
| # the comparison is case-folded rather than matching one spelling. | |
| keep_this() { | |
| [ "$(printf '%s' "${1##*/}" | tr '[:upper:]' '[:lower:]')" = 'readme.md' ] | |
| } | |
| removed=0 | |
| for file in .changeset/*.md .changeset/pre/*.md; do | |
| if keep_this "$file"; then continue; fi | |
| rm -f "$file" | |
| removed=$((removed + 1)) | |
| done | |
| echo "Removed ${removed} pending changeset file(s) from the runner's working tree." | |
| left=0 | |
| for file in .changeset/*.md .changeset/pre/*.md; do | |
| if keep_this "$file"; then continue; fi | |
| echo "still pending: ${file}" | |
| left=$((left + 1)) | |
| done | |
| if [ "${left}" -ne 0 ]; then | |
| echo "::error::${left} changeset file(s) survived the clear step, so changesets/action will take its runVersion branch and publish nothing (objectui#5442)." | |
| exit 1 | |
| fi | |
| # PUBLISH. Reachable only from `push`. Between the job guard (the declared | |
| # version is not on npm) and the step above (nothing pending in the tree), | |
| # the action takes its `!hasChangesets && hasPublishScript` branch, which is | |
| # `runPublish`. No `version:` here: it would be dead configuration, and a | |
| # reader is better served by a step that carries only what it can use. | |
| # | |
| # ⚠️ Do not mistake that for a guarantee. Omitting `version:` does NOT | |
| # disable the refresh branch — the action falls back to plain | |
| # `changeset version` — so what keeps THIS step from force-pushing the | |
| # version PR is now the clear step above, and only that. If you ever drop | |
| # or weaken it, this step starts refreshing the PR again, and the release | |
| # goes back to being skipped exactly as objectui#5442 describes. | |
| - name: Publish to npm | |
| id: changesets-publish | |
| if: github.event_name == 'push' | |
| uses: changesets/action@v1 | |
| with: | |
| publish: pnpm changeset:publish | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| NPM_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # THE LOUD CHECK (objectui#5442, ruled alongside the predicate) | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # The defect this closes was never a red run — it was a GREEN one. Run 3370 | |
| # on `cfeb378b5` (#3598) completed `success` having published nothing, | |
| # purely because three changesets were pending; 17.4.0 was skipped, the next | |
| # version PR bumped past it, and only an audit of CHANGELOG-against-registry | |
| # noticed, 16 versions later. A publish lane whose failure mode is a green | |
| # tick is not a publish lane, so this one is not allowed to finish green | |
| # without the version it exists to ship being on npm. | |
| # | |
| # There is no innocent reading of a failure here: the job only reached this | |
| # step because the `lane` job said this version was ABSENT from npm. | |
| # | |
| # Scope, so it is not mistaken for more. This asserts the fixed group's | |
| # anchor, and that is enough because the OTHER failure shapes are already | |
| # red without it: a per-package publish failure makes `changeset publish` | |
| # exit non-zero and the action forwards that exit code | |
| # (`process.exit(result.exitCode)` in its `src/index.ts`). What only this | |
| # step can catch is the lane publishing NOTHING while reporting success. | |
| # | |
| # The retries are for registry propagation, not for flakiness: `npm publish` | |
| # returns before the version is readable through every CDN edge. An | |
| # unreadable registry is a failure too — a release nobody can confirm is not | |
| # a confirmed release. | |
| - name: Verify the release reached npm | |
| if: github.event_name == 'push' | |
| env: | |
| ANCHOR_PKG: '@object-ui/core' | |
| ANCHOR_MANIFEST: packages/core/package.json | |
| EXPECTED: ${{ needs.lane.outputs.manifest_version }} | |
| ATTEMPTS: '10' | |
| BACKOFF_SECONDS: '15' | |
| run: | | |
| set -euo pipefail | |
| # The lane job read the anchor from a sparse checkout of the same sha; | |
| # if the two disagree the predicate was computed against a different | |
| # tree than the one just published, and nothing below means what it says. | |
| from_tree=$(jq -r '.version // empty' "$ANCHOR_MANIFEST") | |
| if [ "${from_tree}" != "${EXPECTED}" ]; then | |
| echo "::error::The lane job keyed on ${ANCHOR_PKG}@${EXPECTED} but this checkout declares ${from_tree}. The publish decision was made against a different tree (objectui#5442)." | |
| exit 1 | |
| fi | |
| encoded=$(printf '%s' "$ANCHOR_PKG" | sed 's|/|%2F|') | |
| url="https://registry.npmjs.org/${encoded}/${EXPECTED}" | |
| for attempt in $(seq 1 "$ATTEMPTS"); do | |
| code=$(curl -sS -o /dev/null -w '%{http_code}' \ | |
| -H 'Cache-Control: no-cache' --max-time 20 "$url" 2>/dev/null) || code='000' | |
| if [ "${code}" = '200' ]; then | |
| echo "${ANCHOR_PKG}@${EXPECTED} is on npm." | |
| echo "- published: \`${ANCHOR_PKG}@${EXPECTED}\`" >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| echo "attempt ${attempt}/${ATTEMPTS}: registry answered ${code} for ${url}" | |
| # An explicit `if`, not `[ ... ] && sleep`: bash exempts AND-lists | |
| # from `set -e`, so the short form works, but it works for a reason | |
| # a reader has to already know. | |
| if [ "${attempt}" -lt "${ATTEMPTS}" ]; then sleep "$BACKOFF_SECONDS"; fi | |
| done | |
| echo "::error::${ANCHOR_PKG}@${EXPECTED} is STILL not on npm after a publish run that reported success. The repository declares a version the registry has never seen — this is objectui#5442's silent failure, made loud. Check the 'Publish to npm' step's log for which branch changesets/action took." | |
| exit 1 | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # POST-VERSION VALIDATION — THE ONLY GATE THE RELEASE COMMIT EVER PASSES | |
| # (objectui#5397) | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # The release PR runs NO CI, and cannot be made to. Re-measured | |
| # 2026-08-25: `ci.yml` has 849 runs on `changeset-release/main` and every | |
| # recent one is `action_required` with | |
| # `created_at == run_started_at == updated_at` — created and immediately | |
| # parked, nothing executed. GitHub does not start workflow runs from | |
| # events raised by `GITHUB_TOKEN`, and the refresh step below force-pushes | |
| # that branch, so there is no stable head for a human to re-run against | |
| # either. On the 17.6.0 release PR the check-runs endpoint returned | |
| # `total_count: 1` — one job, started 7 SECONDS AFTER the merge. The | |
| # largest machine-generated diff of the week is therefore the only commit | |
| # that reaches `main` without passing the merge queue. | |
| # | |
| # Maintainer ruling 2026-08-22 (「接受所有」): validate the POST-VERSION | |
| # tree HERE, before the action opens or updates the PR. Not by giving the | |
| # PR a PAT / GitHub App token (widens the supply-chain trust surface and | |
| # adds rotation obligations), and not by guaranteeing outputs one artifact | |
| # at a time (objectui#5396 saved exactly one doc row and did not | |
| # generalise). The ruling attached a condition — PRICE IT FIRST — and the | |
| # price is what chose the shape below. | |
| # | |
| # WHY THIS IS NOT `pnpm test` (measured 2026-08-25, not argued) | |
| # ------------------------------------------------------------ | |
| # The naive reading of the ruling is "run the suite on the post-version | |
| # tree". It is the wrong shape, because THE VERSION STEP CANNOT MOVE A | |
| # SOURCE BYTE. Rendered against this repository's real tree (328 pending | |
| # changesets, 17.6.0 -> 17.7.0) `pnpm changeset:version` touches 411 paths | |
| # and every one of them is one of four kinds: | |
| # | |
| # 330 .changeset/*.md deleted (consumed by the version) | |
| # 40 */package.json the `"version"` key and NOTHING else — | |
| # 80 changed lines, all of them `"version":` | |
| # 40 */CHANGELOG.md generated from the changeset bodies | |
| # 1 QUICK_REFERENCE.md rewritten by `changeset:version`'s | |
| # `sync-quick-reference-release.mjs` | |
| # | |
| # Zero `.ts`, zero `.tsx`, zero config. The source in the post-version | |
| # tree is byte-identical to the `main` commit this run checked out, and | |
| # `ci.yml`'s push lane already ran the whole suite over that commit under | |
| # coverage across four shards. A `pnpm test` here would re-test bytes that | |
| # were tested ~13 min ago and would report nothing new — at 40 minutes a | |
| # go (`ci.yml` measured its own unsharded suite at 39 min 51 s of tests / | |
| # 40 min 19 s of job), four times a day on the cron below, i.e. ~2.7 h of | |
| # runner time daily for a PR nobody reads until release day. That is the | |
| # cost objectstack#10850 was closed to REMOVE, walking back in. | |
| # | |
| # So the validation is scoped to what the diff can actually move, and the | |
| # union — `ci.yml` on the source, these steps on the version outputs — is | |
| # the whole post-version tree. | |
| # | |
| # pnpm quick-reference:check QUICK_REFERENCE.md ~1 s | |
| # pnpm check:control-bytes the 40 generated CHANGELOGs ~4 s | |
| # pnpm test scripts/__tests__ 73 files / 1996 tests ~50 s | |
| # | |
| # The tooling suite is named as a DIRECTORY rather than as a list of test | |
| # files, deliberately: every test in this repository that reads a | |
| # manifest version, `QUICK_REFERENCE.md` or a `CHANGELOG.md` lives in it | |
| # (measured — 7 of 7 matches), `doc-version-claims.test.ts` among them, | |
| # and a list would go stale the first time someone adds an eighth. | |
| # Measured green on a real rendered tree: 1 s + 4 s + 50 s. | |
| # | |
| # ⛔ WHAT IS DELIBERATELY NOT RUN, AND WHY IT IS NOT AN OMISSION. | |
| # `check:spec-floors` and `check:published-dist` read dependency RANGES | |
| # and built `dist/` output. The measurement above is what rules them out: | |
| # no range moved (the only changed key is `"version"`) and no source moved, | |
| # so on this tree they can only restate the verdict they gave on `main`. | |
| # `check:published-dist` also costs a full build (8 m 39 s cold, measured), | |
| # and `pnpm changeset:publish` runs both of them first on the publish lane | |
| # anyway, where the tree they judge is the one being shipped. | |
| # | |
| # ⛔ WHY THERE IS NO "ONLY WHEN THE PR CONTENT CHANGED" CONDITION. That | |
| # layer was the other half of the ruling's condition, and the measurement | |
| # retired it: across the seven consecutive 6-hourly windows from | |
| # 2026-08-23T06:08Z to 2026-08-25T00:10Z, SIX carried new changeset files | |
| # (median 18 per window; 219, 46, 23, 18, 13, 9, and one zero). This | |
| # repository lands ~18 merges a working day and most carry a changeset, so | |
| # a content-change predicate would skip about one refresh in seven — it | |
| # would buy ~14% of 70 seconds while adding exactly the thing | |
| # objectui#6081 just deleted from this file: a local prediction of another | |
| # project's state that is free to be wrong silently. The cost was solved | |
| # by SCOPE, so frequency does not need solving. | |
| # | |
| # FAILURE SEMANTICS. A red validation fails the job and the refresh step | |
| # below never runs, so the standing PR keeps its last VALIDATED content | |
| # instead of being force-pushed to a broken one. If the post-version tree | |
| # stays broken the PR goes stale — but loudly, on a red run every six | |
| # hours, which is the opposite of this card's defect. Nothing here can | |
| # block the publish lane: every step is scoped to `schedule` / | |
| # `workflow_dispatch`, and `push` skips all three. | |
| # | |
| # ⚠️ THE RESTORE STEP IS LOAD-BEARING — DO NOT DROP IT. These steps render | |
| # the version into the runner's working tree, and `changesets/action` | |
| # picks its branch from that tree: with `.changeset/` already consumed it | |
| # would find `hasChangesets` false, and with no `publish:` input on the | |
| # refresh step `hasPublishScript` is false too, so v1 takes | |
| # `case !hasChangesets && !hasPublishScript` (`src/index.ts` line 68), | |
| # logs "No changesets present or were removed by merging release PR", and | |
| # RETURNS. The refresh becomes a permanent no-op and the PR fossilises | |
| # with nothing failing anywhere — this file's own failure class, one lane | |
| # over. So the tree is put back and the restoration is ASSERTED rather | |
| # than assumed, in the same shape the clear step asserts its own work. | |
| # The action then runs `pnpm changeset:version` itself, on the inputs | |
| # these steps measured, and it — not this job — owns the commit and the | |
| # push. | |
| - name: Render the post-version tree | |
| id: post_version | |
| if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| # `/^README\.md$/i` is how `@changesets/read` spells its exclusion, so | |
| # the comparison is case-folded rather than matching one spelling. | |
| count_pending() { | |
| local n=0 file | |
| for file in .changeset/*.md .changeset/pre/*.md; do | |
| if [ "$(printf '%s' "${file##*/}" | tr '[:upper:]' '[:lower:]')" = 'readme.md' ]; then | |
| continue | |
| fi | |
| n=$((n + 1)) | |
| done | |
| printf '%s' "$n" | |
| } | |
| pending_before=$(count_pending) | |
| echo "pending_before=${pending_before}" >> "$GITHUB_OUTPUT" | |
| pnpm changeset:version | |
| changed=$(git status --porcelain | wc -l | tr -d ' ') | |
| { | |
| echo "- post-version tree rendered from \`${GITHUB_SHA}\`" | |
| echo "- pending changesets consumed: \`${pending_before}\`" | |
| echo "- paths the version step moved: \`${changed}\`" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Validate the post-version tree | |
| if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' | |
| run: | | |
| set -euo pipefail | |
| pnpm quick-reference:check | |
| pnpm check:control-bytes | |
| pnpm test scripts/__tests__ | |
| - name: Restore the pre-version tree | |
| if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') | |
| env: | |
| PENDING_BEFORE: ${{ steps.post_version.outputs.pending_before }} | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| if [ -z "${PENDING_BEFORE}" ]; then | |
| echo "The render step did not run, so there is nothing to restore." | |
| exit 0 | |
| fi | |
| git checkout -- . | |
| # Narrow on purpose: the version step can only create files beside a | |
| # package manifest, and a `git clean` in a job that also publishes | |
| # should never be able to reach further than the thing it undoes. | |
| git clean -fdq -- packages apps examples .changeset | |
| count_pending() { | |
| local n=0 file | |
| for file in .changeset/*.md .changeset/pre/*.md; do | |
| if [ "$(printf '%s' "${file##*/}" | tr '[:upper:]' '[:lower:]')" = 'readme.md' ]; then | |
| continue | |
| fi | |
| n=$((n + 1)) | |
| done | |
| printf '%s' "$n" | |
| } | |
| pending_after=$(count_pending) | |
| # `--untracked-files=no`: the build that ran earlier in this job | |
| # leaves its own untracked output around, and none of it is part of | |
| # what the version step moved or of what the action reads. Tracked | |
| # state plus the pending count is the whole restoration. | |
| dirty=$(git status --porcelain --untracked-files=no | wc -l | tr -d ' ') | |
| if [ "${pending_after}" != "${PENDING_BEFORE}" ] || [ "${dirty}" -ne 0 ]; then | |
| echo "::error::The pre-version tree was not restored (${PENDING_BEFORE} changesets before, ${pending_after} after; ${dirty} tracked path(s) still modified). changesets/action would read this tree, find nothing pending and return without refreshing the PR — a silent permanent no-op (objectui#5397)." | |
| exit 1 | |
| fi | |
| echo "Pre-version tree restored: ${pending_after} pending changeset(s), working tree clean." | |
| # REFRESH. No `publish:` input, so `runPublish` is unreachable; no npm | |
| # credentials in `env:`, so it is unreachable a second time over. Both are | |
| # deliberate — this lane runs unattended on a clock, and the release act in | |
| # this repository is a human merging the PR it regenerates. | |
| # | |
| # `version:` must stay `pnpm changeset:version` rather than bare | |
| # `changeset version`: the npm script also runs | |
| # `scripts/sync-quick-reference-release.mjs`, which is the only thing that | |
| # keeps `QUICK_REFERENCE.md` from fossilising at release time | |
| # (objectui#5394). `scripts/__tests__/sync-quick-reference-release.test.ts` | |
| # reads this very line and fails if it changes. | |
| # | |
| # ⛔ `@v1` is pinned deliberately here too — see "THE `@v1` PIN IS | |
| # LOAD-BEARING" above the publish step before changing it. | |
| - name: Refresh the version PR | |
| id: changesets-version | |
| if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' | |
| uses: changesets/action@v1 | |
| with: | |
| version: pnpm changeset:version | |
| title: 'chore: release packages' | |
| commit: 'chore: release packages' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # The bookkeeping half of the split documented at the restore step above, | |
| # placed HERE — last in the job, after publish, after the post-version | |
| # tree validation and after the refresh — because that is exactly where | |
| # the post phase it replaces already ran. Nothing this job publishes, | |
| # validates or restores moves with it, and no step above it is touched. | |
| # | |
| # `timeout-minutes: 5` is DERIVED, and the derivation is weaker here than | |
| # at the other six sites — said plainly rather than dressed up. | |
| # objectui#7048 fences inheriting PR #7047's 5, so: this cache is | |
| # `.turbo/cache` under the same `turbo-${{ runner.os }}-${{ github.sha }}` | |
| # key shape as `lint.yml` and `performance-budget.yml`, and the saves of | |
| # THAT cache measured 1s, 2s, 5s and 6s across four jobs on 2026-09-02 | |
| # (`Bundle Analysis` 100096569559, `Type Check` 100096569872, | |
| # `Build Docs` 100094597253, `Lint` 100097466846). A same-SITE save was | |
| # not observable in the sampled runs: this job runs on `push` to `main` | |
| # only when the version is not yet on npm, and on the 6-hourly `schedule` | |
| # the primary key is an exact hit, so `Post Turbo Cache` measured 0s on | |
| # the sampled scheduled run (job 99021319583, 2026-08-29) with its | |
| # restore at 4s. 5 minutes is 50x the slowest of the four same-cache | |
| # saves; if a directly measured save of this site ever lands and lies | |
| # outside that range, re-derive rather than defend this number. | |
| # | |
| # `continue-on-error: true` is the other half. A cache that failed to | |
| # upload costs the next run some build time; it says nothing about | |
| # whether the release succeeded, so it must not be able to redden a job | |
| # that has already published. | |
| # | |
| # Behaviour preserved, spelled out so the equivalence is checkable: | |
| # - `cache-hit != 'true'` reproduces the combined action's own "exact | |
| # hit on the primary key ⇒ do not save" skip — which is precisely the | |
| # path the scheduled run above took. | |
| # - the condition names no status function, so the implicit `success()` | |
| # still applies — matching the combined action's `post-if: success()`. | |
| # - same `path` and same `key` as the restore step above. | |
| - name: Save Turbo Cache | |
| if: steps.turbo-cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@v6 | |
| timeout-minutes: 5 | |
| continue-on-error: true | |
| with: | |
| path: .turbo/cache | |
| key: turbo-${{ runner.os }}-${{ github.sha }} |