Skip to content
Open
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 .github/workflows/preview-cli-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
- name: Publish installable PR preview
id: publish_preview
continue-on-error: ${{ vars.CLI_PR_PREVIEW_REQUIRED != 'true' }}
run: pnpm exec pkg-pr-new publish --bin --comment=update packages/cli packages/cli-engine
run: pnpm exec pkg-pr-new publish --bin --comment=update packages/cli packages/cli-engine packages/prisma

- name: Summarize PR preview publish
if: ${{ always() }}
Expand Down
16 changes: 11 additions & 5 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ name: Publish to npm
# - workflow_dispatch → publish `<base>`
# under the chosen dist-tag (default `latest`); also the dry-run path.
#
# Scope: publishes `@prisma/cli-engine` then `@prisma/cli` (the cli
# depends on the engine, so the engine goes first). `@prisma/compute` is
# excluded from the lockstep by operator ruling (2026-08-10) and keeps
# its own workflow (`publish-compute.yml`).
# Scope: publishes `@prisma/cli-engine`, then `@prisma/cli`, then the
# bare `prisma` wrapper (each depends on the one before it, so they go
# in that order). On the RC line all three land under `next`; the bare
# package's `latest` keeps serving the pre-8 CLI shipped by
# prisma/prisma until the operator moves it deliberately.
# `@prisma/compute` is excluded from the lockstep by operator ruling
# (2026-08-10) and keeps its own workflow (`publish-compute.yml`).

on:
push:
Expand Down Expand Up @@ -152,7 +155,8 @@ jobs:
# manifest. `--no-git-checks` because the packing step touches the
# tree; the version itself is whatever the commit says.
#
# Publish order: the engine first, then the cli that depends on it.
# Publish order: the engine, then the cli that depends on it, then
# the bare `prisma` wrapper that pins the cli.

# Dry-run path: exercises the full publish pipeline (pack, validate
# tarball contents, dependency rewriting) without touching the npm
Expand All @@ -163,6 +167,7 @@ jobs:
run: |
pnpm --filter @prisma/cli-engine publish --tag "${{ steps.version.outputs.tag }}" --access public --no-git-checks --dry-run
pnpm --filter @prisma/cli publish --tag "${{ steps.version.outputs.tag }}" --access public --no-git-checks --dry-run
pnpm --filter prisma publish --tag "${{ steps.version.outputs.tag }}" --access public --no-git-checks --dry-run

# A rerun (or a re-publish dispatch) meets versions that are
# already on the registry. npm refuses to publish over them —
Expand Down Expand Up @@ -191,6 +196,7 @@ jobs:
}
publish_one @prisma/cli-engine
publish_one @prisma/cli
publish_one prisma

# Emit a GitHub Release for releases only — runs whose dist-tag is
# the canonical one for their version (`next` on the RC line,
Expand Down
8 changes: 5 additions & 3 deletions docs/oss/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Prisma 8 ships as a release-candidate line ahead of `8.0.0` final: releases are

RC respins may include breaking changes until `8.0.0` final ships. There are no patch releases on the RC line — a fix ships as the next `rc.N`.

For the packages this repository's publish workflow ships (`@prisma/cli`, `@prisma/cli-engine`), **each release publishes under its line's canonical dist-tag**: RC-line versions under `next`, stable versions under `latest` (operator ruling 2026-08-12; supersedes the earlier "`latest` tracks the newest release, RC or stable"). A dist-tag moves only through a deliberately merged version-bump PR (or a manual `workflow_dispatch`); creating and merging the bump PR is the operator's explicit act. `latest` stays on the pre-8 CLI until the operator moves it deliberately. Existing installs are unaffected — lockfiles pin resolved versions, and nobody lands on the RC line without asking for `@next`.
For the packages this repository's publish workflow ships (`@prisma/cli`, `@prisma/cli-engine`, and the bare `prisma` wrapper), **each release publishes under its line's canonical dist-tag**: RC-line versions under `next`, stable versions under `latest` (operator ruling 2026-08-12; supersedes the earlier "`latest` tracks the newest release, RC or stable"). A dist-tag moves only through a deliberately merged version-bump PR (or a manual `workflow_dispatch`); creating and merging the bump PR is the operator's explicit act. `latest` stays on the pre-8 CLI until the operator moves it deliberately. Existing installs are unaffected — lockfiles pin resolved versions, and nobody lands on the RC line without asking for `@next`.

