diff --git a/.drive/projects/prisma-cli-v8/assets/rollout-plan.md b/.drive/projects/prisma-cli-v8/assets/rollout-plan.md index 7e05c01f..7a3edce6 100644 --- a/.drive/projects/prisma-cli-v8/assets/rollout-plan.md +++ b/.drive/projects/prisma-cli-v8/assets/rollout-plan.md @@ -8,7 +8,7 @@ unified CLI reaches npm, from first pre-release through owning the bare | npm name | Today | End state | | --- | --- | --- | -| `@prisma/cli` | Platform CLI 3.x, published from this repo (OIDC) | Carries v8 RC releases under `latest` via merged bump PRs; deprecated at cutover | +| `@prisma/cli` | Platform CLI 3.x, published from this repo (OIDC) | Carries v8 RC releases under `next` via merged bump PRs (`latest` stays pre-v8 until the deliberate flip; ruling 2026-08-12); deprecated at cutover | | `prisma-next` | Prisma 8 ORM CLI, published from prisma/prisma `main` | Handed off to this repo at S5; rc channel for ORM early adopters; deprecated at cutover | | `prisma7` | Does not exist yet | The v7-and-under release train's new home, published from prisma/prisma | | `prisma` | Prisma 7 CLI, published by prisma/prisma's release train | Owned by this repo via OIDC trusted publishing; the unified CLI | @@ -20,14 +20,13 @@ unified CLI reaches npm, from first pre-release through owning the bare the automatic `dev`-tag publish on main merges continues and is harmless. 2. **S2 done (platform family ported).** v8 RC-line versions - (`8.0.0-rc.N`) publish as `@prisma/cli` under **`latest`**, moved - only by a deliberately merged version-bump PR (operator ruling - 2026-08-10, supersedes the earlier next-tag interim: "Me creating a - version bump PR and merging it counts as a deliberate explicit - action to alter latest"). This repo already owns the name with - OIDC; no cross-repo coordination. Semantically honest: the first - v8 surface is the platform family, i.e. the next major of the - platform CLI. + (`8.0.0-rc.N`) publish as `@prisma/cli`. Tag ruling reversed again + (operator, 2026-08-12, during S7): RC-line bump PRs publish under + **`next`**; `latest` keeps serving the pre-v8 CLI until the operator + moves it deliberately (an explicit `dist-tag: latest` dispatch, or + widening `releaseDistTag` when the line is ready). Merging the bump + PR remains the deliberate publishing act. This repo already owns the + name with OIDC; no cross-repo coordination. 3. **S5 done (ORM family ported).** The `prisma-next` name is handed off: prisma/prisma stops publishing it and its npm trusted-publisher config moves to this repo (npm allows one publisher config per @@ -54,9 +53,11 @@ unified CLI reaches npm, from first pre-release through owning the bare - Every publish path uses OIDC trusted publishing with provenance; no pasted tokens anywhere (a manual-token fallback for prisma/prisma was considered and rejected — the `prisma7` rename makes it unnecessary). -- `latest` moves only through a deliberately merged version-bump PR (or - a manual workflow dispatch), on any of the names (reworded per the - 2026-08-10 operator ruling; previously "never moves automatically"). +- `latest` moves only by an explicit operator act, on any of the names + (tightened per the 2026-08-12 ruling: RC-line bump PRs publish under + `next`, so on this repo's names even a merged bump PR cannot move + `latest` while the line is RC — only an explicit `dist-tag: latest` + dispatch or a stable-version bump can). - Version pins across the tandem packages follow the committed-versions ruling (S3). diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s7.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s7.md new file mode 100644 index 00000000..2905abe0 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s7.md @@ -0,0 +1,44 @@ +# S7 parity divergences — mounting the ORM family + +**No user-visible divergence from any shipping CLI is introduced by +S7.** This file exists because S2 standing ruling 10 requires +divergences to be enumerated rather than discovered, and because a +slice that changes the command tree has to say so explicitly when the +answer is "none". + +Why the answer is none: S7 mounts commands that no binary this repo +ships could reach before. `prisma migration list`, `prisma db verify`, +`prisma init` and the rest of the ORM family answer for the first time. +Adding an invocation that previously did not exist changes nothing a +user already relied on. + +The divergences between the ORM commands as they run under this shell +and as they run under `prisma-next` — the engine's shared flags, json +framing, channel discipline, the `{bin}` substitution in help examples, +and everything else the port changed — belong to S5, which owns that +record and keeps it in prisma/prisma alongside the port. S7 mounts the +family; it does not change what the family does. + +Nothing already shipped changes behaviour: the platform and composer +commands keep their paths, flags and output, no group brief was +reworded, and no existing invocation was retired or moved. The ORM +family's own redirect table (`migration apply`, `migration ref`, and +four retired `migration status` flags) arrives with the family, so it +describes invocations of `prisma-next` that were already retired there, +not invocations this shell used to answer. + +## One operational fact, not a divergence + +`@prisma/orm-toolchain`'s `./cli` entry statically imports `esbuild` +and `arktype` (and eight `@prisma/orm-framework` subpaths), so every +invocation of this bin now pays that import — including `prisma +--version`, which touches no ORM code. Composer's family avoids this by +keeping its heavy graph behind dynamic executor imports; the ORM family +does not do the same yet. + +This costs startup time, not correctness, and no user-visible output +changes because of it. Fixing it means moving orm-toolchain's handler +imports behind dynamic imports, which is prisma/prisma's change to +make, not this repo's. Mirrored in +[`../../deferred.md`](../../deferred.md) under "Upstream, not ours to +land". diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index af4ecf14..eb1c64df 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -4,6 +4,26 @@ Work identified during a slice that is not part of that slice's contract. Each entry: what, why it was deferred, where it lands. Nothing here is tracked outside this file. +## After S7's first real publish + +- **The `v8.0.0-rc.1` GitHub Release has no tarballs attached, and + none can be added.** The first real `next` publish (2026-08-12, run + 31618278670) published both packages to npm successfully, then the + Release step published the Release before uploading assets — and this + repo's releases are immutable, so the upload was refused (HTTP 422) + and the Release froze empty. npm is unaffected; the smoked tarballs + remain retrievable from that run's workflow artifacts (which expire + on the repo's retention schedule) and from npm itself. PR #165 fixes + the step for every future release (draft → attach assets → publish, + the order GitHub's own docs recommend). Repairing rc.1's Release + itself, if ever wanted: merge #165 first, try + `gh release delete v8.0.0-rc.1` (docs are silent on whether a + published immutable release can be deleted; the attempt is the test), + and if deletion works, re-dispatch the publish workflow — the + already-published npm versions are tolerated and the run recreates + the Release complete. Operator ruling (2026-08-12): cosmetic, not + immediate. + ## Still open after S3/D4 — mostly the composer repo, two items need both D4 landed the prisma-cli half (the mount, the node floor, the divergence @@ -48,7 +68,13 @@ composer can use it. and the honest reason is that it is not worth writing: matching pins is a **release requirement for the tandem release**, so the two-copy install is a preview-only state to end rather than a configuration to - support. + support. S7 update (operator ruling, 2026-08-12): the convergence + choreography is deferred until `8.0.0-rc.1` publishes; the S7 branch + adds a third pin in the same shape (`@prisma/orm-toolchain@ + 8.0.0-rc.1-dev.40`, also carrying engine `0.0.9`). The sequence, when + it runs: engine `8.0.0-rc.N` publishes from this repo → orm-toolchain + and composer bump their engine pins and publish → the rc1 bump PR + here pins those versions. - **The prisma bin's mount makes composer's help examples wrong.** Composer writes them as `{bin} deploy src/service.ts`; mounted under the `composer` root the invocation is `prisma composer deploy`, and @@ -172,6 +198,18 @@ composer can use it. "repair" forever. The deploy path is unaffected (it plans on props only — see the S8 note below), so this is a courtesy report to the maintainer, not a blocker. +- **The ORM family's entry module loads esbuild and arktype on every + invocation.** `@prisma/orm-toolchain`'s `./cli` subpath statically + imports `esbuild`, `arktype` and eight `@prisma/orm-framework` + subpaths, so mounting the family (S7 D1) makes every run of this bin + pay that import — `prisma --version` included, which touches no ORM + code. Composer solved the same problem by keeping its heavy graph + behind dynamic executor imports; orm-toolchain has not. It costs + startup time only: no output and no exit code changes. The fix is + prisma/prisma's to land (dynamic handler imports in orm-toolchain's + CLI entry), and it closes when a published orm-toolchain's `cli.mjs` + no longer imports those modules at the top level. Recorded also in + `assets/s2/parity-divergences-s7.md`. ## Answered, feeding a later slice diff --git a/.drive/projects/prisma-cli-v8/plan.md b/.drive/projects/prisma-cli-v8/plan.md index aee9f667..1119b5cb 100644 --- a/.drive/projects/prisma-cli-v8/plan.md +++ b/.drive/projects/prisma-cli-v8/plan.md @@ -123,11 +123,7 @@ One standing caveat: every endpoint above is marked experimental and subject to ### S7 — Release pipeline + rc1 -Repo: prisma-cli. The `prisma` binary package assembled: full grammar -tree mounted with the build-time grammar check, committed-versions -release automation, pinned product versions, and the pipeline emitting -a publishable `prisma@8.0.0-rc1` artifact from a tagged commit. Ends -when the operator can publish with one action (project DoD). +Repo: prisma-cli. **In flight (PR #164).** Ruled 2026-08-12: rc1 publishes under the existing names (`@prisma/cli`, bin `prisma-cli`); the bare-`prisma` cutover follows once `prisma7` frees the name. Shipped so far: the ORM family mounted (one binary answers platform, composer and ORM), the grammar check promoted to `pnpm check:grammar` running in `pr-quality.yml` and before every publish, the declared bin flipped to the v8 tree, and the tarball install smoke in the publish path (packed tarballs verified out-of-workspace on plain Node, uploaded as artifacts, attached to the Release). The operator's one action is merging the bump PR, per `docs/oss/versioning.md`. Engine-pin convergence is deferred until `8.0.0-rc.1` publishes (contract STOP-7). ### S9 — The error-code catalogue (last) diff --git a/.drive/projects/prisma-cli-v8/plans/s7-release.md b/.drive/projects/prisma-cli-v8/plans/s7-release.md new file mode 100644 index 00000000..137b1269 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/plans/s7-release.md @@ -0,0 +1,112 @@ +# S7 dispatch plan — Release pipeline + rc1 (revision 3) + +Contract: `../specs/s7-release.md` rev 1. One repo (prisma-cli), branch +`claude/s7-release-pipeline-rc1-92c89d`, base `main`. Implementers on +Opus, reviewers on Opus-4.8-mid. Standing process rules as in the S2/S3 +plans: tests before implementation, no `vi.mock`/`vi.doMock`, pnpm only +(tarball smoke's sandbox npm install excepted, as ruled in S6), explicit +staging, bot identity with dual sign-off, push to the bot remote only. + +Rulings applied (2026-08-12): the goal is all available commands in one +binary; rc1 publishes under the current names (`@prisma/cli`, bin +`prisma-cli`); the bare-`prisma` cutover and the exception-list +reconciliation are follow-up work. STOP-2/3/4/8 closed. **All STOPs closed +2026-08-12; D1–D6 shipped on PR #164.** + +Ordering: D1 → D2 are independent of the release machinery and can run +while STOP-5/7/8 settle; D3 → D4 → D5 are strictly ordered (the package +must exist before the automation covers it, the automation before the +pipeline verifies it); D6 closes. One PR for the slice. + +## D1 — Mount the ORM family (packages/cli) + +Tests first: extend `v8-mount-coverage.test.ts` (fails until the mount +lands — the 21 expected paths, `ormCommandFamily` in +`MOUNTED_FAMILIES`); a `v8-bin` semantic test running one ORM command +end to end through `createTestCli` (`migration list` against a fixture +project directory: envelope, presented rows, exit 0); a redirect test +(`migration apply` settles as the typed redirect, exit per engine); a +`--help` test naming the `contract`, `db`, `migration`, `ref` groups. +Then: the `@prisma/orm-toolchain` dependency at the STOP-7(ii) interim +exact version; `cli.ts` imports the family from +`@prisma/orm-toolchain/cli`, spreads its commands, adds the four group +briefs. Watch for: the family keys are full mount paths already — no +renaming layer; config-section and redirects ride the family object. +Divergence file `assets/s2/parity-divergences-s7.md` opened (expected +content: "none"; plus the deferred.md entry for the static-import cost). + +## D2 — The completeness check fails the build (repo root + CI) + +Tests first: a fixture-level test proving the check reports (a) a +family command absent from the tree, (b) a mounted command owned by no +family and not excepted — both via a constructed family/tree pair, not +by mutating the real mount. Then: `check:grammar` as a turbo task +(`dependsOn: ["^build"]`, `cache: false`) running the mount-coverage +suite file; wired into `pr-quality.yml` and `publish.yml` before the +first publish step under its `publish == 'true'` condition. The +exception list gets a doc comment naming STOP-4's ratification and the +rule that additions require an operator ruling. +Reshaped by: STOP-4 (if utilities move into the platform family, the +exception set shrinks to the telemetry trio). + +## D3 — The shipped bin becomes the v8 tree (packages/cli) + +Per the 2026-08-12 ruling: no `prisma` package. Tests first: a +packaging test asserting the DECLARED bin (`package.json` `bin` +entry read, not a hard-coded path) is the v8 entry and that running +it with `--version` on plain Node in a bare env prints the lockstep +version at exit 0. Then: flip `bin.prisma-cli` from `./dist/cli.js` +to `./dist/v8/cli.js`. The legacy entry keeps building and shipping +in the tarball (S2d owns its deletion). Nothing else changes. + +## D4 — Committed versions + conformance wiring (manifests + CI) + +RESOLVED 2026-08-12: STOP-5(b) ruled — the inline smoke path below +shipped; STOP-7 deferred until `8.0.0-rc.1` publishes. The STOP-5(a) +branch is kept only as the record of the road not taken: +`packages/cli` pins `@prisma/composer` and `@prisma/orm-toolchain` +exact (already the style; versions per STOP-7); `pnpm conformance` +added to `publish.yml` before publish steps; the S6-3c interim +exception entries (dated triples, one per family) committed if the +pins have not converged by then. +If STOP-5(b): D4 instead implements the inline smoke per the contract +(S6 3b mechanics, ~40 lines, written to S6's spec so absorption is a +move), and the 3c pin comparison is NOT built here — the pins' exactness +is still asserted by the existing manifest style plus D5's install +smoke resolving a single engine copy. + +## D5 — The pipeline (publish.yml + scripts) + +Blocked by: STOP-1. Written against 1(a): +Tests first where testable: the override-computation helper (workspace +package → packed tarball map, recursive) as a pure function with its +own unit tests in `scripts/`; `determine-version` untouched (nothing +dynamic added). Then, in `publish.yml`: pack stage (engine + cli +tarballs via `pnpm pack` — order matters, packing rebuilds dist per +S6's finding 16, so the grammar check and conformance run before +packing); out-of-workspace install smoke (npm, `--ignore-scripts`, +absolute `file:` overrides, every declared bin from every packed +manifest started with `--version` under a timeout, exit 0 required — +after D3 the declared bin IS the v8 tree, so the smoke exercises the +composer and ORM family boundaries); tarball upload as workflow +artifacts; Release assets attached in the existing Release step. +Dry-run dispatch path covers pack + smoke + upload, skipping registry +writes and Release — this is the verification surface for the whole +slice (never a real publish from this work; a real publish is the +operator's action). + +## D6 — Docs, records, close-out prep + +`docs/oss/versioning.md` (the `prisma` package, the artifact stage, the +guarded publish); `rollout-plan.md` step 4 pointed at the pipeline; +`plan.md` §S7 updated; `deferred.md`: close the two-copy-install entry +when STOP-7 convergence lands, add the ORM import-weight entry; PR +description per the ruled structure (grounding example first, +alternatives last). Slice review loop (architect + principal-engineer +personas), findings folded, operator walkthrough. + +Completeness: D1 → the tree; D2 → the check that guards it; D3 → the +package rc1 ships as; D4 → the pins and their verification; D5 → the +automated path from release commit to verified artifact; D6 → the +records. Together: the operator merges one bump PR and rc1's artifacts +exist, verified, published where the registry allows. diff --git a/.drive/projects/prisma-cli-v8/specs/s7-release.md b/.drive/projects/prisma-cli-v8/specs/s7-release.md new file mode 100644 index 00000000..89b86787 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/specs/s7-release.md @@ -0,0 +1,313 @@ +# S7 — Release pipeline + rc1 (slice contract, revision 3 — all STOPs closed) + +Status: revision 2 applies the operator's ruling (2026-08-12): **rc1 +publishes under the existing names** — `@prisma/cli` with its existing +`prisma-cli` bin — and the cutover to the bare `prisma` npm name is a +follow-up piece of work, recorded in `deferred.md`, not this slice. +Former STOP-2 (what rc1's one action is, given the name), STOP-3 (the +`prisma` package's shape) and STOP-8 (`prisma` publish credentials) are +closed by that ruling and their sections record the disposition. +Second ruling (operator, 2026-08-12): **the slice's goal is combining +all available commands into one binary**; the bare-`prisma` cutover is a +following step, and so is reconciling the missing/discrepancy commands +behind the grammar exception list — STOP-4 closes with the current +exception set standing as-is. D1 and D2 are unblocked and in +implementation. STOP-1 and STOP-5…7 remain open for the release-side +deliverables; D1/D2 proceed on the recorded working defaults. +Precedence: this contract > `specs/s2-overview.md` standing rulings > source. +Unpinned facts are STOP-and-surface. + +Repo: prisma-cli. Branch: `claude/s7-release-pipeline-rc1-92c89d`, base `main`. + +Mandate (project plan §S7, spec.md FR10, project DoD): the unified binary +package assembled — full grammar tree mounted behind a build-time +completeness check, committed-versions release automation (R11), and a +pipeline that emits a publishable rc1 artifact which the operator +publishes with one action. Under the 2026-08-12 ruling the rc1 artifact +is `@prisma/cli@8.0.0-rc.N` (bin `prisma-cli`, the v8 tree); the bare +`prisma` name follows later. + +## 1. The grounding example + +Today, a user who wants the unified CLI cannot get it: + +```text +$ npm install -g prisma@8.0.0-rc.1 +npm error notarget No matching version found for prisma@8.0.0-rc.1 +# `prisma` on npm is the v7 train, published by prisma/prisma. + +$ npm install -g @prisma/cli@latest && prisma-cli migrate +# latest is 3.0.0-beta.30 — the pre-v8 platform CLI. No ORM commands. +# Nothing 8.x has ever published from this repo: the workspace says +# 8.0.0-rc.1, npm's newest @prisma/cli-engine is 0.0.9. +``` + +And even at HEAD, the assembled tree is not assembled: `@prisma/cli`'s one +declared bin is `prisma-cli` → `dist/cli.js`, the legacy commander shell. +The v8 tree builds to `dist/v8/cli.js`, undeclared in `bin`, and mounts +platform + composer only. The ORM family — 21 commands, published today in +`@prisma/orm-toolchain@8.0.0-rc.1-dev.40` under the `./cli` subpath — is +mounted nowhere. `prisma migrate`, `prisma db verify`, `prisma init` do not +exist in any binary this repo ships. + +After this slice: a release commit produces, in CI, a verified +`prisma-cli-8.0.0-rc.N.tgz` whose `prisma-cli` bin is the v8 tree, which +answers every platform, composer, and ORM command, whose product pins +agree on one engine, and which installs and starts on plain Node outside +the workspace. The operator performs one deliberate action to release it. +The bare `prisma` name is follow-up work once `prisma7` frees it. + +## 2. What exists today (the facts the deliverables build on) + +- **The mount and its check.** `packages/cli/src/v8/cli.ts` exports + `platformCommandFamily`, `composerCommandFamily`, `cliGroups`, + `mountedCommands`, and `buildCli()`. `packages/cli/tests/ + v8-mount-coverage.test.ts` already asserts both completeness directions — + every family command mounted, every mounted command family-owned — with a + deliberate `FAMILYLESS` exception set (the engine's three `telemetry` + commands, `agent install|update|status`, `feedback`) and an explicit + expected-paths list. It runs in the test suite only; the publish path + runs `pnpm build` + `pnpm test:scripts` and would ship a tree this test + has never seen. +- **The ORM family is importable now.** `@prisma/orm-toolchain@ + 8.0.0-rc.1-dev.40` (dist-tag `dev`, published 2026-08-12) exports + `ormCommandFamily` and `ormConfigSection` from `./cli`. The family + carries `configSection`, `docsBaseUrl`, and the verb/flag `redirects` + (R-S5-23), and keys its 21 commands by full mount path (`"contract + emit"`, `"db update"`, `"migration status"`, `init`, `format`, `migrate`, + `lsp`, `ref set|list|delete`, …). Its `dependencies` pin + `@prisma/cli-engine: "0.0.9"`. Note the tag: nothing on the rc line of + orm-toolchain is published as `latest` yet. +- **Import weight.** orm-toolchain's `dist/cli.mjs` statically imports + `esbuild`, `arktype`, and eight `@prisma/orm-framework` subpaths. + Mounting the family pays that import on every shell invocation, + including `prisma --version`. Composer's family was built to keep its + heavy graph behind dynamic executor imports; the ORM family was not. + Recorded here as a known cost and a candidate upstream fix, not a + blocker (STOP-9 lists it for the operator's awareness). +- **The versioning model is settled and tagless.** `docs/oss/versioning.md` + (ported from prisma/prisma by ruling 2026-08-10): the root + `package.json` `version` is the single source of truth; `pnpm + bump-version` writes lockstep; a push to `main` that changes the root + version publishes `latest`; **merging the bump PR is the deliberate act + that moves `latest`**; `workflow_dispatch` re-publishes or dry-runs. The + GitHub Release (and its `v8.0.0-rc.N` tag) is created BY the publish + run, after npm. `publish.yml` even excludes tag pushes (`tags: + ["!**"]`). There is no tag-triggered path anywhere, by design. +- **The engine pins disagree, knowably.** The shell ships the workspace + engine at `8.0.0-rc.1` (unpublished); `@prisma/composer@0.6.0-dev.16` + and `@prisma/orm-toolchain@…dev.40` both pin `0.0.9`. `deferred.md` + rules matching pins "a release requirement for the tandem release" and + the two-copy install "a preview-only state to end". rc1 is precisely the + release that requirement was written for. +- **S6 is specified, partially built, unmerged.** PR #161 carries + `specs/s6-conformance.md` (rev 3): checks 1–2 built on that branch; + check 3 (tarball verification: 3a declared-deps-vs-built-output, 3b + out-of-workspace npm install with computed `file:` overrides and + `--ignore-scripts`, bins start on plain Node, 3c cross-repo engine-pin + agreement + single-engine-copy resolution) is fully designed but awaits + the operator's STOP-1…STOP-9 rulings there. Its 3b IS the "S5-era + smoke" this slice's mandate names; its 3c IS the pin verification R11 + needs. S6's own STOP-4 and STOP-6 explicitly defer to S7 for publish- + list changes and for the `prisma` bin appearing in the packed bin map. +- **The `prisma` npm name is not ours yet.** Rollout plan step 4: the name + frees only when the ORM team ships `prisma7`; then this repo configures + OIDC trusted publishing for `prisma` and publishes `8.0.0-rc1` under a + pre-release dist-tag. That timing is an open item the operator owns. + `prisma-next` handoff (step 3) is likewise an operator-owned cross-repo + cutover. +- **S8 is changing the tree this slice checks.** PR #162 + (`s8-service-primitives`) adds `service create|list|delete`, the + `service deployment` subgroup, and the lifecycle verbs — all inside the + platform family, so the completeness check keeps passing across that + merge in either order; only the expected-paths list and `cliGroups` + entries collide textually. Coordination is STOP-6. + +## 3. Deliverables + +**D1 — Mount the ORM family.** `packages/cli` gains a `dependencies` entry +on `@prisma/orm-toolchain` at an exact version (R-S5-28 direction; interim +version per STOP-7). `cli.ts` imports `ormCommandFamily` from +`@prisma/orm-toolchain/cli`, adds it to `commandFamilies`, spreads its 21 +commands into `mountedCommands` at the family's own paths, and adds the +`contract`, `db`, `migration`, `ref` group briefs to `cliGroups`. The +family's config section and redirects ride in via the family object; no +per-command wiring. Semantic tests through `createTestCli`: one ORM +command end to end in the shell (`prisma migration list` against a +fixture project is the candidate), redirect resolution (`prisma migration +apply` → typed redirect), section validation reachable, and `--help` +naming all four new groups. No `vi.mock`. + +**D2 — The completeness check fails the build.** Extend +`v8-mount-coverage.test.ts` with the ORM family in `MOUNTED_FAMILIES` and +the 21 new expected paths. Promote the check out of "just a test the +publish never runs": a `check:grammar` invocation (the same test file run +via vitest, not a parallel implementation) wired as a turbo task with +`dependsOn: ["^build"]`, run by `pr-quality.yml` AND by `publish.yml` +before any publish step, under the same `publish == 'true'` condition its +neighbours carry. The exception set is ratified, not grown: STOP-4 puts +the current `FAMILYLESS` list in front of the operator; adding to it after +this slice requires a ruling recorded in the file. + +**D3 — The shipped bin becomes the v8 tree.** Ruled 2026-08-12: no +`prisma` package this slice. Instead, `@prisma/cli`'s declared bin +(`prisma-cli`) moves from `dist/cli.js` (the legacy commander shell) to +`dist/v8/cli.js` (the engine tree) — an 8.x version whose bin is the +retiring commander would misdescribe itself. The legacy entry keeps +building and shipping inside the tarball (its deletion is S2d, out of +scope); only the bin map changes. The packaging test proves the declared +bin prints the lockstep version on plain Node at exit 0. The bare-name +cutover (a `prisma` package or bin rename, OIDC trusted publishing for +the name, dist-tag choice) is recorded in `deferred.md` as follow-up +work blocked on `prisma7`. + +**D4 — Committed-versions release automation (R11).** All product pins in +committed manifests, bumped only in PRs: `@prisma/orm-toolchain` and +`@prisma/composer` exact-pinned in `packages/cli/package.json`; +`@prisma/cli-engine` stays `workspace:` (pnpm rewrites to exact +at pack). No publish-time resolution anywhere — `determine-version.ts` +already refuses to invent versions; this deliverable adds nothing dynamic. +Pin agreement (shell engine version == every mounted family's engine pin) +is verified by S6's 3c, wired per STOP-5 — S7 does not write a second pin +checker. + +**D5 — The pipeline: release commit → verified artifact → one action.** +Per STOP-1's ruling on trigger shape. Written against the recommendation +(STOP-1a): `publish.yml` gains an artifact-emission stage — after `pnpm +build` and the grammar check, it packs the engine and cli tarballs with +`pnpm pack`, runs the tarball smoke (S6 check 3b mechanics: +out-of-workspace install with computed absolute `file:` overrides, +`--ignore-scripts`, every declared bin starts on plain Node, exit 0, +under a timeout — which after D3 means the smoke exercises the v8 tree +and the composer/ORM family boundary), uploads the tarballs as workflow +artifacts, and attaches them to the GitHub Release it already creates. +The operator's one action for rc1 stays what versioning.md already +rules: merge the `chore(release)` bump PR. Everything after that push — +build, grammar check, smoke, npm publishes, Release + tag, artifact +upload — is the pipeline. Dry-run dispatch exercises all of it minus +registry writes. + +**D6 — Docs and records.** `docs/oss/versioning.md` gains the `prisma` +package and the artifact stage; `rollout-plan.md` step 4 updated to point +at the built pipeline; `plan.md` §S7 marked with what shipped; +`deferred.md` updated where this slice closes or supersedes entries (the +two-copy engine install entry closes when STOP-7's convergence lands). +Divergence file: mounting previously-unreachable commands is not a +divergence from a shipping CLI, but `assets/s2/parity-divergences-s7.md` +records anything user-visible this slice changes in already-shipped +surfaces (expected: none; the file says so explicitly if so). + +## 4. Open questions (STOP) + +**STOP-1 — CLOSED (operator, 2026-08-12).** Option (a): the repo's existing publishing mechanisms stand. The mandate's "tagged commit" wording is set aside; the release commit is the merged bump PR, the pipeline tags it after publishing, and no tag-triggered path exists. D5 is built inside `publish.yml`. + +**STOP-2 — CLOSED (operator, 2026-08-12).** rc1 publishes under the +current names; the one action is merging the bump PR, exactly as +versioning.md rules. The bare-`prisma` cutover is follow-up work blocked +on `prisma7`, recorded in `deferred.md`. The project DoD's +"prisma@8.0.0-rc1" reads as "the unified CLI's rc1", which under this +ruling is `@prisma/cli@8.0.0-rc.N`. + +**STOP-3 — CLOSED (operator, 2026-08-12).** No `prisma` package this +slice. D3 is now the bin flip: `prisma-cli` → the v8 entry. The legacy +commander stays in the tarball until S2d. The follow-up cutover work +owns the package/bin naming question when the name frees. + +**STOP-4 — CLOSED (operator, 2026-08-12).** The current exception set +stands: the engine's three `telemetry` commands, `agent +install|update|status`, `feedback`. Reconciling missing/discrepancy +commands behind the exception list is a following step, recorded in +`deferred.md`, not this slice. Additions to the set still require an +operator ruling recorded in the test file. + +**STOP-5 — CLOSED (operator, 2026-08-12).** Option (b): S6 proceeds in parallel and S7 carries the install smoke itself (`scripts/tarball-smoke.mjs` + `tarball-smoke-utils.mjs`), written to S6's check-3b design — same override computation, same `--ignore-scripts` stance, sandbox outside the workspace — so S6 absorbs it as a move, not a rewrite. + +**STOP-6 — CLOSED by events (2026-08-12).** #162 merged first; this branch carries main's S8 merge, and the mount-path collision resolved as the predicted textual union. The original question, for the record: + +PR #162 adds platform-family commands, so +the completeness check is order-independent with S7 — but +`EXPECTED_MOUNT_PATHS`, `cliGroups`, and `v8/cli.ts` imports collide +textually in both orders. Preference? (a) S7 lands first, #162 rebases +(its adds are mechanical); (b) #162 first, S7 rebases (same cost, +S7 is the one branch currently unmerged everywhere). Either is fine; +the mandate tells me not to decide interactions with S8's tree alone. + +**Working defaults while STOP-5…7 stay open** (recorded so D1/D2 can +proceed; override any of them): the interim `@prisma/orm-toolchain` pin +is `8.0.0-rc.1-dev.40`, exact and committed (STOP-7 ii); S8's #162 and +this branch rebase in whichever order you merge them, the collision is +textual only (STOP-6); no S6 mechanism is duplicated in D1/D2 — the +smoke question only arises at D5 (STOP-5). + +**STOP-7 — DEFERRED (operator, 2026-08-12).** The convergence choreography below is set aside until `8.0.0-rc.1` actually publishes; the committed interim pins (orm-toolchain `8.0.0-rc.1-dev.40`, composer `0.6.0-dev.16`, both carrying engine `0.0.9` beside the workspace engine) stand, and the two-copy install remains the accepted preview state. The original question, kept for that day: + +For rc1's +install to resolve ONE engine, before the rc1 bump PR merges: +prisma-cli publishes engine `8.0.0-rc.N` (the existing lockstep publish +does this — note it publishes `@prisma/cli` in the same run, which is +fine: rc respins are cheap and `latest` moving is your merge); then +orm-toolchain and composer bump their `@prisma/cli-engine` pins to that +exact version and publish; then prisma-cli's bump PR pins those exact +product versions and rc1 ships with agreeing pins, S6-3c green with an +empty exception list. Two of those three moves are in repos this slice +does not own. Confirm: (i) you own/sequence the orm-toolchain and +composer pin-bump publishes (same model as the S3 tandem glue), (ii) +until then, S7 development pins `@prisma/orm-toolchain@8.0.0-rc.1-dev.40` +(a `dev`-tag version — acceptable for a committed interim pin, or do you +want an rc-line orm-toolchain published first?), and (iii) the S6-3c +exception list carries the interim triple, dated, as S6's STOP-3a +designed. + +**STOP-8 — CLOSED (operator, 2026-08-12), by dissolution.** No `prisma` +name is published this slice, so no new publish credentials, trusted- +publisher config, or guarded steps exist. `@prisma/cli` and +`@prisma/cli-engine` keep their existing OIDC configuration untouched. +The credentials question moves wholesale into the deferred cutover work. + +**STOP-9 — acknowledged, not asked.** (i) `prisma-next` handoff (rollout +step 3) stays out of S7 unless you say otherwise. (ii) The ORM family's +static `esbuild`/`arktype` import cost on every shell start is real, +measured at the import graph, and belongs to prisma/prisma to fix +(dynamic handler imports like composer's); recorded in deferred.md by +this slice. (iii) rc1 ships with the legacy commander tree still in +`@prisma/cli`'s tarball; its deletion is S2d, which remains open on the +plan. + +## 5. Acceptance + +Written against the final rulings (2026-08-12): STOP-1 keep the existing publish model, STOP-2/3/8 as applied, STOP-4 as listed, STOP-5(b) — the smoke lives in this slice, so "conformance" below means the inline tarball smoke, not S6's checker — and STOP-7 deferred, so pin agreement is NOT an acceptance item; the interim pins stand until `8.0.0-rc.1` publishes. A later ruling (same day) sends RC-line releases to the `next` dist-tag; the one-action item reads accordingly. + +- [ ] `prisma migration list`, `prisma db verify --help`, `prisma init + --help`, `prisma migrate --help` answer from the assembled tree; + one ORM command proven end to end through `createTestCli`; the + family's redirects and config section reachable through the shell. +- [ ] The completeness check covers platform + composer + ORM families + both directions, fails the build on a seeded omission in either + direction (test proves it), runs in `pr-quality.yml` and in + `publish.yml` before any publish step, and its exception list is + exactly the ratified one. +- [ ] `@prisma/cli`'s declared `prisma-cli` bin is the v8 tree; the + packed tarball's bin prints the lockstep version on plain Node at + exit 0. +- [ ] All product pins exact and committed; no publish-time version + resolution. (Pin AGREEMENT is deferred with STOP-7; S6-3c arrives + with S6.) +- [ ] A release run (dry-run dispatch proves it end to end without + registry writes) produces: build → grammar check → conformance → + pack (engine + cli tarballs) → out-of-workspace install smoke + (every bin starts on plain Node, exit 0) → publish steps → + GitHub Release with tarballs attached. +- [ ] The operator's release action is exactly one: merging the + `chore(release): 8.0.0-rc.N` PR. Nothing between that merge and + the published artifacts requires a human. +- [ ] `pnpm typecheck`, root `pnpm lint`, touched suites green, measured + as pnpm's own exit codes. + +## 6. Out of scope + +The bare-`prisma` cutover (package, bin name, OIDC config, dist-tag — +follow-up work, ruled 2026-08-12, blocked on `prisma7`); S2d (commander +retirement in this repo); the `prisma-next` npm handoff; flipping any +`latest` at cutover (rollout step 5); S8's service tree (#162); fixing +the ORM family's static import weight (prisma/prisma); S6's checks 1–3 +themselves (consumed, not built, under STOP-5a). diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index 1973a71e..8e938950 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -57,6 +57,35 @@ jobs: - name: Lint run: biome ci . --error-on-warnings + grammar: + name: Grammar Completeness + runs-on: ubuntu-latest + + 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 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Every family command mounted, every mounted command owned by a + # family, every path spelled as expected. The same check runs + # before any publish, so a PR that breaks the tree fails here + # first rather than at release time. + - name: Check grammar completeness + run: pnpm check:grammar + test: name: Test runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6b9a7f05..97919a58 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,11 +13,11 @@ name: Publish to npm # 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` changed → publish `` -# under dist-tag `latest` and create a GitHub Release (marked -# pre-release for RC-line versions like `8.0.0-rc.N`). `latest` -# tracks the newest release, RC or stable. This is how a merged -# `chore(release): ...` PR auto-ships — merging the bump PR is the -# operator's deliberate act that moves `latest`. +# under its canonical dist-tag — `next` on the RC line, `latest` for +# stable — and create a GitHub Release (marked pre-release on the RC +# line). `latest` keeps serving the pre-v8 CLI until the operator +# deliberately moves it (operator ruling 2026-08-12). This is how a +# merged `chore(release): ...` PR auto-ships. # - workflow_dispatch → publish `` # under the chosen dist-tag (default `latest`); also the dry-run path. # @@ -33,9 +33,9 @@ on: workflow_dispatch: inputs: dist-tag: - description: "npm dist-tag (e.g., latest, dev, beta)" - required: true - default: "latest" + description: "npm dist-tag. Empty = the version's canonical tag (next on the RC line, latest for stable). Pass latest explicitly to move latest onto an RC — that is the deliberate cutover act." + required: false + default: "" type: string dry-run: description: "Dry-run only (build + pack, no npm publish, no GitHub Release)." @@ -104,10 +104,38 @@ jobs: if: ${{ steps.version.outputs.publish == 'true' }} run: pnpm build + # The assembled command tree, checked before anything reaches the + # registry: every family command mounted, every mounted command + # owned by a family, every path spelled as expected. A publish + # that has lost a command fails here instead of shipping. + - name: Check grammar completeness + if: ${{ steps.version.outputs.publish == 'true' }} + run: pnpm check:grammar + - name: Run script tests if: ${{ steps.version.outputs.publish == 'true' }} run: pnpm test:scripts + # Pack the publishable tarballs and prove they survive a registry + # consumer's install: out-of-workspace sandbox, npm with + # --ignore-scripts, unpublished workspace siblings resolved through + # computed file: overrides, every declared bin started on plain + # Node at exit 0. The tarballs this step packs are the ones + # uploaded below; what was verified is what ships. + - name: Pack tarballs and smoke the install + if: ${{ steps.version.outputs.publish == 'true' }} + run: node scripts/tarball-smoke.mjs + + # The verified tarballs, retrievable per run. On a real release the + # same files are attached to the GitHub Release below. + - name: Upload tarball artifacts + if: ${{ steps.version.outputs.publish == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-tarballs + path: artifacts/tarballs/*.tgz + if-no-files-found: error + # NODE_AUTH_TOKEN is intentionally NOT set. npm detects the OIDC # environment (id-token: write) and authenticates via Trusted # Publishing automatically. Setting NODE_AUTH_TOKEN to any value -- @@ -130,41 +158,79 @@ jobs: 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 + # A rerun (or a re-publish dispatch) meets versions that are + # already on the registry. npm refuses to publish over them — + # correctly — but that refusal must not stop the run before the + # Release step gets to repair a missing Release or its assets. An + # already-published version is treated as done; every other + # publish failure still fails the run. - name: Publish packages if: ${{ steps.version.outputs.publish == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }} env: NPM_CONFIG_PROVENANCE: "true" + DIST_TAG: ${{ steps.version.outputs.tag }} run: | - pnpm --filter @prisma/cli-engine publish --tag "${{ steps.version.outputs.tag }}" --access public --no-git-checks - pnpm --filter @prisma/cli publish --tag "${{ steps.version.outputs.tag }}" --access public --no-git-checks - - # Emit a GitHub Release for `latest` publishes only (marked - # pre-release when the version is on the RC line). Dev / beta builds - # publish to npm but do not produce a Release — those would drown out - # the changelog signal. The Release is created at $GITHUB_SHA so the + publish_one() { + local out + if out=$(pnpm --filter "$1" publish --tag "$DIST_TAG" --access public --no-git-checks 2>&1); then + printf '%s\n' "$out" + else + printf '%s\n' "$out" + if grep -qiE 'E409|EPUBLISHCONFLICT|cannot publish over|previously published' <<<"$out"; then + echo "$1: this version is already on the registry — continuing so the Release step can run." + else + return 1 + fi + fi + } + publish_one @prisma/cli-engine + publish_one @prisma/cli + + # Emit a GitHub Release for releases only — runs whose dist-tag is + # the canonical one for their version (`next` on the RC line, + # `latest` for stable; `release` from determine-version.ts). Marked + # pre-release on the RC line. Beta / preview cuts publish to npm + # but do not produce a Release — those would drown out the + # changelog signal. The Release is created at $GITHUB_SHA so the # tag points at the same commit the publish ran from. # - # Idempotent on workflow rerun: if a Release for `v$VERSION` already - # exists (e.g. a previous run published to npm but failed before this - # step), edit it in place rather than re-creating it. - - name: Create GitHub Release for latest publishes - if: ${{ steps.version.outputs.publish == 'true' && steps.version.outputs.tag == 'latest' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }} + # This repo's releases are immutable: once published, neither the + # assets nor the tag can change (uploading to a published release + # answers HTTP 422). So the Release is created as a DRAFT with the + # smoked tarballs already attached, then published — assets first, + # publish second. On a rerun that finds the Release already + # published there is nothing left to repair; the step says so and + # succeeds. + - name: Create GitHub Release + if: ${{ steps.version.outputs.publish == 'true' && steps.version.outputs.release == 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} run: | + if gh release view "v$VERSION" >/dev/null 2>&1; then + echo "Release v$VERSION already exists and releases are immutable — nothing to repair." + exit 0 + fi PRERELEASE_FLAG="" case "$VERSION" in *-rc.*) PRERELEASE_FLAG="--prerelease" ;; esac - if gh release view "v$VERSION" >/dev/null 2>&1; then - gh release edit "v$VERSION" \ - --target "$GITHUB_SHA" \ - --title "v$VERSION" - else - gh release create "v$VERSION" \ - --target "$GITHUB_SHA" \ - --title "v$VERSION" \ - --generate-notes \ - $PRERELEASE_FLAG + gh release create "v$VERSION" \ + --draft \ + --target "$GITHUB_SHA" \ + --title "v$VERSION" \ + --generate-notes \ + $PRERELEASE_FLAG \ + artifacts/tarballs/*.tgz + # A draft's tag does not exist yet, so `gh release edit + # --draft=false` cannot address it; publish through the + # API by the draft's id. + release_id=$(gh api "repos/$GITHUB_REPOSITORY/releases" \ + --jq ".[] | select(.draft and .tag_name == \"v$VERSION\") | .id" | head -1) + if [ -z "$release_id" ]; then + echo "Could not find the draft release for v$VERSION" >&2 + exit 1 fi + gh api -X PATCH "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + -F draft=false >/dev/null + echo "Published release v$VERSION with $(ls artifacts/tarballs/*.tgz | wc -l | tr -d ' ') asset(s)." diff --git a/.gitignore b/.gitignore index 715a5ae8..06e2a5d3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ packages/*/.prisma/ tmp/ # Build and test output +artifacts/ dist/ .publish/ coverage/ diff --git a/docs/oss/versioning.md b/docs/oss/versioning.md index d5e638ce..05f8e31b 100644 --- a/docs/oss/versioning.md +++ b/docs/oss/versioning.md @@ -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`), **`latest` tracks the newest release — RC or stable**. `latest` 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 that alters `latest`. Existing installs are unaffected — lockfiles pin resolved versions, and stable ranges like `^3.x` can never resolve to an RC (pre-releases don't match stable ranges), so nobody is moved onto the RC line by `npm update`; new installs get the newest RC. +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-v8 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). @@ -24,7 +24,8 @@ The lockstep set is: the workspace root, `packages/cli`, `packages/cli-engine`, The npm registry exposes the CLI packages under these dist-tags: -- **`latest`** — the most recent release, RC or stable (`8.0.0-rc.N` on the RC line). Default for any bare `npm install`. New `latest` releases happen only when a release PR merges (see procedure below) or a maintainer dispatches the publish workflow. +- **`latest`** — what a bare `npm install` gets. It stays on the pre-v8 CLI while the v8 RC line matures; moving it to 8.x is a deliberate operator act (dispatching the publish workflow with `dist-tag: latest`, or widening `releaseDistTag` when the RC line is ready), not a side effect of any routine release (operator ruling 2026-08-12). Once the line is stable, stable release bumps publish here. +- **`next`** — the v8 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. @@ -50,10 +51,12 @@ 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 dist-tag `latest` — on the RC line that means `latest` moves to the new `8.0.0-rc.N`, and the accompanying GitHub Release is marked pre-release. 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). **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. +Before anything reaches the registry, the workflow verifies the artifact it is about to ship: the grammar-completeness check (`pnpm check:grammar`) proves the assembled command tree lost nothing, and the tarball smoke ([`scripts/tarball-smoke.mjs`](../../scripts/tarball-smoke.mjs)) packs both publishable packages and installs the CLI's tarball in a sandbox outside the workspace — npm with `--ignore-scripts`, the unpublished engine resolved through a computed `file:` override — and starts every declared bin on plain Node. The verified tarballs upload as workflow artifacts on every publishing run and attach to the GitHub Release on releases, so the artifact a maintainer can retrieve is the one the smoke verified. The dry-run dispatch runs all of this without registry writes. + [`scripts/determine-version.ts`](../../scripts/determine-version.ts) decides whether this run publishes at all, and under which dist-tag. It refuses non-canonical bases (anything other than `X.Y.Z` or `8.0.0-rc.N`), so a malformed root `version` fails the publish before anything reaches the registry. It reads; it never writes. ## Procedure: cut the next release @@ -61,9 +64,9 @@ The publish workflow is **triggered by a change to the root `version`**: a push The release cadence is one PR per release (on the RC line: one PR per `rc.N`). A maintainer: 1. **Runs the [`publish-npm-version` skill](../../skills-contrib/publish-npm-version/SKILL.md)**, which drives `pnpm bump-version` in a fresh worktree off `origin/main` and opens the release PR under real maintainer credentials (so CI runs on it normally). The script reads the root version committed at HEAD, computes the next release version (`8.0.0-rc.N` → `8.0.0-rc.N+1`), and writes it to every lockstep `package.json`. `bump-version` refreshes `pnpm-lock.yaml` itself, because the lockfile records the `workspace:` pins it rewrites and a stale one fails every later frozen install. Commit both and open a `chore(release): 8.0.0-rc.N+1` PR. -2. **Reviews and merges the PR.** This is the point where a human verifies the release is intended — merging the bump PR is the deliberate act that moves `latest`. The resulting push to `main` carries the bumped root `version`, the publish workflow detects the change, publishes under `latest`, and creates a matching GitHub Release (marked pre-release on the RC line). +2. **Reviews and merges the PR.** This is the point where a human verifies the release is intended — merging the bump PR is the deliberate act that publishes. The resulting push to `main` carries the bumped root `version`, the publish workflow detects the change, publishes under the line's canonical dist-tag (`next` on the RC line, `latest` for stable), and creates a matching GitHub Release (marked pre-release on the RC line). -If the publish needs to be re-run (transient registry failure, etc.), a maintainer can dispatch the [`Publish to npm`](../../.github/workflows/publish.yml) workflow from `main` with `dist-tag=latest` and `dry-run=false`; the workflow re-publishes the version currently committed at HEAD. This is the same path used to cut a hand-rolled `beta` (`dist-tag=beta`). +If the publish needs to be re-run (transient registry failure, etc.), a maintainer can dispatch the [`Publish to npm`](../../.github/workflows/publish.yml) workflow from `main` with the version's canonical dist-tag (`next` on the RC line, `latest` for stable) and `dry-run=false`; the workflow re-publishes the version currently committed at HEAD, and because the chosen tag matches the canonical one it also re-creates the GitHub Release if it is missing. This is the same path used to cut a hand-rolled `beta` (`dist-tag=beta`, no Release). ## Procedure: validate publish changes diff --git a/package.json b/package.json index e11e2964..1881bbf1 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,14 @@ "build": "turbo run build", "build:cli": "turbo run build --filter=@prisma/cli", "build:compute": "pnpm --filter @prisma/compute build", + "check:grammar": "turbo run check:grammar", "format": "biome format . --write", "lint": "biome check . --error-on-warnings", "lint:fix": "biome check . --write", "smoke:cli-nextjs": "node scripts/smoke-cli-nextjs-artifact.mjs", "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/tarball-smoke-utils.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/e2e/declared-bin.e2e.ts b/packages/cli/e2e/declared-bin.e2e.ts new file mode 100644 index 00000000..525e4d5c --- /dev/null +++ b/packages/cli/e2e/declared-bin.e2e.ts @@ -0,0 +1,50 @@ +/** + * The bin the package DECLARES is the bin users get. Every other test + * here hard-codes the v8 entry; this one starts from `package.json`'s + * `bin` map, so a manifest pointing at the wrong tree fails even while + * both trees still build. No credentials: `--version` must answer in a + * bare environment on plain Node. + */ +import { execFile } from "node:child_process"; +import path from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +import packageJson from "../package.json" with { type: "json" }; + +const execFileAsync = promisify(execFile); + +const packageRoot = path.resolve(import.meta.dirname, ".."); + +describe("the declared bin", () => { + it("maps prisma-cli to the v8 tree", () => { + expect(packageJson.bin).toEqual({ "prisma-cli": "./dist/v8/cli.js" }); + }); + + it("prints the lockstep version on plain Node at exit 0", async () => { + const declaredBin = path.resolve( + packageRoot, + packageJson.bin["prisma-cli"], + ); + const { stdout } = await execFileAsync( + process.execPath, + [declaredBin, "--version"], + { + env: { + PATH: process.env.PATH, + TMPDIR: process.env.TMPDIR, + }, + timeout: 60_000, + }, + ); + // Piped stdout is not a TTY, so the shell answers in its JSON + // stream; the terminal frame carries the version. + const frame = JSON.parse(stdout.trim()) as { + kind: string; + envelope: { ok: boolean; result: { version: string } }; + }; + expect(frame.kind).toBe("result"); + expect(frame.envelope.ok).toBe(true); + expect(frame.envelope.result.version).toBe(packageJson.version); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 45c720c8..39ac0c68 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -4,7 +4,7 @@ "description": "Command-line interface for the Prisma Developer Platform.", "type": "module", "bin": { - "prisma-cli": "./dist/cli.js" + "prisma-cli": "./dist/v8/cli.js" }, "exports": { "./package.json": "./package.json" @@ -40,6 +40,7 @@ "scripts": { "build": "tsdown", "prepack": "pnpm run build", + "check:grammar": "vitest run tests/v8-mount-coverage.test.ts", "typecheck": "tsc --noEmit", "test": "vitest run", "test:e2e": "vitest run --config vitest.e2e.config.ts" @@ -51,6 +52,7 @@ "@prisma/compute-sdk": "0.39.0", "@prisma/credentials-store": "^7.8.0", "@prisma/management-api-sdk": "1.55.0", + "@prisma/orm-toolchain": "8.0.0-rc.1-dev.40", "@vercel/detect-agent": "^1.2.3", "better-result": "^2.9.2", "colorette": "^2.0.20", diff --git a/packages/cli/src/v8/cli.ts b/packages/cli/src/v8/cli.ts index c0dd0466..2647da08 100644 --- a/packages/cli/src/v8/cli.ts +++ b/packages/cli/src/v8/cli.ts @@ -14,6 +14,11 @@ import { // must pin the same engine version prisma-cli publishes, per the // tandem release order engine → composer → prisma-cli (R-S3-6). import { createComposerFamily } from "@prisma/composer/family"; +// TODO(release): @prisma/orm-toolchain@8.0.0-rc.1-dev.40 pins +// @prisma/cli-engine@0.0.9, the same second copy composer's pin +// installs. Both close the same way: the two packages pin the engine +// version prisma-cli publishes, per the tandem release order. +import { ormCommandFamily as ormToolchainFamily } from "@prisma/orm-toolchain/cli"; import { CLI_DOCS_URL, CLI_NAME } from "../cli-name"; import { getCliVersion } from "../lib/version"; import { agentInstallCommand } from "./agent/install"; @@ -145,6 +150,16 @@ export const platformCommandFamily: CommandFamily = defineCommandFamily({ */ export const composerCommandFamily: CommandFamily = createComposerFamily(); +/** + * The ORM commands, contributed by orm-toolchain's own package. The + * family object carries its `orm` config section, its docs base and its + * redirect table, so nothing here is wired per command. Unlike + * composer's, this family's entry module imports esbuild and arktype + * statically, so every invocation of this bin pays that import; fixing + * that is orm-toolchain's move. + */ +export const ormCommandFamily: CommandFamily = ormToolchainFamily; + /** The engine ships the three telemetry commands and the group help * text that belongs to them; both halves are spread in below. */ const telemetry = telemetryCommandGroup({ docsUrl: CLI_DOCS_URL }); @@ -175,6 +190,10 @@ export const cliGroups: Readonly< }, agent: { brief: "Manage Prisma skills for AI coding agents" }, "auth workspace": { brief: "Manage local workspace sessions" }, + contract: { brief: "Define and emit your application data contract" }, + db: { brief: "Verify, sign and update your database against the contract" }, + migration: { brief: "Plan, inspect and scaffold on-disk migrations" }, + ref: { brief: "Manage named refs that point at contracts" }, ...telemetry.groups, }; @@ -239,6 +258,31 @@ export const mountedCommands: Readonly> = { "composer destroy": composerCommandFamily.commands.destroy, "composer dev": composerCommandFamily.commands.dev, "composer log": composerCommandFamily.commands.log, + // The ORM family. Written out per path: the shell owns the tree + // (R12), so this map — not the family's own keying — is the source of + // truth for where each command mounts. + "contract emit": ormCommandFamily.commands["contract emit"], + "contract infer": ormCommandFamily.commands["contract infer"], + "db init": ormCommandFamily.commands["db init"], + "db schema": ormCommandFamily.commands["db schema"], + "db sign": ormCommandFamily.commands["db sign"], + "db update": ormCommandFamily.commands["db update"], + "db verify": ormCommandFamily.commands["db verify"], + format: ormCommandFamily.commands.format, + init: ormCommandFamily.commands.init, + lsp: ormCommandFamily.commands.lsp, + migrate: ormCommandFamily.commands.migrate, + "migration check": ormCommandFamily.commands["migration check"], + "migration graph": ormCommandFamily.commands["migration graph"], + "migration list": ormCommandFamily.commands["migration list"], + "migration log": ormCommandFamily.commands["migration log"], + "migration new": ormCommandFamily.commands["migration new"], + "migration plan": ormCommandFamily.commands["migration plan"], + "migration show": ormCommandFamily.commands["migration show"], + "migration status": ormCommandFamily.commands["migration status"], + "ref delete": ormCommandFamily.commands["ref delete"], + "ref list": ormCommandFamily.commands["ref list"], + "ref set": ormCommandFamily.commands["ref set"], // Local utilities: no owning package, no config section, no API. "agent install": agentInstallCommand, "agent update": agentUpdateCommand, @@ -252,7 +296,11 @@ export function buildCli(): Cli { return createCli({ name: CLI_NAME, version: getCliVersion(), - commandFamilies: [platformCommandFamily, composerCommandFamily], + commandFamilies: [ + platformCommandFamily, + composerCommandFamily, + ormCommandFamily, + ], groups: cliGroups, commands: mountedCommands, telemetry: { docsUrl: CLI_DOCS_URL }, diff --git a/packages/cli/tests/e2e-coverage.test.ts b/packages/cli/tests/e2e-coverage.test.ts index ce03ab76..a699e5f3 100644 --- a/packages/cli/tests/e2e-coverage.test.ts +++ b/packages/cli/tests/e2e-coverage.test.ts @@ -19,7 +19,36 @@ const E2E_DIR = path.resolve(import.meta.dirname, "../e2e"); * with a reason, not a permanent exemption — an exclusion whose command * has been deleted fails this test, so the list cannot rot quietly. */ +/** + * The ORM family's commands never call the management API this suite + * exists to cover: they work against local files and the user's own + * database. Their real end-to-end suite lives in prisma/prisma (R7, + * argv-in/bytes-out against the engine harness); what this repo owes is + * composition, which `tests/v8-orm-mount.test.ts` proves per family + * (R8). + */ +const ORM_FAMILY_REASON = + "ORM command: no management API involved. Real e2e lives in prisma/prisma (R7); the shell proves composition in v8-orm-mount.test.ts (R8)."; + const EXCLUSIONS: Readonly> = { + "contract emit": ORM_FAMILY_REASON, + "contract infer": ORM_FAMILY_REASON, + "db init": ORM_FAMILY_REASON, + "db schema": ORM_FAMILY_REASON, + "db sign": ORM_FAMILY_REASON, + "db update": ORM_FAMILY_REASON, + "db verify": ORM_FAMILY_REASON, + "migration check": ORM_FAMILY_REASON, + "migration graph": ORM_FAMILY_REASON, + "migration list": ORM_FAMILY_REASON, + "migration log": ORM_FAMILY_REASON, + "migration new": ORM_FAMILY_REASON, + "migration plan": ORM_FAMILY_REASON, + "migration show": ORM_FAMILY_REASON, + "migration status": ORM_FAMILY_REASON, + "ref delete": ORM_FAMILY_REASON, + "ref list": ORM_FAMILY_REASON, + "ref set": ORM_FAMILY_REASON, "auth login": "Interactive browser OAuth. There is no non-interactive path to a real sign-in, so CI cannot drive it.", "auth workspace use": diff --git a/packages/cli/tests/fixtures/v8-orm-project/migrations/.gitkeep b/packages/cli/tests/fixtures/v8-orm-project/migrations/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/cli/tests/v8-mount-coverage.test.ts b/packages/cli/tests/v8-mount-coverage.test.ts index 820e0000..eb671f7a 100644 --- a/packages/cli/tests/v8-mount-coverage.test.ts +++ b/packages/cli/tests/v8-mount-coverage.test.ts @@ -1,4 +1,18 @@ -import { telemetryCommandGroup } from "@prisma/cli-engine"; +/** + * The grammar completeness check. It runs in the test suite and, as + * `pnpm check:grammar`, in `pr-quality.yml` and in `publish.yml` before + * anything is published, so a tree that has lost a command cannot be + * released. + * + * The exception set below (the engine's three telemetry commands, + * `agent install|update|status`, and `feedback`) was ratified by the + * operator on 2026-08-12. Adding to it requires an operator ruling + * recorded here; giving those commands a real owning family, so the + * exception set can shrink, is deferred work. + */ +import type { AnyCommand, CommandFamily } from "@prisma/cli-engine"; +import { defineCommand, telemetryCommandGroup } from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; import { describe, expect, it } from "vitest"; import { CLI_DOCS_URL } from "../src/cli-name"; import { agentInstallCommand } from "../src/v8/agent/install"; @@ -8,6 +22,7 @@ import { cliGroups, composerCommandFamily, mountedCommands, + ormCommandFamily, platformCommandFamily, } from "../src/v8/cli"; import { feedbackCommand } from "../src/v8/feedback"; @@ -25,6 +40,31 @@ const FAMILYLESS: ReadonlySet = new Set([ feedbackCommand, ]); +/** The family commands the tree does not mount, by family key. */ +function unmountedFamilyCommands( + commandFamily: Pick, + tree: Readonly>, +): readonly string[] { + const mounted = new Set(Object.values(tree)); + return Object.entries(commandFamily.commands) + .filter(([, command]) => !mounted.has(command)) + .map(([key]) => key); +} + +/** The mounted paths no family owns and no exception excuses. */ +function unownedMountPaths( + tree: Readonly>, + families: readonly Pick[], + excepted: ReadonlySet, +): readonly string[] { + const owned = new Set( + families.flatMap((commandFamily) => Object.values(commandFamily.commands)), + ); + return Object.entries(tree) + .filter(([, command]) => !owned.has(command) && !excepted.has(command)) + .map(([path]) => path); +} + /** * Every path the v8 tree mounts, written out. The other assertions in * this file compare the two maps only to each other, so deleting a @@ -54,9 +94,28 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ "composer destroy", "composer dev", "composer log", + "contract emit", + "contract infer", + "db init", + "db schema", + "db sign", + "db update", + "db verify", "feedback", + "format", "git connect", "git disconnect", + "init", + "lsp", + "migrate", + "migration check", + "migration graph", + "migration list", + "migration log", + "migration new", + "migration plan", + "migration show", + "migration status", "postgres backup list", "postgres connection create", "postgres connection list", @@ -79,6 +138,9 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ "project rename", "project show", "project transfer", + "ref delete", + "ref list", + "ref set", "service create", "service deployment delete", "service deployment list", @@ -104,6 +166,7 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ const MOUNTED_FAMILIES = { platform: platformCommandFamily, composer: composerCommandFamily, + orm: ormCommandFamily, }; describe("prisma-v8 mount coverage", () => { @@ -114,25 +177,17 @@ describe("prisma-v8 mount coverage", () => { it.each( Object.entries(MOUNTED_FAMILIES), )("mounts every command in the %s family", (_name, commandFamily) => { - const mounted = new Set(Object.values(mountedCommands)); - const unmounted = Object.entries(commandFamily.commands) - .filter(([, command]) => !mounted.has(command)) - .map(([key]) => key); - - expect(unmounted).toEqual([]); + expect(unmountedFamilyCommands(commandFamily, mountedCommands)).toEqual([]); }); it("gives every mounted command a family, except the deliberately familyless ones", () => { - const owned = new Set( - Object.values(MOUNTED_FAMILIES).flatMap((commandFamily) => - Object.values(commandFamily.commands), + expect( + unownedMountPaths( + mountedCommands, + Object.values(MOUNTED_FAMILIES), + FAMILYLESS, ), - ); - const unowned = Object.entries(mountedCommands) - .filter(([, command]) => !owned.has(command) && !FAMILYLESS.has(command)) - .map(([path]) => path); - - expect(unowned).toEqual([]); + ).toEqual([]); }); it("declares a group for every mount path prefix", () => { @@ -143,3 +198,43 @@ describe("prisma-v8 mount coverage", () => { expect(missing).toEqual([]); }); }); + +/** + * The check's own failure behaviour, on constructed families and trees + * rather than the real mount: if these comparisons stopped reporting, + * the assertions above would pass on a tree that had lost a command. + */ +describe("the completeness comparisons report what is wrong", () => { + const toy = (summary: string): AnyCommand => + defineCommand({ + help: { summary }, + handler: async (_args, ctx) => + ok(ctx.present({ data: null, exitCode: 0 }, { human: () => [] })), + }); + + const kept = toy("kept"); + const dropped = toy("dropped"); + const stray = toy("stray"); + const excused = toy("excused"); + const family = { commands: { kept, dropped } }; + + it("names a family command that the tree does not mount", () => { + expect(unmountedFamilyCommands(family, { kept })).toEqual(["dropped"]); + }); + + it("says nothing when the tree mounts the whole family", () => { + expect(unmountedFamilyCommands(family, { kept, dropped })).toEqual([]); + }); + + it("names a mounted command that no family owns", () => { + expect( + unownedMountPaths({ kept, "toy stray": stray }, [family], new Set()), + ).toEqual(["toy stray"]); + }); + + it("excuses a mounted command in the exception set", () => { + expect( + unownedMountPaths({ kept, excused }, [family], new Set([excused])), + ).toEqual([]); + }); +}); diff --git a/packages/cli/tests/v8-orm-mount.test.ts b/packages/cli/tests/v8-orm-mount.test.ts new file mode 100644 index 00000000..3d0c985b --- /dev/null +++ b/packages/cli/tests/v8-orm-mount.test.ts @@ -0,0 +1,138 @@ +/** + * The ORM family, mounted in this shell: the same command objects and + * groups the bin builds, run through the engine's test harness. What is + * proven here is the mount, not orm-toolchain's command logic — that a + * user typing `prisma migration list` reaches a real ORM handler, that + * the family's config section and redirect table came along with it, + * and that the four new groups are named in the root help. + */ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createTestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, it } from "vitest"; +import { + cliGroups, + composerCommandFamily, + mountedCommands, + ormCommandFamily, + platformCommandFamily, +} from "../src/v8/cli"; + +/** A project directory with a real (empty) migrations directory, so + * `migration list` reads the disk rather than reporting its absence. */ +const ORM_PROJECT_DIR = join( + dirname(fileURLToPath(import.meta.url)), + "fixtures", + "v8-orm-project", +); + +/** + * The smallest `orm` section orm-toolchain's validator accepts: a + * family, a target and an adapter descriptor, plus the migrations + * directory. `migration list` reads only the last of those; the other + * three exist because the section is validated whole before any ORM + * command runs, which is itself part of what this file proves. + */ +const ORM_SECTION = { + family: { + kind: "family", + id: "test-family", + familyId: "test-family", + version: "0.0.0", + emission: {}, + create: () => ({}), + }, + target: descriptor("target"), + adapter: descriptor("adapter"), + migrations: { dir: "migrations" }, +}; + +function descriptor(kind: string) { + return { + kind, + id: `test-${kind}`, + familyId: "test-family", + version: "0.0.0", + targetId: "test-target", + create: () => ({}), + }; +} + +function shell(config?: Readonly>) { + return createTestCli({ + commandFamilies: [ + platformCommandFamily, + composerCommandFamily, + ormCommandFamily, + ], + commands: mountedCommands, + groups: cliGroups, + config, + now: () => new Date(0), + }); +} + +describe("the ORM family answers from the assembled tree", () => { + it("runs migration list against a project directory", async () => { + const result = await shell({ orm: ORM_SECTION }).run( + ["migration", "list"], + { cwd: ORM_PROJECT_DIR }, + ); + + expect(result.exitCode).toBe(0); + expect(result.presented?.presentation.json).toEqual({ + ok: true, + spaces: [{ space: "app", migrations: [] }], + summary: "0 migration(s) on disk", + }); + }); + + it("validates the family's config section before running a command", async () => { + const result = await shell({}).run(["migration", "list", "--json"], { + cwd: ORM_PROJECT_DIR, + }); + + expect(result.exitCode).toBe(2); + const frame = result.json[result.json.length - 1]; + if (frame.kind !== "result" || frame.envelope.ok) { + throw new Error("expected an errored result frame"); + } + expect(frame.envelope.error.code).toBe("CLI.CONFIG_SECTION_INVALID"); + expect(frame.envelope.error.summary).toContain("'orm' section"); + }); + + it("answers a retired invocation with the family's redirect", async () => { + const result = await shell({ orm: ORM_SECTION }).run( + ["migration", "apply", "--json"], + { cwd: ORM_PROJECT_DIR }, + ); + + expect(result.exitCode).toBe(2); + const frame = result.json[result.json.length - 1]; + if (frame.kind !== "result" || frame.envelope.ok) { + throw new Error("expected an errored result frame"); + } + expect(frame.envelope.error.code).toBe("CLI.COMMAND_MOVED"); + expect(frame.envelope.nextActions[0]).toMatchObject({ + kind: "run-command", + command: "prisma-test migrate --to ", + }); + }); + + it("names the four new groups in the root help", async () => { + const result = await shell().run(["--help"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + for (const [group, { brief }] of Object.entries({ + contract: cliGroups.contract, + db: cliGroups.db, + migration: cliGroups.migration, + ref: cliGroups.ref, + })) { + expect(result.stdout).toContain(group); + expect(result.stdout).toContain(brief); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a71f4b4..25531eb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@prisma/management-api-sdk': specifier: 1.55.0 version: 1.55.0 + '@prisma/orm-toolchain': + specifier: 8.0.0-rc.1-dev.40 + version: 8.0.0-rc.1-dev.40(magicast@0.5.3)(typanion@3.14.0)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vercel/detect-agent': specifier: ^1.2.3 version: 1.2.4 @@ -1152,6 +1155,10 @@ packages: '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} + engines: {node: '>= 20.19.0'} + '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} @@ -1275,6 +1282,26 @@ packages: '@prisma/management-api-sdk@1.59.0': resolution: {integrity: sha512-MV5Osk7Ie6dBmehzjXz18oLOhJuDMwuKNzb1zsrq5rfPRpKGGmr3EcFfreIMBgrvM4JvKN1MyUUmwDPyS/Mssw==} + '@prisma/orm-framework@8.0.0-rc.1-dev.40': + resolution: {integrity: sha512-x1/NzuDkQkNwLyUlUE8g1NPKgLqHmfooRVlSBK4xMrZUzvCBpKExaroE+kxXPzVtCqJ2zItuQ4ccREP8dt3l2w==} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true + + '@prisma/orm-toolchain@8.0.0-rc.1-dev.40': + resolution: {integrity: sha512-gEHL+as1VT5sN0v/zOcbC5fhM3ggGvQMGLPYHdRSRE1mL/VzBQXEpF+cgwlU1rUJYnsV1ouO/l/0cxr5Da6sBg==} + hasBin: true + peerDependencies: + typescript: '>=5.9' + vite: ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + vite: + optional: true + '@prisma/query-plan-executor@7.2.0': resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} @@ -2016,6 +2043,14 @@ packages: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} + clipanion@4.0.0-rc.4: + resolution: {integrity: sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==} + peerDependencies: + typanion: '*' + + closest-match@1.3.3: + resolution: {integrity: sha512-RSdHrZwNOvt2uMQgqJDJdM/I+5MlJ1tQJEXYrbRjSMXWiCRo06g2hwObJ7+WKt2J9ySK9/pJ0Q2vbL+BPkofDA==} + code-excerpt@4.0.0: resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2027,6 +2062,10 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} @@ -2390,6 +2429,9 @@ packages: json-with-bigint@3.5.10: resolution: {integrity: sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==} + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} @@ -2681,6 +2723,11 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -2874,6 +2921,10 @@ packages: resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -2986,6 +3037,9 @@ packages: resolution: {integrity: sha512-Yl9+ukxH+UmPtKidpDkjn82tvPoEvFNb9UACd9vUomN1Ft0cwl3rx0P8yC1D93W9EOsWRMjllvIDG8y25sFOog==} hasBin: true + typanion@3.14.0: + resolution: {integrity: sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==} + type-fest@5.8.0: resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} engines: {node: '>=20'} @@ -3012,6 +3066,10 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + uniku@0.5.0: + resolution: {integrity: sha512-giSrg7xqM5YWkSlyheulHgTTInhYh/m0cFZOOuChi/TO87hKlxmZLllETlvDw/lPB54NIs1iW/x2rr0y2yFXHg==} + engines: {node: '>=20.19.0'} + universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} @@ -3121,6 +3179,23 @@ packages: jsdom: optional: true + vscode-jsonrpc@9.0.1: + resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.18.2: + resolution: {integrity: sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.18.0: + resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} + + vscode-languageserver@10.1.0: + resolution: {integrity: sha512-9gEWpXkYGXoqG7pBnE8O8hx/yP7+Aabn4+peQ3KDicQv6qunHSWyLTud3OF0w4S2+HfDD+5HqYKiXQW9HAU6mA==} + hasBin: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4022,6 +4097,8 @@ snapshots: '@neon-rs/load@0.0.4': {} + '@noble/hashes@2.3.0': {} + '@nodable/entities@3.0.0': {} '@nodelib/fs.scandir@2.1.5': @@ -4233,6 +4310,45 @@ snapshots: dependencies: openapi-fetch: 0.14.0 + '@prisma/orm-framework@8.0.0-rc.1-dev.40(typescript@6.0.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + arktype: 2.2.3 + pathe: 2.0.3 + uniku: 0.5.0 + optionalDependencies: + typescript: 6.0.3 + + '@prisma/orm-toolchain@8.0.0-rc.1-dev.40(magicast@0.5.3)(typanion@3.14.0)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@clack/prompts': 1.7.0 + '@prisma/cli-engine': 0.0.9(magicast@0.5.3) + '@prisma/orm-framework': 8.0.0-rc.1-dev.40(typescript@6.0.3) + '@vercel/detect-agent': 1.2.4 + arktype: 2.2.3 + c12: 3.3.4(magicast@0.5.3) + ci-info: 4.4.0 + clipanion: 4.0.0-rc.4(typanion@3.14.0) + closest-match: 1.3.3 + colorette: 2.0.20 + commander: 15.0.0 + esbuild: 0.28.2 + jsonc-parser: 3.3.1 + package-manager-detector: 1.8.0 + pathe: 2.0.3 + prettier: 3.9.6 + string-width: 8.2.2 + strip-ansi: 7.2.0 + vscode-languageserver: 10.1.0 + vscode-languageserver-textdocument: 1.0.12 + wrap-ansi: 10.0.0 + optionalDependencies: + typescript: 6.0.3 + vite: 7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + transitivePeerDependencies: + - magicast + - typanion + '@prisma/query-plan-executor@7.2.0': {} '@quansync/fs@1.0.0': @@ -4840,6 +4956,12 @@ snapshots: slice-ansi: 8.0.0 string-width: 8.2.1 + clipanion@4.0.0-rc.4(typanion@3.14.0): + dependencies: + typanion: 3.14.0 + + closest-match@1.3.3: {} + code-excerpt@4.0.0: dependencies: convert-to-spaces: 2.0.1 @@ -4848,6 +4970,8 @@ snapshots: commander@14.0.3: {} + commander@15.0.0: {} + confbox@0.2.4: {} consola@3.4.2: {} @@ -5244,6 +5368,8 @@ snapshots: json-with-bigint@3.5.10: {} + jsonc-parser@3.3.1: {} + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -5506,6 +5632,8 @@ snapshots: powershell-utils@0.1.0: {} + prettier@3.9.6: {} + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -5735,6 +5863,11 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -5856,6 +5989,8 @@ snapshots: '@turbo/windows-64': 2.10.9 '@turbo/windows-arm64': 2.10.9 + typanion@3.14.0: {} + type-fest@5.8.0: dependencies: tagged-tag: 1.0.0 @@ -5877,6 +6012,10 @@ snapshots: unicorn-magic@0.3.0: {} + uniku@0.5.0: + dependencies: + '@noble/hashes': 2.3.0 + universal-user-agent@7.0.3: {} unrun@0.2.39: @@ -5933,6 +6072,21 @@ snapshots: transitivePeerDependencies: - msw + vscode-jsonrpc@9.0.1: {} + + vscode-languageserver-protocol@3.18.2: + dependencies: + vscode-jsonrpc: 9.0.1 + vscode-languageserver-types: 3.18.0 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.18.0: {} + + vscode-languageserver@10.1.0: + dependencies: + vscode-languageserver-protocol: 3.18.2 + webidl-conversions@3.0.1: {} webidl-conversions@7.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 14644379..40e24e7c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,3 +21,7 @@ minimumReleaseAgeExclude: - '@prisma/composer' # Reached through @prisma/composer, which pins it exactly. - '@prisma/cli-engine' + # The ORM family and the framework it reaches through, at the exact + # interim versions packages/cli pins until the rc lines converge. + - '@prisma/orm-toolchain@8.0.0-rc.1-dev.40' + - '@prisma/orm-framework@8.0.0-rc.1-dev.40' diff --git a/scripts/determine-version-utils.test.ts b/scripts/determine-version-utils.test.ts index 25aa39fe..095999f2 100644 --- a/scripts/determine-version-utils.test.ts +++ b/scripts/determine-version-utils.test.ts @@ -5,6 +5,7 @@ import { computeNextMinor, computeNextReleaseVersion, parseVersion, + releaseDistTag, } from "./determine-version-utils.ts"; const NOT_CANONICAL = /not canonical/; @@ -135,3 +136,19 @@ describe("assertCanonicalBase", () => { assert.throws(() => assertCanonicalBase("8.0.0-rc.01"), NOT_CANONICAL); }); }); + +describe("releaseDistTag", () => { + it("sends an RC-line bump to next, so latest stays put until the deliberate flip", () => { + assert.equal(releaseDistTag("8.0.0-rc.1"), "next"); + assert.equal(releaseDistTag("8.0.0-rc.12"), "next"); + }); + + it("sends a stable bump to latest", () => { + assert.equal(releaseDistTag("8.0.0"), "latest"); + assert.equal(releaseDistTag("8.1.0"), "latest"); + }); + + it("refuses a non-canonical base", () => { + assert.throws(() => releaseDistTag("8.0.0-rc.1-dev.3"), NOT_CANONICAL); + }); +}); diff --git a/scripts/determine-version-utils.ts b/scripts/determine-version-utils.ts index fbe87c55..535ef93e 100644 --- a/scripts/determine-version-utils.ts +++ b/scripts/determine-version-utils.ts @@ -82,3 +82,15 @@ export function assertCanonicalBase(base: string): void { ); } } + +/** + * The dist-tag a release-bump push publishes under. RC-line versions go + * to `next`: `latest` keeps serving the pre-v8 CLI until the operator + * deliberately moves it (rollout plan step 5), by dispatching the + * publish workflow with `dist-tag: latest` or by widening this rule + * when the RC line is ready. Stable versions go to `latest`. + */ +export function releaseDistTag(base: string): "latest" | "next" { + assertCanonicalBase(base); + return base.includes("-rc.") ? "next" : "latest"; +} diff --git a/scripts/determine-version.ts b/scripts/determine-version.ts index d6b141e5..8d1b43db 100644 --- a/scripts/determine-version.ts +++ b/scripts/determine-version.ts @@ -9,21 +9,26 @@ * event: * * - `push` → if the root `version` changed in this push, - * ``, dist-tag `latest`. This is how a - * merged `chore(release): ...` PR ships a - * release automatically — on the RC line - * `latest` tracks the newest `8.0.0-rc.N`. + * ``, dist-tag from `releaseDistTag`: + * `next` on the RC line, `latest` for stable. + * This is how a merged `chore(release): ...` + * PR ships a release automatically — `latest` + * keeps serving the pre-v8 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. * - `workflow_dispatch` → `` (no suffix), dist-tag from - * `INPUT_DIST_TAG` (defaults to `latest`). - * Useful as a manual escape hatch (re-publish - * after a transient failure, cut a beta). + * `INPUT_DIST_TAG`; empty means the version's + * canonical tag (`releaseDistTag`). Useful as a + * manual escape hatch (re-publish after a + * transient failure, cut a beta) — and passing + * `latest` explicitly for an RC version is the + * deliberate cutover act. * - * Outputs `publish`, `version` and `tag` to `$GITHUB_OUTPUT` for - * downstream workflow steps to consume. + * 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 @@ -35,7 +40,10 @@ import { appendFileSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { VersionResult } from "./determine-version-utils.ts"; -import { assertCanonicalBase } from "./determine-version-utils.ts"; +import { + assertCanonicalBase, + releaseDistTag, +} from "./determine-version-utils.ts"; const ALL_ZERO_SHA_PATTERN = /^0+$/; @@ -98,6 +106,12 @@ function writeGitHubOutput( if (result === undefined) return; appendFileSync(outputFile, `version< ({ + name, + path: binPath, + })); +} diff --git a/scripts/tarball-smoke-utils.test.mjs b/scripts/tarball-smoke-utils.test.mjs new file mode 100644 index 00000000..effd816f --- /dev/null +++ b/scripts/tarball-smoke-utils.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { computeOverrides, declaredBins } from "./tarball-smoke-utils.mjs"; + +const NEVER_PACKED = /never packed/; +const MUST_BE_ABSOLUTE = /absolute/; + +function workspace(entries) { + return new Map(Object.entries(entries)); +} + +describe("computeOverrides", () => { + it("maps a workspace dependency to a version-qualified file: override", () => { + const overrides = computeOverrides( + { name: "@prisma/cli", dependencies: { "@prisma/cli-engine": "8.0.0" } }, + workspace({ + "@prisma/cli-engine": { + manifest: { name: "@prisma/cli-engine", version: "8.0.0" }, + tarballPath: "/packed/engine.tgz", + }, + }), + ); + + assert.deepEqual(overrides, { + "@prisma/cli-engine@8.0.0": "file:/packed/engine.tgz", + }); + }); + + it("ignores registry dependencies", () => { + const overrides = computeOverrides( + { name: "@prisma/cli", dependencies: { colorette: "^2.0.20" } }, + workspace({}), + ); + + assert.deepEqual(overrides, {}); + }); + + it("recurses into a workspace dependency's own workspace dependencies", () => { + const overrides = computeOverrides( + { name: "a", dependencies: { b: "1.0.0" } }, + workspace({ + b: { + manifest: { + name: "b", + version: "1.0.0", + dependencies: { c: "2.0.0" }, + }, + tarballPath: "/packed/b.tgz", + }, + c: { + manifest: { name: "c", version: "2.0.0" }, + tarballPath: "/packed/c.tgz", + }, + }), + ); + + assert.deepEqual(overrides, { + "b@1.0.0": "file:/packed/b.tgz", + "c@2.0.0": "file:/packed/c.tgz", + }); + }); + + it("survives a dependency cycle between workspace packages", () => { + const overrides = computeOverrides( + { name: "a", dependencies: { b: "1.0.0" } }, + workspace({ + b: { + manifest: { + name: "b", + version: "1.0.0", + dependencies: { b: "1.0.0" }, + }, + tarballPath: "/packed/b.tgz", + }, + }), + ); + + assert.deepEqual(overrides, { "b@1.0.0": "file:/packed/b.tgz" }); + }); + + it("refuses a workspace dependency that was never packed", () => { + assert.throws( + () => + computeOverrides( + { name: "a", dependencies: { b: "1.0.0" } }, + workspace({ + b: { + manifest: { name: "b", version: "1.0.0" }, + tarballPath: undefined, + }, + }), + ), + NEVER_PACKED, + ); + }); + + it("refuses a relative tarball path", () => { + assert.throws( + () => + computeOverrides( + { name: "a", dependencies: { b: "1.0.0" } }, + workspace({ + b: { + manifest: { name: "b", version: "1.0.0" }, + tarballPath: "packed/b.tgz", + }, + }), + ), + MUST_BE_ABSOLUTE, + ); + }); +}); + +describe("declaredBins", () => { + it("reads the bin map", () => { + assert.deepEqual( + declaredBins({ + name: "@prisma/cli", + bin: { "prisma-cli": "./dist/v8/cli.js" }, + }), + [{ name: "prisma-cli", path: "./dist/v8/cli.js" }], + ); + }); + + it("treats a string bin as named after the unscoped package", () => { + assert.deepEqual(declaredBins({ name: "@scope/tool", bin: "./run.js" }), [ + { name: "tool", path: "./run.js" }, + ]); + }); + + it("returns nothing for a package without bins", () => { + assert.deepEqual(declaredBins({ name: "@prisma/cli-engine" }), []); + }); +}); diff --git a/scripts/tarball-smoke.mjs b/scripts/tarball-smoke.mjs new file mode 100644 index 00000000..32fed61b --- /dev/null +++ b/scripts/tarball-smoke.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node + +// biome-ignore-all lint/performance/noAwaitInLoops: every step here is sequential by design — packs share one turbo cache, and a failing bin must be reported against the package that failed. + +// The tarball install smoke: pack the publishable packages, install the +// CLI's tarball in a sandbox OUTSIDE the workspace, and start every +// declared bin on plain Node. This is the check standing between a +// publish and a tarball that does not start for users. +// +// Written to S6's check-3b design (specs/s6-conformance.md on the S6 +// branch, ruled 2026-08-12) so the conformance slice absorbs it as a +// move, not a rewrite. The mechanics it fixes in place: +// +// - `pnpm pack`, never `npm pack`: only pnpm rewrites `workspace:` pins +// to exact versions in the packed manifest. +// - The sandbox lives outside the repo: with corepack enabled, npm's +// shim walks up from the sandbox to the repo root, finds +// `"packageManager": "pnpm"`, and refuses to run. +// - npm installs with `--ignore-scripts`: the publish runner holds +// `id-token: write`, and third-party postinstalls do not run at the +// pipeline's most privileged moment. (npm rather than pnpm here is the +// ruled exception: the sandbox simulates a registry consumer.) +// - Unpublished workspace siblings resolve through computed npm +// `overrides` with absolute `file:` paths and version-qualified keys +// (see tarball-smoke-utils.mjs). + +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { computeOverrides, declaredBins } from "./tarball-smoke-utils.mjs"; + +const execFileAsync = promisify(execFile); + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +/** The lockstep publish list, in publish order. The subject whose bins + * the smoke starts is the CLI; the engine rides in as its override. */ +const PACKED_PACKAGES = ["packages/cli-engine", "packages/cli"]; +const SUBJECT = "@prisma/cli"; + +const TARBALL_DIR = path.join(repoRoot, "artifacts", "tarballs"); +const BIN_TIMEOUT_MS = 60_000; + +async function main() { + await rm(TARBALL_DIR, { recursive: true, force: true }); + await mkdir(TARBALL_DIR, { recursive: true }); + + const workspacePackages = new Map(); + for (const packageDir of PACKED_PACKAGES) { + const absoluteDir = path.join(repoRoot, packageDir); + const manifest = JSON.parse( + await readFile(path.join(absoluteDir, "package.json"), "utf8"), + ); + const { stdout } = await execFileAsync( + "pnpm", + ["pack", "--pack-destination", TARBALL_DIR], + { cwd: absoluteDir }, + ); + const tarballPath = stdout.trim().split("\n").at(-1); + if (!tarballPath?.endsWith(".tgz")) { + throw new Error( + `pnpm pack in ${packageDir} did not report a tarball path (got: ${tarballPath})`, + ); + } + workspacePackages.set(manifest.name, { manifest, tarballPath }); + process.stdout.write(`packed ${manifest.name} -> ${tarballPath}\n`); + } + + const subject = workspacePackages.get(SUBJECT); + if (!subject) throw new Error(`${SUBJECT} was not packed`); + + const sandbox = await mkdtemp(path.join(os.tmpdir(), "prisma-cli-smoke-")); + try { + await writeFile( + path.join(sandbox, "package.json"), + `${JSON.stringify( + { + name: "prisma-cli-tarball-smoke", + private: true, + dependencies: { [SUBJECT]: `file:${subject.tarballPath}` }, + overrides: computeOverrides(subject.manifest, workspacePackages), + }, + null, + 2, + )}\n`, + ); + + await execFileAsync( + "npm", + ["install", "--no-audit", "--no-fund", "--ignore-scripts"], + { cwd: sandbox, timeout: 300_000 }, + ); + + // The bins come from the INSTALLED manifests — the packed, + // workspace-rewritten metadata a registry consumer would get — not + // from the repo's working manifests. + let binsStarted = 0; + for (const name of workspacePackages.keys()) { + const installedDir = path.join( + sandbox, + "node_modules", + ...name.split("/"), + ); + const installedManifest = JSON.parse( + await readFile(path.join(installedDir, "package.json"), "utf8"), + ); + for (const bin of declaredBins(installedManifest)) { + const binPath = path.join(installedDir, bin.path); + const { stdout } = await execFileAsync( + process.execPath, + [binPath, "--version"], + { + cwd: sandbox, + env: { PATH: process.env.PATH, TMPDIR: process.env.TMPDIR }, + timeout: BIN_TIMEOUT_MS, + }, + ); + binsStarted += 1; + process.stdout.write( + `bin ${bin.name} (${name}) started on plain Node: ${stdout.trim().slice(0, 200)}\n`, + ); + } + } + + // An empty subject set is a broken invocation, not a pass. + if (binsStarted === 0) { + throw new Error( + "no declared bin was found in any packed package — the smoke checked nothing", + ); + } + + process.stdout.write( + `tarball smoke passed: ${workspacePackages.size} packages packed, ${binsStarted} bin(s) started\n`, + ); + } finally { + await rm(sandbox, { recursive: true, force: true }); + } +} + +await main(); diff --git a/turbo.json b/turbo.json index e48627f9..01e71c23 100644 --- a/turbo.json +++ b/turbo.json @@ -28,6 +28,17 @@ "$TURBO_ROOT$/packages/tsconfig/base.json" ] }, + // The grammar completeness check: every family command mounted, + // every mounted command family-owned, every path spelled as + // expected. It runs in the test suite too, but it also runs on its + // own before anything is published, so a release cannot ship a tree + // that has lost a command. Never cached: a green cache entry would + // let a publish skip the one check standing between it and a + // half-assembled CLI. + "check:grammar": { + "dependsOn": ["^build"], + "cache": false + }, "test:e2e": { // The package's OWN build, not just its dependencies': these // tests run dist/v8/cli.js, so a stale dist would test the last