Skip to content

Commit f40190e

Browse files
A release of the CLI depends only on released product versions (#192)
## The defect `prisma@8.0.0-rc.3` shipped depending on `@prisma/composer@0.6.0-dev.16` and `@prisma/orm-toolchain@8.0.0-rc.1-dev.40` — dev builds, in a release. Two reasons, and neither was a decision: 1. **Nothing checked.** Conformance verified import purity, validators, and tarball installs. Nothing looked at whether a shipped dependency was a dev build, so interim pins from earlier slices sat in the manifest until a release publish carried them to the registry. 2. **The automation that would have moved them has never run.** `auto-repin.yml` followed each product's release dist-tag, so it would have moved these pins onto real releases — but it needed a token that was never provisioned, so it never executed once. ## The rule, and how it is kept Operator ruling (2026-08-17): **a non-dev CLI depends on the latest non-dev composer and ORM; a dev CLI depends on their latest dev builds.** - The **committed manifests hold the released versions**, kept current by `update-product-versions.yml`. `main` therefore stays releasable, and every version change arrives as a pull request CI has tested. A release publish uses those pins exactly as committed, so a release is reproducible from its tag. - A **dev publish rewrites them** to each product's `dev` dist-tag in CI, ephemerally — the same treatment the CLI's own version already gets. Build and conformance run after the rewrite, in the same run, so a broken product dev build fails the dev publish instead of shipping inside it. - **Check 4 of the conformance suite refuses a release whose dependencies include a `-dev.` version.** It takes the channel from `determine-version.ts`, and it has no exception mechanism on purpose: a suppressed finding exits 0, which is exactly how rc.3 shipped. One script serves both channels (`--channel release|dev`), so the two cannot drift apart. It now edits `packages/prisma/package.json` as well as `packages/cli/package.json` — the old one ignored the unscoped package, whose dependency list must match. ## The shell moves to `@prisma/composer-cli` `@prisma/composer` dropped its `./family` export when composer split its CLI out, so the old pin only worked because it was a dev build from *before* the split. The shell and `packages/prisma` now depend on `@prisma/composer-cli@0.6.0-dev.22`, which declares `@prisma/cli-engine` as an exact peer at `0.1.1` — the version this repo ships. That removes one of the two engine copies an install resolved, and composer's recorded pin exception is deleted. The engine-pin check now reads a family's `peerDependencies` as well as its `dependencies`. Without that it would have silently stopped measuring composer the moment composer did the right thing. `@prisma/composer` stays as a devDependency: the startup probe imports it to prove the eager-loading detector works, and pnpm's strict layout will not resolve an undeclared package. ## Consequence: releases are blocked, correctly Neither product has published a non-dev version this CLI can use, so check 4 fails the release channel today. That is the check working. What each product needs to publish: - **`@prisma/composer-cli@0.6.0`** — its only non-dev version — **cannot be installed at all.** It was published with `npm publish` instead of `pnpm publish`, so its dependency on `@prisma/composer` is the literal string `workspace:0.6.0`; `npm install @prisma/composer-cli@0.6.0` fails with `EUNSUPPORTEDPROTOCOL`. Composer needs to publish with `pnpm publish` from a commit that peers the engine at the shipped version (`0.6.0-dev.22` already does). Composer's repo has no check that installs its own tarball, which is how this reached the registry; check 3 here catches exactly that class and is worth porting. - **`@prisma/orm-toolchain@8.0.0-rc.1`** has no engine relationship, and its `./cli` export contains no `ormCommandFamily` — only `createContractEmitCommand`, `createFormatCommand`, `loadContractFromTs`. The engine-based family exists only in dev builds, and the newest of those went back to declaring the engine at `0.0.9`. prisma/prisma needs to adapt to the current engine and publish that as a release. The ORM's engine-pin exception is the last one left; it dies with that publish, after which the check can move from pin equality to peer satisfaction. Recorded in `deferred.md`. ## Also here - `DEPLOY_GITHUB_TOKEN` replaces `REPIN_PAT`, and the workflow is renamed from `auto-repin` to `update-product-versions`. `docs/oss/release-automation.md` is new: what the token is for, why it belongs to a service account, the exact permissions, the notification step each product repo needs, and a table of failure modes. The token is provisioned in all three repositories. - The workflow closes any earlier open version-update pull request before opening a new one. Two of them race each other's auto-merge and one lands on a stale lockfile. - `minimumReleaseAgeExclude` entries become per-package. They named exact interim versions, so every version change needed an edit here too. - `turbo.json` declares `PUBLISH_CHANNEL` for the conformance task. turbo passes only declared variables through, so undeclared it would have read `release` in every run — found by running it. ## Verification - `pnpm lint`, `pnpm typecheck`, `pnpm test` (979 tests), `pnpm test:scripts` (63) all pass. - `PUBLISH_CHANNEL=dev pnpm check:conformance`: 0 failing, 2 allowed — the ORM's engine mismatch, on the record. - `PUBLISH_CHANNEL=release pnpm check:conformance`: 4 failing, one per dev dependency per published package. This is what would have stopped rc.3. - The `@prisma/composer-cli@0.6.0` install failure was reproduced in a clean sandbox, not inferred from its manifest. --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
1 parent bb79d8f commit f40190e

30 files changed

Lines changed: 1294 additions & 397 deletions

.drive/projects/prisma-cli-v8/deferred.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,3 +428,14 @@ CLI does not do, and each restarts as engine work if wanted:
428428
move `config-loader` into a shared library package the ORM publishes
429429
for exactly this kind of consumer, or cut the cloud extension's use
430430
of it. Spans both product repos; Will decides which.
431+
432+
## Left open by the dev-build fix (2026-08-17)
433+
434+
The release channel is green as of 2026-08-17: `@prisma/composer-cli@0.7.0` and `@prisma/orm-toolchain@8.0.0-rc.2` are both released and both peer `@prisma/cli-engine@0.1.1`, so the conformance run reports nothing and `packages/cli/scripts/conformance.ts` carries no exceptions. What closed, for the record: composer's `0.6.0` was uninstallable (published out-of-band with `npm publish`, leaving `workspace:0.6.0` in its manifest) and the ORM had no released version carrying the command family.
435+
436+
Still open:
437+
438+
- **Both product repos need their publish-notification step** (the work in the closed composer#232 and prisma#30033): a `repository_dispatch` of type `product-published` to `prisma/prisma-cli`, placed immediately after the publish step and keyed on its outcome. Until then a daily scheduled run is what notices a product release, so a new product version reaches the CLI within a day rather than within minutes. `docs/oss/release-automation.md` carries the exact step, and `DEPLOY_GITHUB_TOKEN` is provisioned in all three repositories (2026-08-17).
439+
- **Neither product repo installs its own tarball before publishing.** That is why an uninstallable `@prisma/composer-cli@0.6.0` sat on `latest` unnoticed. prisma-cli's check 3 does exactly this — pack, install into a clean sandbox with `npm --ignore-scripts`, start every declared bin — and is worth porting to both.
440+
- **The engine-pin check compares for equality, not peer satisfaction.** Both families now declare an exact peer equal to the shell's pin, so equality is correct and stricter today. Widening to range satisfaction belongs with the post-GA move to engine ranges (ADR 0004), not before.
441+
- **`credential-manager.ts` uses the banned word.** `packages/cli/src/auth/credential-manager.ts` has a private `#repin` method (about the active-workspace marker, a different concept from dependency versions). The operator banned the word outright; renaming it is a mechanical change to a private method, left out of the publish-channel work to keep that diff to one subject.

.github/workflows/auto-repin.yml

Lines changed: 0 additions & 91 deletions
This file was deleted.

.github/workflows/pr-quality.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,5 +119,13 @@ jobs:
119119
# Finds a broken tarball on the pull request instead of at publish
120120
# time. The turbo task builds its own dependencies, so no explicit
121121
# build step is needed here.
122+
#
123+
# PUBLISH_CHANNEL=dev because merging a pull request publishes a dev
124+
# version of the CLI, and a dev CLI is allowed to depend on the
125+
# products' dev builds. A release publish sets `release` instead and
126+
# is refused if any dependency is a dev build; that is checked in
127+
# publish.yml, which is the only place a release can happen.
122128
- name: Conformance checks
129+
env:
130+
PUBLISH_CHANNEL: dev
123131
run: pnpm check:conformance

.github/workflows/publish.yml

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ name: Publish to npm
1111
# Trigger model:
1212
# - push to `main` with the root `version` unchanged → publish
1313
# `<base>-dev.<run>` under the `dev` dist-tag (operator ruling
14-
# 2026-08-13: automated family repins deploy automatically; only a
15-
# real release needs a human). The dev suffix is stamped
16-
# ephemerally below and never committed.
14+
# 2026-08-13: a product's new version reaches the CLI and deploys
15+
# without a human; only a real release needs one). The dev suffix is
16+
# stamped ephemerally below and never committed.
1717
# - push to `main` with the root `version` changed → publish `<base>`
1818
# under its canonical dist-tag — `next` on the RC line, `latest` for
1919
# stable — and create a GitHub Release (marked pre-release on the RC
@@ -110,10 +110,19 @@ jobs:
110110
PUSH_BEFORE_SHA: ${{ github.event.before }}
111111
run: node scripts/determine-version.ts
112112

113-
# Dev publishes only: stamp `<base>-dev.<run>` across the lockstep
114-
# manifests for this run. Ephemeral — nothing is committed; release
115-
# versions remain exactly what the commit says. The engine is
116-
# excluded from the sweep and ships at its own committed version.
113+
# Dev publishes only, and ephemeral — nothing is committed, so a
114+
# release publishes exactly what the commit says. Two stamps:
115+
#
116+
# 1. `<base>-dev.<run>` across the lockstep manifests. The engine
117+
# is excluded from the sweep and ships at its own committed
118+
# version.
119+
# 2. The product CLI packages move to their `dev` builds. A dev
120+
# CLI depends on the products' latest dev versions and a
121+
# release depends only on their releases (operator ruling
122+
# 2026-08-17), and the committed manifests hold the release
123+
# versions. The build and the conformance checks below run
124+
# after this, so a broken product dev build fails the dev
125+
# publish instead of shipping in it.
117126
- name: Stamp dev version
118127
if: ${{ steps.version.outputs.publish == 'true' && steps.version.outputs.tag == 'dev' }}
119128
# The lockfile refresh is part of the stamp: pnpm verifies
@@ -123,6 +132,7 @@ jobs:
123132
# bump-version does for committed bumps; still ephemeral.
124133
run: |
125134
node scripts/set-version.ts "${{ steps.version.outputs.version }}"
135+
node scripts/update-product-versions.mjs --channel dev
126136
pnpm install --lockfile-only --no-frozen-lockfile
127137
128138
- name: Build packages
@@ -141,20 +151,25 @@ jobs:
141151
if: ${{ steps.version.outputs.publish == 'true' }}
142152
run: pnpm test:scripts
143153

144-
# The three conformance checks against what is about to ship:
145-
# built output imports only declared dependencies, every mounted
146-
# config-section validator survives hostile input, and the packed
154+
# The conformance checks against what is about to ship: built
155+
# output imports only declared dependencies, every mounted
156+
# config-section validator survives hostile input, the packed
147157
# tarballs survive a registry consumer's install — clean sandbox,
148158
# npm with --ignore-scripts, unpublished workspace siblings via
149159
# computed file: overrides, every declared bin started on plain
150-
# Node at exit 0, engine pins agreeing everywhere. Runs before
151-
# BOTH publish paths so the dry run is covered too. The tarballs
152-
# it packs land in artifacts/tarballs and are the ones uploaded
153-
# below: what was verified is what ships. (This absorbed the
154-
# interim scripts/tarball-smoke.mjs, which was written to this
155-
# check's design.)
160+
# Node at exit 0, engine pins agreeing everywhere — and a release
161+
# depends on no dev build. Runs before BOTH publish paths so the
162+
# dry run is covered too. The tarballs it packs land in
163+
# artifacts/tarballs and are the ones uploaded below: what was
164+
# verified is what ships.
165+
#
166+
# PUBLISH_CHANNEL is what makes the dev-build check answer
167+
# correctly for this run. It is the run's own dist-tag, so a dev
168+
# publish is allowed its dev builds and a release is not.
156169
- name: Run conformance checks
157170
if: ${{ steps.version.outputs.publish == 'true' }}
171+
env:
172+
PUBLISH_CHANNEL: ${{ steps.version.outputs.tag == 'dev' && 'dev' || 'release' }}
158173
run: pnpm check:conformance
159174

160175
# The verified tarballs, retrievable per run. On a real release the
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
name: Update product versions
2+
3+
# When composer or prisma/prisma publish a release of a package this repo
4+
# depends on, this workflow moves the dependency and opens a pull
5+
# request; merging it ships a dev version of the CLI (operator rulings
6+
# 2026-08-13 and 2026-08-17; docs/oss/release-automation.md). Only a real
7+
# CLI release needs a human.
8+
#
9+
# It follows each product's RELEASE tag, never `dev`. The dev channel
10+
# gets its dev builds from publish.yml's stamp instead, so what this
11+
# workflow commits is always releasable.
12+
#
13+
# Triggers:
14+
# - repository_dispatch `product-published`: sent by the product repos'
15+
# publish workflows the moment they ship.
16+
# - schedule (daily): the backstop for a missed dispatch — the same
17+
# comparison against the registry, so a lost event delays an update
18+
# by at most a day.
19+
# - workflow_dispatch: run it by hand.
20+
#
21+
# The pull request is opened with DEPLOY_GITHUB_TOKEN, not GITHUB_TOKEN:
22+
# pull requests created with the workflow token never trigger CI, and
23+
# without CI the required checks never report, so auto-merge would wait
24+
# forever. See docs/oss/release-automation.md for what the token is and
25+
# how to replace it.
26+
27+
on:
28+
repository_dispatch:
29+
types: [product-published]
30+
schedule:
31+
- cron: "17 6 * * *"
32+
workflow_dispatch:
33+
34+
concurrency:
35+
group: update-product-versions
36+
cancel-in-progress: false
37+
38+
jobs:
39+
update:
40+
name: Compare versions against the registry
41+
runs-on: ubuntu-latest
42+
# Read-only: every write here goes through DEPLOY_GITHUB_TOKEN (the
43+
# branch push, the pull request, auto-merge), and the checkout keeps
44+
# no credentials, so the workflow token needs nothing but the
45+
# checkout itself. Granting it write access would hand a
46+
# write-capable token to every action in the job for no reason.
47+
permissions:
48+
contents: read
49+
50+
steps:
51+
- name: Checkout
52+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
53+
with:
54+
persist-credentials: false
55+
56+
- name: Set up pnpm
57+
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
58+
59+
- name: Set up Node.js
60+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
61+
with:
62+
node-version-file: .node-version
63+
64+
- name: Compare versions against the registry
65+
id: compare
66+
run: node scripts/update-product-versions.mjs --channel release
67+
68+
# `pnpm install --no-frozen-lockfile` scoped to the lockfile
69+
# refresh: the edit touches two manifests, and the pull request must
70+
# carry the matching lockfile or every CI job fails on
71+
# --frozen-lockfile.
72+
- name: Refresh the lockfile
73+
if: ${{ steps.compare.outputs.changed == 'true' }}
74+
run: |
75+
corepack enable
76+
pnpm install --lockfile-only --no-frozen-lockfile
77+
78+
- name: Open the pull request with auto-merge
79+
if: ${{ steps.compare.outputs.changed == 'true' }}
80+
env:
81+
GH_TOKEN: ${{ secrets.DEPLOY_GITHUB_TOKEN }}
82+
SUMMARY: ${{ steps.compare.outputs.summary }}
83+
run: |
84+
if [ -z "$GH_TOKEN" ]; then
85+
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."
86+
exit 1
87+
fi
88+
# An open pull request from an earlier run already carries these
89+
# versions or is about to be superseded by this one. Close it
90+
# rather than stacking a second: two open version updates race
91+
# each other's auto-merge and one lands on a stale lockfile.
92+
gh pr list --state open --json number,headRefName \
93+
--jq '.[] | select(.headRefName | startswith("product-versions/")) | .number' \
94+
| while read -r number; do
95+
[ -n "$number" ] || continue
96+
gh pr close "$number" \
97+
--comment "Superseded by a newer product version update." \
98+
--delete-branch
99+
done
100+
BRANCH="product-versions/$(date -u +%Y%m%d%H%M%S)"
101+
git config user.name "willbot"
102+
git config user.email "w.a.madden+machine@gmail.com"
103+
git checkout -b "$BRANCH"
104+
git add packages/cli/package.json packages/prisma/package.json pnpm-lock.yaml
105+
git commit -s --trailer "Signed-off-by: Will Madden <madden@prisma.io>" \
106+
-m "Follow the products' released versions: $SUMMARY" \
107+
-m "Opened automatically (update-product-versions.yml); merging ships a dev version of the CLI. A real release stays a human act."
108+
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH"
109+
PR_URL=$(gh pr create \
110+
--title "Follow the products' released versions: $SUMMARY" \
111+
--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." \
112+
--base main --head "$BRANCH")
113+
gh pr merge --auto --squash "$PR_URL"
114+
echo "Opened $PR_URL with auto-merge armed."

docs/architecture/adrs/0004-engine-version-pinning.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Per edge:
3737

3838
**Exact now, range later.** During the rc line the engine breaks its consumers deliberately, slice by slice, so a version range would be fiction. Post-GA, the recorded destination is widening the peers to a range under a written engine compatibility contract, so a non-breaking engine release ships in the next shell release with zero family republishes. Widening is a deliberate future decision against that contract, not a drift.
3939

40-
**Engine versioning — RULED (operator, 2026-08-13): the engine versions independently.** Under the shell lockstep it previously shared, every CLI release manufactured a new engine version — and with it a family-repin obligation — whether or not the engine changed. Decoupled, an engine version means "the engine changed", the exact peers stay valid between real engine changes, and the repin train runs only when there is something to repin for. Mechanically: the engine is excluded from `set-version.ts`'s lockstep (alongside `@prisma/compute`), follows honest pre-1.0 semver (breaking bumps the minor), and publishes at its own manifest version, with an already-published version treated as a no-op by the publish workflow.
40+
**Engine versioning — RULED (operator, 2026-08-13): the engine versions independently.** Under the shell lockstep it previously shared, every CLI release manufactured a new engine version — and obliged every product to publish a new peer version — whether or not the engine changed. Decoupled, an engine version means "the engine changed", the exact peers stay valid between real engine changes, and the products only have to move when there is a real change to move to. Mechanically: the engine is excluded from `set-version.ts`'s lockstep (alongside `@prisma/compute`), follows honest pre-1.0 semver (breaking bumps the minor), and publishes at its own manifest version, with an already-published version treated as a no-op by the publish workflow.
4141

4242
## Enforcement
4343

0 commit comments

Comments
 (0)