From ed1c346253038f39f568ece09d7acffee8b94411 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 3 Nov 2026 15:22:06 +0100 Subject: [PATCH 1/3] A product release releases the CLI: the version-follow pull request also bumps the CLI version prisma@8.0.0-rc.29 pinned @prisma/orm-toolchain 8.0.0-rc.21 for four days after ORM rc.22 shipped. The follow pull request (#381) merged, but it only shipped a dev build, and the release pull request (#380) waited for review. Now the follow pull request runs pnpm bump-version too, so merging it publishes the release. bump-version passes --no-frozen-lockfile, because CI defaults pnpm to a frozen lockfile. Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/update-product-versions.yml | 47 ++++++++++--------- docs/oss/release-automation.md | 11 +++-- docs/oss/versioning.md | 6 +-- scripts/bump-version.ts | 2 +- 4 files changed, 35 insertions(+), 31 deletions(-) diff --git a/.github/workflows/update-product-versions.yml b/.github/workflows/update-product-versions.yml index 725f223b..36e11f03 100644 --- a/.github/workflows/update-product-versions.yml +++ b/.github/workflows/update-product-versions.yml @@ -1,10 +1,10 @@ name: Update product versions # When composer or prisma/prisma publish a release of a package this repo -# depends on, this workflow moves the dependency and opens a pull -# request; merging it ships a dev version of the CLI (operator rulings -# 2026-08-13 and 2026-08-17; docs/oss/release-automation.md). Only a real -# CLI release needs a human. +# depends on, this workflow moves the dependency, bumps the CLI to its next +# version and opens a pull request; merging it releases the CLI +# (docs/oss/release-automation.md). A CLI shipped behind a product release +# breaks every project on that release, so nobody has to remember to cut it. # # It follows each product's RELEASE tag, never `dev`. The dev channel # gets its dev builds from publish.yml's stamp instead, so what this @@ -65,50 +65,53 @@ jobs: id: compare run: node scripts/update-product-versions.mjs --channel release - # `pnpm install --no-frozen-lockfile` scoped to the lockfile - # refresh: the edit touches two manifests, and the pull request must - # carry the matching lockfile or every CI job fails on - # --frozen-lockfile. - - name: Refresh the lockfile + - name: Bump the CLI version + id: bump if: ${{ steps.compare.outputs.changed == 'true' }} run: | corepack enable - pnpm install --lockfile-only --no-frozen-lockfile + pnpm bump-version + echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" - name: Open the pull request with auto-merge if: ${{ steps.compare.outputs.changed == 'true' }} env: GH_TOKEN: ${{ secrets.DEPLOY_GITHUB_TOKEN }} SUMMARY: ${{ steps.compare.outputs.summary }} + VERSION: ${{ steps.bump.outputs.version }} run: | if [ -z "$GH_TOKEN" ]; then echo "::error::DEPLOY_GITHUB_TOKEN is not configured, so this workflow cannot open a pull request that triggers CI. See docs/oss/release-automation.md." exit 1 fi - # An open pull request from an earlier run already carries these - # versions or is about to be superseded by this one. Close it - # rather than stacking a second: two open version updates race - # each other's auto-merge and one lands on a stale lockfile. + # Every open version update or release pull request bumps the same + # version this one does, so only one of them can merge. + COMMENT="Superseded by the automatic release $VERSION, which follows the products' released versions: $SUMMARY." gh pr list --state open --json number,headRefName \ --jq '.[] | select(.headRefName | startswith("product-versions/")) | .number' \ | while read -r number; do [ -n "$number" ] || continue - gh pr close "$number" \ - --comment "Superseded by a newer product version update." \ - --delete-branch + gh pr close "$number" --comment "$COMMENT" --delete-branch + done + gh pr list --state open --json number,title \ + --jq '.[] | select(.title | startswith("Bump to version ")) | .number' \ + | while read -r number; do + [ -n "$number" ] || continue + gh pr close "$number" --comment "$COMMENT" done BRANCH="product-versions/$(date -u +%Y%m%d%H%M%S)" + TITLE="Release $VERSION: follow the products' released versions: $SUMMARY" git config user.name "willbot" git config user.email "w.a.madden+machine@gmail.com" git checkout -b "$BRANCH" - git add packages/cli/package.json packages/prisma/package.json pnpm-lock.yaml + git add -u git commit -s --trailer "Signed-off-by: Will Madden " \ - -m "Follow the products' released versions: $SUMMARY" \ - -m "Opened automatically (update-product-versions.yml); merging ships a dev version of the CLI. A real release stays a human act." + -m "$TITLE" \ + -m "Opened automatically (update-product-versions.yml); merging releases the CLI." git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" PR_URL=$(gh pr create \ - --title "Follow the products' released versions: $SUMMARY" \ - --body "Opened automatically by update-product-versions.yml, following the products' release dist-tags ($SUMMARY). Merging publishes a \`dev\` version of the CLI; the conformance checks on this pull request are what stand between a product release and that dev build." \ + --title "$TITLE" \ + --body "Opened automatically by update-product-versions.yml, following the products' release dist-tags ($SUMMARY). Merging releases \`prisma@$VERSION\` under \`latest\`; the checks on this pull request are what stand between a product release and that CLI release." \ --base main --head "$BRANCH") gh pr merge --auto --squash "$PR_URL" echo "Opened $PR_URL with auto-merge armed." diff --git a/docs/oss/release-automation.md b/docs/oss/release-automation.md index fb494655..70f76a57 100644 --- a/docs/oss/release-automation.md +++ b/docs/oss/release-automation.md @@ -1,6 +1,6 @@ # Release automation across the three repositories -The unified CLI is assembled from three repositories. This page explains how a new product version reaches the CLI without anyone editing a file by hand, what the one shared secret is for, and how to repair the machinery when it stops. +The unified CLI is assembled from three repositories. This page explains how a new product version reaches a released CLI without anyone editing a file or approving a release, what the one shared secret is for, and how to repair the machinery when it stops. | Repository | Publishes | Consumed by the CLI as | | --- | --- | --- | @@ -16,9 +16,10 @@ A **release** of the CLI depends only on **released** versions of the products. 1. The product repository's publish workflow finishes and sends a `repository_dispatch` event of type `product-published` to `prisma/prisma-cli`. 2. [`update-product-versions.yml`](../../.github/workflows/update-product-versions.yml) runs [`scripts/update-product-versions.mjs`](../../scripts/update-product-versions.mjs), which asks the registry for each watched package's version at its **release** dist-tag and rewrites `packages/cli/package.json` and `packages/prisma/package.json` where they differ. Every watched package publishes its releases under `latest`, pre-release versions included: they are all new packages with no pre-8 audience to protect. Holding `latest` back behind `next` matters only for the bare `prisma` name, which this repository publishes and which still serves Prisma 7 there until the deliberate cutover. Each package carries a list of candidate tags rather than one name, most-preferred first, so a product moving where it publishes does not silently freeze the CLI on an old version — and a package that publishes none of its candidates fails the run instead of looking like "nothing to update". -3. If anything changed, the workflow refreshes `pnpm-lock.yaml`, opens a pull request, and arms auto-merge. Any earlier open pull request from this workflow is closed first, so two of them cannot race each other onto a stale lockfile. -4. The pull request runs the full quality and conformance checks. When they pass, it merges, and that push to `main` publishes a `dev` build of the CLI carrying the products' **dev** versions (the dev stamp in [`publish.yml`](../../.github/workflows/publish.yml) rewrites them for that run only). -5. A real CLI release stays a human act: a maintainer cuts the `rc.N` bump, and that release ships the committed release versions. +3. If anything changed, the workflow bumps the CLI to its next version with `pnpm bump-version` (which also refreshes `pnpm-lock.yaml`), opens a pull request titled `Release : follow the products' released versions: ...`, and arms auto-merge. Every earlier open pull request from this workflow, and every open `Bump to version` release pull request, is closed first: each bumps the same version, so only one can merge. +4. The pull request runs the full quality and conformance checks. When they pass, it merges, and that push to `main` changes the root version, so [`publish.yml`](../../.github/workflows/publish.yml) publishes the release under `latest` with the committed product versions, plus the usual `dev` build. + +A product release reaches a released CLI with no person in the loop, because a CLI left on an older product version breaks every project on the newer one: `prisma@8.0.0-rc.29` pinned `@prisma/orm-toolchain` 8.0.0-rc.21 for four days after the ORM's rc.22 shipped, while its release pull request waited for a review. A release for a change in this repository alone is still cut by a maintainer (see [versioning.md](versioning.md)). Everything on `main` ships with either kind of release, so `main` must always be releasable. A **daily schedule** runs the same comparison, so a lost dispatch event delays an update by at most a day rather than indefinitely. `workflow_dispatch` runs it on demand. @@ -40,7 +41,7 @@ One fine-grained personal access token, stored under the same secret name in all Only `prisma/prisma-cli` needs to be listed even though the secret lives in three repositories. In this repository the token pushes the branch and opens the pull request. In composer and prisma/prisma it only sends the `repository_dispatch` event *to* this repository, and that endpoint needs Contents write **on the target repository** — the same permission on the same one repository. -Deliberately **not** granted: the Workflows permission. The pull request only ever touches the two manifests and the lockfile, so a change under `.github/workflows/` would be rejected — a useful limit on an unattended token. +Deliberately **not** granted: the Workflows permission. The pull request only ever touches the files `pnpm bump-version` and `update-product-versions.mjs` write, so a change under `.github/workflows/` would be rejected — a useful limit on an unattended token. **When it expires** the workflow fails: an absent secret is reported as an error before anything else runs, and an expired one fails the first `git push` or `gh` call. The catch is *where* it fails — a scheduled run nobody is watching — so the visible symptom is stale versions rather than a red pull request, and the daily backstop cannot recover on its own until the token is replaced. Record the renewal date somewhere you will see it. To replace it, generate a new token with the table above and update the secret in all three repositories; nothing in any repository names the account, so no code changes. diff --git a/docs/oss/versioning.md b/docs/oss/versioning.md index d63bfc75..c64f1805 100644 --- a/docs/oss/versioning.md +++ b/docs/oss/versioning.md @@ -28,7 +28,7 @@ How the packages published by *other* repositories relate to the engine's versio **A release of the CLI depends only on released versions of the products; a dev build of the CLI depends on their dev builds** (operator ruling 2026-08-17). The two channels are kept apart by two mechanisms: -- The committed manifests hold the products' **released** versions, and [`update-product-versions.yml`](../../.github/workflows/update-product-versions.yml) keeps them current — so `main` always points at released versions, and every version change is a reviewed pull request that CI has tested. A release publish uses those pins exactly as committed, which is what keeps a release reproducible from its tag. Pointing at released versions is not the same as being ready to release: a released version can still be unusable, and what proves the difference is the conformance checks passing on the release channel. +- The committed manifests hold the products' **released** versions, and [`update-product-versions.yml`](../../.github/workflows/update-product-versions.yml) keeps them current — so `main` always points at released versions, and every version change is a pull request that CI has tested. That workflow also releases the CLI, so a product release reaches `latest` without a maintainer ([release-automation.md](release-automation.md)). A release publish uses those pins exactly as committed, which is what keeps a release reproducible from its tag. Pointing at released versions is not the same as being ready to release: a released version can still be unusable, and what proves the difference is the conformance checks passing on the release channel. - A **dev** publish rewrites them to each product's `dev` dist-tag in CI, ephemerally, exactly as it rewrites the CLI's own version. The build and the conformance checks run after that rewrite, in the same run, so a broken product dev build fails the dev publish instead of shipping inside it. This is enforced, not remembered: the conformance checks refuse to publish a release whose dependencies include any `-dev.` version. `prisma@8.0.0-rc.3` shipped two of them — `@prisma/composer@0.6.0-dev.16` and `@prisma/orm-toolchain@8.0.0-rc.1-dev.40` — because nothing looked. @@ -70,7 +70,7 @@ This is by design. The alternatives cause silent problems: 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 `latest` (the accompanying GitHub Release is marked pre-release on the RC line). 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. +**Nothing rewrites a `version` field outside a commit.** `set-version.ts` is run by `pnpm bump-version`, whose output is committed by a maintainer or by `update-product-versions.yml`; 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. Before anything reaches the registry, the workflow verifies the artifact it is about to ship: the grammar-completeness check (`pnpm check:grammar`) proves the assembled command tree lost nothing, and the conformance checks (`pnpm check:conformance`, [`packages/cli/scripts/conformance.ts`](../../packages/cli/scripts/conformance.ts)) prove the built output imports only declared dependencies, every mounted config-section validator survives hostile input, and the packed tarballs survive a registry consumer's install — a clean sandbox, npm with `--ignore-scripts`, unpublished workspace siblings resolved through computed `file:` overrides, every declared bin started on plain Node, the `@prisma/cli-engine` pins agreeing between the shell and every mounted family, and — on a release — no dependency being a dev build. The verified tarballs upload as workflow artifacts on every publishing run and attach to the GitHub Release on releases, so the artifact a maintainer can retrieve is the one the checks verified. The dry-run dispatch runs all of this without registry writes. @@ -78,7 +78,7 @@ Before anything reaches the registry, the workflow verifies the artifact it is a ## Procedure: cut the next release -The release cadence is one PR per release (on the RC line: one PR per `rc.N`). A maintainer: +The release cadence is one PR per release (on the RC line: one PR per `rc.N`). A product release cuts its own CLI release ([release-automation.md](release-automation.md)). For a change in this repository alone, a maintainer: 1. **Runs the [`publish-npm-version` skill](../../skills-contrib/publish-npm-version/SKILL.md)**, which drives `pnpm bump-version` in a fresh worktree off `origin/main` and opens the release PR under real maintainer credentials (so CI runs on it normally). The script reads the root version committed at HEAD, computes the next release version (`8.0.0-rc.N` → `8.0.0-rc.N+1`), and writes it to every lockstep `package.json`. `bump-version` refreshes `pnpm-lock.yaml` itself, because the lockfile records the `workspace:` pins it rewrites and a stale one fails every later frozen install. Commit both and open a `chore(release): 8.0.0-rc.N+1` PR. 2. **Reviews and merges the PR.** This is the point where a human verifies the release is intended — merging the bump PR is the deliberate act that publishes. The resulting push to `main` carries the bumped root `version`, the publish workflow detects the change, publishes under `latest`, and creates a matching GitHub Release (marked pre-release on the RC line). diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts index 8db8ebf1..2cf51984 100644 --- a/scripts/bump-version.ts +++ b/scripts/bump-version.ts @@ -68,7 +68,7 @@ execFileSync("node", [setVersionScript, nextVersion], { console.log(""); console.log("Refreshing pnpm-lock.yaml to match the rewritten specifiers..."); -execFileSync("pnpm", ["install", "--lockfile-only"], { +execFileSync("pnpm", ["install", "--lockfile-only", "--no-frozen-lockfile"], { cwd: rootDir, stdio: "inherit", }); From 872217cc0d20dfbeb8c8a29a95841de2599a52b7 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 3 Nov 2026 15:33:00 +0100 Subject: [PATCH 2/3] Release main's product pins when no release shipped them If a maintainer's release takes the version number before a follow pull request merges, the follow pull request's squash changes no version and ships only a dev build. The registry comparison then finds nothing to move, so the pins stayed unreleased. The release channel now also compares main's pins with the last release's tag, so the next run releases them. Docs name the CodeRabbit approval the merge needs and drop the text that says a release needs a person. Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/update-product-versions.yml | 2 + README.md | 6 +- docs/oss/release-automation.md | 8 ++- docs/oss/versioning.md | 2 +- scripts/determine-version-utils.ts | 6 +- scripts/update-product-versions.mjs | 60 ++++++++++++++++--- scripts/update-product-versions.test.mjs | 43 +++++++++++++ 7 files changed, 108 insertions(+), 19 deletions(-) diff --git a/.github/workflows/update-product-versions.yml b/.github/workflows/update-product-versions.yml index 36e11f03..639f7cdf 100644 --- a/.github/workflows/update-product-versions.yml +++ b/.github/workflows/update-product-versions.yml @@ -52,6 +52,8 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false + # The release tags, to compare main's pins with the last release. + fetch-depth: 0 - name: Set up pnpm uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 diff --git a/README.md b/README.md index 65681a77..6b449844 100644 --- a/README.md +++ b/README.md @@ -135,9 +135,9 @@ lockstep) is the release source of truth. The workflow publishes Release channels: -- `latest`: releases on the Prisma 8 RC line (`8.0.0-rc.N`). A merged - `chore(release)` PR that bumps the root version (via `pnpm bump-version`) - publishes automatically; `workflow_dispatch` is the manual escape hatch and +- `latest`: releases on the Prisma 8 RC line (`8.0.0-rc.N`). A merged PR that + bumps the root version (via `pnpm bump-version`) publishes automatically: + a maintainer's release PR, or the version-follow PR a product release opens; `workflow_dispatch` is the manual escape hatch and the dry-run path. - `dev`: latest successful `main` build. Every push to `main` that does not change the root version publishes `-dev.N` under the `dev` dist-tag. diff --git a/docs/oss/release-automation.md b/docs/oss/release-automation.md index 70f76a57..b8a0253d 100644 --- a/docs/oss/release-automation.md +++ b/docs/oss/release-automation.md @@ -16,8 +16,8 @@ A **release** of the CLI depends only on **released** versions of the products. 1. The product repository's publish workflow finishes and sends a `repository_dispatch` event of type `product-published` to `prisma/prisma-cli`. 2. [`update-product-versions.yml`](../../.github/workflows/update-product-versions.yml) runs [`scripts/update-product-versions.mjs`](../../scripts/update-product-versions.mjs), which asks the registry for each watched package's version at its **release** dist-tag and rewrites `packages/cli/package.json` and `packages/prisma/package.json` where they differ. Every watched package publishes its releases under `latest`, pre-release versions included: they are all new packages with no pre-8 audience to protect. Holding `latest` back behind `next` matters only for the bare `prisma` name, which this repository publishes and which still serves Prisma 7 there until the deliberate cutover. Each package carries a list of candidate tags rather than one name, most-preferred first, so a product moving where it publishes does not silently freeze the CLI on an old version — and a package that publishes none of its candidates fails the run instead of looking like "nothing to update". -3. If anything changed, the workflow bumps the CLI to its next version with `pnpm bump-version` (which also refreshes `pnpm-lock.yaml`), opens a pull request titled `Release : follow the products' released versions: ...`, and arms auto-merge. Every earlier open pull request from this workflow, and every open `Bump to version` release pull request, is closed first: each bumps the same version, so only one can merge. -4. The pull request runs the full quality and conformance checks. When they pass, it merges, and that push to `main` changes the root version, so [`publish.yml`](../../.github/workflows/publish.yml) publishes the release under `latest` with the committed product versions, plus the usual `dev` build. +3. If any pin changed, or `main` holds pins the last release (its `v` tag) did not ship, the workflow bumps the CLI to its next version with `pnpm bump-version` (which also refreshes `pnpm-lock.yaml`), opens a pull request titled `Release : follow the products' released versions: ...`, and arms auto-merge. Every earlier open pull request from this workflow, and every open `Bump to version` release pull request, is closed first: each bumps the same version, so only one can merge. +4. The pull request runs the full quality and conformance checks. When they pass and CodeRabbit approves it (the `main` ruleset requires one approving review), it merges, and that push to `main` changes the root version, so [`publish.yml`](../../.github/workflows/publish.yml) publishes the release under `latest` with the committed product versions, plus the usual `dev` build. A product release reaches a released CLI with no person in the loop, because a CLI left on an older product version breaks every project on the newer one: `prisma@8.0.0-rc.29` pinned `@prisma/orm-toolchain` 8.0.0-rc.21 for four days after the ORM's rc.22 shipped, while its release pull request waited for a review. A release for a change in this repository alone is still cut by a maintainer (see [versioning.md](versioning.md)). Everything on `main` ships with either kind of release, so `main` must always be releasable. @@ -72,7 +72,9 @@ A workflow step that must run should fail when its secret or token is missing, n | No pull request after a product release | Dispatch event not sent, or the token is missing or expired | The daily run will catch it up; check the product repository's notify step and the secret | | 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 | +| Two open version-update pull requests | The close-the-previous step failed | Close the older one by hand; they race each other's version and lockfile | +| Checks pass but the pull request does not merge | The `main` ruleset requires one approving review, which CodeRabbit gives; it commented or was rate-limited instead | Approve the pull request by hand | +| `main` holds product pins no release shipped | A release took the version number before the follow pull request merged | The next run (the daily schedule at the latest) compares `main`'s pins with the last release's tag and opens a release for them; dispatch the workflow to do it now | | 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 | diff --git a/docs/oss/versioning.md b/docs/oss/versioning.md index c64f1805..c1ae99ac 100644 --- a/docs/oss/versioning.md +++ b/docs/oss/versioning.md @@ -43,7 +43,7 @@ The npm registry exposes the CLI packages under these dist-tags: - **`next`** — kept alive but no longer moved by the publish path (operator ruling 2026-08-25). Removing it would break every `prisma@next` instruction immediately, and an automated sync to `latest` would need a standing token — re-pointing a tag on an already-published version cannot authenticate over OIDC, and npm has no token permission narrower than package write, which is publish-capable. So the operator points it at the current release by hand (`npm dist-tag add prisma@ next`, same for `@prisma/cli` and the engine), accepts that it lags between such moves — a lagging `next` still resolves a complete, working release — and retires it with an announcement once `8.0.0` stable ships. New text should say plain `prisma`. - **`beta`** — reserved for hand-cut previews ahead of significant changes, published by dispatching the workflow with that dist-tag. Routine releases do not use this tag. -- **`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 — a merged `rc.N` bump publishing under `latest` — is a human act. +- **`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: every push to `main` ships a dev build. A product release goes further and cuts a real release on its own; see [release automation](./release-automation.md). Only a release for a change in this repository alone 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. The dev channel covers `@prisma/cli` and `prisma` only. The engine has no dev builds; [Lockstep across the workspace](#lockstep-across-the-workspace) gives the reasons. diff --git a/scripts/determine-version-utils.ts b/scripts/determine-version-utils.ts index cac4255c..e56e14dd 100644 --- a/scripts/determine-version-utils.ts +++ b/scripts/determine-version-utils.ts @@ -68,10 +68,8 @@ export interface VersionResult { /** * The version a routine main push publishes under the `dev` dist-tag - * (operator ruling 2026-08-13: a product's new version deploys as a dev - * version without a human; only a real release needs one). The run - * number makes - * the suffix monotonic per workflow run without reading the registry. + * (operator ruling 2026-08-13). The run number makes the suffix monotonic + * per workflow run without reading the registry. */ export function devVersion(base: string, runNumber: string): string { assertCanonicalBase(base); diff --git a/scripts/update-product-versions.mjs b/scripts/update-product-versions.mjs index e89b4adf..a38a5826 100644 --- a/scripts/update-product-versions.mjs +++ b/scripts/update-product-versions.mjs @@ -146,6 +146,47 @@ export function applyUpdates(manifests, updates) { } } +/** + * Each watched pin that differs between two sets of manifests, once per + * package. Pure; exported for tests. + * + * @param {ReadonlyArray<{ path: string; manifest: Record }>} before + * @param {ReadonlyArray<{ path: string; manifest: Record }>} after + * @returns {Array<{ name: string; from: string; to: string }>} + */ +export function pinChanges(before, after) { + const published = new Map(); + for (const { manifest } of after) { + for (const field of FIELDS) { + for (const { name } of WATCHED) { + const version = manifest[field]?.[name]; + if (version !== undefined) published.set(name, version); + } + } + } + const changes = new Map(); + for (const { name, from, to } of computeUpdates(before, published)) { + if (!changes.has(name)) changes.set(name, { name, from, to }); + } + return [...changes.values()]; +} + +/** The manifests as the last release shipped them, read from its tag. */ +function releasedManifests() { + const { version } = JSON.parse( + readFileSync(join(rootDir, "package.json"), "utf-8"), + ); + return MANIFEST_PATHS.map((path) => ({ + path, + manifest: JSON.parse( + execFileSync("git", ["show", `v${version}:${path}`], { + cwd: rootDir, + encoding: "utf-8", + }), + ), + })); +} + const PACKAGE_ABSENT = /E404|404 Not Found/; /** @@ -244,16 +285,19 @@ function main() { const outputFile = process.env.GITHUB_OUTPUT; if (outputFile) { - // One line per package, not per manifest: both manifests carry the - // same pins, so naming each twice makes an unreadable PR title. - const summary = [ - ...new Set( - updates.map(({ name, from, to }) => `${name} ${from} -> ${to}`), - ), - ].join("; "); + // A release ships what main moved since the last release, which + // includes pins a follow pull request merged after a release that + // already took its version number. + const changes = + channel === "release" + ? pinChanges(releasedManifests(), manifests) + : [...new Map(updates.map((update) => [update.name, update])).values()]; + const summary = changes + .map(({ name, from, to }) => `${name} ${from} -> ${to}`) + .join("; "); appendFileSync( outputFile, - `changed< 0)}\nEOF\n`, + `changed< 0)}\nEOF\n`, ); appendFileSync(outputFile, `summary< { }); }); }); + +describe("pinChanges", () => { + const manifests = (cli, prisma) => [ + { path: CLI, manifest: { dependencies: cli } }, + { path: PRISMA, manifest: { dependencies: prisma } }, + ]; + + /** + * The case that needs it: a follow pull request merged after a release + * that already took its version number, so main holds pins no release + * shipped and the registry comparison finds nothing to move. + */ + it("lists each watched pin main moved since the last release, once", () => { + assert.deepEqual( + pinChanges( + manifests( + { "@prisma/orm-toolchain": "8.0.0-rc.21", execa: "^9.0.0" }, + { "@prisma/orm-toolchain": "8.0.0-rc.21" }, + ), + manifests( + { "@prisma/orm-toolchain": "8.0.0-rc.22", execa: "^9.6.1" }, + { "@prisma/orm-toolchain": "8.0.0-rc.22" }, + ), + ), + [ + { + name: "@prisma/orm-toolchain", + from: "8.0.0-rc.21", + to: "8.0.0-rc.22", + }, + ], + ); + }); + + it("finds nothing when main holds the pins the last release shipped", () => { + const pins = { "@prisma/composer-cli": "0.36.0" }; + assert.deepEqual( + pinChanges(manifests(pins, pins), manifests(pins, pins)), + [], + ); + }); +}); From 96285fbb10d37c80f4c6b78d555f5e82acba6820 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 3 Nov 2026 15:36:27 +0100 Subject: [PATCH 3/3] Compare with the registry alone while the last release has no tag yet publish.yml tags a release only after it publishes, so a run in that gap, or after a failed publish, warns and falls back to the registry comparison instead of failing. Signed-off-by: willbot Signed-off-by: Will Madden --- README.md | 5 +--- docs/oss/release-automation.md | 2 +- scripts/update-product-versions.mjs | 40 +++++++++++++++++++---------- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 6b449844..e1b83d3d 100644 --- a/README.md +++ b/README.md @@ -135,10 +135,7 @@ lockstep) is the release source of truth. The workflow publishes Release channels: -- `latest`: releases on the Prisma 8 RC line (`8.0.0-rc.N`). A merged PR that - bumps the root version (via `pnpm bump-version`) publishes automatically: - a maintainer's release PR, or the version-follow PR a product release opens; `workflow_dispatch` is the manual escape hatch and - the dry-run path. +- `latest`: releases on the Prisma 8 RC line (`8.0.0-rc.N`). A merged PR that bumps the root version (via `pnpm bump-version`) publishes automatically: a maintainer's release PR, or the version-follow PR a product release opens; `workflow_dispatch` is the manual escape hatch and the dry-run path. - `dev`: latest successful `main` build. Every push to `main` that does not change the root version publishes `-dev.N` under the `dev` dist-tag. Commit traceability comes from npm provenance and the GitHub Actions run. diff --git a/docs/oss/release-automation.md b/docs/oss/release-automation.md index b8a0253d..844561de 100644 --- a/docs/oss/release-automation.md +++ b/docs/oss/release-automation.md @@ -19,7 +19,7 @@ A **release** of the CLI depends only on **released** versions of the products. 3. If any pin changed, or `main` holds pins the last release (its `v` tag) did not ship, the workflow bumps the CLI to its next version with `pnpm bump-version` (which also refreshes `pnpm-lock.yaml`), opens a pull request titled `Release : follow the products' released versions: ...`, and arms auto-merge. Every earlier open pull request from this workflow, and every open `Bump to version` release pull request, is closed first: each bumps the same version, so only one can merge. 4. The pull request runs the full quality and conformance checks. When they pass and CodeRabbit approves it (the `main` ruleset requires one approving review), it merges, and that push to `main` changes the root version, so [`publish.yml`](../../.github/workflows/publish.yml) publishes the release under `latest` with the committed product versions, plus the usual `dev` build. -A product release reaches a released CLI with no person in the loop, because a CLI left on an older product version breaks every project on the newer one: `prisma@8.0.0-rc.29` pinned `@prisma/orm-toolchain` 8.0.0-rc.21 for four days after the ORM's rc.22 shipped, while its release pull request waited for a review. A release for a change in this repository alone is still cut by a maintainer (see [versioning.md](versioning.md)). Everything on `main` ships with either kind of release, so `main` must always be releasable. +A product release reaches a released CLI with no person in the loop, because a CLI left on an older product version breaks every project on the newer one: `prisma@8.0.0-rc.29` pinned `@prisma/orm-toolchain` 8.0.0-rc.21 for four days after the ORM's rc.22 shipped, while its release pull request waited for a review. A release for a change in this repository alone is still cut by a maintainer (see [versioning.md](versioning.md)). Everything on `main` ships with either kind of release, so `main` must always be releasable. Any commit that changes a product pin on `main`, by hand or not, is released by the next run. A **daily schedule** runs the same comparison, so a lost dispatch event delays an update by at most a day rather than indefinitely. `workflow_dispatch` runs it on demand. diff --git a/scripts/update-product-versions.mjs b/scripts/update-product-versions.mjs index a38a5826..e80e1a4a 100644 --- a/scripts/update-product-versions.mjs +++ b/scripts/update-product-versions.mjs @@ -171,20 +171,33 @@ export function pinChanges(before, after) { return [...changes.values()]; } -/** The manifests as the last release shipped them, read from its tag. */ +/** + * The manifests as the last release shipped them, read from its tag, or + * undefined while that tag does not exist: publish.yml tags a release only + * after it publishes, so a run in between, or after a failed publish, has + * nothing to compare with. + */ function releasedManifests() { const { version } = JSON.parse( readFileSync(join(rootDir, "package.json"), "utf-8"), ); - return MANIFEST_PATHS.map((path) => ({ - path, - manifest: JSON.parse( - execFileSync("git", ["show", `v${version}:${path}`], { - cwd: rootDir, - encoding: "utf-8", - }), - ), - })); + try { + return MANIFEST_PATHS.map((path) => ({ + path, + manifest: JSON.parse( + execFileSync("git", ["show", `v${version}:${path}`], { + cwd: rootDir, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }), + ), + })); + } catch { + console.warn( + `::warning::No tag v${version} yet (its publish is still running, or failed), so only the registry's versions are compared.`, + ); + return undefined; + } } const PACKAGE_ABSENT = /E404|404 Not Found/; @@ -288,10 +301,11 @@ function main() { // A release ships what main moved since the last release, which // includes pins a follow pull request merged after a release that // already took its version number. + const released = channel === "release" ? releasedManifests() : undefined; const changes = - channel === "release" - ? pinChanges(releasedManifests(), manifests) - : [...new Map(updates.map((update) => [update.name, update])).values()]; + released === undefined + ? [...new Map(updates.map((update) => [update.name, update])).values()] + : pinChanges(released, manifests); const summary = changes .map(({ name, from, to }) => `${name} ${from} -> ${to}`) .join("; ");