diff --git a/docs/oss/versioning.md b/docs/oss/versioning.md index 3be0837f..386dc373 100644 --- a/docs/oss/versioning.md +++ b/docs/oss/versioning.md @@ -18,7 +18,7 @@ The transition onto the RC line is a one-time bump from the pre-8 base to `8.0.0 Every lockstep workspace package — publishable, private, and the workspace root — carries the same `version`. One read of root [`package.json`](../../package.json) answers "what version is this code?" for the repository. -**Exceptions:** `@prisma/compute` versions independently, pending extraction to another repository (operator ruling 2026-08-10), and keeps its own publish workflow ([`publish-compute.yml`](../../.github/workflows/publish-compute.yml)). `@prisma/cli-engine` also versions independently ([ADR 0004](../architecture/adrs/0004-engine-version-pinning.md), operator ruling 2026-08-13): an engine version means "the engine changed", not "the CLI released", which is what keeps the exact peer pins the product CLI packages hold on it cheap — they change only when the engine actually moves. The engine follows honest pre-1.0 semver (a breaking change bumps the minor); bumping it is one command — `pnpm bump-cli-engine-version ` — which edits `packages/cli-engine/package.json`, the shell's `workspace:` pin, and the lockfile together, landed as a reviewed commit like any other version change (run it in the PR that changes the engine). Both packages are hard-excluded in [`scripts/set-version.ts`](../../scripts/set-version.ts), which still sweeps their `workspace:` pins on lockstep siblings so those never go stale. At publish time the engine ships at its own manifest version; an already-published engine version is a no-op. The engine's own line continues from `0.1.0` (after the published `0.0.x` series); the `8.0.0-rc.N` engine versions that shipped while it was still in lockstep are burned values — they exist on the registry, nothing pins them, and version numbers are never reused. +**Exceptions:** `@prisma/compute` versions independently, pending extraction to another repository (operator ruling 2026-08-10), and keeps its own publish workflow ([`publish-compute.yml`](../../.github/workflows/publish-compute.yml)). `@prisma/cli-engine` also versions independently ([ADR 0004](../architecture/adrs/0004-engine-version-pinning.md), operator ruling 2026-08-13): an engine version means "the engine changed", not "the CLI released", which is what keeps the exact peer pins the product CLI packages hold on it cheap — they change only when the engine actually moves. The engine follows honest pre-1.0 semver (a breaking change bumps the minor); bumping it is one command — `pnpm bump-cli-engine-version ` — which edits `packages/cli-engine/package.json`, the `workspace:` pin in every consumer manifest (`packages/cli`, `packages/prisma`), and the lockfile together, landed as a reviewed commit like any other version change (run it in the PR that changes the engine). Both packages are hard-excluded in [`scripts/set-version.ts`](../../scripts/set-version.ts), which still sweeps their `workspace:` pins on lockstep siblings so those never go stale. At publish time the engine ships at its own manifest version; an already-published engine version is a no-op. The engine's own line continues from `0.1.0` (after the published `0.0.x` series); the `8.0.0-rc.N` engine versions that shipped while it was still in lockstep are burned values — they exist on the registry, nothing pins them, and version numbers are never reused. The lockstep set is: the workspace root, `packages/cli`, `packages/cli-telemetry`, `packages/cli-conformance`, and `packages/tsconfig`. Private packages are never published (`pnpm publish` skips them), but they still version in lockstep so a contributor cloning the repo at any commit sees one consistent answer to "what version is this code?". Workspace-internal dependencies are pinned as `workspace:` (e.g. `workspace:8.0.0-rc.1`); pnpm resolves them locally during development and rewrites them to the exact version at publish time, so every published package carries an exact-version pin on its siblings. diff --git a/packages/cli-conformance/src/checks/tarball.ts b/packages/cli-conformance/src/checks/tarball.ts index 0a81f606..897cd571 100644 --- a/packages/cli-conformance/src/checks/tarball.ts +++ b/packages/cli-conformance/src/checks/tarball.ts @@ -141,6 +141,7 @@ export async function checkTarball( channel: input.channel, }), ); + findings.push(...enginePinAgreementFindings(input, packed)); const shell = packed.get(input.shellPackage); if (shell === undefined) return findings; @@ -150,6 +151,35 @@ export async function checkTarball( return applyExceptions(findings, input.exceptions); } +/** + * 3c, sibling leg: every packed manifest that depends on the engine + * must pin exactly the engine version packed beside it. This is how + * `prisma@8.0.0-rc.4` crashed on import: the engine moved to 0.2.0 + * while `packages/prisma` kept pinning 0.1.1, so the published package + * resolved a registry engine missing the exports it was built against. + */ +function enginePinAgreementFindings( + input: TarballInput, + packed: ReadonlyMap, +): readonly Finding[] { + const engineVersion = packed.get(input.enginePackage)?.manifest.version; + if (engineVersion === undefined) return []; + const findings: Finding[] = []; + for (const [name, entry] of packed) { + if (name === input.enginePackage) continue; + const pin = entry.manifest.dependencies?.[input.enginePackage]; + if (pin === undefined || pin === engineVersion) continue; + findings.push( + finding( + "engine-pin-mismatch", + name, + `${name} pins ${input.enginePackage}@${pin} while this release packs ${input.enginePackage}@${engineVersion} — the published package would resolve a different engine than the one shipping`, + ), + ); + } + return findings; +} + /** 3a: check 1 over the tarball's own files and manifest. */ async function packedImportPurity( name: string, diff --git a/packages/cli-conformance/tests/tarball.test.ts b/packages/cli-conformance/tests/tarball.test.ts index 127b1521..7cb289db 100644 --- a/packages/cli-conformance/tests/tarball.test.ts +++ b/packages/cli-conformance/tests/tarball.test.ts @@ -240,6 +240,59 @@ describe("checkTarball", () => { ).toBe(true); }); + /** + * How `prisma@8.0.0-rc.4` shipped crashing: the engine packed at + * 0.2.0 while a sibling package still pinned 0.1.1, so the publish + * resolved a registry engine missing the exports it was built + * against. Every packed sibling must pin the engine version packed + * beside it. + */ + test("a packed sibling pinning a different engine version than the packed engine is a finding", async () => { + const io = fakeIo({ + readPackedManifest: (tarball) => { + if (tarball.includes("cli-engine")) { + return Promise.resolve({ ...ENGINE_MANIFEST, version: "0.2.0" }); + } + if (tarball.includes("prisma-wrapper")) { + return Promise.resolve({ + dependencies: { "@prisma/cli-engine": "0.1.1" }, + }); + } + return Promise.resolve({ + ...SHELL_MANIFEST, + dependencies: { + ...SHELL_MANIFEST.dependencies, + "@prisma/cli-engine": "0.2.0", + }, + }); + }, + readPackedFiles: () => Promise.resolve(new Map()), + }); + const findings = await checkTarball( + input({ + packages: [ + { name: "@prisma/cli", dir: "packages/cli" }, + { name: "prisma", dir: "packages/prisma-wrapper" }, + { name: "@prisma/cli-engine", dir: "packages/cli-engine" }, + ], + }), + io, + ); + const stale = findings.filter( + (f) => f.kind === "engine-pin-mismatch" && f.subject === "prisma", + ); + expect(stale).toHaveLength(1); + expect(stale[0]?.summary).toContain("0.1.1"); + expect(stale[0]?.summary).toContain("0.2.0"); + expect(stale[0]?.suppressedBy).toBeUndefined(); + // The shell, pinning the packed engine's exact version, is clean. + expect( + findings.some( + (f) => f.kind === "engine-pin-mismatch" && f.subject === "@prisma/cli", + ), + ).toBe(false); + }); + test("3a: the packed output's imports are held to the packed manifest", async () => { const io = fakeIo({ readPackedFiles: (tarball) => diff --git a/packages/prisma/package.json b/packages/prisma/package.json index af80a523..9283420e 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -43,7 +43,7 @@ "typecheck": "tsc --noEmit" }, "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/pnpm-lock.yaml b/pnpm-lock.yaml index 94ce25ac..e9b408b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,7 +195,7 @@ importers: packages/prisma: 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/bump-cli-engine-version.ts b/scripts/bump-cli-engine-version.ts index 888a1563..b2b2f356 100644 --- a/scripts/bump-cli-engine-version.ts +++ b/scripts/bump-cli-engine-version.ts @@ -13,7 +13,8 @@ * * What one invocation edits, so the bump lands as one consistent commit: * - `packages/cli-engine/package.json` `version` - * - `packages/cli/package.json`'s `workspace:` pin on the engine + * - the `workspace:` pin on the engine in every consumer + * manifest (`packages/cli`, `packages/prisma`) * - `pnpm-lock.yaml`, refreshed to match * * Reads the current version from HEAD (not disk) so re-running before @@ -30,7 +31,14 @@ import { computeNextEngineVersion } from "./bump-cli-engine-version-utils.ts"; const ENGINE_PACKAGE = "@prisma/cli-engine"; const ENGINE_MANIFEST = "packages/cli-engine/package.json"; -const SHELL_MANIFEST = "packages/cli/package.json"; +// Every manifest that pins the engine. `prisma@8.0.0-rc.4` shipped +// crashing because the engine moved to 0.2.0 while `packages/prisma` +// kept its pin on the registry's broken 0.1.1 — a consumer this list +// missed is a consumer that publishes against the wrong engine. +const CONSUMER_MANIFESTS = [ + "packages/cli/package.json", + "packages/prisma/package.json", +]; const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); @@ -70,18 +78,20 @@ engine.version = nextVersion; writeFileSync(enginePath, `${JSON.stringify(engine, null, 2)}\n`); console.log(`Updated ${ENGINE_MANIFEST}`); -const shellPath = join(rootDir, SHELL_MANIFEST); -const shell = JSON.parse(readFileSync(shellPath, "utf-8")) as { - dependencies?: Record; -}; -if (shell.dependencies?.[ENGINE_PACKAGE] === undefined) { - throw new Error( - `${SHELL_MANIFEST} no longer depends on ${ENGINE_PACKAGE}; this script needs updating.`, - ); +for (const manifestPath of CONSUMER_MANIFESTS) { + const consumerPath = join(rootDir, manifestPath); + const consumer = JSON.parse(readFileSync(consumerPath, "utf-8")) as { + dependencies?: Record; + }; + if (consumer.dependencies?.[ENGINE_PACKAGE] === undefined) { + throw new Error( + `${manifestPath} no longer depends on ${ENGINE_PACKAGE}; this script needs updating.`, + ); + } + consumer.dependencies[ENGINE_PACKAGE] = `workspace:${nextVersion}`; + writeFileSync(consumerPath, `${JSON.stringify(consumer, null, 2)}\n`); + console.log(`Updated ${manifestPath} pin to workspace:${nextVersion}`); } -shell.dependencies[ENGINE_PACKAGE] = `workspace:${nextVersion}`; -writeFileSync(shellPath, `${JSON.stringify(shell, null, 2)}\n`); -console.log(`Updated ${SHELL_MANIFEST} pin to workspace:${nextVersion}`); console.log(""); console.log("Refreshing pnpm-lock.yaml to match the rewritten specifiers...");