The transition onto the RC line is a one-time bump from the pre-8 base to `8.0.0-rc.1`; `pnpm bump-version` encodes it (a pre-8 stable base advances to `8.0.0-rc.1`, an RC base advances its counter).

Expand All @@ -18,7 +18,7 @@ Every lockstep workspace package — publishable, private, and the workspace roo

**Exception:** `@prisma/compute` versions independently, pending extraction to another repository (operator ruling 2026-08-10). It is hard-excluded in [`scripts/set-version.ts`](../../scripts/set-version.ts) and keeps its own publish workflow ([`publish-compute.yml`](../../.github/workflows/publish-compute.yml)).

The lockstep set is: the workspace root, `packages/cli`, `packages/cli-engine`, `packages/cli-telemetry`, 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.
The lockstep set is: the workspace root, `packages/cli`, `packages/cli-engine`, `packages/prisma`, `packages/cli-telemetry`, 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.

## Dist-tag convention

Expand All @@ -28,6 +28,8 @@ The npm registry exposes the CLI packages under these dist-tags:
- **`next`** — the Prisma 8 RC line (`8.0.0-rc.N`). A merged release PR on the RC line publishes here automatically.
- **`beta`** — reserved for hand-cut previews ahead of significant changes, published by dispatching the workflow with that dist-tag. Routine releases do not use this tag.

