From f537de3e61019fe7ce53711ec4efdccbf7d0a4ad Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sun, 23 Aug 2026 06:27:56 +0000 Subject: [PATCH 1/2] fix(release): key the publish lane on npm presence, not on pending changesets (#5442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish half of `changeset-release.yml` asked "are changesets pending?" while being read as if it asked "did this version ship?". The two come apart on every release: the version PR is cut from `main` at T and merged at T+n, `main` takes ~18 merges a working day, and the merge does not remove the changesets that landed in between. Those belong to the next version — but keyed on them, the version this commit just bumped to is skipped and the next version PR bumps past it. Measured on bc21c704b: 16 of the 90 versions `packages/core/CHANGELOG.md` declares never reached npm, and the repo says 17.6.0 where `dist-tags.latest` says 17.5.0. The `lane` job now also answers "is the declared version already on npm?" from the same sparse checkout, and the release job's push half turns on that instead. It is cheaper as well as more correct: an ordinary landing does not move the manifest version, so it skips the expensive job, where the old predicate ran it in full on every landing that happened to find `.changeset/` empty. `changesets/action@v1` picks its branch from repository state, so the publish lane clears pending changesets from the runner's working tree before invoking it — never committed, never pushed. And it ends with a loud check: the defect was a green run that published nothing, so the lane now reads the registry back and fails if the version it exists to ship is still absent. The refresh lane is untouched and still denied publishing twice over: no `publish:` input, no npm credentials. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0124Qg8rLvpXnQDwCmpKUmaJ --- .../npm-presence-publish-predicate-5442.md | 30 ++ .github/workflows/changeset-release.yml | 316 ++++++++++++++++-- content/docs/guide/ci-cd-pipeline.md | 56 +++- 3 files changed, 370 insertions(+), 32 deletions(-) create mode 100644 .changeset/npm-presence-publish-predicate-5442.md diff --git a/.changeset/npm-presence-publish-predicate-5442.md b/.changeset/npm-presence-publish-predicate-5442.md new file mode 100644 index 0000000000..c77030eafe --- /dev/null +++ b/.changeset/npm-presence-publish-predicate-5442.md @@ -0,0 +1,30 @@ +--- +--- + +CI only — this publishes nothing, declared explicitly with an empty frontmatter rather +than left undeclared. + +`.github/workflows/changeset-release.yml` keys its publish lane on **"is the version this +commit declares already on npm?"** instead of on **"are changesets pending?"** +(objectui#5442, maintainer ruling 2026-08-22, Option B). + +The two predicates read as interchangeable and come apart exactly where it costs a +release. The version PR is cut from `main` at T and merged at T+n; `main` takes ~18 +merges a working day and the merge does not remove the changesets that landed in +between. Those belong to the *next* version, but keyed on them the version this commit +just bumped to is skipped, and the next version PR bumps straight past it. Measured on +`bc21c704b`: of the 90 versions `packages/core/CHANGELOG.md` declares, 16 never reached +npm, and the repository said `17.6.0` while `dist-tags.latest` said `17.5.0`. + +The npm predicate is also cheaper than the one it replaces rather than a trade against +it: an ordinary landing does not move the manifest version, so it answers "already +published" and the expensive job is skipped. + +Alongside it, a **loud check**. The defect was never a red run — run 3370 on `cfeb378b5` +completed `success` having published nothing — so the publish lane now reads the registry +back and fails if the version it exists to ship is still absent. A repo/npm divergence is +a failing run, not something an audit finds 16 versions later. + +The refresh lane is untouched and still cannot publish: no `publish:` input and no npm +credentials in its `env:`, the double denial that keeps a scheduled tick from releasing +something nobody merged. diff --git a/.github/workflows/changeset-release.yml b/.github/workflows/changeset-release.yml index 3de3b8fcf1..03ad573702 100644 --- a/.github/workflows/changeset-release.yml +++ b/.github/workflows/changeset-release.yml @@ -68,16 +68,22 @@ name: Changeset Release # # THE LANES # --------- -# push to main, `.changeset/` empty -> PUBLISH. The version PR has just been -# merged; this is the release. Unchanged -# from before, including the retry on a -# later push that still finds it empty, -# which is how @object-ui/*@17.5.0 -# eventually shipped (see the `pnpm test` -# note further down). -# push to main, changesets pending -> NOTHING. The whole release job is -# skipped; the refresh is the clock's -# job now. This is the ruling. +# 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 @@ -86,6 +92,51 @@ name: Changeset Release # someone can get wrong. # dispatch without the input -> NOTHING, loudly. See the input's note. # +# 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 @@ -207,13 +258,20 @@ jobs: contents: read outputs: pending_changesets: ${{ steps.detect.outputs.pending_changesets }} + manifest_version: ${{ steps.npm.outputs.manifest_version }} + version_on_npm: ${{ steps.npm.outputs.version_on_npm }} steps: - # `.changeset/` only — the whole question is answered by that directory, and - # a full checkout of this repository is ~15s that every landing would pay. - - name: Check out .changeset + # 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 + sparse-checkout: | + .changeset + packages/core # ⛔ This must stay a faithful mirror of `readChangesetState` in # `changesets/action`'s `src/index.ts` (it re-exports `@changesets/read`), @@ -281,25 +339,110 @@ jobs: 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 + no pending changesets -> the version PR was merged: PUBLISH. - # push + pending changesets -> skipped; the clock refreshes the PR now. + # 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 `pending_changesets`. That is + # the whole of objectui#5442: pending changesets belong to the next version + # and say nothing about whether THIS one shipped. `pending_changesets` is + # still produced and still load-bearing, but for the step that clears the + # tree for the action — not for deciding 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.pending_changesets == 'false') || + (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 @@ -426,17 +569,80 @@ jobs: # # The `id:`s are for the log, nothing reads them. - # PUBLISH. Reachable only from `push`, and the job guard above has already - # established that this commit carries no pending changesets — so the - # action takes its `!hasChangesets && hasPublishScript` branch, which is + # ⚠️ 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. + - name: Clear pending changesets for the publish branch + if: github.event_name == 'push' && needs.lane.outputs.pending_changesets == 'true' + 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 the job's `needs.lane` guard, and only that. If you ever - # relax that guard, this step starts refreshing the PR again. + # 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' @@ -448,6 +654,72 @@ jobs: 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 + # 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 diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 50d25c38b7..206cac1e1a 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -715,24 +715,60 @@ Uses [Lychee](https://github.com/lycheeverse/lychee) with configuration from `ly Uses [Changesets](https://github.com/changesets/changesets) for automated versioning and npm publishing, in **two lanes that cannot do each other's job**: -| Event | `.changeset/` | What runs | +| Event | Predicate | What runs | |---|---|---| -| Push to `main` | empty | **Publish to npm.** The version PR has just been merged; that merge is the release act. | -| Push to `main` | changesets pending | **Nothing.** The whole release job is skipped. | -| Cron `0 */6 * * *` | either | **Refresh the version PR** ([#5400](https://github.com/objectstack-ai/objectui/pull/5400)) — never publishes. | -| Manual, `refresh_version_pr` checked | either | The same refresh, on demand. | -| Manual, unchecked | either | Nothing; the run says so with a `::notice::`. | +| Push to `main` | declared version **not** on npm | **Publish to npm.** Normally the version-PR merge, which is the release act; also the retry for a release whose own run failed. | +| Push to `main` | declared version already on npm | **Nothing.** Every ordinary landing — a merge that is not a release does not move the version. | +| Cron `0 */6 * * *` | n/a | **Refresh the version PR** ([#5400](https://github.com/objectstack-ai/objectui/pull/5400)) — never publishes. | +| Manual, `refresh_version_pr` checked | n/a | The same refresh, on demand. | +| Manual, unchecked | n/a | Nothing; the run says so with a `::notice::`. | The refresh used to run on **every** push to `main`, which force-pushed the version PR ~18 times a working day while releases are weekly — so its branch CI never converged, and every refresh was a CI run spent on bookkeeping nobody reads until release day -([objectstack#10850](https://github.com/objectstack-ai/objectstack/issues/10850)). A `lane` job -answers "does this commit carry pending changesets?" from a sparse checkout of `.changeset/` -before anything installs or builds, so a landing that owes no publish costs one cheap job. +([objectstack#10850](https://github.com/objectstack-ai/objectstack/issues/10850)). A cheap `lane` +job answers the questions the split turns on from a sparse checkout of `.changeset/` and +`packages/core/`, before anything installs or builds. + +#### The publish lane is keyed on npm, not on `.changeset/` + +The publish half asks **"is the version this commit declares already on npm?"** — not "are +changesets pending?" ([#5442](https://github.com/objectstack-ai/objectui/issues/5442)). The two +read as interchangeable and come apart exactly where it costs a release: the version PR is cut +from `main` at T and merged at T+n, `main` takes ~18 merges a working day, and the merge does not +remove the changesets that landed in between. Those belong to the *next* version — but keyed on +them, the version this commit just bumped to is skipped, and the next version PR bumps straight +past it. Measured when #5442 was fixed: of the 90 versions `packages/core/CHANGELOG.md` declared, +**16 had never reached npm**, and the repository said `17.6.0` while `dist-tags.latest` said +`17.5.0`. + +The npm predicate is also **cheaper** than the one it replaced, rather than a trade against it. An +ordinary landing does not move the manifest version, so it answers "already published" and the +expensive job is skipped — where "no changesets pending" ran the job in full on every landing that +happened to find `.changeset/` empty, only to publish nothing. + +`@object-ui/core` is the version anchor because every package in the `fixed` group of +`.changeset/config.json` moves as one version, so any member answers for the whole release. The +lane **asserts** that membership instead of assuming it, and it refuses to guess a lane if the +registry cannot be read: 200 is published, 404 is not, and anything else fails the run. + +`changesets/action@v1` chooses publish-vs-version from repository state rather than from an input, +so the predicate cannot reach it on its own — with changesets present it would take its version +branch and publish nothing. The publish lane therefore clears the pending `.changeset/*.md` from +the **runner's working tree** before invoking it. Nothing is committed and nothing is pushed +(`runPublish` pushes tags and creates releases; it never commits), so `.changeset/` on `main` is +untouched and those changesets are still owed to the next version PR. + +#### The loud check + +#5442's defect was never a red run — it was a green one: run 3370 on `cfeb378b5` completed +`success` having published nothing, and only a CHANGELOG-against-registry audit noticed, 16 +versions later. So the publish lane now reads the registry back afterwards and **fails** if the +version it exists to ship is still absent. A repo/npm divergence is a failing run, not a finding. The refresh lane is invoked **without** a `publish:` script and **without** npm credentials, so it cannot publish by construction rather than by a condition — the release act in this -repository stays the human merge of the version PR. Step 3 of the publish lane runs +repository stays the human merge of the version PR. The publish lane runs `pnpm changeset:publish`, and that script is `node scripts/check-published-dist-tooling.mjs && changeset publish` — the **blocking** copy of the Published Dist Gate above. A published package whose `dist/` carries tooling material stops From cfbb5f96f298b3172d87510f9177bbbb78b0bbd9 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sun, 23 Aug 2026 08:07:12 +0000 Subject: [PATCH 2/2] docs(release): write the `changesets/action@v1` pin's rationale beside the pin (#5442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⛔ Comments only. This commit is a NO-OP to the workflow's execution: the pinned version, every input, every `if:` and every step body are byte-identical, and `yaml.safe_load` of the file before and after parses to structurally identical objects. Verified mechanically, not by eye. Dependabot proposed changesets/action v1 -> v2 in both repositories and it was declined in both — objectstack#9208 (closed unmerged 2026-08-19) and objectui#4945 (closed unmerged 2026-08-23). objectstack recorded the reasoning in its own repo (objectstack#9916); objectui had no equivalent, and #5442 makes that urgent: before it the pin rested on one v1 property, after it on two. The block states why v1 is pinned (safety properties asserted against v1's source, not a version preference), the two properties an upgrade must re-verify — the `!hasChangesets && hasPublishScript -> runPublish` dispatch table, and that `runPublish` never commits and never pushes a branch — and the five v2 changes readable from its CHANGELOG that would break this file. The second property is the sharp one: it is what makes the clear step's runner-local deletion safe, and v2's #692 moves pushes to the GitHub API by default, which is a different code path than the one that verification covers. It also records the plausible wrong answer explicitly: the Changesets CLI generation is NOT what pins us. v2's #699 directs CLI v2 users to @v1, and this repository is on CLI v3 (`^3.0.0`, lockfile 3.0.1). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0124Qg8rLvpXnQDwCmpKUmaJ --- .github/workflows/changeset-release.yml | 97 +++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/.github/workflows/changeset-release.yml b/.github/workflows/changeset-release.yml index 03ad573702..c6fd373afb 100644 --- a/.github/workflows/changeset-release.yml +++ b/.github/workflows/changeset-release.yml @@ -568,6 +568,100 @@ jobs: # 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. + # + # Same class of drift, and live under any bump: objectui#5775 records that + # the `lane` job's declared mirror of `readChangesetState` already + # understates what the installed `@changesets/read` ignores. Any upgrade + # re-opens that question, because the mirror tracks a bundled copy this + # file does not pin. # ⚠️ THE STEP THAT LETS THE PREDICATE REACH THE ACTION (objectui#5442). # @@ -731,6 +825,9 @@ jobs: # 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'