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); + }); +});