diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index bbc23c33..a8032062 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -429,6 +429,16 @@ CLI does not do, and each restarts as engine work if wanted: for exactly this kind of consumer, or cut the cloud extension's use of it. Spans both product repos; Will decides which. +## The rc.4 engine mismatch (2026-08-18) — the repair chain + +`prisma@8.0.0-rc.4` on `next` crashes on import: #183 and #184 changed the engine without bumping its version, so the registry's `@prisma/cli-engine@0.1.1` (published ten minutes before #183 merged) lacks exports the CLI imports. The registry is immutable and both products peer the engine exactly, so npm fails with ERESOLVE on any mismatch; the repair is a chain, in order: + +1. **prisma-cli #200**: engine → 0.2.0, plus the CI job that fails any PR changing `packages/cli-engine/` while its version is already on the registry (the check whose absence let rc.4 ship). Merging it publishes engine 0.2.0. +2. **Both product repos**: re-declare the exact engine peer at 0.2.0 and release (`composer-cli` 0.7.1, `orm-toolchain` 8.0.0-rc.3). Their lockfiles cannot resolve 0.2.0 before step 1 publishes. +3. **prisma-cli**: pick up those product versions and cut `8.0.0-rc.5` — the first version on `next` that works again. rc.4 itself cannot be repaired. + +The cost the incident exposes: every engine API change forces this three-repo, three-release sequence, because the exact peer is what guarantees one engine per install (ADR 0004). Making the chain cheaper — automation that opens the product peer-bump PRs when a new engine publishes, riding the same `DEPLOY_GITHUB_TOKEN` as the notification step below — is a design question for Will. + ## Left open by the dev-build fix (2026-08-17) 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. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4314fa0e..7fadabc9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,3 +58,26 @@ jobs: - name: Run CLI tests run: pnpm --filter @prisma/cli test + + # A changed engine must claim a version the registry does not have + # yet, or the release ships a CLI built against exports the published + # engine lacks (how prisma@8.0.0-rc.4 broke `npx prisma@next`). + engine-version: + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + fetch-depth: 0 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + + - name: The engine version must be new if the engine changed + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: node scripts/check-engine-version.mjs "$BASE_SHA" diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index 8dcb2cf5..acaca244 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli-engine", - "version": "0.1.1", + "version": "0.2.0", "description": "The execution engine of the unified Prisma CLI.", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index f61a4976..2a57d5d5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,7 +48,7 @@ "conformance": "tsx scripts/conformance.ts" }, "dependencies": { - "@prisma/cli-engine": "workspace:0.1.1", + "@prisma/cli-engine": "workspace:0.2.0", "@prisma/composer-cli": "0.7.0", "@prisma/compute-sdk": "0.39.0", "@prisma/credentials-store": "^7.8.0", diff --git a/packages/cli/scripts/conformance.ts b/packages/cli/scripts/conformance.ts index 46880bc0..75e3f899 100644 --- a/packages/cli/scripts/conformance.ts +++ b/packages/cli/scripts/conformance.ts @@ -100,15 +100,34 @@ async function tarball(): Promise { shellPackage: "@prisma/cli", enginePackage: "@prisma/cli-engine", familyPackages: ["@prisma/composer-cli", "@prisma/orm-toolchain"], - // No exceptions. Both families declare @prisma/cli-engine as an - // exact peer at the version this repo ships, so one engine - // resolves in an install — what ADR 0004 asks for, reached - // 2026-08-17 by @prisma/composer-cli@0.7.0 and - // @prisma/orm-toolchain@8.0.0-rc.2. Anything that reopens the - // two-engine defect now fails the publish instead of being - // excused, and adding an entry here is a decision to be argued - // for, not a way to get green. - exceptions: [], + // The empty list is the goal state: both families peering the + // exact engine version this repo ships, one engine per install + // (ADR 0004). The two entries below are an engine version + // transition in flight — a family cannot peer an engine version + // that is not on the registry, so the engine publishes first and + // the mismatch is real until both families release against it. + // The entries expire with the versions they name, and the rc.5 + // bump PR removes them; while they stand, a release could ship + // the two-engine install they describe, which is why they must + // not outlive the transition. + exceptions: [ + { + familyPackage: "@prisma/composer-cli", + familyPin: "0.1.1", + shellPin: "0.2.0", + reason: "engine 0.2.0 must publish before composer-cli can peer it", + removeWhen: + "composer-cli releases peering 0.2.0 and the 8.0.0-rc.5 bump PR pins that release", + }, + { + familyPackage: "@prisma/orm-toolchain", + familyPin: "0.1.1", + shellPin: "0.2.0", + reason: "engine 0.2.0 must publish before orm-toolchain can peer it", + removeWhen: + "orm-toolchain releases peering 0.2.0 and the 8.0.0-rc.5 bump PR pins that release", + }, + ], channel: CHANNEL, sandboxDir: join(WORK_DIR, "sandbox"), }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec2504e2..94ce25ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: packages/cli: dependencies: '@prisma/cli-engine': - specifier: workspace:0.1.1 + specifier: workspace:0.2.0 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.7.0 diff --git a/scripts/check-engine-version.mjs b/scripts/check-engine-version.mjs new file mode 100644 index 00000000..441c6f9c --- /dev/null +++ b/scripts/check-engine-version.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node + +// Fails a pull request that changes `packages/cli-engine` while leaving +// its version at one the registry already has. That is how +// `prisma@8.0.0-rc.4` shipped: the CLI was built against engine exports +// that the published `@prisma/cli-engine@0.1.1` does not contain, and +// `npx prisma@next` crashed on import. The registry is immutable, so a +// changed engine must claim a new version (`pnpm bump-cli-engine-version`). +// +// Usage: node scripts/check-engine-version.mjs + +import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const NPM_NOT_FOUND_PATTERN = /\bE404\b/; + +/** Whether a failed `npm view` means the version is absent, as opposed + * to npm itself failing — no binary, no network, no auth. */ +function isNotFoundError(error) { + if (typeof error !== "object" || error === null) return false; + const { stderr, stdout } = error; + return NPM_NOT_FOUND_PATTERN.test(`${stderr ?? ""}\n${stdout ?? ""}`); +} + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); + +/** + * @param {{ changedFiles: readonly string[], engineVersion: string, versionOnRegistry: boolean }} input + * @returns {string | null} the failure message, or null when the change is fine + */ +export function engineBumpVerdict({ + changedFiles, + engineVersion, + versionOnRegistry, +}) { + const engineChanged = changedFiles.some((file) => + file.startsWith("packages/cli-engine/"), + ); + if (!engineChanged || !versionOnRegistry) return null; + return ( + `packages/cli-engine changed, but its version (${engineVersion}) is already on the registry, ` + + "and published versions are immutable. Run `pnpm bump-cli-engine-version ` " + + "so the changed engine ships under a new version." + ); +} + +async function main() { + const baseSha = process.argv[2]; + if (!baseSha) { + console.error("Usage: node scripts/check-engine-version.mjs "); + process.exit(1); + } + + const { stdout: mergeBase } = await execFileAsync( + "git", + ["merge-base", baseSha, "HEAD"], + { cwd: rootDir }, + ); + const { stdout: diff } = await execFileAsync( + "git", + ["diff", "--name-only", mergeBase.trim(), "HEAD"], + { cwd: rootDir }, + ); + const changedFiles = diff.split("\n").filter(Boolean); + + const manifest = JSON.parse( + readFileSync(join(rootDir, "packages/cli-engine/package.json"), "utf-8"), + ); + const engineVersion = manifest.version; + + let versionOnRegistry = true; + try { + await execFileAsync("npm", [ + "view", + `@prisma/cli-engine@${engineVersion}`, + "version", + "--prefer-online", + ]); + } catch (error) { + if (!isNotFoundError(error)) throw error; + versionOnRegistry = false; + } + + const verdict = engineBumpVerdict({ + changedFiles, + engineVersion, + versionOnRegistry, + }); + if (verdict !== null) { + console.error(`::error::${verdict}`); + process.exit(1); + } + console.log( + `Engine version ${engineVersion} is consistent with this change set.`, + ); +} + +const isDirectRun = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href; +if (isDirectRun) await main(); diff --git a/scripts/check-engine-version.test.mjs b/scripts/check-engine-version.test.mjs new file mode 100644 index 00000000..25b869fb --- /dev/null +++ b/scripts/check-engine-version.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { engineBumpVerdict } from "./check-engine-version.mjs"; + +const NAMES_THE_STALE_VERSION = /0\.1\.1/; +const NAMES_THE_BUMP_COMMAND = /bump-cli-engine-version/; + +describe("engineBumpVerdict", () => { + it("passes when the change does not touch the engine", () => { + const verdict = engineBumpVerdict({ + changedFiles: ["packages/cli/src/runtime.ts", "scripts/set-version.ts"], + engineVersion: "0.1.1", + versionOnRegistry: true, + }); + assert.equal(verdict, null); + }); + + it("passes when the engine changed and its version is new to the registry", () => { + const verdict = engineBumpVerdict({ + changedFiles: ["packages/cli-engine/src/commands.ts"], + engineVersion: "0.2.0", + versionOnRegistry: false, + }); + assert.equal(verdict, null); + }); + + it("fails when the engine changed but its version already shipped", () => { + const verdict = engineBumpVerdict({ + changedFiles: [ + "packages/cli-engine/src/exports/index.ts", + "packages/cli/src/auth/refresh.ts", + ], + engineVersion: "0.1.1", + versionOnRegistry: true, + }); + assert.match(verdict, NAMES_THE_STALE_VERSION); + assert.match(verdict, NAMES_THE_BUMP_COMMAND); + }); + + it("does not mistake other packages' paths for the engine", () => { + const verdict = engineBumpVerdict({ + changedFiles: ["packages/cli-engine-docs/readme.md"], + engineVersion: "0.1.1", + versionOnRegistry: true, + }); + assert.equal(verdict, null); + }); +});