diff --git a/.drive/projects/prisma-cli-v8/assets/briefs/composer-cli-split-handover.md b/.drive/projects/prisma-cli-v8/assets/briefs/composer-cli-split-handover.md new file mode 100644 index 00000000..495cca20 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/assets/briefs/composer-cli-split-handover.md @@ -0,0 +1,89 @@ +# Handover brief — split @prisma/composer into library + CLI package, engine becomes a peer + +Written 2026-08-13 for an independent agent with NO prior context. The operator is Will Madden ("the operator"). Where this brief summarizes a document, the document wins. Repo paths are absolute; "this project directory" means `.drive/projects/prisma-cli-v8/` in the prisma-cli repo. + +## 1. Read first, in this order + +1. This project directory in **prisma-cli** (`/Users/wmadden/Projects/prisma/prisma-cli`, branch `main` — always `git fetch origin main` first, local checkouts go stale): `spec.md` (project frame), `plan.md` (slices and dependency graph), `design-notes.md` (settled design decisions), `specs/s2-overview.md` (standing rulings that bind every slice). +2. `specs/s6-conformance.md` in the same directory — the conformance-checker contract. Its §5 records the operator rulings of 2026-08-12, and its §1 documents the defect class this whole strategy exists to kill: installing `@prisma/cli` today resolves multiple copies of `@prisma/cli-engine`. +3. `deferred.md` in the same directory, the entry "The engine pin moves to whatever the tandem release publishes" (~line 37) — the standing ruling that pins must match, as a release requirement. +4. `specs/s3-composer.md` — the contract under which composer's current shape was built (one process, the family export, `ctx.spawn`, the S3 acceptance list). You are changing its packaging, not its design. +5. The composer repo itself: `/Users/wmadden/Projects/prisma/composer`. **Its local `main` is routinely stale — `git fetch origin main` and read via `origin/main` before trusting anything on disk.** Do all work in a fresh worktree off `origin/main`. + +## 2. The ruled strategy (operator, 2026-08-13) + +The engine (`@prisma/cli-engine`) must exist exactly once in any installed tree that runs the CLI. The recorded strategy, agreed in discussion with the operator: + +- Product CLI packages (composer's command family; the ORM's `@prisma/orm-toolchain`) declare the engine as an **exact `peerDependency`** (plus a `devDependency` for their own tests). The shell (`@prisma/cli`) carries the one real engine `dependency` and satisfies everyone's peer. Peers resolve against the ancestor, so one engine exists in any tree shape, and an unsatisfiable peer is an **install-time error** instead of a silent second copy. +- Widening exact peers to a **range** is the recorded destination, post-GA, once the engine has a written compatibility contract. Not now: during the rc line the engine breaks consumers deliberately, so a range would be fiction. +- Product **libraries carry no engine relationship at all**. Applications depend on libraries (`@prisma/composer`, `@prisma/orm-postgres`); only the consolidated CLI depends on product CLI packages; product CLI packages are reached only transitively through the CLI. +- The target dependency tree, in the operator's words: `app → @prisma/cli → { orm-cli → engine(peer), composer-cli → engine(peer), engine }` (the shell's published name is `@prisma/cli`), with `app → @prisma/composer` and `app → @prisma/orm-postgres` as ordinary library dependencies alongside. + +The ORM side already conforms structurally: `@prisma/orm-toolchain` is the dev/CLI package (applications reach its vite plugin through `@prisma/orm-postgres/vite-plugin-contract-emit`, a forwarded export — verified 2026-08-13), so it needs only the dependency-field change, in its own repo, **not in this brief's scope**. Composer is the one package that mixes the application-facing runtime library with the CLI family in one manifest. That split is your job. + +## 3. Verified current state of composer (2026-08-13, origin/main and the published 0.6.0-dev.16 tarball) + +- One publishable package `@prisma/composer` (`packages/9-public/composer/`), version 0.6.0 on disk. Exports the **library** surface (`.`, `./config`, `./control`, `./deploy`, `./local-target`, `./report`, `./casts`, `./assertions`, `./arktype`, `./service-rpc`, `./node`, `./node/control`, `./nextjs`, `./nextjs/control`), the **CLI** surface (`./family`, `./testing`), and a bin (`prisma-composer` → `./dist/bin.mjs`). +- `@prisma/cli-engine: "0.0.9"` sits in `dependencies`. No engine peer exists. +- The import graphs are **already disjoint** in the published output: the only dist files whose static graph names the engine are `family.mjs`, `bin.mjs` and their declaration files; zero shared chunks import it; the library entrypoints load with no engine reachable. The split is a packaging operation, not an untangling. +- The family implementation lives in the private workspace package `@internal/cli` (`packages/0-framework/3-tooling/cli/`); the publishable package's `src/exports/family.ts` is a re-export barrel, bundled by tsdown with `noExternal: [/^@internal\//]` and `external: ['esbuild', '@prisma/cli-engine']` (`packages/9-public/composer/tsdown.config.ts`). The comment there explains why the engine is external: composer and the prisma bin must share one engine instance. +- A second publishable package `@prisma/composer-prisma-cloud` exists; it has no engine relationship and is out of scope. +- Composer's own conformance-ish checks: `scripts/check-cli-engine-pin.mjs` (pin exact + identical across composer's two manifests + surviving into the packed manifest + a packed chunk retaining a bare engine import + `dist/bin.mjs` present), `scripts/check-family-static-graph.mjs` (packed output free of `alchemy`/`effect` imports, anchored at `dist/family.mjs`, `dist/bin.mjs`, `dist/testing.mjs`), `scripts/check-floor-imports.mjs`, `scripts/check-npm-effect-resolution.mjs` — all on PR CI (`ci.yml`) only — and `scripts/check-publish-deps.mjs`, the sole check in `publish.yml`. +- The consumer today: prisma-cli's shell imports `createComposerFamily` from `@prisma/composer/family` (`packages/cli/src/cli.ts`) and pins `@prisma/composer` exactly. Its conformance check (`packages/cli/scripts/conformance.ts`) currently expects the composer family package to pin the engine in `dependencies` and carries a recorded exception for the 0.0.9-vs-8.0.0-rc.1 mismatch. + +## 4. The work + +### D1 — the package split + +Create a new publishable package `@prisma/composer-cli` (name is STOP-1) in `packages/9-public/`, following the existing package's conventions (tsdown config extending `@internal/tsdown-config`, same `files`, license, repository fields — copy the manifest discipline from `packages/9-public/composer/package.json`). It takes over from `@prisma/composer`: + +- the `./family` export (the `CommandFamily`, `createComposerFamily`, `composerSection`, the operations seam), +- the `./testing` export (the family's test double belongs with the family), +- the `prisma-composer` bin (STOP-2 covers its fate; default: it moves here unchanged). + +`@prisma/composer` keeps every library export and **loses** `./family`, `./testing`, the bin, and its `@prisma/cli-engine` dependency entirely. Breaking change to the package's export map: record it in composer's changelog/release notes machinery, and note that the only known consumer of the removed subpaths is the prisma-cli shell (§5). + +Both packages bundle from the same `@internal/*` sources; nothing moves in `packages/0-framework/`. The split is manifests, tsdown entries, and export maps. + +### D2 — the engine becomes an exact peer + +In `@prisma/composer-cli`: `peerDependencies: { "@prisma/cli-engine": "0.0.9" }` (or whatever exact version composer builds against at the time), plus the same version in `devDependencies` so composer's own tests and the workspace resolve it. The version stays EXACT — the range destination is post-GA and is not yours to take. + +### D3 — composer's checks follow the packages + +- `check-cli-engine-pin.mjs`: the engine reference it asserts is now `@prisma/composer-cli`'s peer (exact, matching `@internal/cli`'s devDependency, surviving into the packed manifest); the packed-chunk bare-import assertion and the `dist/bin.mjs` presence assertion move to the new package's tarball. +- `check-family-static-graph.mjs`: its three anchored entrypoints now live in `@prisma/composer-cli`'s dist. +- `check-publish-deps.mjs` and `check-npm-effect-resolution.mjs`: three publishable packages now, not two. +- **New assertion, from the ruled strategy: `@prisma/composer`'s packed output must be engine-free** — no `@prisma/cli-engine` import anywhere in its tarball's JavaScript, and no engine entry in any consumer-installed dependency field. This is the library half of the invariant and nothing checks it today. + +### D4 — the publish path runs the checks + +Composer's `publish.yml` runs only `check:publish-deps` today; the pin, static-graph and effect-resolution checks run on PR CI only. Add them to `publish.yml` after `check:publish-deps` (an already-identified hole, in scope here because you are editing these checks anyway). + +## 5. Explicit handshake: what you do NOT do + +- **Do not touch prisma-cli.** After `@prisma/composer-cli` publishes, the shell repins (`@prisma/composer` → `@prisma/composer-cli` in `dependencies` and in `packages/cli/src/cli.ts`), and its conformance check's 3c evolves from pin-equality to peer-satisfaction with the exception list deleted. That is a follow-up in the prisma-cli repo — name it in your PR body as the required next step, with the file pointers above. +- **Do not touch prisma/prisma.** `@prisma/orm-toolchain`'s dependencies→peer change is the same strategy in another repo, separately dispatched. +- **Do not change the engine's versioning.** The engine now versions independently of the shell's lockstep (ruled 2026-08-13, recorded in ADR 0004) — but that ruling is implemented in prisma-cli, not here; composer only consumes whatever exact engine version it builds against. +- **Do not write the strategy ADR.** It is being recorded separately in prisma-cli; your PR implements composer's share of it. + +## 6. STOP — surface before implementing + +- **STOP-1: the package name.** `@prisma/composer-cli` is the operator's sketch; confirm it (npm scope availability, repo conventions) before creating anything. +- **STOP-2: the bin's fate.** The operator: composer "will not continue to publish its own standalone bin (probably)". Default for this slice: the bin moves to `@prisma/composer-cli` unchanged, retirement is a separate decision. If you find the bin materially complicates the split, surface that instead of working around it. +- **STOP-3: versioning of the new package.** Lockstep with `@prisma/composer` (shared `set-version` machinery) is the presumable default; confirm, because it decides how the tandem release names the pair. +- Anything in composer's release/tag automation that assumes exactly two publishable packages. + +## 7. Process rules (operator-enforced, non-negotiable) + +- Work in a fresh worktree off composer `origin/main`. Never trust a stale local `main` — fetch first, in every repo you read. +- Git identity: the `wmadden-electric` bot. Commit `git commit -s --trailer "Signed-off-by: Will Madden "`; end commit bodies with a `Co-Authored-By:` line naming your model. Push ONLY to `git@github-wmadden-electric:prisma/composer.git`. Never force-push. Stage by path, never `git add -A` on directories you have not inspected. +- pnpm only, never npm/npx — except inside `check-npm-effect-resolution.mjs`'s sandbox, which is deliberately npm. +- Tests before implementation; dependency injection, never `vi.mock`/module mocking; composer's existing check scripts show the io-seam style. +- NEVER hard-wrap markdown prose. Plain-English reports; no invented jargon; banned words: "load-bearing", "smoking gun", "belt and suspenders", "gate". +- PR: one PR, base `main`, DRAFT first. Description structure: grounding example first (a real install/run, before/after), then the decision, then the narrative, alternatives last. Reference the strategy discussion date (2026-08-13) and prisma-cli PR #161 / prisma/prisma PR #29998 as the sibling conformance work. +- Verification, each measured as the command's own exit code: composer's full suite, its script tests, every check script run end to end (including your changed ones), and a `publish.yml` dry-run path if the repo offers one. + +## 8. Your first report + +Confirm you read the project docs and this brief; state the STOP-1..3 answers you need; list any fact in §3 that no longer holds on composer `origin/main` (the repo moves fast — re-verify, do not trust this brief's snapshot); then your dispatch plan. diff --git a/.drive/projects/prisma-cli-v8/assets/briefs/orm-toolchain-engine-peer-handover.md b/.drive/projects/prisma-cli-v8/assets/briefs/orm-toolchain-engine-peer-handover.md new file mode 100644 index 00000000..13d96a25 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/assets/briefs/orm-toolchain-engine-peer-handover.md @@ -0,0 +1,54 @@ +# Handover brief — @prisma/orm-toolchain declares the engine as an exact peer + +Written 2026-08-13 for an independent agent with NO prior context. The operator is Will Madden ("the operator"). Where this brief summarizes a document, the document wins. This is the prisma/prisma share of the strategy whose composer share is `composer-cli-split-handover.md` in this directory; the two can land independently. + +## 1. Read first, in this order + +1. **ADR 0004** in prisma-cli: `docs/architecture/adrs/0004-engine-version-pinning.md` (on `main`, or on the `claude/composer-cli-split-brief` branch if not yet merged). It is the normative strategy record: one engine per install, product CLI packages declare `@prisma/cli-engine` as an **exact peerDependency** the shell satisfies, product libraries carry no engine relationship, ranges are post-GA only. +2. This project directory in prisma-cli (`.drive/projects/prisma-cli-v8/`): `specs/s6-conformance.md` §1 and §5 for the defect class and the 2026-08-12 rulings. +3. In prisma/prisma (`/Users/wmadden/Projects/prisma/prisma`): the repo's agent guidance (CLAUDE.md / AGENTS.md), `docs/architecture docs/adrs/ADR 242 - Public npm surface...` (the shell/publish-surface model — note the directory name contains a literal space), and `scripts/check-conformance.mjs` (the conformance checks landed 2026-08-12 as PR #29998). +4. **Always `git fetch origin main` before reading any repo** — local checkouts on this machine are routinely stale, and that has produced wrong conclusions twice. Work in a fresh worktree off prisma/prisma `origin/main`. + +## 2. The work + +`@prisma/orm-toolchain` is already the ORM's dev/CLI package — applications never install it directly (they reach the vite plugin through `@prisma/orm-postgres/vite-plugin-contract-emit`, a forwarded export), so no package split is needed. The change is the dependency field: + +- `@prisma/orm-toolchain`'s published manifest moves `@prisma/cli-engine` from `dependencies` to **`peerDependencies` with the same exact version**, keeping a `devDependency` at that version so the workspace and its tests resolve it. +- **The manifest is generated, not hand-edited.** ADR 242's shell-build derives the published packages' manifests from the internal packages they bundle (`packages/0-config/tsdown/shell-build.ts`, `@internal/publish-surface` at `packages/0-shared/publish-surface/`). There is precedent for hand-declared peers — the `handWrittenPeers` set in shell-build already carries `typescript`, and the packed manifest today shows `typescript` and `vite` as peers. Find the sanctioned route for declaring the engine as a peer of the toolchain shell (likely: the engine joins the hand-written peers for that shell, and `@internal/cli`'s own manifest keeps the engine as a devDependency). Do not fight the generator; if the generator cannot express this, that is a STOP, not an improvisation. +- `@internal/cli` (`packages/1-framework/3-tooling/cli/`) currently carries `"@prisma/cli-engine": "0.0.9"` in `dependencies`. Decide with the generator's rules where it belongs after the change (devDependencies is the expectation, since the published artifact no longer ships the engine as a dependency); `test/integration`'s own pin is private/dev usage and stays. + +## 3. The checks follow the strategy (same repo, same PR) + +`scripts/check-conformance.mjs` currently asserts the engine pin is exact and identical across the manifests that declare it in `dependencies`. Under ADR 0004 it becomes: + +- The toolchain's packed manifest declares the engine in **`peerDependencies`, exact, and NOT in `dependencies`** — a `dependencies` entry reappearing is a finding. +- Every remaining engine reference in the repo (internal cli devDependency, integration tests) agrees with the peer's version. +- The packed-output import-purity check keeps treating a bare `@prisma/cli-engine` import in `cli.mjs` as satisfied — peers count as consumer-installed (they already do in that script's field set; verify, don't assume). +- `scripts/check-publish-deps.mjs` must still pass — read its rules before changing any manifest field; its `@internal/*` exact-pin logic must not start flagging the new arrangement. + +Sandbox note for the tarball leg: with the engine as a peer, `npm install` (v7+) auto-installs it from the registry — but the exact peer version may be UNPUBLISHED at check time (that is the point of the tandem release). The conformance sandbox already supplies unpublished workspace siblings through computed version-qualified `file:` overrides; extend that mechanism to satisfy the engine peer from a packed/local source if needed, and prove the `prisma-next` bin still starts in the sandbox. + +## 4. What you do NOT do + +- No changes in prisma-cli or composer. The shell's side (peer-satisfaction check, exception-list deletion) is a separate follow-up there. +- No version-range peers. Exact only; ranges are a post-GA decision the operator has not made. +- No engine version changes. Whether the engine decouples from the shell's lockstep is marked OPEN in ADR 0004. + +## 5. STOP — surface before implementing + +- **STOP-1**: the generator route for the peer (handWrittenPeers vs something else), if shell-build's model resists it. +- **STOP-2**: if moving `@internal/cli`'s engine dep to devDependencies breaks how shell-build computes the toolchain's dependency set, surface the options rather than picking one. +- Anything that would change `@prisma/orm-toolchain`'s export map or bin — out of scope, surface it. + +## 6. Process rules (operator-enforced, non-negotiable) + +- Git identity: the `wmadden-electric` bot. Commit `git commit -s --trailer "Signed-off-by: Will Madden "`; end commit bodies with a `Co-Authored-By:` line naming your model. Push ONLY to `git@github-wmadden-electric:prisma/prisma.git`. Never force-push. Stage by path. +- pnpm only, never npm/npx — except inside the conformance sandbox, which is deliberately npm. +- Tests first; the repo's io-seam style (`scripts/check-conformance.test.mjs` is the model); no module mocking. New/changed script tests must be in the root `test:scripts` list or they never run. +- NEVER hard-wrap markdown prose. Plain-English reports; banned words: "load-bearing", "smoking gun", "belt and suspenders", "gate". +- One DRAFT PR, base `main`. Description: grounding example first (a real install/resolution before/after), then the decision (cite ADR 0004), then the narrative, alternatives last. Reference prisma/prisma#29998 and prisma-cli#161 as the sibling conformance work. +- Verification, each as the command's own exit code: `pnpm test:scripts`, `node --test scripts/check-conformance.test.mjs`, `pnpm check:conformance` end to end (must exit 0), `pnpm check:publish-deps`, and the touched packages' suites. + +## 7. Your first report + +Confirm you read ADR 0004 and this brief; state STOP answers you need; list any §2/§3 fact that no longer holds on origin/main (re-verify, do not trust this snapshot); then your plan. diff --git a/.github/workflows/auto-repin.yml b/.github/workflows/auto-repin.yml new file mode 100644 index 00000000..e21738d4 --- /dev/null +++ b/.github/workflows/auto-repin.yml @@ -0,0 +1,91 @@ +name: Auto-repin product CLI packages + +# When composer or prisma/prisma publish a family package, this repo +# repins automatically and the merged repin ships as a dev version of +# the CLI (operator ruling 2026-08-13; docs/oss/versioning.md). Only a +# real CLI release needs a human. +# +# Triggers: +# - repository_dispatch `family-published`: sent by the product +# repos' publish workflows the moment they ship. +# - schedule (daily): the backstop for a missed dispatch — the same +# comparison against the registry, so a lost event delays a repin +# by at most a day. +# - workflow_dispatch: run it by hand. +# +# The PR is opened with REPIN_PAT, not GITHUB_TOKEN, for two reasons: +# pull requests created with the workflow token never trigger CI, and +# without CI the required checks never report, so auto-merge would hang +# forever. The PAT is the wmadden-electric bot's. + +on: + repository_dispatch: + types: [family-published] + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +concurrency: + group: auto-repin + cancel-in-progress: false + +jobs: + repin: + name: Repin against the registry + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .node-version + + - name: Compare pins against the registry + id: repin + run: node scripts/auto-repin.mjs + + # `pnpm install --no-frozen-lockfile` scoped to the lockfile + # refresh: the repin edits one manifest, and the PR must carry the + # matching lockfile or every CI job fails on --frozen-lockfile. + - name: Refresh the lockfile + if: ${{ steps.repin.outputs.changed == 'true' }} + run: | + corepack enable + pnpm install --lockfile-only --no-frozen-lockfile + + - name: Open the repin PR with auto-merge + if: ${{ steps.repin.outputs.changed == 'true' }} + env: + GH_TOKEN: ${{ secrets.REPIN_PAT }} + SUMMARY: ${{ steps.repin.outputs.summary }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::error::REPIN_PAT is not configured; cannot open a CI-triggering PR. Add the bot PAT as the REPIN_PAT repository secret." + exit 1 + fi + BRANCH="auto/repin-$(date -u +%Y%m%d%H%M%S)" + git config user.name "willbot" + git config user.email "w.a.madden+machine@gmail.com" + git checkout -b "$BRANCH" + git add packages/cli/package.json pnpm-lock.yaml + git commit -s --trailer "Signed-off-by: Will Madden " \ + -m "Repin product CLI packages to the registry: $SUMMARY" \ + -m "Opened automatically (auto-repin.yml); merging ships a dev version of the CLI. A real release stays a human act." + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + PR_URL=$(gh pr create \ + --title "Repin product CLI packages: $SUMMARY" \ + --body "Automated repin from the registry ($SUMMARY). Merging publishes a \`dev\` version of the CLI automatically; the conformance checks on this PR are what stand between a family publish and that dev build. Opened by auto-repin.yml." \ + --base main --head "$BRANCH") + gh pr merge --auto --squash "$PR_URL" + echo "Opened $PR_URL with auto-merge armed." diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a0770342..f6ddb9a9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,9 +9,11 @@ name: Publish to npm # rewrites a manifest to get there. # # Trigger model: -# - push to `main` with the root `version` unchanged → nothing to -# publish; the committed version is already on the registry. Use -# the per-PR pkg.pr.new previews to try an unreleased build. +# - push to `main` with the root `version` unchanged → publish +# `-dev.` under the `dev` dist-tag (operator ruling +# 2026-08-13: automated family repins deploy automatically; only a +# real release needs a human). The dev suffix is stamped +# ephemerally below and never committed. # - push to `main` with the root `version` changed → publish `` # under its canonical dist-tag — `next` on the RC line, `latest` for # stable — and create a GitHub Release (marked pre-release on the RC @@ -22,9 +24,15 @@ name: Publish to npm # 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`). +# depends on the engine, so the engine goes first). The engine versions +# INDEPENDENTLY of the lockstep (ADR 0004, operator 2026-08-13): it +# publishes at whatever version its own manifest carries, and because +# an already-published version is treated as done (see publish_one), an +# unbumped engine is a no-op while a bumped one ships in the same run. +# Its dist-tag rides the run's tag; every consumer pins it exactly, so +# the tag is cosmetic for the engine. `@prisma/compute` is excluded +# from the lockstep by operator ruling (2026-08-10) and keeps its own +# workflow (`publish-compute.yml`). on: push: @@ -100,6 +108,14 @@ jobs: PUSH_BEFORE_SHA: ${{ github.event.before }} run: node scripts/determine-version.ts + # Dev publishes only: stamp `-dev.` across the lockstep + # manifests for this run. Ephemeral — nothing is committed; release + # versions remain exactly what the commit says. The engine is + # excluded from the sweep and ships at its own committed version. + - name: Stamp dev version + if: ${{ steps.version.outputs.publish == 'true' && steps.version.outputs.tag == 'dev' }} + run: node scripts/set-version.ts "${{ steps.version.outputs.version }}" + - name: Build packages if: ${{ steps.version.outputs.publish == 'true' }} run: pnpm build diff --git a/docs/architecture/adrs/0004-engine-version-pinning.md b/docs/architecture/adrs/0004-engine-version-pinning.md new file mode 100644 index 00000000..84879eec --- /dev/null +++ b/docs/architecture/adrs/0004-engine-version-pinning.md @@ -0,0 +1,62 @@ +# ADR 0004 - One engine per install: peers on product CLI packages, engine-free libraries + +## Status + +Accepted (operator, 2026-08-13). The engine-versioning sub-decision was ruled the same day: the engine versions independently. + +## Context + +The unified CLI is assembled from packages published by three repositories: this repo publishes the shell (`@prisma/cli`) and the engine (`@prisma/cli-engine`); the composer and prisma/prisma repos each publish a command-family package the shell mounts. The engine must exist exactly once in any installed tree that runs the CLI: families construct their command objects with the engine they resolve, the shell executes them with the engine it resolves, and while structured errors deliberately survive two copies (`Symbol.for` markers, test-pinned), execution and signal behaviour across copies is unsupported by ruling. + +Under the original arrangement — every family carrying the engine in `dependencies` with an exact pin — that invariant held only when every published pin agreed exactly. It failed in practice: at the time of this decision, installing `@prisma/cli` resolved **three** engine copies (the shell's, plus one each dragged in by `@prisma/composer` and `@prisma/orm-toolchain`, both pinning an older engine). The conformance checker (S6) reports this at publish time, but pins can only detect the problem in trees we publish; nothing prevents npm from assembling a duplicated tree from versions that individually looked fine. Two further costs: every engine release forced a four-repo republish train (engine → composer → prisma/prisma → shell) even for releases that did not change the engine, and applications depending on `@prisma/composer` as a *library* installed an engine copy their code never imports, because the library and the command family shared one manifest. + +## Decision + +The dependency tree the strategy targets: + +```text +app +├── prisma CLI (@prisma/cli) the one binary users run +│ ├── @prisma/cli-engine dependencies, exact — the single real engine +│ ├── @prisma/composer-cli dependencies, exact +│ │ └── @prisma/cli-engine peerDependencies, exact +│ └── @prisma/orm-toolchain dependencies, exact +│ └── @prisma/cli-engine peerDependencies, exact +├── @prisma/composer library; NO engine relationship +└── @prisma/orm-postgres library; NO engine relationship +``` + +Per edge: + +1. **app → CLI**: the user's ordinary semver choice. The CLI is an application: a given CLI version fully determines its tree, so `npx prisma@` is reproducible. +2. **shell → product CLI packages**: exact pins in `dependencies`. A range here would let the CLI's behaviour change on the registry without a shell release. +3. **product CLI packages → engine**: an **exact `peerDependency`** (plus a `devDependency` at the same version for the package's own tests). The shell supplies the one engine that satisfies every peer. Peers resolve against the ancestor, so exactly one engine exists in any tree shape npm can assemble, and a version conflict is an **install-time error** rather than a silent second copy. +4. **product libraries**: no engine relationship of any kind. Applications depend on libraries; only the CLI depends on product CLI packages; product CLI packages reach applications only transitively through the CLI. + +**Consequence for packaging:** a product may not mix its application-facing library and its command family in one published package, because a peer on the combined package would be auto-installed for every library consumer. The ORM already conforms (`@prisma/orm-toolchain` is dev/CLI-only; applications reach its vite plugin through `@prisma/orm-postgres`'s forwarded export). Composer splits: `@prisma/composer-cli` takes the family, the testing surface and the bin; `@prisma/composer` keeps the library exports and drops the engine entirely. The import graphs were verified disjoint before the split was scheduled, so it is a packaging change, not an untangling. + +**Exact now, range later.** During the rc line the engine breaks its consumers deliberately, slice by slice, so a version range would be fiction. Post-GA, the recorded destination is widening the peers to a range under a written engine compatibility contract, so a non-breaking engine release ships in the next shell release with zero family republishes. Widening is a deliberate future decision against that contract, not a drift. + +**Engine versioning — RULED (operator, 2026-08-13): the engine versions independently.** Under the shell lockstep it previously shared, every CLI release manufactured a new engine version — and with it a family-repin obligation — whether or not the engine changed. Decoupled, an engine version means "the engine changed", the exact peers stay valid between real engine changes, and the repin train runs only when there is something to repin for. Mechanically: the engine is excluded from `set-version.ts`'s lockstep (alongside `@prisma/compute`), follows honest pre-1.0 semver (breaking bumps the minor), and publishes at its own manifest version, with an already-published version treated as a no-op by the publish workflow. + +## Enforcement + +The conformance checkers in both publishing repos are the mechanism, evolved from pin-equality to this strategy: + +- **Peer satisfaction**: the shell's engine version must satisfy every mounted family's engine peer. Replaces pairwise pin comparison. +- **Singleton install**: the packed shell tarball, installed into a clean sandbox, resolves exactly one engine copy — with **no exception list**. Under peers a mismatch fails at install, so the S6-era recorded exceptions are deleted the moment the first tandem release lands the peers. +- **Engine-free libraries**: a library package's packed output names the engine in no import and in no consumer-installed dependency field. + +## Cases considered + +- *User installs the CLI alone* (global, `npx`, devDependency): peers guarantee one engine regardless of how the package manager arranges the tree. +- *App depends on a product library and the CLI*: the library carries no engine, so no duplication is possible through that route. This case was unsolvable under `dependencies` pinning and is the reason peers plus the split were chosen. +- *Release skew between the component repos*: an unsatisfied exact peer fails at install/publish-check time instead of shipping a duplicated tree; with the engine versioned independently, skew windows open only when the engine actually changes. +- *Two CLI versions in one monorepo*: nested and isolated; each tree satisfies its own peers. +- *CLI ↔ library version skew* (the app's `@prisma/composer` vs the CLI's `composer-cli`): explicitly **not** a duplication problem and not addressed here; it is a compatibility-policy question (support floor or runtime detection) that needs its own owner. + +## Alternatives considered + +- **Exact pins in `dependencies` everywhere (status quo ante).** Detects duplication only in published combinations; silent duplication in assembled trees; maximal republish train; forces an engine install on library consumers. Rejected on the library case alone, which no amount of pin discipline can fix. +- **Range peers immediately.** Removes the train now, but promises compatibility the rc-line engine deliberately does not offer; a family built against engine N running against engine M is exactly the untested-across-copies behaviour the invariant exists to avoid. Deferred, not rejected. +- **npm `overrides` guidance for affected users.** Overrides work only at the application root and shift the burden to every consumer. Not a strategy. diff --git a/docs/architecture/adrs/README.md b/docs/architecture/adrs/README.md index c32c9572..c8a50bb5 100644 --- a/docs/architecture/adrs/README.md +++ b/docs/architecture/adrs/README.md @@ -14,6 +14,7 @@ long-term architecture boundaries. | [0001](0001-preview-package-and-publishing.md) | Accepted | Use `@prisma/cli` latest for official beta releases, `dev` for integrated main builds, and PR previews for trusted unmerged work. | | [0002](0002-workflow-command-model.md) | Accepted | Group commands by developer workflow using `prisma `. | | [0003](0003-structured-output-and-errors.md) | Accepted | Treat structured output and stable error codes as public contracts. | +| [0004](0004-engine-version-pinning.md) | Accepted | One engine per install: product CLI packages declare the engine as an exact peer, product libraries carry no engine relationship. | ## ADR Template diff --git a/docs/oss/versioning.md b/docs/oss/versioning.md index 7454818e..d700b765 100644 --- a/docs/oss/versioning.md +++ b/docs/oss/versioning.md @@ -16,9 +16,11 @@ 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. -**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)). +**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 repin only when the engine actually moves. The engine follows honest pre-1.0 semver (a breaking change bumps the minor); bumping it is a deliberate edit to `packages/cli-engine/package.json` plus the shell's `workspace:` pin, landed as a reviewed commit like any other version change. 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-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:` (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-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. + +How the packages published by *other* repositories relate to the engine's version — the product CLI packages the shell mounts, and the product libraries applications install — is governed by [ADR 0004](../architecture/adrs/0004-engine-version-pinning.md): product CLI packages declare `@prisma/cli-engine` as an exact peer dependency the shell satisfies, and product libraries carry no engine relationship at all. ## Dist-tag convention @@ -28,7 +30,7 @@ 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. -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. +- **`dev`** — every routine push to `main` publishes `-dev.` here automatically (operator ruling 2026-08-13, superseding the earlier "no dev channel" ruling). The suffix derives from the workflow run number and is stamped ephemerally in CI, never committed, so release versions remain exactly what a commit says. The channel exists so automated family repins ([`auto-repin.yml`](../../.github/workflows/auto-repin.yml)) deploy without a human: composer's and prisma/prisma's publish workflows dispatch to this repo, an auto-merge repin PR runs the full quality and conformance checks, and its merge ships the dev build. Only a real release — an `rc.N` bump under `next`, or moving `latest` — is a human act. 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. @@ -76,6 +78,6 @@ The pure version-computation helpers are covered by `pnpm test:scripts` (run in ## Non-goals -- **Independent per-package versioning** (beyond the ruled `@prisma/compute` exclusion). Lockstep is the invariant the rest of the contract is built on. +- **Independent per-package versioning** (beyond the ruled exclusions: `@prisma/compute`, and `@prisma/cli-engine` per ADR 0004). Lockstep is the invariant the rest of the contract is built on for everything else. - **A scripted `beta` cadence.** The `beta` dist-tag exists but cutting beta builds is a manual `workflow_dispatch`. (The RC cadence, by contrast, *is* the routine scripted path.) - **Patch releases on the RC line.** A fix ships as the next `rc.N`. diff --git a/package.json b/package.json index 4ab6121c..695ffd68 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "lint:fix": "biome check . --write", "bump-version": "node scripts/bump-version.ts", "test": "turbo run test", - "test:scripts": "node --test scripts/determine-version-utils.test.ts scripts/set-version-utils.test.ts scripts/resolve-package-version.test.mjs", + "test:scripts": "node --test scripts/determine-version-utils.test.ts scripts/set-version-utils.test.ts scripts/resolve-package-version.test.mjs scripts/auto-repin.test.mjs", "typecheck": "turbo run typecheck", "prisma-cli": "tsx packages/cli/src/bin.ts", "prisma": "tsx packages/cli/src/bin.ts", diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index 8df2375d..f9d5a579 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli-engine", - "version": "8.0.0-rc.2", + "version": "0.1.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 e91e3a01..0955d70d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -47,7 +47,7 @@ "conformance": "tsx scripts/conformance.ts" }, "dependencies": { - "@prisma/cli-engine": "workspace:8.0.0-rc.2", + "@prisma/cli-engine": "workspace:0.1.0", "@prisma/composer": "0.6.0-dev.16", "@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 d4c394fc..d02d2d29 100644 --- a/packages/cli/scripts/conformance.ts +++ b/packages/cli/scripts/conformance.ts @@ -87,7 +87,7 @@ async function tarball(): Promise { { familyPackage: "@prisma/composer", familyPin: "0.0.9", - shellPin: "8.0.0-rc.2", + shellPin: "0.1.0", reason: "operator ruling 2026-08-12: ignore for now — composer cannot pin an engine version that is not published yet", removeWhen: @@ -96,7 +96,7 @@ async function tarball(): Promise { { familyPackage: "@prisma/orm-toolchain", familyPin: "0.0.9", - shellPin: "8.0.0-rc.2", + shellPin: "0.1.0", reason: "same class, same ruling: the ORM toolchain cannot pin an engine version that is not published yet", removeWhen: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4814532d..b92d0c8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: packages/cli: dependencies: '@prisma/cli-engine': - specifier: workspace:8.0.0-rc.2 + specifier: workspace:0.1.0 version: link:../cli-engine '@prisma/composer': specifier: 0.6.0-dev.16 diff --git a/scripts/auto-repin.mjs b/scripts/auto-repin.mjs new file mode 100644 index 00000000..fb093755 --- /dev/null +++ b/scripts/auto-repin.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +// Repins the shell's exact dependencies on the product CLI packages to +// what their repos last published (operator ruling 2026-08-13: family +// publishes flow into this repo automatically and deploy as dev +// versions; only a real CLI release needs a human). Run by +// `.github/workflows/auto-repin.yml` on a repository_dispatch from a +// product repo's publish workflow, on a daily schedule as the backstop +// for missed dispatches, and by hand via workflow_dispatch. +// +// The script edits `packages/cli/package.json` in place and prints one +// line per changed pin; the workflow turns a non-empty change set into +// an auto-merge pull request. Exit codes: 0 with changes or without +// (the workflow reads the summary file), 1 on any error. +// +// Which dist-tag "last published" means is per package: composer +// releases under `latest`; prisma/prisma's RC-line releases go to +// `next`. A package absent from the shell's dependencies is skipped — +// the list below names candidates, not requirements — so the +// composer → composer-cli hand-over needs no edit here. + +import { execFileSync } from "node:child_process"; +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const WATCHED = /** @type {const} */ ([ + { name: "@prisma/composer", tag: "latest" }, + { name: "@prisma/composer-cli", tag: "latest" }, + { name: "@prisma/orm-toolchain", tag: "next" }, +]); + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); +const manifestPath = join(rootDir, "packages", "cli", "package.json"); + +/** + * Computes the repin edits for a manifest given the registry's current + * versions. Pure; exported for tests. + * + * @param {{ dependencies?: Record }} manifest + * @param {ReadonlyMap} published name → version at its watched tag + * @returns {Array<{ name: string; from: string; to: string }>} + */ +export function computeRepins(manifest, published) { + const changes = []; + const deps = manifest.dependencies ?? {}; + for (const { name } of WATCHED) { + const current = deps[name]; + if (current === undefined) continue; + const latest = published.get(name); + if (latest === undefined || latest === current) continue; + changes.push({ name, from: current, to: latest }); + } + return changes; +} + +function publishedVersion(name, tag) { + try { + const out = execFileSync("npm", ["view", `${name}@${tag}`, "version"], { + encoding: "utf-8", + }).trim(); + return out.length > 0 ? out : undefined; + } catch { + // Not published yet (or the tag does not exist) — nothing to repin to. + return undefined; + } +} + +function main() { + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); + const published = new Map( + WATCHED.map(({ name, tag }) => [name, publishedVersion(name, tag)]), + ); + const changes = computeRepins(manifest, published); + + for (const { name, from, to } of changes) { + manifest.dependencies[name] = to; + console.log(`${name}: ${from} -> ${to}`); + } + if (changes.length > 0) { + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + } else { + console.log("All watched pins already match the registry."); + } + + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + const summary = changes + .map(({ name, from, to }) => `${name} ${from} -> ${to}`) + .join("; "); + appendFileSync( + outputFile, + `changed< 0)}\nEOF\n`, + ); + appendFileSync(outputFile, `summary< { + it("repins a watched dependency whose registry version moved", () => { + const changes = computeRepins( + { dependencies: { "@prisma/composer": "0.6.0-dev.16", left: "1.0.0" } }, + new Map([["@prisma/composer", "0.7.0"]]), + ); + assert.deepEqual(changes, [ + { name: "@prisma/composer", from: "0.6.0-dev.16", to: "0.7.0" }, + ]); + }); + + it("skips watched packages the shell does not depend on — candidates, not requirements", () => { + const changes = computeRepins( + { dependencies: { "@prisma/composer": "0.6.0-dev.16" } }, + new Map([ + ["@prisma/composer", "0.6.0-dev.16"], + ["@prisma/composer-cli", "0.7.0"], + ]), + ); + assert.deepEqual(changes, []); + }); + + it("skips a package the registry has never seen at its watched tag", () => { + const changes = computeRepins( + { dependencies: { "@prisma/orm-toolchain": "8.0.0-rc.1-dev.40" } }, + new Map([["@prisma/orm-toolchain", undefined]]), + ); + assert.deepEqual(changes, []); + }); + + it("reports every drifted pin in one run", () => { + const changes = computeRepins( + { + dependencies: { + "@prisma/composer": "0.6.0", + "@prisma/orm-toolchain": "8.0.0-rc.1", + }, + }, + new Map([ + ["@prisma/composer", "0.7.0"], + ["@prisma/orm-toolchain", "8.0.0-rc.2"], + ]), + ); + assert.equal(changes.length, 2); + }); +}); diff --git a/scripts/determine-version-utils.test.ts b/scripts/determine-version-utils.test.ts index 00cac7dd..2ad941d3 100644 --- a/scripts/determine-version-utils.test.ts +++ b/scripts/determine-version-utils.test.ts @@ -4,6 +4,7 @@ import { assertCanonicalBase, computeNextMinor, computeNextReleaseVersion, + devVersion, parseVersion, releaseDistTag, } from "./determine-version-utils.ts"; @@ -152,3 +153,20 @@ describe("releaseDistTag", () => { assert.throws(() => releaseDistTag("8.0.0-rc.1-dev.3"), NOT_CANONICAL); }); }); + +describe("devVersion", () => { + it("suffixes the base with the workflow run number under -dev", () => { + assert.equal(devVersion("8.0.0-rc.2", "417"), "8.0.0-rc.2-dev.417"); + assert.equal(devVersion("0.17.0", "3"), "0.17.0-dev.3"); + }); + + it("refuses a missing or malformed run number", () => { + assert.throws(() => devVersion("8.0.0-rc.2", "")); + assert.throws(() => devVersion("8.0.0-rc.2", "0")); + assert.throws(() => devVersion("8.0.0-rc.2", "abc")); + }); + + it("refuses a non-canonical base, same as every other publish path", () => { + assert.throws(() => devVersion("8.0.0-rc.2-dev.1", "4")); + }); +}); diff --git a/scripts/determine-version-utils.ts b/scripts/determine-version-utils.ts index 1db9d407..bc7f25f4 100644 --- a/scripts/determine-version-utils.ts +++ b/scripts/determine-version-utils.ts @@ -11,6 +11,7 @@ const STABLE_BASE_PATTERN = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}$`); // reaching the pipeline is a mistake to fail on, not a case to support. // A future RC line widens this constant. const RC_BASE_PATTERN = /^8\.0\.0-rc\.([1-9]\d*)$/; +const RUN_NUMBER_PATTERN = /^[1-9]\d*$/; export interface ParsedVersion { major: number; @@ -65,6 +66,22 @@ export interface VersionResult { tag: string; } +/** + * The version a routine main push publishes under the `dev` dist-tag + * (operator ruling 2026-08-13: automatic repins deploy automatic dev + * versions; only a real release needs a human). The run number makes + * the suffix monotonic per workflow run without reading the registry. + */ +export function devVersion(base: string, runNumber: string): string { + assertCanonicalBase(base); + if (!RUN_NUMBER_PATTERN.test(runNumber)) { + throw new Error( + `dev publishes derive their suffix from the workflow run number; got "${runNumber}"`, + ); + } + return `${base}-dev.${runNumber}`; +} + /** * Asserts that a base version is canonical: either a clean release * (`major.minor.patch`) or a version on the supported RC line diff --git a/scripts/determine-version.ts b/scripts/determine-version.ts index 958873ed..5fec59a4 100644 --- a/scripts/determine-version.ts +++ b/scripts/determine-version.ts @@ -15,10 +15,11 @@ * PR ships a release automatically — `latest` * keeps serving the pre-8 CLI until the * operator deliberately moves it. - * Otherwise there is nothing to publish: the - * committed version is already on the registry. - * `publish` is written as `false` and the - * workflow skips the remaining steps. + * Otherwise `-dev.` under the `dev` + * dist-tag: every routine main push — an + * automated family repin above all — ships an + * installable dev build automatically + * (operator ruling 2026-08-13). * - `workflow_dispatch` → `` (no suffix), dist-tag from * `INPUT_DIST_TAG`; empty means the version's * canonical tag (`releaseDistTag`). Useful as a @@ -30,9 +31,10 @@ * Outputs `publish`, `version`, `tag` and `release` to `$GITHUB_OUTPUT` * for downstream workflow steps to consume. * - * This script never rewrites a manifest. The version it reports is the - * one committed at this ref, always — which is the whole point of - * keeping the version in `package.json` (docs/oss/versioning.md). + * This script never rewrites a manifest. Release versions are the ones + * committed at this ref, always; a dev version derives its suffix from + * the run number, and the workflow stamps it ephemerally in CI without + * committing it (docs/oss/versioning.md). */ import { execFileSync } from "node:child_process"; @@ -42,6 +44,7 @@ import { fileURLToPath } from "node:url"; import type { VersionResult } from "./determine-version-utils.ts"; import { assertCanonicalBase, + devVersion, releaseDistTag, } from "./determine-version-utils.ts"; @@ -155,12 +158,21 @@ switch (eventName) { `Previous root version: ${previous.version ?? "(unset)"} → release bump detected.`, ); result = { version: baseVersion, tag: releaseDistTag(baseVersion) }; - } else { + } else if (previous.available) { + // Routine push: publish `-dev.` under `dev` (operator + // ruling 2026-08-13 — automatic repins from the product repos + // deploy automatically; only a real release needs a human). The + // suffix is derived here and stamped ephemerally in CI; it is + // never committed, so releases remain committed-at-HEAD. + const runNumber = process.env.GITHUB_RUN_NUMBER ?? ""; console.log( - previous.available - ? "Root version unchanged by this push — nothing to publish." - : "Could not read the previous root version — not publishing.", + `Root version unchanged by this push → dev publish (run ${runNumber}).`, ); + result = { version: devVersion(baseVersion, runNumber), tag: "dev" }; + } else { + // A transient git error must never publish anything; skipping is + // recoverable by dispatching the workflow. + console.log("Could not read the previous root version — not publishing."); result = undefined; } break; diff --git a/scripts/set-version-utils.test.ts b/scripts/set-version-utils.test.ts index 27d36fc0..21b66933 100644 --- a/scripts/set-version-utils.test.ts +++ b/scripts/set-version-utils.test.ts @@ -151,6 +151,26 @@ describe("rewriteWorkspaceDeps", () => { }); }); + it("keeps pins on excluded packages at their own version, per ADR 0004", () => { + const pkg: MutablePackageJson = { + name: "@prisma/cli", + version: "8.0.0-rc.1", + dependencies: { + "@prisma/cli-engine": "workspace:0.1.0", + }, + devDependencies: { + "@repo/tsconfig": "workspace:8.0.0-rc.1", + }, + }; + rewriteWorkspaceDeps(pkg, "8.0.0-rc.2", new Set(["@prisma/cli-engine"])); + assert.deepEqual(pkg.dependencies, { + "@prisma/cli-engine": "workspace:0.1.0", + }); + assert.deepEqual(pkg.devDependencies, { + "@repo/tsconfig": "workspace:8.0.0-rc.2", + }); + }); + it("tolerates a package with missing dep-field objects", () => { const pkg: MutablePackageJson = { name: "sparse", version: "0.7.0" }; rewriteWorkspaceDeps(pkg, "0.8.0"); diff --git a/scripts/set-version-utils.ts b/scripts/set-version-utils.ts index 66b458b1..ffc50cf2 100644 --- a/scripts/set-version-utils.ts +++ b/scripts/set-version-utils.ts @@ -42,12 +42,18 @@ const DEP_FIELDS = [ export function rewriteWorkspaceDeps( packageJson: MutablePackageJson, version: string, + excluded: ReadonlySet = new Set(), ): void { for (const field of DEP_FIELDS) { const deps = packageJson[field]; if (!deps) continue; for (const [name, spec] of Object.entries(deps)) { if (typeof spec !== "string" || !spec.startsWith("workspace:")) continue; + // A pin on an independently-versioned sibling keeps ITS version: + // rewriting @prisma/cli's workspace: to the CLI's + // lockstep version would silently point the shell at an engine + // version that does not exist (ADR 0004 decouples the engine). + if (excluded.has(name)) continue; deps[name] = `workspace:${version}`; } } diff --git a/scripts/set-version.ts b/scripts/set-version.ts index c8f48543..72cd0932 100644 --- a/scripts/set-version.ts +++ b/scripts/set-version.ts @@ -10,10 +10,14 @@ import { rewriteWorkspaceDeps, } from "./set-version-utils.ts"; -// Operator ruling 2026-08-10: `@prisma/compute` versions independently -// pending extraction to another repo; the lockstep set is the root plus -// the CLI packages. Its manifest and publish workflow stay untouched. -const LOCKSTEP_EXCLUDED = new Set(["@prisma/compute"]); +// Operator rulings: `@prisma/compute` versions independently pending +// extraction to another repo (2026-08-10), and `@prisma/cli-engine` +// versions independently so an engine version means "the engine +// changed" rather than "the CLI released" — which is what keeps the +// exact peer pins on it cheap (2026-08-13, ADR 0004). A lockstep bump +// never changes their `version` field; their workspace pins on +// lockstep siblings ARE still swept, so those never go stale. +const LOCKSTEP_EXCLUDED = new Set(["@prisma/compute", "@prisma/cli-engine"]); const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); @@ -56,23 +60,27 @@ let updatedCount = 0; // private packages drifted, that invariant would be silently violated // by direct invocations of this script. for (const pkg of workspacePackages) { - if (LOCKSTEP_EXCLUDED.has(pkg.name)) { - console.log(`Skipped ${pkg.name} (excluded from lockstep)`); - continue; - } + const excluded = LOCKSTEP_EXCLUDED.has(pkg.name); const packageJsonPath = path.join(pkg.path, "package.json"); // biome-ignore lint/performance/noAwaitInLoops: each package prints its "Updated" line as its manifest is rewritten, so a run that fails part way through leaves an accurate record of which manifests already changed. const content = await fs.readFile(packageJsonPath, "utf-8"); const packageJson: PackageJson = JSON.parse(content); - packageJson.version = version; - rewriteWorkspaceDeps(packageJson, version); + // An excluded package keeps its own version, but its workspace pins + // on LOCKSTEP siblings must still move with the bump, or they name a + // version that stops existing at publish time. + if (!excluded) packageJson.version = version; + rewriteWorkspaceDeps(packageJson, version, LOCKSTEP_EXCLUDED); await fs.writeFile( packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, ); - console.log(`Updated ${pkg.name} to ${version}`); + console.log( + excluded + ? `Updated ${pkg.name} workspace pins only (excluded from lockstep)` + : `Updated ${pkg.name} to ${version}`, + ); updatedCount++; } @@ -98,7 +106,7 @@ for (const manifestPath of trackedManifests) { const packageJson: PackageJson = JSON.parse(content); if (!participatesInLockstep(packageJson)) continue; packageJson.version = version; - rewriteWorkspaceDeps(packageJson, version); + rewriteWorkspaceDeps(packageJson, version, LOCKSTEP_EXCLUDED); await fs.writeFile(manifestPath, `${JSON.stringify(packageJson, null, 2)}\n`); console.log( `Updated ${path.relative(rootDir, manifestPath)} (project-boundary manifest) to ${version}`,