The bare `prisma` package is shared with the pre-8 CLI: its `latest`, `dev`, and the other historical dist-tags are still published by [prisma/prisma](https://github.com/prisma/prisma)'s release automation. This repository publishes only the RC line to that package, under `next`, via a thin wrapper ([`packages/prisma`](../../packages/prisma)) that pins `@prisma/cli` exactly and exposes the `prisma` binary. Moving the bare package's `latest` onto the 8.x line is the cutover act described above and happens on the operator's schedule, not before.

There is no `dev` channel. A push to `main` that does not change the version publishes nothing, because the version at that commit is already on the registry and there is nothing else this repository could honestly call the build. To hand someone an unreleased build, use the per-PR preview below.

PR previews go through [`pkg.pr.new`](https://pkg.pr.new) ([`preview-cli-package.yml`](../../.github/workflows/preview-cli-package.yml)); they carry the committed base version and install via per-commit URLs, not dist-tags.
Expand All @@ -51,7 +53,7 @@ This is by design. The alternatives cause silent problems:

[`scripts/set-version.ts`](../../scripts/set-version.ts) is what enforces lockstep: a single invocation walks every lockstep workspace `package.json` and writes the requested version (rewriting `workspace:` dependency pins to match). It is a maintainer's tool, invoked through `pnpm bump-version`; the publish workflow does not run it.

The publish workflow is **triggered by a change to the root `version`**: a push to `main` whose root `package.json` carries a different `version` than the previous tip is recognised as a release bump and ships that version under its canonical dist-tag — `next` on the RC line (the accompanying GitHub Release is marked pre-release), `latest` for stable. This is what makes "merge the release PR" the publish trigger; there is no separate dispatch step. A push that leaves the version alone publishes nothing. Within a publish, `@prisma/cli-engine` goes first, then `@prisma/cli` (which depends on it).
The publish workflow is **triggered by a change to the root `version`**: a push to `main` whose root `package.json` carries a different `version` than the previous tip is recognised as a release bump and ships that version under its canonical dist-tag — `next` on the RC line (the accompanying GitHub Release is marked pre-release), `latest` for stable. This is what makes "merge the release PR" the publish trigger; there is no separate dispatch step. A push that leaves the version alone publishes nothing. Within a publish, `@prisma/cli-engine` goes first, then `@prisma/cli` (which depends on it), then the bare `prisma` wrapper (which pins `@prisma/cli`).

**Nothing rewrites a `version` field outside a commit.** `set-version.ts` is run by `pnpm bump-version`, whose output a maintainer reviews and commits; the publish workflow never invokes it. That is what makes "the version is whatever `package.json` says" true rather than aspirational — CI has no way to ship a version no commit describes. It also keeps `pnpm-lock.yaml` honest: the lockfile records the `workspace:` specifiers that `set-version.ts` rewrites, so `bump-version` refreshes it in the same breath and the bump lands as one internally consistent commit.

Expand Down
116 changes: 92 additions & 24 deletions packages/cli-conformance/src/checks/tarball.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface TarballIo {
): Promise<readonly { version: string; path: string }[]>;
startBin(input: {
readonly sandboxDir: string;
/** The installed package whose tree contains the bin file. */
readonly packageName: string;
readonly binName: string;
readonly relPath: string;
readonly argv: readonly string[];
Expand All @@ -80,6 +82,13 @@ export interface PinException extends Suppression {
export interface TarballInput {
readonly packages: readonly { name: string; dir: string }[];
readonly shellPackage: string;
/**
* A published package that re-exposes the shell under another name
* (the bare `prisma` package wrapping `@prisma/cli`). When set, the
* sandbox install is rooted here, its bins are started alongside the
* shell's, and its packed manifest must pin the shell exactly.
*/
readonly wrapperPackage?: string;
readonly enginePackage: string;
/** Command-family packages the shell mounts; must be shell deps. */
readonly familyPackages: readonly string[];
Expand Down Expand Up @@ -130,9 +139,19 @@ export async function checkTarball(

const shell = packed.get(input.shellPackage);
if (shell === undefined) return findings;
const wrapper =
input.wrapperPackage === undefined
? undefined
: packed.get(input.wrapperPackage);
if (input.wrapperPackage !== undefined && wrapper === undefined) {
return findings;
}

findings.push(...manifestPinFindings(input, shell.manifest));
findings.push(...(await sandboxFindings(input, shell, packed, io)));
if (wrapper !== undefined) {
findings.push(...wrapperPinFindings(input, wrapper.manifest));
}
findings.push(...(await sandboxFindings(input, shell, wrapper, packed, io)));
return applyExceptions(findings, input.exceptions);
}

Expand Down Expand Up @@ -188,13 +207,48 @@ function manifestPinFindings(
return findings;
}

/** The wrapper's leg of 3c: it must pin the shell, exactly. */
function wrapperPinFindings(
input: TarballInput,
wrapperManifest: PackedManifest,
): readonly Finding[] {
const pin = wrapperManifest.dependencies?.[input.shellPackage];
if (pin === undefined) {
return [
finding(
"engine-pin-mismatch",
input.wrapperPackage ?? "(wrapper)",
`the wrapper does not declare ${input.shellPackage} as a dependency — it has nothing to delegate to`,
),
];
}
if (!EXACT_VERSION.test(pin)) {
return [
finding(
"engine-pin-mismatch",
input.wrapperPackage ?? "(wrapper)",
`the wrapper pins ${input.shellPackage} as "${pin}", which is not an exact version — a workspace: or range specifier survived packing`,
),
];
}
return [];
}

/** 3b + 3c's installed legs, all downstream of one sandbox install. */
async function sandboxFindings(
input: TarballInput,
shell: { tarball: string; manifest: PackedManifest },
wrapper: { tarball: string; manifest: PackedManifest } | undefined,
packed: ReadonlyMap<string, { tarball: string; manifest: PackedManifest }>,
io: TarballIo,
): Promise<readonly Finding[]> {
// The install is rooted at what a user would install: the wrapper
// when one ships, the shell otherwise.
const root = wrapper ?? shell;
const rootName =
wrapper === undefined
? input.shellPackage
: (input.wrapperPackage ?? input.shellPackage);
// 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 @@ -208,38 +262,48 @@ async function sandboxFindings(
visit(entry.manifest);
}
};
visit(shell.manifest);
visit(root.manifest);
const install = await io.installSandbox({
sandboxDir: input.sandboxDir,
rootTarball: shell.tarball,
rootTarball: root.tarball,
overrides,
});
if (!install.ok) {
return [
finding(
"install-failed",
input.shellPackage,
rootName,
"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)),
const subjects: readonly { name: string; manifest: PackedManifest }[] = [
{ name: rootName, manifest: root.manifest },
...(wrapper === undefined
? []
: [{ name: input.shellPackage, manifest: shell.manifest }]),
];
const findings: Finding[] = [];
for (const subject of subjects) {
// biome-ignore lint/performance/noAwaitInLoops: bins start one package at a time so a failure names its package
findings.push(...(await binFindings(input, subject, io)));
}
findings.push(...(await installedPinFindings(input, shell.manifest, io)));
return findings;
}

async function binFindings(
input: TarballInput,
shellManifest: PackedManifest,
subject: { name: string; manifest: PackedManifest },
io: TarballIo,
): Promise<readonly Finding[]> {
const findings: Finding[] = [];
for (const [binName, relPath] of declaredBins(shellManifest)) {
for (const [binName, relPath] of declaredBins(subject.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,
packageName: subject.name,
binName,
relPath,
argv: ["--version"],
Expand All @@ -249,7 +313,7 @@ async function binFindings(
findings.push(
finding(
"bin-failed",
input.shellPackage,
subject.name,
`bin ${binName} timed out instead of exiting`,
run.stderr,
),
Expand All @@ -258,7 +322,7 @@ async function binFindings(
findings.push(
finding(
"bin-failed",
input.shellPackage,
subject.name,
`bin ${binName} exited ${run.exitCode} on plain node`,
`stdout:\n${run.stdout}\nstderr:\n${run.stderr}`,
),
Expand Down Expand Up @@ -306,19 +370,23 @@ async function installedPinFindings(
}
}

const copies = await io.listInstalledCopies(
input.sandboxDir,
input.enginePackage,
);
if (copies.length > 1) {
findings.push(
finding(
"engine-pin-mismatch",
input.enginePackage,
`${copies.length} copies of ${input.enginePackage} resolve in the installed tree (${copies.map((c) => c.version).join(", ")})`,
copies.map((c) => `${c.version} ${c.path}`).join("\n"),
),
);
const singletons =
input.wrapperPackage === undefined
? [input.enginePackage]
: [input.enginePackage, input.shellPackage];
for (const name of singletons) {
// biome-ignore lint/performance/noAwaitInLoops: one sweep per singleton package keeps findings ordered
const copies = await io.listInstalledCopies(input.sandboxDir, name);
if (copies.length > 1) {
findings.push(
finding(
"engine-pin-mismatch",
name,
`${copies.length} copies of ${name} resolve in the installed tree (${copies.map((c) => c.version).join(", ")})`,
copies.map((c) => `${c.version} ${c.path}`).join("\n"),
),
);
}
}
return findings;
}
Expand Down
10 changes: 2 additions & 8 deletions packages/cli-conformance/src/tarball-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,22 +171,16 @@ export function realTarballIo(

async startBin({
sandboxDir,
packageName,
binName: _binName,
relPath,
argv,
timeoutMs,
}) {
const rootManifestPath = join(sandbox(), "package.json");
const rootManifest = JSON.parse(
readFileSync(rootManifestPath, "utf8"),
) as {
dependencies: Record<string, string>;
};
const shellName = Object.keys(rootManifest.dependencies)[0] ?? "";
const binPath = join(
sandboxDir,
"node_modules",
...shellName.split("/"),
...packageName.split("/"),
relPath,
);
try {
Expand Down
Loading
Loading