diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 39d92fb2..4d029822 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,40 +1,13 @@
name: Publish to npm
-# Source-of-truth model (ported from prisma/prisma; see
-# docs/oss/versioning.md):
-# The version comes from the root `package.json` `version` field.
-# Maintainers advance it via `pnpm bump-version`, which writes it
-# across the workspace and is committed. This workflow can never
-# publish a version other than what is committed at HEAD, and never
-# rewrites a manifest to get there.
+# Every run publishes a dev build. A run also publishes a release when
+# the push changed the root `version`, or a dispatch asked for one. The
+# two are not alternatives: when they were, the release commit never
+# reached the dev channel and `dev` sat on an older version than the
+# release. See docs/oss/versioning.md.
#
-# Trigger model:
-# - push to `main` with the root `version` unchanged → publish
-# `-dev.` under the `dev` dist-tag (operator ruling
-# 2026-08-13: a product's new version reaches the CLI and deploys
-# without a human; only a real release needs one). The dev suffix is
-# stamped ephemerally below and never committed.
-# - push to `main` with the root `version` changed → publish ``
-# under its canonical dist-tag — `next` on the RC line, `latest` for
-# stable — and create a GitHub Release (marked pre-release on the RC
-# line). `latest` keeps serving the pre-8 CLI until the operator
-# deliberately moves it (operator ruling 2026-08-12). This is how a
-# merged `chore(release): ...` PR auto-ships.
-# - workflow_dispatch → publish ``
-# under the chosen dist-tag (default `latest`); also the dry-run path.
-#
-# Scope: publishes `@prisma/cli-engine`, then `@prisma/cli`, then
-# `prisma` — the unscoped name is the same shell under the `prisma` bin,
-# and it goes last because it carries the whole tree (dependents after
-# dependencies). The engine versions
-# INDEPENDENTLY of the lockstep (ADR 0004, operator 2026-08-13): it
-# publishes at whatever version its own manifest carries, and because
-# an already-published version is treated as done (see publish_one), an
-# unbumped engine is a no-op while a bumped one ships in the same run.
-# Its dist-tag rides the run's tag; every consumer pins it exactly, so
-# the tag is cosmetic for the engine. `@prisma/compute` is excluded
-# from the lockstep by operator ruling (2026-08-10) and keeps its own
-# workflow (`publish-compute.yml`).
+# The version is always what the root `package.json` says at this ref.
+# Dev suffixes are stamped in CI and never committed.
on:
push:
@@ -61,11 +34,8 @@ jobs:
publish:
name: Publish packages to npm
runs-on: ubuntu-latest
- # Only `main` may produce a real publish. A dry-run dispatch is permitted
- # from any branch so maintainers can validate the pipeline before merging
- # changes that touch publishing. The dry-run path performs no registry
- # writes and skips the GitHub Release step, so non-main runs cannot
- # affect production state.
+ # Only `main` publishes; a dry-run dispatch may validate the pipeline
+ # from any branch.
if: ${{ github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry-run == 'true') }}
permissions:
contents: write # Required to create the GitHub Release + tag for latest publishes
@@ -76,11 +46,8 @@ jobs:
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- # Need history reaching `github.event.before` so `determine-version.ts`
- # can compare the root `package.json` version at that ref to HEAD and
- # decide whether this push is a release bump (publish `latest`) or a
- # routine commit (publish `dev`). A multi-commit push can place
- # `before` arbitrarily far back, so fetch the full history.
+ # determine-version.ts reads package.json at `before`, which a
+ # multi-commit push can place arbitrarily far back.
fetch-depth: 0
- name: Set up pnpm
@@ -103,182 +70,136 @@ jobs:
env:
GITHUB_EVENT_NAME: ${{ github.event_name }}
INPUT_DIST_TAG: ${{ github.event.inputs.dist-tag }}
- # `before` is the ref `main` pointed at before this push.
- # `determine-version.ts` reads the root `package.json` at that ref
- # to detect release bumps. Empty for `workflow_dispatch`, which
- # the script also handles.
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: node scripts/determine-version.ts
- # Dev publishes only, and ephemeral — nothing is committed, so a
- # release publishes exactly what the commit says. Two stamps:
- #
- # 1. `-dev.` across the lockstep manifests. The engine
- # is excluded from the sweep and ships at its own committed
- # version.
- # 2. The product CLI packages move to their `dev` builds. A dev
- # CLI depends on the products' latest dev versions and a
- # release depends only on their releases (operator ruling
- # 2026-08-17), and the committed manifests hold the release
- # versions. The build and the conformance checks below run
- # after this, so a broken product dev build fails the dev
- # publish instead of shipping in it.
- - name: Stamp dev version
- if: ${{ steps.version.outputs.publish == 'true' && steps.version.outputs.tag == 'dev' }}
- # The lockfile refresh is part of the stamp: pnpm verifies
- # manifests against the lockfile before running any script, so a
- # stamped workspace with an unstamped lockfile fails the next
- # pnpm invocation (it did, publish run 48). Same pairing
- # bump-version does for committed bumps; still ephemeral.
+ # --- The dev build: every run, no conditions. ---
+
+ # The lockfile refresh is part of the stamp: pnpm verifies
+ # manifests against the lockfile before running any script.
+ - name: Stamp the dev version
+ env:
+ DEV_VERSION: ${{ steps.version.outputs.devVersion }}
run: |
- node scripts/set-version.ts "${{ steps.version.outputs.version }}"
+ node scripts/set-version.ts "$DEV_VERSION"
node scripts/update-product-versions.mjs --channel dev
pnpm install --lockfile-only --no-frozen-lockfile
- - name: Build packages
- if: ${{ steps.version.outputs.publish == 'true' }}
+ - name: Build the dev version
run: pnpm build
- # The assembled command tree, checked before anything reaches the
- # registry: every family command mounted, every mounted command
- # owned by a family, every path spelled as expected. A publish
- # that has lost a command fails here instead of shipping.
- - name: Check grammar completeness
- if: ${{ steps.version.outputs.publish == 'true' }}
- run: pnpm check:grammar
+ - name: Check the dev version
+ env:
+ PUBLISH_CHANNEL: dev
+ run: |
+ pnpm check:grammar
+ pnpm test:scripts
+ pnpm check:conformance
+
+ - name: Publish the dev version
+ if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true' }}
+ env:
+ NPM_CONFIG_PROVENANCE: "true"
+ run: bash scripts/publish-packages.sh dev @prisma/cli-engine @prisma/cli prisma
+
+ - name: Verify the dev version resolves
+ if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true' }}
+ env:
+ DEV_VERSION: ${{ steps.version.outputs.devVersion }}
+ run: |
+ node scripts/verify-published.mjs "@prisma/cli@$DEV_VERSION" "prisma@$DEV_VERSION"
+
+ # --- The release: only when the committed version changed. ---
- - name: Run script tests
- if: ${{ steps.version.outputs.publish == 'true' }}
- run: pnpm test:scripts
+ - name: Restore the committed versions
+ if: ${{ steps.version.outputs.release == 'true' }}
+ run: |
+ git checkout -- .
+ pnpm install --frozen-lockfile
+ pnpm build
- # The conformance checks against what is about to ship: built
- # output imports only declared dependencies, every mounted
- # config-section validator survives hostile input, the packed
- # tarballs survive a registry consumer's install — clean sandbox,
- # npm with --ignore-scripts, unpublished workspace siblings via
- # computed file: overrides, every declared bin started on plain
- # Node at exit 0, engine pins agreeing everywhere — and a release
- # depends on no dev build. Runs before BOTH publish paths so the
- # dry run is covered too. The tarballs it packs land in
- # artifacts/tarballs and are the ones uploaded below: what was
- # verified is what ships.
- #
- # PUBLISH_CHANNEL is what makes the dev-build check answer
- # correctly for this run. It is the run's own dist-tag, so a dev
- # publish is allowed its dev builds and a release is not.
- - name: Run conformance checks
- if: ${{ steps.version.outputs.publish == 'true' }}
+ - name: Check the release
+ if: ${{ steps.version.outputs.release == 'true' }}
env:
- PUBLISH_CHANNEL: ${{ steps.version.outputs.tag == 'dev' && 'dev' || 'release' }}
- run: pnpm check:conformance
+ PUBLISH_CHANNEL: release
+ run: |
+ pnpm check:grammar
+ pnpm check:conformance
- # The verified tarballs, retrievable per run. On a real release the
- # same files are attached to the GitHub Release below.
- name: Upload tarball artifacts
- if: ${{ steps.version.outputs.publish == 'true' }}
+ if: ${{ steps.version.outputs.release == 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: npm-tarballs
path: artifacts/tarballs/*.tgz
if-no-files-found: error
- # NODE_AUTH_TOKEN is intentionally NOT set. npm detects the OIDC
- # environment (id-token: write) and authenticates via Trusted
- # Publishing automatically. Setting NODE_AUTH_TOKEN to any value --
- # even empty string -- would block OIDC.
- #
- # `pnpm publish` (not `npm publish`) so `workspace:`
- # specifiers are rewritten to exact versions in the published
- # manifest. `--no-git-checks` because the packing step touches the
- # tree; the version itself is whatever the commit says.
- #
- # Publish order: the engine first, then the cli that depends on it.
-
- # Dry-run path: exercises the full publish pipeline (pack, validate
- # tarball contents, dependency rewriting) without touching the npm
- # registry. Use from any branch via `workflow_dispatch` to validate
- # changes that affect publishing before merging.
- - name: Publish packages (dry-run)
- if: ${{ steps.version.outputs.publish == 'true' && github.event_name == 'workflow_dispatch' && github.event.inputs.dry-run == 'true' }}
- run: |
- pnpm --filter @prisma/cli-engine publish --tag "${{ steps.version.outputs.tag }}" --access public --no-git-checks --dry-run
- pnpm --filter @prisma/cli publish --tag "${{ steps.version.outputs.tag }}" --access public --no-git-checks --dry-run
- pnpm --filter prisma publish --tag "${{ steps.version.outputs.tag }}" --access public --no-git-checks --dry-run
-
- # A rerun (or a re-publish dispatch) meets versions that are
- # already on the registry. npm refuses to publish over them —
- # correctly — but that refusal must not stop the run before the
- # Release step gets to repair a missing Release or its assets. An
- # already-published version is treated as done; every other
- # publish failure still fails the run.
- - name: Publish packages
- if: ${{ steps.version.outputs.publish == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }}
+ # NODE_AUTH_TOKEN is intentionally NOT set: npm authenticates over
+ # OIDC, and setting it to any value — even empty — blocks that.
+ # `pnpm publish`, not `npm publish`, or `workspace:` specifiers
+ # survive into the published manifest.
+ - name: Publish the release
+ if: ${{ steps.version.outputs.release == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }}
env:
NPM_CONFIG_PROVENANCE: "true"
- DIST_TAG: ${{ steps.version.outputs.tag }}
- run: |
- publish_one() {
- local out
- if out=$(pnpm --filter "$1" publish --tag "$DIST_TAG" --access public --no-git-checks 2>&1); then
- printf '%s\n' "$out"
- else
- printf '%s\n' "$out"
- if grep -qiE 'E409|EPUBLISHCONFLICT|cannot publish over|previously published' <<<"$out"; then
- echo "$1: this version is already on the registry — continuing so the Release step can run."
- else
- return 1
- fi
- fi
- }
- publish_one @prisma/cli-engine
- publish_one @prisma/cli
- publish_one prisma
+ DIST_TAG: ${{ steps.version.outputs.releaseTag }}
+ run: bash scripts/publish-packages.sh "$DIST_TAG" @prisma/cli-engine @prisma/cli prisma
- # Emit a GitHub Release for releases only — runs whose dist-tag is
- # the canonical one for their version (`next` on the RC line,
- # `latest` for stable; `release` from determine-version.ts). Marked
- # pre-release on the RC line. Beta / preview cuts publish to npm
- # but do not produce a Release — those would drown out the
- # changelog signal. The Release is created at $GITHUB_SHA so the
- # tag points at the same commit the publish ran from.
- #
- # This repo's releases are immutable: once published, neither the
- # assets nor the tag can change (uploading to a published release
- # answers HTTP 422). So the Release is created as a DRAFT with the
- # smoked tarballs already attached, then published — assets first,
- # publish second. On a rerun that finds the Release already
- # published there is nothing left to repair; the step says so and
- # succeeds.
+ - name: Verify the release resolves
+ if: ${{ steps.version.outputs.release == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }}
+ env:
+ RELEASE_VERSION: ${{ steps.version.outputs.releaseVersion }}
+ run: |
+ engine=$(node -p "require('./packages/cli-engine/package.json').version")
+ node scripts/verify-published.mjs \
+ "@prisma/cli-engine@$engine" \
+ "@prisma/cli@$RELEASE_VERSION" \
+ "prisma@$RELEASE_VERSION"
+
+ # Draft first, assets, then publish: a published release is
+ # immutable, so a later upload answers 422. Created through the API
+ # for the id — searching the releases listing for a draft races its
+ # eventual consistency.
- name: Create GitHub Release
- if: ${{ steps.version.outputs.publish == 'true' && steps.version.outputs.release == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }}
+ if: ${{ steps.version.outputs.githubRelease == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- VERSION: ${{ steps.version.outputs.version }}
+ VERSION: ${{ steps.version.outputs.releaseVersion }}
run: |
- if gh release view "v$VERSION" >/dev/null 2>&1; then
- echo "Release v$VERSION already exists and releases are immutable — nothing to repair."
- exit 0
+ # The by-tag endpoints do not see drafts, so search the listing.
+ existing=$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases" \
+ | jq -c --arg tag "v$VERSION" '.[] | select(.tag_name == $tag)' | head -n 1)
+ if [ -n "$existing" ]; then
+ if [ "$(jq -r .draft <<<"$existing")" = "false" ]; then
+ echo "Release v$VERSION is already published and releases are immutable — nothing to repair."
+ exit 0
+ fi
+ # A draft from a failed attempt never reached anyone; replace it.
+ gh api -X DELETE "repos/$GITHUB_REPOSITORY/releases/$(jq -r .id <<<"$existing")"
fi
- PRERELEASE_FLAG=""
+ PRERELEASE=false
case "$VERSION" in
- *-rc.*) PRERELEASE_FLAG="--prerelease" ;;
+ *-rc.*) PRERELEASE=true ;;
esac
- gh release create "v$VERSION" \
- --draft \
- --target "$GITHUB_SHA" \
- --title "v$VERSION" \
- --generate-notes \
- $PRERELEASE_FLAG \
- artifacts/tarballs/*.tgz
- # A draft's tag does not exist yet, so `gh release edit
- # --draft=false` cannot address it; publish through the
- # API by the draft's id.
release_id=$(gh api "repos/$GITHUB_REPOSITORY/releases" \
- --jq ".[] | select(.draft and .tag_name == \"v$VERSION\") | .id" | head -1)
+ -f tag_name="v$VERSION" \
+ -f target_commitish="$GITHUB_SHA" \
+ -f name="v$VERSION" \
+ -F draft=true \
+ -F prerelease=$PRERELEASE \
+ -F generate_release_notes=true \
+ --jq .id)
if [ -z "$release_id" ]; then
- echo "Could not find the draft release for v$VERSION" >&2
+ echo "Creating the draft release for v$VERSION returned no id" >&2
exit 1
fi
+ # By id, not tag: uploads to a fresh draft race the by-tag lookup.
+ for tarball in artifacts/tarballs/*.tgz; do
+ gh api -X POST \
+ -H "Content-Type: application/gzip" \
+ "https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$release_id/assets?name=$(basename "$tarball")" \
+ --input "$tarball" >/dev/null
+ done
gh api -X PATCH "repos/$GITHUB_REPOSITORY/releases/$release_id" \
-F draft=false >/dev/null
- echo "Published release v$VERSION with $(ls artifacts/tarballs/*.tgz | wc -l | tr -d ' ') asset(s)."
+ echo "Published release v$VERSION with $(find artifacts/tarballs -name '*.tgz' | wc -l | tr -d ' ') asset(s)."
diff --git a/docs/oss/release-automation.md b/docs/oss/release-automation.md
index cb3a7812..769c9e4b 100644
--- a/docs/oss/release-automation.md
+++ b/docs/oss/release-automation.md
@@ -70,6 +70,7 @@ The payload is informational — this repository always re-reads the registry ra
| The workflow fails with "DEPLOY_GITHUB_TOKEN is not configured" | The secret is absent from this repository | Add it (see above) |
| A pull request opens but never merges | Required checks failing | Read the checks — this is the automation working; a product release broke something |
| Two open version-update pull requests | The close-the-previous step failed | Close the older one by hand; they race each other's lockfile |
+| A published version never appears on the registry | npm accepted the publish but the version is not resolvable | The publish run polls for it and fails if it never appears; if that fails, re-dispatch the workflow — an already-published version is treated as done, so only the missing one publishes |
| A release publish fails on the dev-build check | The committed pins are dev builds — a dev stamp was committed by mistake, or a product has no usable release | Run `node scripts/update-product-versions.mjs --channel release`; if that changes nothing, the product must publish a real release |
## The state this replaced
diff --git a/docs/oss/versioning.md b/docs/oss/versioning.md
index 2f7f8cb0..3be0837f 100644
--- a/docs/oss/versioning.md
+++ b/docs/oss/versioning.md
@@ -45,6 +45,8 @@ The npm registry exposes the CLI packages under these dist-tags:
- **`dev`** — every routine push to `main` publishes `-dev.` here automatically (operator ruling 2026-08-13, superseding the earlier "no dev channel" ruling). The suffix derives from the workflow run number and is stamped ephemerally in CI, never committed, so release versions remain exactly what a commit says. The channel exists so a product's new version reaches a working CLI without a human: an auto-merging pull request moves the version, runs the full quality and conformance checks, and its merge ships the dev build. Today a daily scheduled run is what notices a product release; the immediate path needs a notification step in each product repository, which neither has yet. See [release automation](./release-automation.md). Only a real release — an `rc.N` bump under `next`, or moving `latest` — is a human act.
+ **Every** run of the publish workflow ships a dev build, including the one that cuts a release — the release publish is an additional half, not an alternative (operator ruling 2026-08-18). When they were alternatives, the release commit was the one merge to `main` that never reached the dev channel, so `dev` named an older version than the release until an unrelated commit landed. The dev build is published as its own version rather than by moving the `dev` tag, because OIDC trusted publishing authorises `npm publish` and nothing else.
+
PR previews go through [`pkg.pr.new`](https://pkg.pr.new) ([`preview-cli-package.yml`](../../.github/workflows/preview-cli-package.yml)); they carry the committed base version and install via per-commit URLs, not dist-tags.
## Who can publish
@@ -66,7 +68,7 @@ This is by design. The alternatives cause silent problems:
[`scripts/set-version.ts`](../../scripts/set-version.ts) is what enforces lockstep: a single invocation walks every lockstep workspace `package.json` and writes the requested version (rewriting `workspace:` dependency pins to match). It is a maintainer's tool, invoked through `pnpm bump-version`; the publish workflow does not run it.
-The publish workflow is **triggered by a change to the root `version`**: a push to `main` whose root `package.json` carries a different `version` than the previous tip is recognised as a release bump and ships that version under its canonical dist-tag — `next` on the RC line (the accompanying GitHub Release is marked pre-release), `latest` for stable. This is what makes "merge the release PR" the publish trigger; there is no separate dispatch step. A push that leaves the version alone publishes a `dev` build instead of a release (operator ruling 2026-08-13; before that ruling it published nothing). Within a publish, `@prisma/cli-engine` goes first, then `@prisma/cli` (which depends on it), then `prisma`.
+The publish workflow is **triggered by a change to the root `version`**: a push to `main` whose root `package.json` carries a different `version` than the previous tip is recognised as a release bump and ships that version under its canonical dist-tag — `next` on the RC line (the accompanying GitHub Release is marked pre-release), `latest` for stable. This is what makes "merge the release PR" the publish trigger; there is no separate dispatch step. Every push publishes a `dev` build; one that changes the version publishes a release as well (operator ruling 2026-08-18). Within a publish, `@prisma/cli-engine` goes first, then `@prisma/cli` (which depends on it), then `prisma`.
**Nothing rewrites a `version` field outside a commit.** `set-version.ts` is run by `pnpm bump-version`, whose output a maintainer reviews and commits; the publish workflow never invokes it. That is what makes "the version is whatever `package.json` says" true rather than aspirational — CI has no way to ship a version no commit describes. It also keeps `pnpm-lock.yaml` honest: the lockfile records the `workspace:` specifiers that `set-version.ts` rewrites, so `bump-version` refreshes it in the same breath and the bump lands as one internally consistent commit.
diff --git a/package.json b/package.json
index df9312ba..4894cd13 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"lint:fix": "biome check . --write",
"bump-version": "node scripts/bump-version.ts",
"test": "turbo run test",
- "test:scripts": "node --test scripts/determine-version-utils.test.ts scripts/set-version-utils.test.ts scripts/bump-cli-engine-version-utils.test.ts scripts/resolve-package-version.test.mjs scripts/update-product-versions.test.mjs",
+ "test:scripts": "node --test scripts/determine-version-utils.test.ts scripts/set-version-utils.test.ts scripts/bump-cli-engine-version-utils.test.ts scripts/resolve-package-version.test.mjs scripts/update-product-versions.test.mjs scripts/verify-published.test.mjs",
"typecheck": "turbo run typecheck",
"prisma-cli": "tsx packages/cli/src/bin.ts",
"prisma": "tsx packages/cli/src/bin.ts",
diff --git a/scripts/determine-version-utils.test.ts b/scripts/determine-version-utils.test.ts
index 529f16a5..be6159b0 100644
--- a/scripts/determine-version-utils.test.ts
+++ b/scripts/determine-version-utils.test.ts
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
assertCanonicalBase,
+ assertValidDistTag,
computeNextMinor,
computeNextReleaseVersion,
devVersion,
@@ -11,6 +12,7 @@ import {
} from "./determine-version-utils.ts";
const NOT_CANONICAL = /not canonical/;
+const NOT_A_DIST_TAG = /not a valid dist-tag/;
describe("parseVersion", () => {
it("parses a clean release", () => {
@@ -187,3 +189,24 @@ describe("isReleasePublish", () => {
assert.equal(isReleasePublish("8.0.0-rc.2", "latest"), false);
});
});
+
+describe("assertValidDistTag", () => {
+ it("accepts the tags the contract uses", () => {
+ for (const tag of ["latest", "next", "beta", "dev"]) {
+ assert.doesNotThrow(() => assertValidDistTag(tag));
+ }
+ });
+
+ it("refuses anything that is not a plain lowercase word", () => {
+ for (const tag of ["", "Latest", "--tag", "8.0.0", "a tag"]) {
+ assert.throws(() => assertValidDistTag(tag), NOT_A_DIST_TAG);
+ }
+ });
+
+ it("refuses a value with a newline, which could smuggle extra workflow outputs", () => {
+ assert.throws(
+ () => assertValidDistTag("next\nEOF\ngithubRelease<`, dist-tag from `releaseDistTag`:
- * `next` on the RC line, `latest` for stable.
- * This is how a merged `chore(release): ...`
- * PR ships a release automatically — `latest`
- * keeps serving the pre-8 CLI until the
- * operator deliberately moves it.
- * Otherwise `-dev.` under the `dev`
- * dist-tag: every routine main push — above
- * all one that follows a product's new version
- * — ships an installable dev build
- * automatically (operator ruling 2026-08-13).
- * - `workflow_dispatch` → `` (no suffix), dist-tag from
- * `INPUT_DIST_TAG`; empty means the version's
- * canonical tag (`releaseDistTag`). Useful as a
- * manual escape hatch (re-publish after a
- * transient failure, cut a beta) — and passing
- * `latest` explicitly for an RC version is the
- * deliberate cutover act.
- *
- * Outputs `publish`, `version`, `tag` and `release` to `$GITHUB_OUTPUT`
- * for downstream workflow steps to consume.
- *
- * This script never rewrites a manifest. Release versions are the ones
- * committed at this ref, always; a dev version derives its suffix from
- * the run number, and the workflow stamps it ephemerally in CI without
- * committing it (docs/oss/versioning.md).
+ * Outputs `devVersion`, `release`, `releaseVersion`, `releaseTag` and
+ * `githubRelease`. Never rewrites a manifest.
*/
import { execFileSync } from "node:child_process";
import { appendFileSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
-import type { VersionResult } from "./determine-version-utils.ts";
import {
assertCanonicalBase,
+ assertValidDistTag,
devVersion,
isReleasePublish,
releaseDistTag,
@@ -72,13 +45,7 @@ type PreviousVersionLookup =
| { available: true; version: string | undefined }
| { available: false };
-/**
- * Reads the root `package.json` `version` at `PUSH_BEFORE_SHA` (the ref
- * that `main` pointed at *before* the push). Distinguishes "we
- * successfully read the previous file" (so the comparison is meaningful)
- * from "we couldn't" (shallow clone, missing SHA, etc.) so the caller
- * can fall back to the safe `dev` path on any I/O hiccup.
- */
+/** The root `version` at `PUSH_BEFORE_SHA`, if it can be read at all. */
function readPreviousRootVersion(): PreviousVersionLookup {
const beforeSha = process.env.PUSH_BEFORE_SHA;
if (!beforeSha || ALL_ZERO_SHA_PATTERN.test(beforeSha)) {
@@ -101,27 +68,29 @@ function readPreviousRootVersion(): PreviousVersionLookup {
}
function writeGitHubOutput(
- base: string,
- result: VersionResult | undefined,
- publish: boolean,
+ devVersion: string,
+ release: { version: string; tag: string } | undefined,
): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (!outputFile) return;
- appendFileSync(outputFile, `publish<-dev.` under `dev` (operator
- // ruling 2026-08-13 — a product's new version reaches the CLI and
- // deploys without a human; only a real release needs one). The
- // suffix is derived here and stamped ephemerally in CI; it is
- // never committed, so releases remain committed-at-HEAD.
- const runNumber = process.env.GITHUB_RUN_NUMBER ?? "";
+ } else if (previous.version !== baseVersion) {
console.log(
- `Root version unchanged by this push → dev publish (run ${runNumber}).`,
+ `Previous root version: ${previous.version ?? "(unset)"} → release bump detected.`,
);
- result = { version: devVersion(baseVersion, runNumber), tag: "dev" };
+ release = { version: baseVersion, tag: releaseDistTag(baseVersion) };
} else {
- // A transient git error must never publish anything; skipping is
- // recoverable by dispatching the workflow.
- console.log("Could not read the previous root version — not publishing.");
- result = undefined;
+ console.log("Root version unchanged by this push → dev build only.");
}
break;
}
@@ -185,10 +136,14 @@ switch (eventName) {
throw new Error(`don't know how to handle event ${eventName}`);
}
-if (result === undefined) {
- writeGitHubOutput(baseVersion, undefined, false);
-} else {
- console.log(`Resolved version: ${result.version}`);
- console.log(`Resolved dist-tag: ${result.tag}`);
- writeGitHubOutput(baseVersion, result, true);
+if (release !== undefined && !isReleasePublish(baseVersion, release.tag)) {
+ console.log(
+ `Dist-tag ${release.tag} is not ${baseVersion}'s canonical tag — publishing it, but not as a release.`,
+ );
}
+
+console.log(`Dev version: ${dev}`);
+console.log(
+ `Release: ${release === undefined ? "no" : `${release.version} under ${release.tag}`}`,
+);
+writeGitHubOutput(dev, release);
diff --git a/scripts/publish-packages.sh b/scripts/publish-packages.sh
new file mode 100755
index 00000000..7c050933
--- /dev/null
+++ b/scripts/publish-packages.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+
+# Usage: publish-packages.sh ...
+#
+# `pnpm publish` for each package, treating an already-published version
+# as done: a re-run of a partially failed workflow run must reach the
+# later steps. Every other failure fails the run.
+
+set -euo pipefail
+
+tag="$1"
+shift
+
+for pkg in "$@"; do
+ if out=$(pnpm --filter "$pkg" publish --tag "$tag" --access public --no-git-checks 2>&1); then
+ printf '%s\n' "$out"
+ else
+ printf '%s\n' "$out"
+ if grep -qiE 'E409|EPUBLISHCONFLICT|cannot publish over|previously published' <<<"$out"; then
+ echo "$pkg: this version is already on the registry — continuing."
+ else
+ exit 1
+ fi
+ fi
+done
diff --git a/scripts/verify-published.mjs b/scripts/verify-published.mjs
new file mode 100644
index 00000000..af7a92b3
--- /dev/null
+++ b/scripts/verify-published.mjs
@@ -0,0 +1,96 @@
+#!/usr/bin/env node
+
+// Confirms the registry actually serves versions the workflow just
+// published: `pnpm publish` reporting success is not the same as the
+// version being resolvable. The registry is eventually consistent, so a
+// miss is not a failure — never appearing is.
+//
+// Usage: node scripts/verify-published.mjs ...
+
+import { execFile } from "node:child_process";
+import { pathToFileURL } from "node:url";
+import { promisify } from "node:util";
+
+const execFileAsync = promisify(execFile);
+
+const ATTEMPTS = 20;
+const DELAY_MS = 15_000;
+const NPM_NOT_FOUND_PATTERN = /\bE404\b/;
+
+/**
+ * Polls until every spec resolves, or reports the first that never does.
+ *
+ * @param {readonly string[]} specs
+ * @param {{ check: (spec: string) => Promise, sleep: (ms: number) => Promise, attempts?: number }} io
+ * @returns {Promise<{ ok: true } | { ok: false, spec: string }>}
+ */
+export async function waitForAll(specs, io) {
+ const attempts = io.attempts ?? ATTEMPTS;
+ for (const spec of specs) {
+ let resolved = false;
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ // biome-ignore lint/performance/noAwaitInLoops: each attempt exists only because the previous one failed
+ if (await io.check(spec)) {
+ resolved = true;
+ break;
+ }
+ if (attempt < attempts) await io.sleep(DELAY_MS);
+ }
+ if (!resolved) return { ok: false, spec };
+ }
+ return { ok: true };
+}
+
+/**
+ * Whether a failed `npm view` means the version is absent (E404), as
+ * opposed to npm itself failing — no binary, no network, no auth. Only
+ * the absence is worth polling through; everything else must stop the
+ * run and name the real cause.
+ *
+ * @param {unknown} error
+ * @returns {boolean}
+ */
+export function isNotFoundError(error) {
+ if (typeof error !== "object" || error === null) return false;
+ const { stderr, stdout } =
+ /** @type {{ stderr?: string, stdout?: string }} */ (error);
+ return NPM_NOT_FOUND_PATTERN.test(`${stderr ?? ""}\n${stdout ?? ""}`);
+}
+
+async function resolvesOnRegistry(spec) {
+ try {
+ await execFileAsync("npm", ["view", spec, "version", "--prefer-online"]);
+ return true;
+ } catch (error) {
+ if (isNotFoundError(error)) return false;
+ throw error;
+ }
+}
+
+async function main() {
+ const specs = process.argv.slice(2);
+ if (specs.length === 0) {
+ console.error("Usage: node scripts/verify-published.mjs ...");
+ process.exit(1);
+ }
+ const result = await waitForAll(specs, {
+ check: async (spec) => {
+ const ok = await resolvesOnRegistry(spec);
+ console.log(ok ? `${spec} resolves.` : `${spec} not resolvable yet...`);
+ return ok;
+ },
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
+ });
+ if (!result.ok) {
+ console.error(
+ `::error::${result.spec} never became resolvable. It may still be propagating, but this run cannot say it shipped.`,
+ );
+ process.exit(1);
+ }
+ console.log(`All ${specs.length} published version(s) resolve.`);
+}
+
+const isDirectRun =
+ process.argv[1] !== undefined &&
+ import.meta.url === pathToFileURL(process.argv[1]).href;
+if (isDirectRun) await main();
diff --git a/scripts/verify-published.test.mjs b/scripts/verify-published.test.mjs
new file mode 100644
index 00000000..4a466c2e
--- /dev/null
+++ b/scripts/verify-published.test.mjs
@@ -0,0 +1,101 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { isNotFoundError, waitForAll } from "./verify-published.mjs";
+
+/** A check that answers false the first `misses` times, then true. */
+function resolvesAfter(misses) {
+ let seen = 0;
+ return () => Promise.resolve(seen++ >= misses);
+}
+
+describe("waitForAll", () => {
+ it("passes when every spec resolves at once, without waiting", async () => {
+ const waits = [];
+ const result = await waitForAll(["a@1", "b@1"], {
+ check: () => Promise.resolve(true),
+ sleep: (ms) => {
+ waits.push(ms);
+ return Promise.resolve();
+ },
+ });
+ assert.deepEqual(result, { ok: true });
+ assert.deepEqual(waits, []);
+ });
+
+ /** The registry is eventually consistent; a miss is not a failure. */
+ it("keeps waiting while a spec has not appeared yet", async () => {
+ let waits = 0;
+ const result = await waitForAll(["a@1"], {
+ check: resolvesAfter(3),
+ sleep: () => {
+ waits++;
+ return Promise.resolve();
+ },
+ });
+ assert.deepEqual(result, { ok: true });
+ assert.equal(waits, 3);
+ });
+
+ it("gives up after the attempt limit and names the spec that never appeared", async () => {
+ const result = await waitForAll(["a@1", "b@2"], {
+ check: (spec) => Promise.resolve(spec === "a@1"),
+ sleep: () => Promise.resolve(),
+ attempts: 4,
+ });
+ assert.deepEqual(result, { ok: false, spec: "b@2" });
+ });
+
+ it("stops at the first spec that never appears", async () => {
+ const checked = [];
+ await waitForAll(["a@1", "b@2", "c@3"], {
+ check: (spec) => {
+ checked.push(spec);
+ return Promise.resolve(false);
+ },
+ sleep: () => Promise.resolve(),
+ attempts: 2,
+ });
+ assert.deepEqual(new Set(checked), new Set(["a@1"]));
+ });
+
+ it("does not sleep after the final failed attempt", async () => {
+ let waits = 0;
+ await waitForAll(["a@1"], {
+ check: () => Promise.resolve(false),
+ sleep: () => {
+ waits++;
+ return Promise.resolve();
+ },
+ attempts: 4,
+ });
+ assert.equal(waits, 3);
+ });
+
+ it("treats no specs as nothing to prove, not as a pass to celebrate", async () => {
+ const result = await waitForAll([], {
+ check: () => Promise.resolve(false),
+ sleep: () => Promise.resolve(),
+ });
+ assert.deepEqual(result, { ok: true });
+ });
+});
+
+describe("isNotFoundError", () => {
+ it("recognises npm's E404 for a version the registry does not have", () => {
+ const error = new Error("Command failed: npm view ...");
+ error.stderr = "npm error code E404\nnpm error 404 Not Found";
+ assert.equal(isNotFoundError(error), true);
+ });
+
+ it("treats a network failure as a real error, not an absent version", () => {
+ const error = new Error("Command failed: npm view ...");
+ error.stderr = "npm error code ENOTFOUND\nnpm error network request failed";
+ assert.equal(isNotFoundError(error), false);
+ });
+
+ it("treats a missing npm binary as a real error", () => {
+ const error = new Error("spawn npm ENOENT");
+ error.code = "ENOENT";
+ assert.equal(isNotFoundError(error), false);
+ });
+});