Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/oss/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <patch|minor|X.Y.Z>` — which edits `packages/cli-engine/package.json`, the shell's `workspace:<engine version>` 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 <patch|minor|X.Y.Z>` — which edits `packages/cli-engine/package.json`, the `workspace:<engine version>` 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:<version>` (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.

Expand Down
30 changes: 30 additions & 0 deletions packages/cli-conformance/src/checks/tarball.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, { tarball: string; manifest: PackedManifest }>,
): 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,
Expand Down
53 changes: 53 additions & 0 deletions packages/cli-conformance/tests/tarball.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
2 changes: 1 addition & 1 deletion packages/prisma/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 23 additions & 13 deletions scripts/bump-cli-engine-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<version>` pin on the engine
* - the `workspace:<version>` 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
Expand All @@ -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)));

Expand Down Expand Up @@ -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<string, string>;
};
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<string, string>;
};
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...");
Expand Down
Loading