From 8515f0e5b589817d1fedf768f3e1c07f9aa5d33f Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 18 Aug 2026 08:00:23 +0200 Subject: [PATCH 1/4] Three defects the 8.0.0-rc.4 release exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release published correctly but the run went red and needed hands to finish, which is not a release process. 1. The GitHub Release step raced itself. It created a draft with the CLI, then searched the releases listing for it by tag a second later. That listing is eventually consistent and had not caught up, so the step exited 1 and left v8.0.0-rc.4 sitting as a draft. It now creates the release through the API and keeps the id from the response, so there is nothing to search for. 2. Nothing checked that a published version could be resolved. `pnpm publish` printed a success line for prisma@8.0.0-rc.4 while the version stayed unresolvable for several minutes, and no one could say from the run whether the release had shipped. A verification step now polls the registry for each published version and fails the run if one never appears. 3. A release commit never reached the dev channel. `determine-version.ts` treats release and dev as alternatives, so the release commit is the one merge to `main` that publishes no dev build, and `dev` keeps naming an older version than the release until an unrelated commit lands. It now publishes a dev build too — a real one, with the product pins moved to their dev builds and conformance run against it, as a second publish rather than a dist-tag move, because OIDC authorises `npm publish` and nothing else. Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/publish.yml | 107 ++++++++++++++++++++++++++++----- docs/oss/release-automation.md | 1 + docs/oss/versioning.md | 2 + 3 files changed, 96 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 39d92fb2..d89c28c7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -234,6 +234,36 @@ jobs: publish_one @prisma/cli publish_one prisma + # What the registry actually serves, not what the publisher said. + # `pnpm publish` prints its own success line, and on run 32104368661 + # it printed one for `prisma@8.0.0-rc.4` while the version stayed + # unresolvable for several minutes — leaving nobody able to say + # whether the release had shipped. The registry is eventually + # consistent, so this polls rather than asking once, and fails the + # run if a version never appears. + - name: Verify the published versions resolve + if: ${{ steps.version.outputs.publish == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }} + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + engine_version=$(node -p "require('./packages/cli-engine/package.json').version") + verify_one() { + local spec="$1" + for attempt in $(seq 1 20); do + if npm view "$spec" version --prefer-online >/dev/null 2>&1; then + echo "$spec resolves." + return 0 + fi + echo "$spec not resolvable yet (attempt $attempt), waiting..." + sleep 15 + done + echo "::error::$spec never became resolvable. It may still be propagating, but this run cannot say it shipped." + return 1 + } + verify_one "@prisma/cli-engine@$engine_version" + verify_one "@prisma/cli@$VERSION" + verify_one "prisma@$VERSION" + # 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 @@ -259,26 +289,75 @@ jobs: echo "Release v$VERSION already exists and releases are immutable — nothing to repair." exit 0 fi - PRERELEASE_FLAG="" + # Created through the API rather than `gh release create`, for + # the id in the response. The previous version created the + # draft with the CLI and then searched the releases listing for + # it by tag; the listing is eventually consistent and had not + # caught up a second later, so the step failed and left v8.0.0-rc.4 + # sitting as a draft (run 32104368661). Addressing the release + # by the id its own creation returned cannot race. + 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 + # Assets first, publish second: a published release is + # immutable, so uploading afterwards answers HTTP 422. + for tarball in artifacts/tarballs/*.tgz; do + gh release upload "v$VERSION" "$tarball" --clobber + 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)." + + # A release commit is a build of `main` too, so the `dev` channel + # gets one. `determine-version.ts` treats the two as alternatives — + # version changed means release, unchanged means dev — so the + # release commit is the ONE merge to `main` that never reaches the + # dev channel. Left alone, `dev` keeps naming the last routine push, + # an older version than the release, until someone lands an + # unrelated commit. Anything following `dev` then tests older code + # than the release carries. This repo reads the products' `dev` tags + # for exactly that purpose, so it holds itself to the same rule + # (operator ruling 2026-08-17; docs/oss/release-automation.md). + # + # This is a second `npm publish`, not `npm dist-tag add`: OIDC + # trusted publishing authorises `npm publish` and nothing else, so + # moving a tag would need a long-lived npm token — and not having + # one is the property that makes trusted publishing worth having. + # + # It is a real dev build, not a relabelled release: the product + # pins move to their dev builds and the conformance checks run + # against the result, exactly as on the dev path. Nothing is + # committed. The version sorts above the release + # (8.0.0-rc.4-dev.55 > 8.0.0-rc.4), which is correct — a later + # build of the same commit. + - name: Publish a dev build of the release commit + if: ${{ steps.version.outputs.publish == 'true' && steps.version.outputs.release == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }} + env: + NPM_CONFIG_PROVENANCE: "true" + RELEASE_VERSION: ${{ steps.version.outputs.version }} + PUBLISH_CHANNEL: dev + run: | + DEV_VERSION="${RELEASE_VERSION}-dev.${GITHUB_RUN_NUMBER}" + echo "Publishing $DEV_VERSION under the dev tag, from the same commit as $RELEASE_VERSION." + node scripts/set-version.ts "$DEV_VERSION" + node scripts/update-product-versions.mjs --channel dev + pnpm install --lockfile-only --no-frozen-lockfile + pnpm build + pnpm check:grammar + pnpm check:conformance + for pkg in @prisma/cli prisma; do + pnpm --filter "$pkg" publish --tag dev --access public --no-git-checks + done 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..2b472113 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. + A **release** commit publishes a dev build as well as the release. It is a build of `main` like any other, and without this the `dev` tag would keep naming the last routine push — an older version than the release just published — until someone landed an unrelated commit. That dev build is a real one: the product pins move to their dev builds and the conformance checks run against the result. It is published as a second 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 From 5e50df8f3389fbc18fbc462efb2e7b4c181b7dd6 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 18 Aug 2026 08:06:09 +0200 Subject: [PATCH 2/4] The dev publish is unconditional; the release is the conditional half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator ruling 2026-08-18. `determine-version.ts` treated dev and release as alternatives, which made the release commit 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 previous attempt at this bolted a second dev publish onto the end of the release path, which was more machinery to express a worse idea. Now the workflow reads as two halves. The dev half has no conditions: stamp the version and the products' dev builds, build, check, publish, verify. The release half runs when the committed version changed: restore the committed tree, build, check on the release channel, publish, verify, create the GitHub Release. `determine-version.ts` emits both versions rather than choosing between them. Also in this change, both found by the 8.0.0-rc.4 release: - The Release step searched the releases listing for the draft it had just created, by tag, a second later. That listing is eventually consistent; when it had not caught up the step failed and left v8.0.0-rc.4 sitting as a draft. It now creates the release through the API and keeps the id from the response. - Nothing checked that a published version could be resolved. `pnpm publish` printed a success line for prisma@8.0.0-rc.4 while the version stayed unresolvable for minutes. scripts/verify-published.mjs polls the registry and fails the run if a version never appears; the lookup and the clock are injected so its tests need neither. Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/publish.yml | 337 ++++++++++++------------------ docs/oss/versioning.md | 4 +- package.json | 2 +- scripts/determine-version.ts | 151 +++++++------ scripts/verify-published.mjs | 86 ++++++++ scripts/verify-published.test.mjs | 68 ++++++ 6 files changed, 364 insertions(+), 284 deletions(-) create mode 100644 scripts/verify-published.mjs create mode 100644 scripts/verify-published.test.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d89c28c7..2a1ae148 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,32 +8,35 @@ name: Publish to npm # publish a version other than what is committed at HEAD, and never # rewrites a manifest to get there. # -# 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. +# Publish model: +# EVERY run publishes a dev build — `-dev.` under the `dev` +# dist-tag, stamped ephemerally and never committed, with the product +# CLI packages moved to their own dev builds. # -# 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 +# A run ALSO publishes a release when the push changed the root +# `version` (or a `workflow_dispatch` asked for one): `` under its +# canonical dist-tag — `next` on the RC line, `latest` for stable — from +# the committed tree, plus a GitHub Release. This is how a merged +# `chore(release): ...` PR auto-ships. `latest` keeps serving the pre-8 +# CLI until the operator deliberately moves it (operator ruling +# 2026-08-12). +# +# The two halves are not alternatives. When they were, the release +# commit was the one merge to `main` that never reached the dev +# channel, so `dev` sat on an older version than the release until an +# unrelated commit landed — and anything following `dev` (this repo +# follows the products' `dev` tags) silently tested older code than the +# release carried. Operator ruling 2026-08-18: dev is unconditional, +# release is the conditional half. +# +# Scope: `@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. The engine versions +# INDEPENDENTLY of the lockstep (ADR 0004): it publishes at whatever +# version its own manifest carries, and because an already-published +# version is treated as done, an unbumped engine is a no-op while a +# bumped one ships in the same run. `@prisma/compute` is excluded from +# the lockstep by operator ruling (2026-08-10) and keeps its own # workflow (`publish-compute.yml`). on: @@ -104,78 +107,89 @@ jobs: 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. + # `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. No conditions: every run of this workflow ships + # one. That is what keeps the `dev` dist-tag from ever naming an + # older version than the release tag. + # ---------------------------------------------------------------- + + # Ephemeral, never committed: the version, and the product CLI + # packages moved to their own dev builds. A dev CLI depends on the + # products' dev builds; a release depends only on their releases + # (operator ruling 2026-08-17). 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. + - name: Stamp the dev version run: | - node scripts/set-version.ts "${{ steps.version.outputs.version }}" + node scripts/set-version.ts "${{ steps.version.outputs.devVersion }}" 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 + # Everything that stands between a build and the registry: the + # assembled command tree still complete, the version helpers still + # correct, and the conformance checks — built output importing only + # declared dependencies, every config-section validator surviving + # hostile input, and the packed tarballs installing into a clean + # sandbox with every declared bin starting on plain Node. + - 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: | + for pkg in @prisma/cli-engine @prisma/cli prisma; do + pnpm --filter "$pkg" publish --tag dev --access public --no-git-checks + done + + - 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" - - name: Run script tests - if: ${{ steps.version.outputs.publish == 'true' }} - run: pnpm test:scripts + # ---------------------------------------------------------------- + # The release. Only when the committed version changed (or a + # dispatch asked for one). Runs from the committed tree, so a + # release publishes exactly what its commit says. + # ---------------------------------------------------------------- - # 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: Restore the committed versions + if: ${{ steps.version.outputs.release == 'true' }} + run: | + git checkout -- . + pnpm install --frozen-lockfile + pnpm build + + - 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. + # The tarballs these checks packed are the ones attached to the + # GitHub Release below: what was verified is what ships. - 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 @@ -184,38 +198,23 @@ jobs: # 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. + # 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. + # `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. # - # 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') }} + # A rerun meets versions already on the registry. npm refuses to + # publish over them — correctly — but that refusal must not stop + # the run before the Release step can repair a missing Release. An + # already-published version is treated as done; every other publish + # failure still fails the run. + - 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 }} + DIST_TAG: ${{ steps.version.outputs.releaseTag }} run: | publish_one() { local out @@ -234,68 +233,41 @@ jobs: publish_one @prisma/cli publish_one prisma - # What the registry actually serves, not what the publisher said. - # `pnpm publish` prints its own success line, and on run 32104368661 - # it printed one for `prisma@8.0.0-rc.4` while the version stayed - # unresolvable for several minutes — leaving nobody able to say - # whether the release had shipped. The registry is eventually - # consistent, so this polls rather than asking once, and fails the - # run if a version never appears. - - name: Verify the published versions resolve - if: ${{ steps.version.outputs.publish == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }} + # What the registry serves, not what the publisher said. `pnpm + # publish` printed a success line for prisma@8.0.0-rc.4 while the + # version stayed unresolvable for minutes, and nobody could tell + # from the run whether the release had shipped (run 32104368661). + - name: Verify the release resolves + if: ${{ steps.version.outputs.release == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }} env: - VERSION: ${{ steps.version.outputs.version }} + RELEASE_VERSION: ${{ steps.version.outputs.releaseVersion }} run: | - engine_version=$(node -p "require('./packages/cli-engine/package.json').version") - verify_one() { - local spec="$1" - for attempt in $(seq 1 20); do - if npm view "$spec" version --prefer-online >/dev/null 2>&1; then - echo "$spec resolves." - return 0 - fi - echo "$spec not resolvable yet (attempt $attempt), waiting..." - sleep 15 - done - echo "::error::$spec never became resolvable. It may still be propagating, but this run cannot say it shipped." - return 1 - } - verify_one "@prisma/cli-engine@$engine_version" - verify_one "@prisma/cli@$VERSION" - verify_one "prisma@$VERSION" + 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" - # 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. + # Releases here are immutable: once published, neither the assets + # nor the tag can change. So the Release is created as a draft, the + # verified tarballs are attached, and only then is it published. # - # 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. + # Created through the API rather than `gh release create`, for the + # id in the response: the previous version searched the releases + # listing for the draft by tag a second after creating it, that + # listing is eventually consistent, and when it had not caught up + # the step failed and left v8.0.0-rc.4 sitting as a draft + # (run 32104368661). - 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 fi - # Created through the API rather than `gh release create`, for - # the id in the response. The previous version created the - # draft with the CLI and then searched the releases listing for - # it by tag; the listing is eventually consistent and had not - # caught up a second later, so the step failed and left v8.0.0-rc.4 - # sitting as a draft (run 32104368661). Addressing the release - # by the id its own creation returned cannot race. PRERELEASE=false case "$VERSION" in *-rc.*) PRERELEASE=true ;; @@ -312,52 +284,9 @@ jobs: echo "Creating the draft release for v$VERSION returned no id" >&2 exit 1 fi - # Assets first, publish second: a published release is - # immutable, so uploading afterwards answers HTTP 422. for tarball in artifacts/tarballs/*.tgz; do gh release upload "v$VERSION" "$tarball" --clobber 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)." - - # A release commit is a build of `main` too, so the `dev` channel - # gets one. `determine-version.ts` treats the two as alternatives — - # version changed means release, unchanged means dev — so the - # release commit is the ONE merge to `main` that never reaches the - # dev channel. Left alone, `dev` keeps naming the last routine push, - # an older version than the release, until someone lands an - # unrelated commit. Anything following `dev` then tests older code - # than the release carries. This repo reads the products' `dev` tags - # for exactly that purpose, so it holds itself to the same rule - # (operator ruling 2026-08-17; docs/oss/release-automation.md). - # - # This is a second `npm publish`, not `npm dist-tag add`: OIDC - # trusted publishing authorises `npm publish` and nothing else, so - # moving a tag would need a long-lived npm token — and not having - # one is the property that makes trusted publishing worth having. - # - # It is a real dev build, not a relabelled release: the product - # pins move to their dev builds and the conformance checks run - # against the result, exactly as on the dev path. Nothing is - # committed. The version sorts above the release - # (8.0.0-rc.4-dev.55 > 8.0.0-rc.4), which is correct — a later - # build of the same commit. - - name: Publish a dev build of the release commit - if: ${{ steps.version.outputs.publish == 'true' && steps.version.outputs.release == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }} - env: - NPM_CONFIG_PROVENANCE: "true" - RELEASE_VERSION: ${{ steps.version.outputs.version }} - PUBLISH_CHANNEL: dev - run: | - DEV_VERSION="${RELEASE_VERSION}-dev.${GITHUB_RUN_NUMBER}" - echo "Publishing $DEV_VERSION under the dev tag, from the same commit as $RELEASE_VERSION." - node scripts/set-version.ts "$DEV_VERSION" - node scripts/update-product-versions.mjs --channel dev - pnpm install --lockfile-only --no-frozen-lockfile - pnpm build - pnpm check:grammar - pnpm check:conformance - for pkg in @prisma/cli prisma; do - pnpm --filter "$pkg" publish --tag dev --access public --no-git-checks - done diff --git a/docs/oss/versioning.md b/docs/oss/versioning.md index 2b472113..3be0837f 100644 --- a/docs/oss/versioning.md +++ b/docs/oss/versioning.md @@ -45,7 +45,7 @@ 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. - A **release** commit publishes a dev build as well as the release. It is a build of `main` like any other, and without this the `dev` tag would keep naming the last routine push — an older version than the release just published — until someone landed an unrelated commit. That dev build is a real one: the product pins move to their dev builds and the conformance checks run against the result. It is published as a second version rather than by moving the `dev` tag, because OIDC trusted publishing authorises `npm publish` and nothing else. + **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. @@ -68,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.ts b/scripts/determine-version.ts index f99a1447..3bf67352 100644 --- a/scripts/determine-version.ts +++ b/scripts/determine-version.ts @@ -1,47 +1,45 @@ #!/usr/bin/env node /** - * Composes the version + dist-tag the publish workflow will use. + * Composes the versions the publish workflow will use. + * + * Every run publishes a dev build; a run also publishes a release when + * the committed version changed. The two are not alternatives, and that + * is deliberate: when they were, the release commit became the one merge + * to `main` that never reached the dev channel, so the `dev` dist-tag sat + * on an older version than the release until an unrelated commit landed + * (operator ruling 2026-08-18). * * The base version comes from the root `package.json` (the workspace-wide - * lockstep source of truth — see docs/oss/versioning.md). This script is - * responsible only for the suffix and dist-tag appropriate to the GitHub - * event: + * lockstep source of truth — see docs/oss/versioning.md). * - * - `push` → if the root `version` changed in this push, - * ``, 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. + * - dev, always → `-dev.` under the `dev` dist-tag. The + * suffix derives from the run number and the + * workflow stamps it ephemerally, never committing + * it. + * - release, when → `` under `releaseDistTag`: `next` on the RC + * the version line, `latest` for stable. This is how a merged + * changed `chore(release): ...` PR ships automatically; + * `latest` keeps serving the pre-8 CLI until the + * operator deliberately moves it. + * - `workflow_dispatch` always offers the release half, with the dist-tag + * from `INPUT_DIST_TAG`; empty means the canonical + * tag. The manual escape hatch: re-publish after a + * transient failure, or cut a beta. 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. + * Outputs `devVersion`, `release`, `releaseVersion`, `releaseTag` and + * `githubRelease` to `$GITHUB_OUTPUT`. * * 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). + * committed at this ref, always. */ 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, devVersion, @@ -101,27 +99,30 @@ 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 +176,16 @@ 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); +// A beta or preview cut publishes to npm but is not a release: it gets +// no GitHub Release, and `isReleasePublish` is what tells them apart. +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/verify-published.mjs b/scripts/verify-published.mjs new file mode 100644 index 00000000..033c1a8b --- /dev/null +++ b/scripts/verify-published.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +// Confirms that versions the publish workflow just pushed are actually +// served by the registry. +// +// `pnpm publish` printing a success line is not the same as the version +// being resolvable: on publish run 32104368661 it reported +// `✅ Published package prisma@8.0.0-rc.4` while `npm view` answered 404 +// for several minutes, and nothing in the run could say whether the +// release had shipped. +// +// The registry is eventually consistent, so a miss is not a failure — +// this polls. Never appearing is a failure. +// +// Usage: node scripts/verify-published.mjs ... + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const ATTEMPTS = 20; +const DELAY_MS = 15_000; + +/** + * Polls until every spec resolves, or reports the first that never does. + * The registry lookup and the clock are injected so the tests need + * neither. + * + * @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: polling is sequential by nature — each attempt exists only because the previous one failed + if (await io.check(spec)) { + resolved = true; + break; + } + await io.sleep(DELAY_MS); + } + if (!resolved) return { ok: false, spec }; + } + return { ok: true }; +} + +async function resolvesOnRegistry(spec) { + try { + await execFileAsync("npm", ["view", spec, "version", "--prefer-online"]); + return true; + } catch { + return false; + } +} + +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 === new URL(`file://${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..2ca2791d --- /dev/null +++ b/scripts/verify-published.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { 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("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 }); + }); +}); From b655783acf6a20ec132053b69907476e79229563 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 18 Aug 2026 09:19:30 +0200 Subject: [PATCH 3/4] Cut the comments back to what the code cannot say publish.yml carried 127 comment lines; most narrated the incidents that motivated each step, restated the line below them, or cited rulings that docs/oss/versioning.md already records. It has 27 now, and the file is 71 lines shorter than it was on main. What survives is the traps: NODE_AUTH_TOKEN blocking OIDC, `pnpm publish` rewriting `workspace:` specifiers, assets before publish because releases are immutable, `||` not `??` on an empty dist-tag input, and why the lockfile refresh belongs to the stamp. The incident history is in git and in the pull request, which is where it can be read once rather than every time someone opens the file. Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/publish.yml | 129 +++++++--------------------------- scripts/determine-version.ts | 66 +++-------------- scripts/verify-published.mjs | 19 ++--- 3 files changed, 41 insertions(+), 173 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2a1ae148..0329bd06 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,43 +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. # -# Publish model: -# EVERY run publishes a dev build — `-dev.` under the `dev` -# dist-tag, stamped ephemerally and never committed, with the product -# CLI packages moved to their own dev builds. -# -# A run ALSO publishes a release when the push changed the root -# `version` (or a `workflow_dispatch` asked for one): `` under its -# canonical dist-tag — `next` on the RC line, `latest` for stable — from -# the committed tree, plus a GitHub Release. This is how a merged -# `chore(release): ...` PR auto-ships. `latest` keeps serving the pre-8 -# CLI until the operator deliberately moves it (operator ruling -# 2026-08-12). -# -# The two halves are not alternatives. When they were, the release -# commit was the one merge to `main` that never reached the dev -# channel, so `dev` sat on an older version than the release until an -# unrelated commit landed — and anything following `dev` (this repo -# follows the products' `dev` tags) silently tested older code than the -# release carried. Operator ruling 2026-08-18: dev is unconditional, -# release is the conditional half. -# -# Scope: `@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. The engine versions -# INDEPENDENTLY of the lockstep (ADR 0004): it publishes at whatever -# version its own manifest carries, and because an already-published -# version is treated as done, an unbumped engine is a no-op while a -# bumped one ships in the same run. `@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: @@ -64,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 @@ -79,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 @@ -106,26 +70,13 @@ 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 - # ---------------------------------------------------------------- - # The dev build. No conditions: every run of this workflow ships - # one. That is what keeps the `dev` dist-tag from ever naming an - # older version than the release tag. - # ---------------------------------------------------------------- + # --- The dev build: every run, no conditions. --- - # Ephemeral, never committed: the version, and the product CLI - # packages moved to their own dev builds. A dev CLI depends on the - # products' dev builds; a release depends only on their releases - # (operator ruling 2026-08-17). 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. + # The lockfile refresh is part of the stamp: pnpm verifies + # manifests against the lockfile before running any script. - name: Stamp the dev version run: | node scripts/set-version.ts "${{ steps.version.outputs.devVersion }}" @@ -135,12 +86,6 @@ jobs: - name: Build the dev version run: pnpm build - # Everything that stands between a build and the registry: the - # assembled command tree still complete, the version helpers still - # correct, and the conformance checks — built output importing only - # declared dependencies, every config-section validator surviving - # hostile input, and the packed tarballs installing into a clean - # sandbox with every declared bin starting on plain Node. - name: Check the dev version env: PUBLISH_CHANNEL: dev @@ -165,11 +110,7 @@ jobs: run: | node scripts/verify-published.mjs "@prisma/cli@$DEV_VERSION" "prisma@$DEV_VERSION" - # ---------------------------------------------------------------- - # The release. Only when the committed version changed (or a - # dispatch asked for one). Runs from the committed tree, so a - # release publishes exactly what its commit says. - # ---------------------------------------------------------------- + # --- The release: only when the committed version changed. --- - name: Restore the committed versions if: ${{ steps.version.outputs.release == 'true' }} @@ -186,8 +127,6 @@ jobs: pnpm check:grammar pnpm check:conformance - # The tarballs these checks packed are the ones attached to the - # GitHub Release below: what was verified is what ships. - name: Upload tarball artifacts if: ${{ steps.version.outputs.release == 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -196,26 +135,18 @@ jobs: 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. - # - # A rerun meets versions already on the registry. npm refuses to - # publish over them — correctly — but that refusal must not stop - # the run before the Release step can repair a missing Release. An - # already-published version is treated as done; every other publish - # failure still fails the run. + # 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.releaseTag }} run: | + # An already-published version is treated as done, so a rerun + # reaches the Release step; every other failure fails the run. publish_one() { local out if out=$(pnpm --filter "$1" publish --tag "$DIST_TAG" --access public --no-git-checks 2>&1); then @@ -233,10 +164,6 @@ jobs: publish_one @prisma/cli publish_one prisma - # What the registry serves, not what the publisher said. `pnpm - # publish` printed a success line for prisma@8.0.0-rc.4 while the - # version stayed unresolvable for minutes, and nobody could tell - # from the run whether the release had shipped (run 32104368661). - name: Verify the release resolves if: ${{ steps.version.outputs.release == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }} env: @@ -248,16 +175,10 @@ jobs: "@prisma/cli@$RELEASE_VERSION" \ "prisma@$RELEASE_VERSION" - # Releases here are immutable: once published, neither the assets - # nor the tag can change. So the Release is created as a draft, the - # verified tarballs are attached, and only then is it published. - # - # Created through the API rather than `gh release create`, for the - # id in the response: the previous version searched the releases - # listing for the draft by tag a second after creating it, that - # listing is eventually consistent, and when it had not caught up - # the step failed and left v8.0.0-rc.4 sitting as a draft - # (run 32104368661). + # 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.githubRelease == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }} env: diff --git a/scripts/determine-version.ts b/scripts/determine-version.ts index 3bf67352..aa3d66b6 100644 --- a/scripts/determine-version.ts +++ b/scripts/determine-version.ts @@ -1,39 +1,13 @@ #!/usr/bin/env node /** - * Composes the versions the publish workflow will use. - * - * Every run publishes a dev build; a run also publishes a release when - * the committed version changed. The two are not alternatives, and that - * is deliberate: when they were, the release commit became the one merge - * to `main` that never reached the dev channel, so the `dev` dist-tag sat - * on an older version than the release until an unrelated commit landed - * (operator ruling 2026-08-18). - * - * The base version comes from the root `package.json` (the workspace-wide - * lockstep source of truth — see docs/oss/versioning.md). - * - * - dev, always → `-dev.` under the `dev` dist-tag. The - * suffix derives from the run number and the - * workflow stamps it ephemerally, never committing - * it. - * - release, when → `` under `releaseDistTag`: `next` on the RC - * the version line, `latest` for stable. This is how a merged - * changed `chore(release): ...` PR ships automatically; - * `latest` keeps serving the pre-8 CLI until the - * operator deliberately moves it. - * - `workflow_dispatch` always offers the release half, with the dist-tag - * from `INPUT_DIST_TAG`; empty means the canonical - * tag. The manual escape hatch: re-publish after a - * transient failure, or cut a beta. Passing `latest` - * explicitly for an RC version is the deliberate - * cutover act. + * The versions the publish workflow will use, from the root + * `package.json` at this ref: always a dev version, and a release + * version when this push changed it. Not either/or — see + * docs/oss/versioning.md for why. * * Outputs `devVersion`, `release`, `releaseVersion`, `releaseTag` and - * `githubRelease` to `$GITHUB_OUTPUT`. - * - * This script never rewrites a manifest. Release versions are the ones - * committed at this ref, always. + * `githubRelease`. Never rewrites a manifest. */ import { execFileSync } from "node:child_process"; @@ -70,13 +44,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)) { @@ -112,8 +80,7 @@ function writeGitHubOutput( if (release === undefined) return; appendFileSync(outputFile, `releaseVersion<... @@ -24,8 +17,6 @@ const DELAY_MS = 15_000; /** * Polls until every spec resolves, or reports the first that never does. - * The registry lookup and the clock are injected so the tests need - * neither. * * @param {readonly string[]} specs * @param {{ check: (spec: string) => Promise, sleep: (ms: number) => Promise, attempts?: number }} io @@ -36,7 +27,7 @@ export async function waitForAll(specs, io) { for (const spec of specs) { let resolved = false; for (let attempt = 1; attempt <= attempts; attempt++) { - // biome-ignore lint/performance/noAwaitInLoops: polling is sequential by nature — each attempt exists only because the previous one failed + // biome-ignore lint/performance/noAwaitInLoops: each attempt exists only because the previous one failed if (await io.check(spec)) { resolved = true; break; From 0c6874ff88e4e500408092674235f513604bc8f1 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 18 Aug 2026 09:59:42 +0200 Subject: [PATCH 4/4] Address the review: shared publish tolerance, draft repair, stricter inputs The dev publish now tolerates already-published versions the same way the release publish does (scripts/publish-packages.sh, shared by both), so a re-run of the same workflow run reaches the release steps instead of failing on its own earlier success. The Release step no longer mistakes a leftover draft for a published release: it searches the listing (the by-tag endpoints do not see drafts), deletes a stale draft, and uploads assets by release id so a fresh draft cannot race the by-tag lookup. determine-version refuses a dispatch dist-tag that is not a plain lowercase word, so nothing with a newline or a leading dash reaches pnpm publish or GITHUB_OUTPUT. verify-published treats only E404 as "not published yet" and stops on real npm failures, skips the sleep after the final attempt, and uses pathToFileURL for the direct-run check. The stamped dev version reaches its step through the environment, not inline expansion. Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/publish.yml | 50 +++++++++++-------------- scripts/determine-version-utils.test.ts | 23 ++++++++++++ scripts/determine-version-utils.ts | 16 ++++++++ scripts/determine-version.ts | 2 + scripts/publish-packages.sh | 25 +++++++++++++ scripts/verify-published.mjs | 27 +++++++++++-- scripts/verify-published.test.mjs | 35 ++++++++++++++++- 7 files changed, 144 insertions(+), 34 deletions(-) create mode 100755 scripts/publish-packages.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0329bd06..4d029822 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -78,8 +78,10 @@ jobs: # 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.devVersion }}" + node scripts/set-version.ts "$DEV_VERSION" node scripts/update-product-versions.mjs --channel dev pnpm install --lockfile-only --no-frozen-lockfile @@ -98,10 +100,7 @@ jobs: if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true' }} env: NPM_CONFIG_PROVENANCE: "true" - run: | - for pkg in @prisma/cli-engine @prisma/cli prisma; do - pnpm --filter "$pkg" publish --tag dev --access public --no-git-checks - done + 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' }} @@ -144,25 +143,7 @@ jobs: env: NPM_CONFIG_PROVENANCE: "true" DIST_TAG: ${{ steps.version.outputs.releaseTag }} - run: | - # An already-published version is treated as done, so a rerun - # reaches the Release step; every other failure fails the 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 + run: bash scripts/publish-packages.sh "$DIST_TAG" @prisma/cli-engine @prisma/cli prisma - name: Verify the release resolves if: ${{ steps.version.outputs.release == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }} @@ -185,9 +166,16 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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=false case "$VERSION" in @@ -205,9 +193,13 @@ jobs: 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 release upload "v$VERSION" "$tarball" --clobber + 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/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< ... +# +# `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 index 770c2b95..af7a92b3 100644 --- a/scripts/verify-published.mjs +++ b/scripts/verify-published.mjs @@ -8,12 +8,14 @@ // 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. @@ -32,19 +34,36 @@ export async function waitForAll(specs, io) { resolved = true; break; } - await io.sleep(DELAY_MS); + 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 { - return false; + } catch (error) { + if (isNotFoundError(error)) return false; + throw error; } } @@ -73,5 +92,5 @@ async function main() { const isDirectRun = process.argv[1] !== undefined && - import.meta.url === new URL(`file://${process.argv[1]}`).href; + 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 index 2ca2791d..4a466c2e 100644 --- a/scripts/verify-published.test.mjs +++ b/scripts/verify-published.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { waitForAll } from "./verify-published.mjs"; +import { isNotFoundError, waitForAll } from "./verify-published.mjs"; /** A check that answers false the first `misses` times, then true. */ function resolvesAfter(misses) { @@ -58,6 +58,19 @@ describe("waitForAll", () => { 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), @@ -66,3 +79,23 @@ describe("waitForAll", () => { 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); + }); +});