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..c6fd373afb 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 @@ -425,18 +568,175 @@ 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). + # + # 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." - # 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 + 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 +748,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 @@ -459,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' 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