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
5 changes: 5 additions & 0 deletions .drive/projects/prisma-cli-v8/deferred.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,3 +471,8 @@ The agent-skills project (skills sync/list, `prisma init`, the staleness notice;
- **Windows CI: `skills-sync.test.ts` timed out once at the 5s default** (run 32474645762) with a teardown ENOTEMPTY from cleanup racing the timed-out test. If it recurs, raise the suite's per-test timeout on Windows rather than chasing the race.
- **`isLikelyGlobalNpmEntrypoint` (update-check.ts) matches only `prisma-cli` install paths**, so a globally-installed `prisma` gets the docs-link fallback instead of a concrete update command; `selectUpdateInstruction` still names `@prisma/cli`. Newly conspicuous after the CLI_NAME → prisma rename.
- **The feedback client's user-agent changed from `prisma-cli/<version>` to `prisma/<version>`** — wire-visible; whoever reads that dashboard should know.
## Left open by the rc.8 broken release (2026-08-24)

- **`prisma@8.0.0-rc.8` on npm is broken and immutable.** The `prisma` wrapper package carries its own copies of the product pins, and the grammar-cleanup branch bumped only `packages/cli/package.json` — so the published `prisma` bin resolved `@prisma/orm-toolchain@8.0.0-rc.4`, whose old family keys make the mount table's lookups undefined and every invocation crash ("Cannot read properties of undefined (reading 'needs')"). rc.9 fixes it. Consider `npm deprecate prisma@8.0.0-rc.8` (needs a maintainer's npm auth; CI publishes via OIDC and has no deprecate step).
- ~~**The release checks did not catch a `prisma` bin that crashes on install.**~~ Closed (2026-08-24, on the rc.9 PR): worse than hoisting — check 3b never installed or started the wrapper's bin at all, only the shell's. Three guards now exist: `packages/cli/tests/manifest-pins.test.ts` (every PR: the wrapper's dependencies must deep-equal the shell's), the tarball check's new `sibling-pin-mismatch` finding (pack time: shared dependency names across packed manifests must carry identical specifiers), and per-package sandboxes in check 3b (every bin-bearing package installs and starts from its own tree). Each guard was proven against the planted rc.8 defect.
- **Two manifests hand-carry the same pins.** `update-product-versions.mjs` rewrites both, and three checks now fail on divergence (see the closed entry above), so the class cannot ship again. Deriving one manifest from the other at pack time would remove the duplication itself — still a design call, no longer urgent.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "prisma-cli",
"version": "8.0.0-rc.8",
"version": "8.0.0-rc.9",
"private": true,
"engines": {
"node": ">=24"
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-conformance/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@repo/cli-conformance",
"private": true,
"version": "8.0.0-rc.8",
"version": "8.0.0-rc.9",
"description": "Reusable conformance checks for the engine's consumers: import purity over built output, config-section validators that never throw, and verification of the tarballs a registry would receive. Depends on no package it checks.",
"type": "module",
"exports": {
Expand All @@ -24,7 +24,7 @@
"test": "pnpm run typecheck && vitest run"
},
"devDependencies": {
"@repo/tsconfig": "workspace:8.0.0-rc.8",
"@repo/tsconfig": "workspace:8.0.0-rc.9",
"@types/node": "^22.19.19",
"es-module-lexer": "^2.1.0",
"tsx": "^4.22.4",
Expand Down
100 changes: 78 additions & 22 deletions packages/cli-conformance/src/checks/tarball.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { join } from "node:path";
import type { Finding, Suppression } from "../findings";
import { bareImportRoots } from "../module-graph";
import { checkImportPurity, type PackageManifest } from "./import-purity";
Expand Down Expand Up @@ -105,6 +106,10 @@ export interface TarballInput {

const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;

/** Package-name characters that cannot appear in a directory name. */
const SANDBOX_NAME_UNSAFE = /[@/]/g;
const LEADING_DASH = /^-/;

/**
* Check 3: the tarballs a registry would receive. 3a — packed output
* imports only what the packed manifest declares. 3b — the root tarball
Expand Down Expand Up @@ -161,11 +166,57 @@ export async function checkTarball(
const shell = packed.get(input.shellPackage);
if (shell === undefined) return findings;

findings.push(...siblingPinAgreementFindings(input, shell.manifest, packed));
findings.push(...manifestPinFindings(input, shell.manifest));
findings.push(...(await sandboxFindings(input, shell, packed, io)));
// Every packed package that declares a bin installs into its OWN
// sandbox and starts there, so resolution happens the way that
// package's real install resolves it. prisma@8.0.0-rc.8 shipped a
// wrapper bin that crashed on every invocation while a shell-only
// sandbox stayed green: the wrapper's stale product pin was hoisted
// away by the shell's correct one.
for (const [name, entry] of packed) {
if (declaredBins(entry.manifest).length === 0) continue;
// biome-ignore lint/performance/noAwaitInLoops: sandboxes install one at a time so a failure names its package and concurrent npm installs cannot confound each other
findings.push(...(await sandboxFindings(input, name, entry, packed, io)));
}
return applyExceptions(findings, input.exceptions);
}

/**
* The rc.8 guard: sibling packages that ship the same bundled source —
* the shell and the `prisma` wrapper — hand-carry their dependency
* lists in separate manifests, and which copy of a dependency a user's
* install resolves depends on hoisting. Any dependency name two packed
* manifests share must therefore carry the identical specifier.
*/
function siblingPinAgreementFindings(
input: TarballInput,
shellManifest: PackedManifest,
packed: ReadonlyMap<string, { tarball: string; manifest: PackedManifest }>,
): readonly Finding[] {
const findings: Finding[] = [];
const shellDeps = shellManifest.dependencies ?? {};
for (const [name, entry] of packed) {
if (name === input.shellPackage) continue;
for (const [dep, specifier] of Object.entries(
entry.manifest.dependencies ?? {},
)) {
const shellSpecifier = shellDeps[dep];
if (shellSpecifier === undefined || shellSpecifier === specifier) {
continue;
}
findings.push(
finding(
"sibling-pin-mismatch",
name,
`${name} pins ${dep}@${specifier} while ${input.shellPackage} pins ${shellSpecifier} — which one an install resolves depends on hoisting`,
),
);
}
}
return findings;
}

/**
* 3c, sibling leg: every packed manifest that depends on the engine
* must pin exactly the engine version packed beside it. This is how
Expand Down Expand Up @@ -249,13 +300,18 @@ function manifestPinFindings(
return findings;
}

/** 3b + 3c's installed legs, all downstream of one sandbox install. */
/** 3b + 3c's installed legs, one sandbox per bin-bearing package. */
async function sandboxFindings(
input: TarballInput,
shell: { tarball: string; manifest: PackedManifest },
packageName: string,
root: { tarball: string; manifest: PackedManifest },
packed: ReadonlyMap<string, { tarball: string; manifest: PackedManifest }>,
io: TarballIo,
): Promise<readonly Finding[]> {
const sandboxDir = join(
input.sandboxDir,
packageName.replace(SANDBOX_NAME_UNSAFE, "-").replace(LEADING_DASH, ""),
);
// Transitive: a sibling reached only through another sibling still
// needs its override, or the install falls back to the registry.
const overrides: Record<string, string> = {};
Expand All @@ -269,38 +325,40 @@ async function sandboxFindings(
visit(entry.manifest);
}
};
visit(shell.manifest);
visit(root.manifest);
const install = await io.installSandbox({
sandboxDir: input.sandboxDir,
rootTarball: shell.tarball,
sandboxDir,
rootTarball: root.tarball,
overrides,
});
if (!install.ok) {
return [
finding(
"install-failed",
input.shellPackage,
packageName,
"the packed tarball did not install into a clean tree",
install.output,
),
];
}
return [
...(await binFindings(input, shell.manifest, io)),
...(await installedPinFindings(input, shell.manifest, io)),
...(await binFindings(input, packageName, sandboxDir, root.manifest, io)),
...(await installedPinFindings(input, sandboxDir, root.manifest, io)),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
];
}

async function binFindings(
input: TarballInput,
shellManifest: PackedManifest,
packageName: string,
sandboxDir: string,
manifest: PackedManifest,
io: TarballIo,
): Promise<readonly Finding[]> {
const findings: Finding[] = [];
for (const [binName, relPath] of declaredBins(shellManifest)) {
for (const [binName, relPath] of declaredBins(manifest)) {
// biome-ignore lint/performance/noAwaitInLoops: bins start one at a time so a failure names its bin and concurrent processes cannot confound each other's exit
const run = await io.startBin({
sandboxDir: input.sandboxDir,
sandboxDir,
binName,
relPath,
argv: ["--version"],
Expand All @@ -310,7 +368,7 @@ async function binFindings(
findings.push(
finding(
"bin-failed",
input.shellPackage,
packageName,
`bin ${binName} timed out instead of exiting`,
run.stderr,
),
Expand All @@ -319,7 +377,7 @@ async function binFindings(
findings.push(
finding(
"bin-failed",
input.shellPackage,
packageName,
`bin ${binName} exited ${run.exitCode} on plain node`,
`stdout:\n${run.stdout}\nstderr:\n${run.stderr}`,
),
Expand Down Expand Up @@ -376,31 +434,29 @@ function familyPinFindings(

async function installedPinFindings(
input: TarballInput,
shellManifest: PackedManifest,
sandboxDir: string,
manifest: PackedManifest,
io: TarballIo,
): Promise<readonly Finding[]> {
const findings: Finding[] = [];
const shellPin = shellManifest.dependencies?.[input.enginePackage];
const shellPin = manifest.dependencies?.[input.enginePackage];

for (const family of input.familyPackages) {
// biome-ignore lint/performance/noAwaitInLoops: one manifest read per mounted family — two today — keeps findings ordered with the family list
const installed = await io.readInstalledManifest(input.sandboxDir, family);
const installed = await io.readInstalledManifest(sandboxDir, family);
if (installed === undefined) continue;
findings.push(
...familyPinFindings(
input,
family,
shellManifest.dependencies?.[family],
manifest.dependencies?.[family],
installed,
shellPin,
),
);
}

const copies = await io.listInstalledCopies(
input.sandboxDir,
input.enginePackage,
);
const copies = await io.listInstalledCopies(sandboxDir, input.enginePackage);
if (copies.length > 1) {
findings.push(
finding(
Expand Down
4 changes: 4 additions & 0 deletions packages/cli-conformance/src/findings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export type FindingKind =
| "bin-failed"
/** The shell and a family it mounts disagree about the engine version. */
| "engine-pin-mismatch"
/** Two packed sibling manifests declare the same dependency at
* different versions, so which one an install resolves depends on
* hoisting. */
| "sibling-pin-mismatch"
/** A release depends on a dev build. */
| "dev-build-in-release";

Expand Down
8 changes: 4 additions & 4 deletions packages/cli-conformance/src/tarball-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export function realTarballIo(
// exact files CI uploads and attaches to the GitHub Release.
const tarballDir = resolve(options.tarballDir ?? join(absWork, "tarballs"));
rmSync(tarballDir, { recursive: true, force: true });
const sandbox = () => join(absWork, "sandbox");

return {
async pack(pkgDir) {
Expand Down Expand Up @@ -88,8 +87,9 @@ export function realTarballIo(
return files;
},

async installSandbox({ rootTarball, overrides }) {
const dir = sandbox();
async installSandbox({ sandboxDir, rootTarball, overrides }) {
const dir = resolve(sandboxDir);
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
const rootManifest = await this.readPackedManifest(rootTarball);
const name = manifestName(rootManifest);
Expand Down Expand Up @@ -176,7 +176,7 @@ export function realTarballIo(
argv,
timeoutMs,
}) {
const rootManifestPath = join(sandbox(), "package.json");
const rootManifestPath = join(sandboxDir, "package.json");
const rootManifest = JSON.parse(
readFileSync(rootManifestPath, "utf8"),
) as {
Expand Down
Loading
Loading