diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index 3c2bc5c3..7a961e3f 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -420,3 +420,14 @@ CLI does not do, and each restarts as engine work if wanted: move `config-loader` into a shared library package the ORM publishes for exactly this kind of consumer, or cut the cloud extension's use of it. Spans both product repos; Will decides which. + +## Left open by the dev-build fix (2026-08-17) + +The release channel is green as of 2026-08-17: `@prisma/composer-cli@0.7.0` and `@prisma/orm-toolchain@8.0.0-rc.2` are both released and both peer `@prisma/cli-engine@0.1.1`, so the conformance run reports nothing and `packages/cli/scripts/conformance.ts` carries no exceptions. What closed, for the record: composer's `0.6.0` was uninstallable (published out-of-band with `npm publish`, leaving `workspace:0.6.0` in its manifest) and the ORM had no released version carrying the command family. + +Still open: + +- **Both product repos need their publish-notification step** (the work in the closed composer#232 and prisma#30033): a `repository_dispatch` of type `product-published` to `prisma/prisma-cli`, placed immediately after the publish step and keyed on its outcome. Until then a daily scheduled run is what notices a product release, so a new product version reaches the CLI within a day rather than within minutes. `docs/oss/release-automation.md` carries the exact step, and `DEPLOY_GITHUB_TOKEN` is provisioned in all three repositories (2026-08-17). +- **Neither product repo installs its own tarball before publishing.** That is why an uninstallable `@prisma/composer-cli@0.6.0` sat on `latest` unnoticed. prisma-cli's check 3 does exactly this — pack, install into a clean sandbox with `npm --ignore-scripts`, start every declared bin — and is worth porting to both. +- **The engine-pin check compares for equality, not peer satisfaction.** Both families now declare an exact peer equal to the shell's pin, so equality is correct and stricter today. Widening to range satisfaction belongs with the post-GA move to engine ranges (ADR 0004), not before. +- **`credential-manager.ts` uses the banned word.** `packages/cli/src/auth/credential-manager.ts` has a private `#repin` method (about the active-workspace marker, a different concept from dependency versions). The operator banned the word outright; renaming it is a mechanical change to a private method, left out of the publish-channel work to keep that diff to one subject. diff --git a/.github/workflows/auto-repin.yml b/.github/workflows/auto-repin.yml deleted file mode 100644 index e21738d4..00000000 --- a/.github/workflows/auto-repin.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Auto-repin product CLI packages - -# When composer or prisma/prisma publish a family package, this repo -# repins automatically and the merged repin ships as a dev version of -# the CLI (operator ruling 2026-08-13; docs/oss/versioning.md). Only a -# real CLI release needs a human. -# -# Triggers: -# - repository_dispatch `family-published`: sent by the product -# repos' publish workflows the moment they ship. -# - schedule (daily): the backstop for a missed dispatch — the same -# comparison against the registry, so a lost event delays a repin -# by at most a day. -# - workflow_dispatch: run it by hand. -# -# The PR is opened with REPIN_PAT, not GITHUB_TOKEN, for two reasons: -# pull requests created with the workflow token never trigger CI, and -# without CI the required checks never report, so auto-merge would hang -# forever. The PAT is the wmadden-electric bot's. - -on: - repository_dispatch: - types: [family-published] - schedule: - - cron: "17 6 * * *" - workflow_dispatch: - -concurrency: - group: auto-repin - cancel-in-progress: false - -jobs: - repin: - name: Repin against the registry - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - - - name: Set up pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version-file: .node-version - - - name: Compare pins against the registry - id: repin - run: node scripts/auto-repin.mjs - - # `pnpm install --no-frozen-lockfile` scoped to the lockfile - # refresh: the repin edits one manifest, and the PR must carry the - # matching lockfile or every CI job fails on --frozen-lockfile. - - name: Refresh the lockfile - if: ${{ steps.repin.outputs.changed == 'true' }} - run: | - corepack enable - pnpm install --lockfile-only --no-frozen-lockfile - - - name: Open the repin PR with auto-merge - if: ${{ steps.repin.outputs.changed == 'true' }} - env: - GH_TOKEN: ${{ secrets.REPIN_PAT }} - SUMMARY: ${{ steps.repin.outputs.summary }} - run: | - if [ -z "$GH_TOKEN" ]; then - echo "::error::REPIN_PAT is not configured; cannot open a CI-triggering PR. Add the bot PAT as the REPIN_PAT repository secret." - exit 1 - fi - BRANCH="auto/repin-$(date -u +%Y%m%d%H%M%S)" - git config user.name "willbot" - git config user.email "w.a.madden+machine@gmail.com" - git checkout -b "$BRANCH" - git add packages/cli/package.json pnpm-lock.yaml - git commit -s --trailer "Signed-off-by: Will Madden " \ - -m "Repin product CLI packages to the registry: $SUMMARY" \ - -m "Opened automatically (auto-repin.yml); merging ships a dev version of the CLI. A real release stays a human act." - git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" - PR_URL=$(gh pr create \ - --title "Repin product CLI packages: $SUMMARY" \ - --body "Automated repin from the registry ($SUMMARY). Merging publishes a \`dev\` version of the CLI automatically; the conformance checks on this PR are what stand between a family publish and that dev build. Opened by auto-repin.yml." \ - --base main --head "$BRANCH") - gh pr merge --auto --squash "$PR_URL" - echo "Opened $PR_URL with auto-merge armed." diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index 8fa96a8f..59f153ee 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -119,5 +119,13 @@ jobs: # Finds a broken tarball on the pull request instead of at publish # time. The turbo task builds its own dependencies, so no explicit # build step is needed here. + # + # PUBLISH_CHANNEL=dev because merging a pull request publishes a dev + # version of the CLI, and a dev CLI is allowed to depend on the + # products' dev builds. A release publish sets `release` instead and + # is refused if any dependency is a dev build; that is checked in + # publish.yml, which is the only place a release can happen. - name: Conformance checks + env: + PUBLISH_CHANNEL: dev run: pnpm check:conformance diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 03424a96..39d92fb2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,9 +11,9 @@ name: Publish to npm # Trigger model: # - push to `main` with the root `version` unchanged → publish # `-dev.` under the `dev` dist-tag (operator ruling -# 2026-08-13: automated family repins deploy automatically; only a -# real release needs a human). The dev suffix is stamped -# ephemerally below and never committed. +# 2026-08-13: a product's new version reaches the CLI and deploys +# without a human; only a real release needs one). The dev suffix is +# stamped ephemerally below and never committed. # - push to `main` with the root `version` changed → publish `` # under its canonical dist-tag — `next` on the RC line, `latest` for # stable — and create a GitHub Release (marked pre-release on the RC @@ -110,10 +110,19 @@ jobs: PUSH_BEFORE_SHA: ${{ github.event.before }} run: node scripts/determine-version.ts - # Dev publishes only: stamp `-dev.` across the lockstep - # manifests for this run. Ephemeral — nothing is committed; release - # versions remain exactly what the commit says. The engine is - # excluded from the sweep and ships at its own committed version. + # Dev publishes only, and ephemeral — nothing is committed, so a + # release publishes exactly what the commit says. Two stamps: + # + # 1. `-dev.` across the lockstep manifests. The engine + # is excluded from the sweep and ships at its own committed + # version. + # 2. The product CLI packages move to their `dev` builds. A dev + # CLI depends on the products' latest dev versions and a + # release depends only on their releases (operator ruling + # 2026-08-17), and the committed manifests hold the release + # versions. The build and the conformance checks below run + # after this, so a broken product dev build fails the dev + # publish instead of shipping in it. - name: Stamp dev version if: ${{ steps.version.outputs.publish == 'true' && steps.version.outputs.tag == 'dev' }} # The lockfile refresh is part of the stamp: pnpm verifies @@ -123,6 +132,7 @@ jobs: # bump-version does for committed bumps; still ephemeral. run: | node scripts/set-version.ts "${{ steps.version.outputs.version }}" + node scripts/update-product-versions.mjs --channel dev pnpm install --lockfile-only --no-frozen-lockfile - name: Build packages @@ -141,20 +151,25 @@ jobs: if: ${{ steps.version.outputs.publish == 'true' }} run: pnpm test:scripts - # The three conformance checks against what is about to ship: - # built output imports only declared dependencies, every mounted - # config-section validator survives hostile input, and the packed + # The conformance checks against what is about to ship: built + # output imports only declared dependencies, every mounted + # config-section validator survives hostile input, the packed # tarballs survive a registry consumer's install — clean sandbox, # npm with --ignore-scripts, unpublished workspace siblings via # computed file: overrides, every declared bin started on plain - # Node at exit 0, engine pins agreeing everywhere. Runs before - # BOTH publish paths so the dry run is covered too. The tarballs - # it packs land in artifacts/tarballs and are the ones uploaded - # below: what was verified is what ships. (This absorbed the - # interim scripts/tarball-smoke.mjs, which was written to this - # check's design.) + # Node at exit 0, engine pins agreeing everywhere — and a release + # depends on no dev build. Runs before BOTH publish paths so the + # dry run is covered too. The tarballs it packs land in + # artifacts/tarballs and are the ones uploaded below: what was + # verified is what ships. + # + # PUBLISH_CHANNEL is what makes the dev-build check answer + # correctly for this run. It is the run's own dist-tag, so a dev + # publish is allowed its dev builds and a release is not. - name: Run conformance checks if: ${{ steps.version.outputs.publish == 'true' }} + env: + PUBLISH_CHANNEL: ${{ steps.version.outputs.tag == 'dev' && 'dev' || 'release' }} run: pnpm check:conformance # The verified tarballs, retrievable per run. On a real release the diff --git a/.github/workflows/update-product-versions.yml b/.github/workflows/update-product-versions.yml new file mode 100644 index 00000000..725f223b --- /dev/null +++ b/.github/workflows/update-product-versions.yml @@ -0,0 +1,114 @@ +name: Update product versions + +# When composer or prisma/prisma publish a release of a package this repo +# depends on, this workflow moves the dependency and opens a pull +# request; merging it ships a dev version of the CLI (operator rulings +# 2026-08-13 and 2026-08-17; docs/oss/release-automation.md). Only a real +# CLI release needs a human. +# +# It follows each product's RELEASE tag, never `dev`. The dev channel +# gets its dev builds from publish.yml's stamp instead, so what this +# workflow commits is always releasable. +# +# Triggers: +# - repository_dispatch `product-published`: sent by the product repos' +# publish workflows the moment they ship. +# - schedule (daily): the backstop for a missed dispatch — the same +# comparison against the registry, so a lost event delays an update +# by at most a day. +# - workflow_dispatch: run it by hand. +# +# The pull request is opened with DEPLOY_GITHUB_TOKEN, not GITHUB_TOKEN: +# pull requests created with the workflow token never trigger CI, and +# without CI the required checks never report, so auto-merge would wait +# forever. See docs/oss/release-automation.md for what the token is and +# how to replace it. + +on: + repository_dispatch: + types: [product-published] + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +concurrency: + group: update-product-versions + cancel-in-progress: false + +jobs: + update: + name: Compare versions against the registry + runs-on: ubuntu-latest + # Read-only: every write here goes through DEPLOY_GITHUB_TOKEN (the + # branch push, the pull request, auto-merge), and the checkout keeps + # no credentials, so the workflow token needs nothing but the + # checkout itself. Granting it write access would hand a + # write-capable token to every action in the job for no reason. + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .node-version + + - name: Compare versions against the registry + id: compare + run: node scripts/update-product-versions.mjs --channel release + + # `pnpm install --no-frozen-lockfile` scoped to the lockfile + # refresh: the edit touches two manifests, and the pull request must + # carry the matching lockfile or every CI job fails on + # --frozen-lockfile. + - name: Refresh the lockfile + if: ${{ steps.compare.outputs.changed == 'true' }} + run: | + corepack enable + pnpm install --lockfile-only --no-frozen-lockfile + + - name: Open the pull request with auto-merge + if: ${{ steps.compare.outputs.changed == 'true' }} + env: + GH_TOKEN: ${{ secrets.DEPLOY_GITHUB_TOKEN }} + SUMMARY: ${{ steps.compare.outputs.summary }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::error::DEPLOY_GITHUB_TOKEN is not configured, so this workflow cannot open a pull request that triggers CI. See docs/oss/release-automation.md." + exit 1 + fi + # An open pull request from an earlier run already carries these + # versions or is about to be superseded by this one. Close it + # rather than stacking a second: two open version updates race + # each other's auto-merge and one lands on a stale lockfile. + gh pr list --state open --json number,headRefName \ + --jq '.[] | select(.headRefName | startswith("product-versions/")) | .number' \ + | while read -r number; do + [ -n "$number" ] || continue + gh pr close "$number" \ + --comment "Superseded by a newer product version update." \ + --delete-branch + done + BRANCH="product-versions/$(date -u +%Y%m%d%H%M%S)" + git config user.name "willbot" + git config user.email "w.a.madden+machine@gmail.com" + git checkout -b "$BRANCH" + git add packages/cli/package.json packages/prisma/package.json pnpm-lock.yaml + git commit -s --trailer "Signed-off-by: Will Madden " \ + -m "Follow the products' released versions: $SUMMARY" \ + -m "Opened automatically (update-product-versions.yml); merging ships a dev version of the CLI. A real release stays a human act." + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + PR_URL=$(gh pr create \ + --title "Follow the products' released versions: $SUMMARY" \ + --body "Opened automatically by update-product-versions.yml, following the products' release dist-tags ($SUMMARY). Merging publishes a \`dev\` version of the CLI; the conformance checks on this pull request are what stand between a product release and that dev build." \ + --base main --head "$BRANCH") + gh pr merge --auto --squash "$PR_URL" + echo "Opened $PR_URL with auto-merge armed." diff --git a/docs/architecture/adrs/0004-engine-version-pinning.md b/docs/architecture/adrs/0004-engine-version-pinning.md index 84879eec..521ef65a 100644 --- a/docs/architecture/adrs/0004-engine-version-pinning.md +++ b/docs/architecture/adrs/0004-engine-version-pinning.md @@ -37,7 +37,7 @@ Per edge: **Exact now, range later.** During the rc line the engine breaks its consumers deliberately, slice by slice, so a version range would be fiction. Post-GA, the recorded destination is widening the peers to a range under a written engine compatibility contract, so a non-breaking engine release ships in the next shell release with zero family republishes. Widening is a deliberate future decision against that contract, not a drift. -**Engine versioning — RULED (operator, 2026-08-13): the engine versions independently.** Under the shell lockstep it previously shared, every CLI release manufactured a new engine version — and with it a family-repin obligation — whether or not the engine changed. Decoupled, an engine version means "the engine changed", the exact peers stay valid between real engine changes, and the repin train runs only when there is something to repin for. Mechanically: the engine is excluded from `set-version.ts`'s lockstep (alongside `@prisma/compute`), follows honest pre-1.0 semver (breaking bumps the minor), and publishes at its own manifest version, with an already-published version treated as a no-op by the publish workflow. +**Engine versioning — RULED (operator, 2026-08-13): the engine versions independently.** Under the shell lockstep it previously shared, every CLI release manufactured a new engine version — and obliged every product to publish a new peer version — whether or not the engine changed. Decoupled, an engine version means "the engine changed", the exact peers stay valid between real engine changes, and the products only have to move when there is a real change to move to. Mechanically: the engine is excluded from `set-version.ts`'s lockstep (alongside `@prisma/compute`), follows honest pre-1.0 semver (breaking bumps the minor), and publishes at its own manifest version, with an already-published version treated as a no-op by the publish workflow. ## Enforcement diff --git a/docs/oss/release-automation.md b/docs/oss/release-automation.md new file mode 100644 index 00000000..cb3a7812 --- /dev/null +++ b/docs/oss/release-automation.md @@ -0,0 +1,86 @@ +# Release automation across the three repositories + +The unified CLI is assembled from three repositories. This page explains how a new product version reaches the CLI without anyone editing a file by hand, what the one shared secret is for, and how to repair the machinery when it stops. + +| Repository | Publishes | Consumed by the CLI as | +| --- | --- | --- | +| `prisma/prisma-cli` (this one) | `@prisma/cli-engine`, `@prisma/cli`, `prisma` | — | +| `prisma/composer` | `@prisma/composer-cli` (and the `@prisma/composer` library) | a dependency of the shell | +| `prisma/prisma` | `@prisma/orm-toolchain` | a dependency of the shell | + +## The rule + +A **release** of the CLI depends only on **released** versions of the products. A **dev build** of the CLI depends on the products' **dev builds** (operator ruling 2026-08-17). Nothing in between, and nothing hand-maintained. + +## What happens when a product publishes + +1. The product repository's publish workflow finishes and sends a `repository_dispatch` event of type `product-published` to `prisma/prisma-cli`. +2. [`update-product-versions.yml`](../../.github/workflows/update-product-versions.yml) runs [`scripts/update-product-versions.mjs`](../../scripts/update-product-versions.mjs), which asks the registry for each watched package's version at its **release** dist-tag and rewrites `packages/cli/package.json` and `packages/prisma/package.json` where they differ. Every watched package publishes its releases under `latest`, pre-release versions included: they are all new packages with no pre-8 audience to protect. Holding `latest` back behind `next` matters only for the bare `prisma` name, which this repository publishes and which still serves Prisma 7 there until the deliberate cutover. Each package carries a list of candidate tags rather than one name, most-preferred first, so a product moving where it publishes does not silently freeze the CLI on an old version — and a package that publishes none of its candidates fails the run instead of looking like "nothing to update". +3. If anything changed, the workflow refreshes `pnpm-lock.yaml`, opens a pull request, and arms auto-merge. Any earlier open pull request from this workflow is closed first, so two of them cannot race each other onto a stale lockfile. +4. The pull request runs the full quality and conformance checks. When they pass, it merges, and that push to `main` publishes a `dev` build of the CLI carrying the products' **dev** versions (the dev stamp in [`publish.yml`](../../.github/workflows/publish.yml) rewrites them for that run only). +5. A real CLI release stays a human act: a maintainer cuts the `rc.N` bump, and that release ships the committed release versions. + +A **daily schedule** runs the same comparison, so a lost dispatch event delays an update by at most a day rather than indefinitely. `workflow_dispatch` runs it on demand. + +## The shared secret: `DEPLOY_GITHUB_TOKEN` + +One fine-grained personal access token, stored under the same secret name in all three repositories. + +**Why it exists.** A pull request created with the built-in `GITHUB_TOKEN` does not trigger workflows. Without CI, the required checks never report, so auto-merge would wait forever and the automation would silently do nothing. The token must therefore be a real account's, not the workflow's. + +**Why it belongs to a service account, not a person.** It pushes branches and merges pull requests on a schedule, unattended. Tying that to an individual's account means the automation stops when they rotate a token or leave, and every automated commit is attributed to them. + +**What it needs.** Scoped to `prisma/prisma-cli` only: + +| Repository permission | Access | +| --- | --- | +| Contents | Read and write | +| Pull requests | Read and write | +| Metadata | Read-only (GitHub requires this alongside any other permission) | + +Only `prisma/prisma-cli` needs to be listed even though the secret lives in three repositories. In this repository the token pushes the branch and opens the pull request. In composer and prisma/prisma it only sends the `repository_dispatch` event *to* this repository, and that endpoint needs Contents write **on the target repository** — the same permission on the same one repository. + +Deliberately **not** granted: the Workflows permission. The pull request only ever touches the two manifests and the lockfile, so a change under `.github/workflows/` would be rejected — a useful limit on an unattended token. + +**When it expires** the workflow fails: an absent secret is reported as an error before anything else runs, and an expired one fails the first `git push` or `gh` call. The catch is *where* it fails — a scheduled run nobody is watching — so the visible symptom is stale versions rather than a red pull request, and the daily backstop cannot recover on its own until the token is replaced. Record the renewal date somewhere you will see it. To replace it, generate a new token with the table above and update the secret in all three repositories; nothing in any repository names the account, so no code changes. + +## What each product repository must add + +A step in the publish workflow, immediately after its publish step and keyed on that step's outcome, so a failed publish never announces itself: + +```yaml +- name: Notify prisma-cli + if: ${{ steps.publish.outcome == 'success' }} + env: + GH_TOKEN: ${{ secrets.DEPLOY_GITHUB_TOKEN }} + run: | + gh api repos/prisma/prisma-cli/dispatches \ + -f event_type=product-published \ + -F 'client_payload[package]=@prisma/composer-cli' \ + -F "client_payload[version]=$VERSION" +``` + +The payload is informational — this repository always re-reads the registry rather than trusting the event, so a malformed or replayed event cannot pin a version that does not exist. + +## When it stops working + +| Symptom | Cause | Fix | +| --- | --- | --- | +| No pull request after a product release | Dispatch event not sent, or the token is missing or expired | The daily run will catch it up; check the product repository's notify step and the secret | +| The workflow fails with "DEPLOY_GITHUB_TOKEN is not configured" | The secret is absent from this repository | Add it (see above) | +| A pull request opens but never merges | Required checks failing | Read the checks — this is the automation working; a product release broke something | +| Two open version-update pull requests | The close-the-previous step failed | Close the older one by hand; they race each other's lockfile | +| A release publish fails on the dev-build check | The committed pins are dev builds — a dev stamp was committed by mistake, or a product has no usable release | Run `node scripts/update-product-versions.mjs --channel release`; if that changes nothing, the product must publish a real release | + +## The state this replaced + +`prisma@8.0.0-rc.3` shipped depending on `@prisma/composer@0.6.0-dev.16` and `@prisma/orm-toolchain@8.0.0-rc.1-dev.40`. Both were interim pins from earlier work that nothing moved off and no check refused. + +When the dev-build check first ran it refused the release channel, because neither product had a usable released version: `@prisma/composer-cli@0.6.0` could not be installed at all (published with `npm publish` rather than `pnpm publish`, so its dependency on `@prisma/composer` was the literal string `workspace:0.6.0` and `npm install` answered `EUNSUPPORTEDPROTOCOL`), and `@prisma/orm-toolchain@8.0.0-rc.1` had no engine relationship and no command family in its `./cli` export. + +Both were fixed the same day: composer published `0.7.0` and prisma/prisma published `8.0.0-rc.2`, each declaring `@prisma/cli-engine@0.1.1` as an exact peer. The release channel has reported nothing since, and the conformance checks carry no recorded exceptions. + +Two lessons worth keeping: + +- **Publish through the workflow.** The uninstallable `0.6.0` was published out-of-band with `npm publish`, which does not rewrite `workspace:` specifiers. The same batch's `@prisma/composer-prisma-cloud@0.6.0`, published by the workflow, was correct. +- **A repository should install its own tarball before trusting it.** Nothing in composer's repository did, which is why an uninstallable package sat on `latest`. Check 3 here does exactly that, in a clean sandbox with `npm --ignore-scripts`, and is worth porting to both product repositories. diff --git a/docs/oss/versioning.md b/docs/oss/versioning.md index 22b988d0..2f7f8cb0 100644 --- a/docs/oss/versioning.md +++ b/docs/oss/versioning.md @@ -8,7 +8,9 @@ Prisma 8 ships as a release-candidate line ahead of `8.0.0` final: releases are RC respins may include breaking changes until `8.0.0` final ships. There are no patch releases on the RC line — a fix ships as the next `rc.N`. -For the packages this repository's publish workflow ships (`@prisma/cli`, `@prisma/cli-engine`), **each release publishes under its line's canonical dist-tag**: RC-line versions under `next`, stable versions under `latest` (operator ruling 2026-08-12; supersedes the earlier "`latest` tracks the newest release, RC or stable"). A dist-tag moves only through a deliberately merged version-bump PR (or a manual `workflow_dispatch`); creating and merging the bump PR is the operator's explicit act. `latest` stays on the pre-8 CLI until the operator moves it deliberately. Existing installs are unaffected — lockfiles pin resolved versions, and nobody lands on the RC line without asking for `@next`. +For the packages this repository's publish workflow ships (`prisma`, `@prisma/cli`, `@prisma/cli-engine`), **each release publishes under its line's canonical dist-tag**: RC-line versions under `next`, stable versions under `latest` (operator ruling 2026-08-12; supersedes the earlier "`latest` tracks the newest release, RC or stable"). A dist-tag moves only through a deliberately merged version-bump PR (or a manual `workflow_dispatch`); creating and merging the bump PR is the operator's explicit act. `latest` stays on the pre-8 CLI until the operator moves it deliberately. Existing installs are unaffected — lockfiles pin resolved versions, and nobody lands on the RC line without asking for `@next`. + +**The bare `prisma` name is what this discipline is for.** Its `latest` serves Prisma 7 (`7.9.1`) to everyone who types `npm install prisma`, so the v8 line lives on `next` until a deliberate cutover. The products' packages are all new — nobody is installing `@prisma/composer-cli` or `@prisma/orm-toolchain` expecting a version 7 — so they publish releases straight to `latest`, pre-release versions included, and this repository follows `latest` for them (operator ruling 2026-08-17). 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). @@ -16,12 +18,23 @@ The transition onto the RC line is a one-time bump from the pre-8 base to `8.0.0 Every lockstep workspace package — publishable, private, and the workspace root — carries the same `version`. One read of root [`package.json`](../../package.json) answers "what version is this code?" for the repository. -**Exceptions:** `@prisma/compute` versions independently, pending extraction to another repository (operator ruling 2026-08-10), and keeps its own publish workflow ([`publish-compute.yml`](../../.github/workflows/publish-compute.yml)). `@prisma/cli-engine` also versions independently ([ADR 0004](../architecture/adrs/0004-engine-version-pinning.md), operator ruling 2026-08-13): an engine version means "the engine changed", not "the CLI released", which is what keeps the exact peer pins the product CLI packages hold on it cheap — they repin only when the engine actually moves. The engine follows honest pre-1.0 semver (a breaking change bumps the minor); bumping it is one command — `pnpm bump-cli-engine-version ` — which edits `packages/cli-engine/package.json`, the shell's `workspace:` pin, and the lockfile together, landed as a reviewed commit like any other version change (run it in the PR that changes the engine). Both packages are hard-excluded in [`scripts/set-version.ts`](../../scripts/set-version.ts), which still sweeps their `workspace:` pins on lockstep siblings so those never go stale. At publish time the engine ships at its own manifest version; an already-published engine version is a no-op. The engine's own line continues from `0.1.0` (after the published `0.0.x` series); the `8.0.0-rc.N` engine versions that shipped while it was still in lockstep are burned values — they exist on the registry, nothing pins them, and version numbers are never reused. +**Exceptions:** `@prisma/compute` versions independently, pending extraction to another repository (operator ruling 2026-08-10), and keeps its own publish workflow ([`publish-compute.yml`](../../.github/workflows/publish-compute.yml)). `@prisma/cli-engine` also versions independently ([ADR 0004](../architecture/adrs/0004-engine-version-pinning.md), operator ruling 2026-08-13): an engine version means "the engine changed", not "the CLI released", which is what keeps the exact peer pins the product CLI packages hold on it cheap — they change only when the engine actually moves. The engine follows honest pre-1.0 semver (a breaking change bumps the minor); bumping it is one command — `pnpm bump-cli-engine-version ` — which edits `packages/cli-engine/package.json`, the shell's `workspace:` pin, and the lockfile together, landed as a reviewed commit like any other version change (run it in the PR that changes the engine). Both packages are hard-excluded in [`scripts/set-version.ts`](../../scripts/set-version.ts), which still sweeps their `workspace:` pins on lockstep siblings so those never go stale. At publish time the engine ships at its own manifest version; an already-published engine version is a no-op. The engine's own line continues from `0.1.0` (after the published `0.0.x` series); the `8.0.0-rc.N` engine versions that shipped while it was still in lockstep are burned values — they exist on the registry, nothing pins them, and version numbers are never reused. The lockstep set is: the workspace root, `packages/cli`, `packages/cli-telemetry`, `packages/cli-conformance`, and `packages/tsconfig`. Private packages are never published (`pnpm publish` skips them), but they still version in lockstep so a contributor cloning the repo at any commit sees one consistent answer to "what version is this code?". Workspace-internal dependencies are pinned as `workspace:` (e.g. `workspace:8.0.0-rc.1`); pnpm resolves them locally during development and rewrites them to the exact version at publish time, so every published package carries an exact-version pin on its siblings. How the packages published by *other* repositories relate to the engine's version — the product CLI packages the shell mounts, and the product libraries applications install — is governed by [ADR 0004](../architecture/adrs/0004-engine-version-pinning.md): product CLI packages declare `@prisma/cli-engine` as an exact peer dependency the shell satisfies, and product libraries carry no engine relationship at all. +## Which product versions a CLI ships + +**A release of the CLI depends only on released versions of the products; a dev build of the CLI depends on their dev builds** (operator ruling 2026-08-17). The two channels are kept apart by two mechanisms: + +- The committed manifests hold the products' **released** versions, and [`update-product-versions.yml`](../../.github/workflows/update-product-versions.yml) keeps them current — so `main` always points at released versions, and every version change is a reviewed pull request that CI has tested. A release publish uses those pins exactly as committed, which is what keeps a release reproducible from its tag. Pointing at released versions is not the same as being ready to release: a released version can still be unusable, and what proves the difference is the conformance checks passing on the release channel. +- A **dev** publish rewrites them to each product's `dev` dist-tag in CI, ephemerally, exactly as it rewrites the CLI's own version. The build and the conformance checks run after that rewrite, in the same run, so a broken product dev build fails the dev publish instead of shipping inside it. + +This is enforced, not remembered: the conformance checks refuse to publish a release whose dependencies include any `-dev.` version. `prisma@8.0.0-rc.3` shipped two of them — `@prisma/composer@0.6.0-dev.16` and `@prisma/orm-toolchain@8.0.0-rc.1-dev.40` — because nothing looked. + +**Reached 2026-08-17:** the committed manifests hold `@prisma/composer-cli@0.7.0` and `@prisma/orm-toolchain@8.0.0-rc.2`, both released, both declaring `@prisma/cli-engine@0.1.1` as an exact peer. The release channel's conformance run reports nothing — no dev dependencies, one engine in the installed tree, and no recorded exceptions left. Before this the manifests held dev builds and `prisma@8.0.0-rc.3` shipped them. + ## Dist-tag convention The npm registry exposes the CLI packages under these dist-tags: @@ -30,7 +43,7 @@ The npm registry exposes the CLI packages under these dist-tags: - **`next`** — the Prisma 8 RC line (`8.0.0-rc.N`). A merged release PR on the RC line publishes here automatically. - **`beta`** — reserved for hand-cut previews ahead of significant changes, published by dispatching the workflow with that dist-tag. Routine releases do not use this tag. -- **`dev`** — every routine push to `main` publishes `-dev.` here automatically (operator ruling 2026-08-13, superseding the earlier "no dev channel" ruling). The suffix derives from the workflow run number and is stamped ephemerally in CI, never committed, so release versions remain exactly what a commit says. The channel exists so automated family repins ([`auto-repin.yml`](../../.github/workflows/auto-repin.yml)) deploy without a human: composer's and prisma/prisma's publish workflows dispatch to this repo, an auto-merge repin PR runs the full quality and conformance checks, and its merge ships the dev build. Only a real release — an `rc.N` bump under `next`, or moving `latest` — is a human act. +- **`dev`** — every routine push to `main` publishes `-dev.` here automatically (operator ruling 2026-08-13, superseding the earlier "no dev channel" ruling). The suffix derives from the workflow run number and is stamped ephemerally in CI, never committed, so release versions remain exactly what a commit says. The channel exists so a product's new version reaches a working CLI without a human: an auto-merging pull request moves the version, runs the full quality and conformance checks, and its merge ships the dev build. Today a daily scheduled run is what notices a product release; the immediate path needs a notification step in each product repository, which neither has yet. See [release automation](./release-automation.md). Only a real release — an `rc.N` bump under `next`, or moving `latest` — is a human act. PR previews go through [`pkg.pr.new`](https://pkg.pr.new) ([`preview-cli-package.yml`](../../.github/workflows/preview-cli-package.yml)); they carry the committed base version and install via per-commit URLs, not dist-tags. @@ -53,11 +66,11 @@ This is by design. The alternatives cause silent problems: [`scripts/set-version.ts`](../../scripts/set-version.ts) is what enforces lockstep: a single invocation walks every lockstep workspace `package.json` and writes the requested version (rewriting `workspace:` dependency pins to match). It is a maintainer's tool, invoked through `pnpm bump-version`; the publish workflow does not run it. -The publish workflow is **triggered by a change to the root `version`**: a push to `main` whose root `package.json` carries a different `version` than the previous tip is recognised as a release bump and ships that version under its canonical dist-tag — `next` on the RC line (the accompanying GitHub Release is marked pre-release), `latest` for stable. This is what makes "merge the release PR" the publish trigger; there is no separate dispatch step. A push that leaves the version alone publishes nothing. Within a publish, `@prisma/cli-engine` goes first, then `@prisma/cli` (which depends on it). +The publish workflow is **triggered by a change to the root `version`**: a push to `main` whose root `package.json` carries a different `version` than the previous tip is recognised as a release bump and ships that version under its canonical dist-tag — `next` on the RC line (the accompanying GitHub Release is marked pre-release), `latest` for stable. This is what makes "merge the release PR" the publish trigger; there is no separate dispatch step. A push that leaves the version alone publishes a `dev` build instead of a release (operator ruling 2026-08-13; before that ruling it published nothing). Within a publish, `@prisma/cli-engine` goes first, then `@prisma/cli` (which depends on it), then `prisma`. **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 conformance checks (`pnpm check:conformance`, [`packages/cli/scripts/conformance.ts`](../../packages/cli/scripts/conformance.ts)) prove the built output imports only declared dependencies, every mounted config-section validator survives hostile input, and the packed tarballs survive a registry consumer's install — a clean sandbox, npm with `--ignore-scripts`, unpublished workspace siblings resolved through computed `file:` overrides, every declared bin started on plain Node, and the `@prisma/cli-engine` pins agreeing between the shell and every mounted family. 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 checks verified. The dry-run dispatch runs all of this without registry writes. +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 conformance checks (`pnpm check:conformance`, [`packages/cli/scripts/conformance.ts`](../../packages/cli/scripts/conformance.ts)) prove the built output imports only declared dependencies, every mounted config-section validator survives hostile input, and the packed tarballs survive a registry consumer's install — a clean sandbox, npm with `--ignore-scripts`, unpublished workspace siblings resolved through computed `file:` overrides, every declared bin started on plain Node, the `@prisma/cli-engine` pins agreeing between the shell and every mounted family, and — on a release — no dependency being a dev build. 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 checks 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. diff --git a/package.json b/package.json index 66be8b8b..2c62eab1 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "lint:fix": "biome check . --write", "bump-version": "node scripts/bump-version.ts", "test": "turbo run test", - "test:scripts": "node --test scripts/determine-version-utils.test.ts scripts/set-version-utils.test.ts scripts/bump-cli-engine-version-utils.test.ts scripts/resolve-package-version.test.mjs scripts/auto-repin.test.mjs", + "test:scripts": "node --test scripts/determine-version-utils.test.ts scripts/set-version-utils.test.ts scripts/bump-cli-engine-version-utils.test.ts scripts/resolve-package-version.test.mjs scripts/update-product-versions.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-conformance/package.json b/packages/cli-conformance/package.json index d1a2d7ac..5aa95fee 100644 --- a/packages/cli-conformance/package.json +++ b/packages/cli-conformance/package.json @@ -12,6 +12,7 @@ "./findings": "./src/findings.ts", "./package.json": "./package.json", "./tarball": "./src/checks/tarball.ts", + "./release-pins": "./src/checks/release-pins.ts", "./tarball-io": "./src/tarball-io.ts" }, "engines": { diff --git a/packages/cli-conformance/src/checks/release-pins.ts b/packages/cli-conformance/src/checks/release-pins.ts new file mode 100644 index 00000000..bc32b3ef --- /dev/null +++ b/packages/cli-conformance/src/checks/release-pins.ts @@ -0,0 +1,84 @@ +import type { Finding } from "../findings"; +import type { PackageManifest } from "./import-purity"; + +export type PublishChannel = "release" | "dev"; + +/** Only what this check reads, so `tarball.ts` can call it without a cycle. */ +export interface NamedManifest extends PackageManifest { + readonly name?: string; +} + +export interface ReleasePinsInput { + /** Manifests as a registry would receive them, already packed. */ + readonly manifests: readonly NamedManifest[]; + readonly channel: PublishChannel; +} + +/** + * Everything after the patch number in a version or a range: + * `1.2.3-dev.4` and `>=1.0.0-dev.1 <1.1.0` both yield `dev.4`-shaped + * text to inspect. + */ +const PRE_RELEASE = /\d+\.\d+\.\d+-([0-9A-Za-z.-]+)/g; + +/** + * `dev` must stand alone between separators: the dev channel stamps + * `-dev.` onto a base version, so an RC line reads + * `8.0.0-rc.1-dev.40` — hyphens separate as much as dots do. Splitting + * on both keeps `1.0.0-development.1` a release. + */ +const SEPARATORS = /[.-]/; + +/** The fields a consumer's install resolves. devDependencies are not. */ +const INSTALLED_FIELDS = [ + "dependencies", + "optionalDependencies", + "peerDependencies", +] as const; + +function isDevBuild(specifier: string): boolean { + for (const match of specifier.matchAll(PRE_RELEASE)) { + if ((match[1] ?? "").split(SEPARATORS).includes("dev")) return true; + } + return false; +} + +/** + * Check 4: a release ships no dev builds. The dev channel exists so the + * CLI can track the products' newest code, and a dev CLI depending on + * dev families is the point of it; a release depending on them is a + * version nobody can reproduce and nobody reviewed (operator ruling + * 2026-08-17). There is no exception mechanism — a suppressed finding + * exits 0, which is how `prisma@8.0.0-rc.3` shipped two of them. + */ +export function checkReleasePins(input: ReleasePinsInput): readonly Finding[] { + if (input.manifests.length === 0) { + return [ + { + kind: "no-subjects", + check: "release-pins", + subject: "(none)", + summary: "no manifests were supplied, so no pin was measured", + }, + ]; + } + if (input.channel === "dev") return []; + + const findings: Finding[] = []; + for (const manifest of input.manifests) { + const owner = manifest.name ?? "(unnamed package)"; + for (const field of INSTALLED_FIELDS) { + for (const [name, specifier] of Object.entries(manifest[field] ?? {})) { + if (!isDevBuild(specifier)) continue; + findings.push({ + kind: "dev-build-in-release", + check: "release-pins", + subject: name, + summary: `${owner} publishes a release that depends on ${name}@${specifier}, a dev build — a release depends only on released versions`, + where: { path: `${field}.${name}` }, + }); + } + } + } + return findings; +} diff --git a/packages/cli-conformance/src/checks/tarball.ts b/packages/cli-conformance/src/checks/tarball.ts index c5cc29bf..0a81f606 100644 --- a/packages/cli-conformance/src/checks/tarball.ts +++ b/packages/cli-conformance/src/checks/tarball.ts @@ -1,6 +1,7 @@ import type { Finding, Suppression } from "../findings"; import { bareImportRoots } from "../module-graph"; import { checkImportPurity, type PackageManifest } from "./import-purity"; +import { checkReleasePins, type PublishChannel } from "./release-pins"; /** A packed manifest also names its bins. */ export interface PackedManifest extends PackageManifest { @@ -86,6 +87,11 @@ export interface TarballInput { readonly exceptions: readonly PinException[]; readonly sandboxDir: string; readonly binTimeoutMs?: number; + /** + * Which channel these tarballs are for. Check 4 measures the packed + * manifests against it; a dev publish is allowed its dev builds. + */ + readonly channel: PublishChannel; } const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; @@ -95,7 +101,8 @@ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; * imports only what the packed manifest declares. 3b — the root tarball * installs into a clean tree and its bins start. 3c — the shell and * every family it mounts agree about the engine, measured on manifests - * and again as copies in the installed tree. + * and again as copies in the installed tree. Check 4 rides along on the + * same packed manifests: a release depends on no dev build. */ export async function checkTarball( input: TarballInput, @@ -128,6 +135,13 @@ export async function checkTarball( ); } + findings.push( + ...checkReleasePins({ + manifests: [...packed.values()].map((entry) => entry.manifest), + channel: input.channel, + }), + ); + const shell = packed.get(input.shellPackage); if (shell === undefined) return findings; @@ -268,6 +282,51 @@ async function binFindings( return findings; } +/** 3c for one mounted family, once its installed manifest is in hand. */ +function familyPinFindings( + input: TarballInput, + family: string, + declared: string | undefined, + installed: PackedManifest, + shellPin: string | undefined, +): readonly Finding[] { + const findings: Finding[] = []; + if (declared !== undefined && installed.version !== declared) { + findings.push( + finding( + "engine-pin-mismatch", + family, + `the shell declares ${family}@${declared} but ${installed.version ?? "an unknown version"} is installed`, + ), + ); + } + // A family declares the engine as an exact peer (ADR 0004); the older + // shape, a regular dependency, is read too so a family that has not + // moved yet is still measured rather than skipped. + const peer = installed.peerDependencies?.[input.enginePackage]; + const familyPin = peer ?? installed.dependencies?.[input.enginePackage]; + if ( + familyPin === undefined || + shellPin === undefined || + familyPin === shellPin + ) { + return findings; + } + const verb = peer === undefined ? "pins" : "peers"; + const consequence = + peer === undefined + ? "an install resolves two engines" + : "the family did not build against the engine it will be given"; + findings.push( + finding( + "engine-pin-mismatch", + family, + `${family} ${verb} ${input.enginePackage}@${familyPin} while the shell ships ${shellPin} — ${consequence}`, + ), + ); + return findings; +} + async function installedPinFindings( input: TarballInput, shellManifest: PackedManifest, @@ -277,33 +336,18 @@ async function installedPinFindings( const shellPin = shellManifest.dependencies?.[input.enginePackage]; for (const family of input.familyPackages) { - const declared = shellManifest.dependencies?.[family]; - // biome-ignore lint/performance/noAwaitInLoops: one manifest read per mounted family — one today — keeps findings ordered with the family list + // biome-ignore lint/performance/noAwaitInLoops: one manifest read per mounted family — two today — keeps findings ordered with the family list const installed = await io.readInstalledManifest(input.sandboxDir, family); if (installed === undefined) continue; - if (declared !== undefined && installed.version !== declared) { - findings.push( - finding( - "engine-pin-mismatch", - family, - `the shell declares ${family}@${declared} but ${installed.version ?? "an unknown version"} is installed`, - ), - ); - } - const familyPin = installed.dependencies?.[input.enginePackage]; - if ( - familyPin !== undefined && - shellPin !== undefined && - familyPin !== shellPin - ) { - findings.push( - finding( - "engine-pin-mismatch", - family, - `${family} pins ${input.enginePackage}@${familyPin} while the shell ships ${shellPin} — an install resolves two engines`, - ), - ); - } + findings.push( + ...familyPinFindings( + input, + family, + shellManifest.dependencies?.[family], + installed, + shellPin, + ), + ); } const copies = await io.listInstalledCopies( diff --git a/packages/cli-conformance/src/findings.ts b/packages/cli-conformance/src/findings.ts index d0f16096..7e5b0745 100644 --- a/packages/cli-conformance/src/findings.ts +++ b/packages/cli-conformance/src/findings.ts @@ -4,7 +4,11 @@ * one-line summary, and bulky evidence kept separate from it. */ -export type CheckName = "import-purity" | "validator-no-throw" | "tarball"; +export type CheckName = + | "import-purity" + | "validator-no-throw" + | "tarball" + | "release-pins"; export type FindingKind = /** Built output imports a package the manifest does not declare. */ @@ -25,7 +29,9 @@ export type FindingKind = | "install-failed" | "bin-failed" /** The shell and a family it mounts disagree about the engine version. */ - | "engine-pin-mismatch"; + | "engine-pin-mismatch" + /** A release depends on a dev build. */ + | "dev-build-in-release"; /** * A recorded reason a finding does not fail the run. Suppressed findings diff --git a/packages/cli-conformance/tests/release-pins.test.ts b/packages/cli-conformance/tests/release-pins.test.ts new file mode 100644 index 00000000..9f845a98 --- /dev/null +++ b/packages/cli-conformance/tests/release-pins.test.ts @@ -0,0 +1,126 @@ +/** + * Check 4. Plain values in, findings out — the manifests arrive from + * whoever packed them, so this suite touches nothing. + */ +import { describe, expect, test } from "vitest"; +import { checkReleasePins } from "../src/checks/release-pins"; + +const SHELL = { + name: "@prisma/cli", + version: "8.0.0-rc.4", + dependencies: { + "@prisma/cli-engine": "0.1.1", + "@prisma/composer-cli": "0.6.0-dev.22", + "@prisma/orm-toolchain": "8.0.0-rc.1-dev.40", + dotenv: "^17.4.2", + }, + devDependencies: { "@repo/tsconfig": "8.0.0-rc.4-dev.7" }, +}; + +describe("checkReleasePins", () => { + test("a release that depends on dev builds is reported, one finding per dependency", () => { + const findings = checkReleasePins({ + manifests: [SHELL], + channel: "release", + }); + expect(findings.map((f) => f.subject)).toEqual([ + "@prisma/composer-cli", + "@prisma/orm-toolchain", + ]); + expect(findings.every((f) => f.kind === "dev-build-in-release")).toBe(true); + expect(findings[0]?.summary).toContain("0.6.0-dev.22"); + expect(findings[0]?.summary).toContain("@prisma/cli"); + }); + + /** + * No exception mechanism, deliberately: a suppressed finding exits 0, + * and letting a release ship dev builds on a recorded excuse is the + * hole this check exists to close. + */ + test("nothing suppresses a dev build in a release", () => { + const findings = checkReleasePins({ + manifests: [SHELL], + channel: "release", + }); + expect(findings.every((f) => f.suppressedBy === undefined)).toBe(true); + }); + + test("a dev publish is allowed to depend on dev builds — that is the channel", () => { + expect(checkReleasePins({ manifests: [SHELL], channel: "dev" })).toEqual( + [], + ); + }); + + test("a release line that is itself a pre-release is not a dev build", () => { + expect( + checkReleasePins({ + manifests: [ + { + name: "@prisma/cli", + dependencies: { + "@prisma/orm-toolchain": "8.0.0-rc.1", + "@prisma/composer-cli": "0.6.1", + }, + }, + ], + channel: "release", + }), + ).toEqual([]); + }); + + test("`dev` must stand alone, so a version that merely contains the letters is a release", () => { + expect( + checkReleasePins({ + manifests: [ + { + name: "@prisma/cli", + dependencies: { + deviant: "1.0.0-development.1", + "some-pkg": "2.0.0-predev", + }, + }, + ], + channel: "release", + }), + ).toEqual([]); + }); + + test("every dependency field a consumer's install resolves is measured, and devDependencies are not", () => { + const findings = checkReleasePins({ + manifests: [ + { + name: "@prisma/cli", + dependencies: { a: "1.0.0-dev.1" }, + optionalDependencies: { b: "1.0.0-dev.2" }, + peerDependencies: { c: "1.0.0-dev.3" }, + devDependencies: { d: "1.0.0-dev.4" }, + }, + ], + channel: "release", + }); + expect(findings.map((f) => f.subject)).toEqual(["a", "b", "c"]); + }); + + test("a range that admits only dev builds is reported too", () => { + const findings = checkReleasePins({ + manifests: [ + { name: "@prisma/cli", dependencies: { a: ">=1.0.0-dev.1 <1.1.0" } }, + ], + channel: "release", + }); + expect(findings.map((f) => f.subject)).toEqual(["a"]); + }); + + test("no manifests means nothing was proved, not that everything passed", () => { + const findings = checkReleasePins({ manifests: [], channel: "release" }); + expect(findings.map((f) => f.kind)).toEqual(["no-subjects"]); + }); + + test("every finding names the check, so a mixed report stays readable", () => { + const findings = checkReleasePins({ + manifests: [SHELL], + channel: "release", + }); + expect(findings.every((f) => f.check === "release-pins")).toBe(true); + }); +}); diff --git a/packages/cli-conformance/tests/tarball.test.ts b/packages/cli-conformance/tests/tarball.test.ts index ef988bc0..127b1521 100644 --- a/packages/cli-conformance/tests/tarball.test.ts +++ b/packages/cli-conformance/tests/tarball.test.ts @@ -98,6 +98,9 @@ function input(overrides: Partial = {}): TarballInput { familyPackages: ["@prisma/composer"], exceptions: [], sandboxDir: "sandbox", + // Check 4 has its own suite; the fixtures here carry dev pins, so + // these tests declare the channel that tolerates them. + channel: "dev", ...overrides, }; } @@ -407,6 +410,85 @@ describe("checkTarball", () => { expect(kinds(findings)).toEqual(["no-subjects"]); }); + /** + * Check 4 measures the packed manifests, not the workspace ones: what + * ships is what is measured. No exception covers it, so the release + * fails. + */ + test("on the release channel a dev build in a packed manifest fails, uncovered by any exception", async () => { + const findings = await checkTarball( + input({ + channel: "release", + exceptions: [ + { + familyPackage: "@prisma/composer", + familyPin: "0.0.9", + shellPin: "8.0.0-rc.1", + reason: "covers the engine pin, and must not reach check 4", + removeWhen: "the families publish against the shipped engine", + }, + ], + }), + fakeIo(), + ); + const dev = findings.filter((f) => f.kind === "dev-build-in-release"); + expect(dev).toHaveLength(1); + expect(dev[0]?.subject).toBe("@prisma/composer"); + expect(dev[0]?.suppressedBy).toBeUndefined(); + }); + + test("a family that declares the engine as a peer is measured, not skipped", async () => { + const io = fakeIo({ + readInstalledManifest: (_s, name) => + Promise.resolve( + name === "@prisma/composer" + ? { + version: "0.6.0-dev.16", + peerDependencies: { "@prisma/cli-engine": "0.0.9" }, + } + : undefined, + ), + }); + const findings = await checkTarball(input(), io); + expect( + findings.some( + (f) => + f.kind === "engine-pin-mismatch" && + f.subject === "@prisma/composer" && + f.summary.includes("peers"), + ), + ).toBe(true); + }); + + test("a family whose engine peer equals the shell's pin reports nothing", async () => { + const io = fakeIo({ + readInstalledManifest: (_s, name) => + Promise.resolve( + name === "@prisma/composer" + ? { + version: "0.6.0-dev.16", + peerDependencies: { "@prisma/cli-engine": "8.0.0-rc.1" }, + } + : undefined, + ), + listInstalledCopies: (_s, name) => + Promise.resolve( + name === "@prisma/cli-engine" + ? [ + { + version: "8.0.0-rc.1", + path: "node_modules/@prisma/cli-engine", + }, + ] + : [], + ), + }); + const findings = await checkTarball(input(), io); + expect(findings.filter((f) => f.kind === "engine-pin-mismatch")).toEqual( + [], + ); + }); + test("a family package missing from the shell's packed dependencies is a finding", async () => { const findings = await checkTarball( input({ familyPackages: ["@prisma/composer", "@prisma/orm"] }), diff --git a/packages/cli/package.json b/packages/cli/package.json index 306782b6..ddb2ba31 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,11 +49,11 @@ }, "dependencies": { "@prisma/cli-engine": "workspace:0.1.1", - "@prisma/composer": "0.6.0-dev.16", + "@prisma/composer-cli": "0.7.0", "@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", + "@prisma/orm-toolchain": "8.0.0-rc.2", "@vercel/detect-agent": "^1.2.3", "better-result": "^2.9.2", "dotenv": "^17.4.2", @@ -61,6 +61,7 @@ "open": "^11.0.0" }, "devDependencies": { + "@prisma/composer": "0.7.0", "@repo/cli-conformance": "workspace:8.0.0-rc.3", "@repo/cli-telemetry": "workspace:8.0.0-rc.3", "@repo/tsconfig": "workspace:8.0.0-rc.3", diff --git a/packages/cli/scripts/conformance.ts b/packages/cli/scripts/conformance.ts index 9c7e002b..46880bc0 100644 --- a/packages/cli/scripts/conformance.ts +++ b/packages/cli/scripts/conformance.ts @@ -1,11 +1,16 @@ /** - * The conformance entry for this repo's publish path: all three checks + * The conformance entry for this repo's publish path: every check * against what would ship. Run as `pnpm check:conformance` (a turbo * task, so both dists are built first). * * Ordering is deliberate: check 1 reads dist/, and packing REBUILDS * dist/ (prepack runs the build, tsdown cleans), so the tarball check * goes last and reads only the extracted tarballs. + * + * PUBLISH_CHANNEL says which channel the run is for, and check 4 turns + * on it: a dev publish may depend on the products' dev builds, a + * release may not. Unset means `release`, so a workflow that forgets to + * say blocks a release rather than waving it through. */ import { readFile } from "node:fs/promises"; import { join } from "node:path"; @@ -20,6 +25,7 @@ import { type PackageManifest, } from "@repo/cli-conformance/import-purity"; import { sweepBuiltOutput } from "@repo/cli-conformance/module-graph"; +import type { PublishChannel } from "@repo/cli-conformance/release-pins"; import { sectionsFrom } from "@repo/cli-conformance/subjects"; import { checkTarball } from "@repo/cli-conformance/tarball"; import { realTarballIo } from "@repo/cli-conformance/tarball-io"; @@ -39,6 +45,9 @@ const PRISMA_DIR = join(CLI_DIR, "..", "prisma"); // time out unrelated tests. const WORK_DIR = join(CLI_DIR, "..", "..", ".conformance", "cli"); +const CHANNEL: PublishChannel = + process.env.PUBLISH_CHANNEL === "dev" ? "dev" : "release"; + async function manifest(dir: string): Promise { return JSON.parse( await readFile(join(dir, "package.json"), "utf8"), @@ -50,13 +59,13 @@ async function importPurity(): Promise { label: "@prisma/cli", output: await sweepBuiltOutput(join(CLI_DIR, "dist")), manifest: await manifest(CLI_DIR), - requiredSpecifiers: ["@prisma/cli-engine", "@prisma/composer/family"], + requiredSpecifiers: ["@prisma/cli-engine", "@prisma/composer-cli/family"], }); const unscoped = checkImportPurity({ label: "prisma", output: await sweepBuiltOutput(join(PRISMA_DIR, "dist")), manifest: await manifest(PRISMA_DIR), - requiredSpecifiers: ["@prisma/cli-engine", "@prisma/composer/family"], + requiredSpecifiers: ["@prisma/cli-engine", "@prisma/composer-cli/family"], }); const engine = checkImportPurity({ label: "@prisma/cli-engine", @@ -90,27 +99,17 @@ async function tarball(): Promise { ], shellPackage: "@prisma/cli", enginePackage: "@prisma/cli-engine", - familyPackages: ["@prisma/composer", "@prisma/orm-toolchain"], - exceptions: [ - { - familyPackage: "@prisma/composer", - familyPin: "0.0.9", - shellPin: "0.1.1", - reason: - "operator ruling 2026-08-12: ignore for now — composer cannot pin an engine version that is not published yet", - removeWhen: - "composer republishes pinning the engine version prisma-cli ships (tandem order engine → composer → prisma-cli, R-S3-6)", - }, - { - familyPackage: "@prisma/orm-toolchain", - familyPin: "0.0.9", - shellPin: "0.1.1", - reason: - "same class, same ruling: the ORM toolchain cannot pin an engine version that is not published yet", - removeWhen: - "prisma/prisma republishes @prisma/orm-toolchain pinning the engine version prisma-cli ships", - }, - ], + familyPackages: ["@prisma/composer-cli", "@prisma/orm-toolchain"], + // No exceptions. Both families declare @prisma/cli-engine as an + // exact peer at the version this repo ships, so one engine + // resolves in an install — what ADR 0004 asks for, reached + // 2026-08-17 by @prisma/composer-cli@0.7.0 and + // @prisma/orm-toolchain@8.0.0-rc.2. Anything that reopens the + // two-engine defect now fails the publish instead of being + // excused, and adding an entry here is a decision to be argued + // for, not a way to get green. + exceptions: [], + channel: CHANNEL, sandboxDir: join(WORK_DIR, "sandbox"), }, realTarballIo(WORK_DIR, { @@ -129,5 +128,6 @@ const findings: Finding[] = [ ...(await tarball()), ]; const report = { findings, subjectsChecked: 2 + 1 + 2 }; +process.stdout.write(`conformance: publishing as a ${CHANNEL}\n`); process.stdout.write(renderHuman(report)); process.exitCode = exitCodeFor(report); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ee1202cf..ece568a9 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -6,18 +6,7 @@ import { defineCommandFamily, telemetryCommandGroup, } from "@prisma/cli-engine"; -// TODO(release): @prisma/composer@0.6.0-dev.16 pins -// @prisma/cli-engine@0.0.9, while this package ships the workspace -// engine at the lockstep version (8.0.0-rc.1). Those are different -// versions, so an install of @prisma/cli resolves two copies of the -// engine. Closing it is composer's move, not this repo's: composer -// 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 { createComposerFamily } from "@prisma/composer-cli/family"; import { ormCommandFamily as ormToolchainFamily } from "@prisma/orm-toolchain/cli"; import { CLI_DOCS_URL, CLI_NAME } from "./cli-name"; import { agentInstallCommand } from "./commands/agent/install"; diff --git a/packages/cli/tests/fixtures/startup-probe.mjs b/packages/cli/tests/fixtures/startup-probe.mjs index 5f56377d..55cba2fd 100644 --- a/packages/cli/tests/fixtures/startup-probe.mjs +++ b/packages/cli/tests/fixtures/startup-probe.mjs @@ -34,7 +34,7 @@ writeFileSync( JSON.stringify({ exitCode, familyEvaluated: evaluated.some((url) => - url.includes("@prisma/composer/dist/family.mjs"), + url.includes("@prisma/composer-cli/dist/family.mjs"), ), constellation: evaluated .filter((url) => CONSTELLATION.test(url)) diff --git a/packages/cli/tests/v8-conformance.test.ts b/packages/cli/tests/v8-conformance.test.ts index 335f9ddb..742199db 100644 --- a/packages/cli/tests/v8-conformance.test.ts +++ b/packages/cli/tests/v8-conformance.test.ts @@ -64,7 +64,10 @@ describe("conformance: import purity", () => { // Anti-vacuity, and more: these two are the engine boundary this // package exists to compose, so a build that stopped importing // either one is a broken shell rather than a tidy one. - requiredSpecifiers: ["@prisma/cli-engine", "@prisma/composer/family"], + requiredSpecifiers: [ + "@prisma/cli-engine", + "@prisma/composer-cli/family", + ], }), ).toEqual([]); }); diff --git a/packages/prisma/package.json b/packages/prisma/package.json index db42a12f..35b3d04b 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -44,11 +44,11 @@ }, "dependencies": { "@prisma/cli-engine": "workspace:0.1.1", - "@prisma/composer": "0.6.0-dev.16", + "@prisma/composer-cli": "0.7.0", "@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", + "@prisma/orm-toolchain": "8.0.0-rc.2", "@vercel/detect-agent": "^1.2.3", "better-result": "^2.9.2", "dotenv": "^17.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ecd9e0b..8dce0aef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,9 @@ importers: '@prisma/cli-engine': specifier: workspace:0.1.1 version: link:../cli-engine - '@prisma/composer': - specifier: 0.6.0-dev.16 - version: 0.6.0-dev.16(@types/node@22.19.19)(magicast@0.5.3)(rollup@4.62.2)(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))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0) + '@prisma/composer-cli': + specifier: 0.7.0 + version: 0.7.0(@prisma/cli-engine@packages+cli-engine)(@types/node@22.19.19)(magicast@0.5.3)(rollup@4.62.2)(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))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0) '@prisma/compute-sdk': specifier: 0.39.0 version: 0.39.0(@prisma/management-api-sdk@1.55.0)(rollup@4.62.2) @@ -39,8 +39,8 @@ importers: 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)) + specifier: 8.0.0-rc.2 + version: 8.0.0-rc.2(@prisma/cli-engine@packages+cli-engine)(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 @@ -57,6 +57,9 @@ importers: specifier: ^11.0.0 version: 11.0.0 devDependencies: + '@prisma/composer': + specifier: 0.7.0 + version: 0.7.0(@types/node@22.19.19)(magicast@0.5.3)(rollup@4.62.2)(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))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0) '@repo/cli-conformance': specifier: workspace:8.0.0-rc.3 version: link:../cli-conformance @@ -194,9 +197,9 @@ importers: '@prisma/cli-engine': specifier: workspace:0.1.1 version: link:../cli-engine - '@prisma/composer': - specifier: 0.6.0-dev.16 - version: 0.6.0-dev.16(@types/node@22.19.19)(magicast@0.5.3)(rollup@4.62.2)(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))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0) + '@prisma/composer-cli': + specifier: 0.7.0 + version: 0.7.0(@prisma/cli-engine@packages+cli-engine)(@types/node@22.19.19)(magicast@0.5.3)(rollup@4.62.2)(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))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0) '@prisma/compute-sdk': specifier: 0.39.0 version: 0.39.0(@prisma/management-api-sdk@1.55.0)(rollup@4.62.2) @@ -207,8 +210,8 @@ importers: 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)) + specifier: 8.0.0-rc.2 + version: 8.0.0-rc.2(@prisma/cli-engine@packages+cli-engine)(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 @@ -1151,14 +1154,16 @@ packages: '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - '@prisma/cli-engine@0.0.9': - resolution: {integrity: sha512-OiAolWJBhgwVXJ+0aJ9uERplJkxXsKNaUSaUXNDYGvdEoq5c0365OZbGMH5fTmFpqPlPv7MwZga3WkN6ldQyjQ==} - engines: {node: '>=22.12.0'} - - '@prisma/composer@0.6.0-dev.16': - resolution: {integrity: sha512-Lh/bKwrc/DPGc8krMCw0DfK3PeAhdaliv+wtU/IQZV7Ipo62XQ8OKh+ehLwDYSsEzmTmlpi+/XhsjBmxWLmS+A==} + '@prisma/composer-cli@0.7.0': + resolution: {integrity: sha512-06GcFYP4UCZ2a2ObARUrYZebJpu42GU5CC4UePOs5ArBvJ9AToCANIF2IFT/tjtIc1N0HU4hp4uBDM1YxyBN+g==} engines: {node: '>=22.18.0'} hasBin: true + peerDependencies: + '@prisma/cli-engine': 0.1.1 + + '@prisma/composer@0.7.0': + resolution: {integrity: sha512-e9iVz4lIbMyUiTnVw3HCE1PIIrL5U+j7TAbN1mJqweC3VQ2en1nVJ7aFoR6MdRc3AuOeL1Tmef0MKEWFsfW9YA==} + engines: {node: '>=22.18.0'} '@prisma/compute-sdk@0.39.0': resolution: {integrity: sha512-Ir4yuCiqyv7XjhqsqolKZjXzzMCFiZJ4vvk1jl0dn6MjZx1j6puojD+1BLbCE8ty0NakaOyhWmXWyO63YeUf/Q==} @@ -1181,21 +1186,21 @@ packages: '@prisma/management-api-sdk@1.55.0': resolution: {integrity: sha512-WuDDOhxOHfROGY7QAU6wtlbWR0diNwfsQw1epQkhalvgOUe/JzIjxqpBpQzMA54zFVlMmcnT6OEiQfwCIKiRjA==} - '@prisma/management-api-sdk@1.59.0': - resolution: {integrity: sha512-MV5Osk7Ie6dBmehzjXz18oLOhJuDMwuKNzb1zsrq5rfPRpKGGmr3EcFfreIMBgrvM4JvKN1MyUUmwDPyS/Mssw==} + '@prisma/management-api-sdk@1.62.0': + resolution: {integrity: sha512-XJjNcsMEmvXA2vE88cBSwCQBKiWx3ArGQ00MIFePP653ZdY0Uiaodoyw++WnwewvHqOvpzgSC3XsijjRUuveHQ==} - '@prisma/orm-framework@8.0.0-rc.1-dev.40': - resolution: {integrity: sha512-x1/NzuDkQkNwLyUlUE8g1NPKgLqHmfooRVlSBK4xMrZUzvCBpKExaroE+kxXPzVtCqJ2zItuQ4ccREP8dt3l2w==} + '@prisma/orm-framework@8.0.0-rc.2': + resolution: {integrity: sha512-GtrL+Ouw7d4wiDEr2VHMGyIcXGBNtiIe2BvRfe6ZpCjSrU7ajDymIbkpDHIz0tVcymp+FO6H4JcBMLuFgD5nSg==} 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 + '@prisma/orm-toolchain@8.0.0-rc.2': + resolution: {integrity: sha512-i2pJ0F34sG1pYsLFCBmHL8uHgVIjZsBMvsLKXAHHtQEEaANdsoGR1vBuf07PNk2u1B1qwWxiJepd6zxbVYxq5A==} peerDependencies: + '@prisma/cli-engine': 0.1.1 typescript: '>=5.9' vite: ^7.0.0 || ^8.0.0 peerDependenciesMeta: @@ -1957,10 +1962,6 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - 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==} @@ -4008,23 +4009,46 @@ snapshots: '@oxc-project/types@0.139.0': {} - '@prisma/cli-engine@0.0.9(magicast@0.5.3)': + '@prisma/composer-cli@0.7.0(@prisma/cli-engine@packages+cli-engine)(@types/node@22.19.19)(magicast@0.5.3)(rollup@4.62.2)(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))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0)': dependencies: - '@clack/prompts': 1.5.0 - '@prisma/management-api-sdk': 1.55.0 - '@stricli/core': 1.3.0 + '@prisma/cli-engine': link:packages/cli-engine + '@prisma/composer': 0.7.0(@types/node@22.19.19)(magicast@0.5.3)(rollup@4.62.2)(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))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0) + alchemy: 2.0.0-beta.67(@types/node@22.19.19)(effect@4.0.0-beta.103)(rollup@4.62.2)(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))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0) c12: 3.3.4(magicast@0.5.3) - colorette: 2.0.20 - package-manager-detector: 1.8.0 - string-width: 8.2.2 + effect: 4.0.0-beta.103 + esbuild: 0.28.2 transitivePeerDependencies: + - '@aws/durable-execution-sdk-js' + - '@effect/platform-bun' + - '@effect/platform-node' + - '@effect/sql-pg' + - '@mongodb-js/zstd' + - '@types/node' + - '@types/react' + - bufferutil + - drizzle-kit + - drizzle-orm + - encoding + - gcp-metadata + - kerberos - magicast + - mongodb-client-encryption + - pg-native + - react-devtools-core + - rollup + - snappy + - socks + - supports-color + - typescript + - utf-8-validate + - vite + - vitest + - workerd + - ws - '@prisma/composer@0.6.0-dev.16(@types/node@22.19.19)(magicast@0.5.3)(rollup@4.62.2)(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))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0)': + '@prisma/composer@0.7.0(@types/node@22.19.19)(magicast@0.5.3)(rollup@4.62.2)(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))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0)': dependencies: - '@effect/vitest': 4.0.0-beta.103(effect@4.0.0-beta.103)(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) - '@prisma/cli-engine': 0.0.9(magicast@0.5.3) - '@prisma/management-api-sdk': 1.59.0 + '@prisma/management-api-sdk': 1.62.0 '@standard-schema/spec': 1.1.0 alchemy: 2.0.0-beta.67(@types/node@22.19.19)(effect@4.0.0-beta.103)(rollup@4.62.2)(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))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0) arktype: 2.2.3 @@ -4118,11 +4142,11 @@ snapshots: dependencies: openapi-fetch: 0.14.0 - '@prisma/management-api-sdk@1.59.0': + '@prisma/management-api-sdk@1.62.0': dependencies: openapi-fetch: 0.14.0 - '@prisma/orm-framework@8.0.0-rc.1-dev.40(typescript@6.0.3)': + '@prisma/orm-framework@8.0.0-rc.2(typescript@6.0.3)': dependencies: '@standard-schema/spec': 1.1.0 arktype: 2.2.3 @@ -4131,11 +4155,10 @@ snapshots: 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))': + '@prisma/orm-toolchain@8.0.0-rc.2(@prisma/cli-engine@packages+cli-engine)(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) + '@prisma/cli-engine': link:packages/cli-engine + '@prisma/orm-framework': 8.0.0-rc.2(typescript@6.0.3) '@vercel/detect-agent': 1.2.4 arktype: 2.2.3 c12: 3.3.4(magicast@0.5.3) @@ -4143,7 +4166,6 @@ snapshots: 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 @@ -4775,8 +4797,6 @@ snapshots: colorette@2.0.20: {} - commander@15.0.0: {} - confbox@0.2.4: {} consola@3.4.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 40e24e7c..7d3fc123 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,13 +15,15 @@ allowBuilds: # publishes; it's the wrong threat model for packages we build ourselves, and # provenance is the stronger control for "is this really our build". Exempt at # the package level rather than re-pinning each version on every release. +# Exempt by package, never by version: the family pins move with every +# product release (update-product-versions.yml), and a version-qualified +# exemption would need editing on each move. minimumReleaseAgeExclude: - '@prisma/compute-sdk' - '@prisma/management-api-sdk' - '@prisma/composer' - # Reached through @prisma/composer, which pins it exactly. + - '@prisma/composer-cli' - '@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' + # The ORM family and the framework it reaches through. + - '@prisma/orm-toolchain' + - '@prisma/orm-framework' diff --git a/scripts/auto-repin.mjs b/scripts/auto-repin.mjs deleted file mode 100644 index fb093755..00000000 --- a/scripts/auto-repin.mjs +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env node - -// Repins the shell's exact dependencies on the product CLI packages to -// what their repos last published (operator ruling 2026-08-13: family -// publishes flow into this repo automatically and deploy as dev -// versions; only a real CLI release needs a human). Run by -// `.github/workflows/auto-repin.yml` on a repository_dispatch from a -// product repo's publish workflow, on a daily schedule as the backstop -// for missed dispatches, and by hand via workflow_dispatch. -// -// The script edits `packages/cli/package.json` in place and prints one -// line per changed pin; the workflow turns a non-empty change set into -// an auto-merge pull request. Exit codes: 0 with changes or without -// (the workflow reads the summary file), 1 on any error. -// -// Which dist-tag "last published" means is per package: composer -// releases under `latest`; prisma/prisma's RC-line releases go to -// `next`. A package absent from the shell's dependencies is skipped — -// the list below names candidates, not requirements — so the -// composer → composer-cli hand-over needs no edit here. - -import { execFileSync } from "node:child_process"; -import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const WATCHED = /** @type {const} */ ([ - { name: "@prisma/composer", tag: "latest" }, - { name: "@prisma/composer-cli", tag: "latest" }, - { name: "@prisma/orm-toolchain", tag: "next" }, -]); - -const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); -const manifestPath = join(rootDir, "packages", "cli", "package.json"); - -/** - * Computes the repin edits for a manifest given the registry's current - * versions. Pure; exported for tests. - * - * @param {{ dependencies?: Record }} manifest - * @param {ReadonlyMap} published name → version at its watched tag - * @returns {Array<{ name: string; from: string; to: string }>} - */ -export function computeRepins(manifest, published) { - const changes = []; - const deps = manifest.dependencies ?? {}; - for (const { name } of WATCHED) { - const current = deps[name]; - if (current === undefined) continue; - const latest = published.get(name); - if (latest === undefined || latest === current) continue; - changes.push({ name, from: current, to: latest }); - } - return changes; -} - -function publishedVersion(name, tag) { - try { - const out = execFileSync("npm", ["view", `${name}@${tag}`, "version"], { - encoding: "utf-8", - }).trim(); - return out.length > 0 ? out : undefined; - } catch { - // Not published yet (or the tag does not exist) — nothing to repin to. - return undefined; - } -} - -function main() { - const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); - const published = new Map( - WATCHED.map(({ name, tag }) => [name, publishedVersion(name, tag)]), - ); - const changes = computeRepins(manifest, published); - - for (const { name, from, to } of changes) { - manifest.dependencies[name] = to; - console.log(`${name}: ${from} -> ${to}`); - } - if (changes.length > 0) { - writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - } else { - console.log("All watched pins already match the registry."); - } - - const outputFile = process.env.GITHUB_OUTPUT; - if (outputFile) { - const summary = changes - .map(({ name, from, to }) => `${name} ${from} -> ${to}`) - .join("; "); - appendFileSync( - outputFile, - `changed< 0)}\nEOF\n`, - ); - appendFileSync(outputFile, `summary< { - it("repins a watched dependency whose registry version moved", () => { - const changes = computeRepins( - { dependencies: { "@prisma/composer": "0.6.0-dev.16", left: "1.0.0" } }, - new Map([["@prisma/composer", "0.7.0"]]), - ); - assert.deepEqual(changes, [ - { name: "@prisma/composer", from: "0.6.0-dev.16", to: "0.7.0" }, - ]); - }); - - it("skips watched packages the shell does not depend on — candidates, not requirements", () => { - const changes = computeRepins( - { dependencies: { "@prisma/composer": "0.6.0-dev.16" } }, - new Map([ - ["@prisma/composer", "0.6.0-dev.16"], - ["@prisma/composer-cli", "0.7.0"], - ]), - ); - assert.deepEqual(changes, []); - }); - - it("skips a package the registry has never seen at its watched tag", () => { - const changes = computeRepins( - { dependencies: { "@prisma/orm-toolchain": "8.0.0-rc.1-dev.40" } }, - new Map([["@prisma/orm-toolchain", undefined]]), - ); - assert.deepEqual(changes, []); - }); - - it("reports every drifted pin in one run", () => { - const changes = computeRepins( - { - dependencies: { - "@prisma/composer": "0.6.0", - "@prisma/orm-toolchain": "8.0.0-rc.1", - }, - }, - new Map([ - ["@prisma/composer", "0.7.0"], - ["@prisma/orm-toolchain", "8.0.0-rc.2"], - ]), - ); - assert.equal(changes.length, 2); - }); -}); diff --git a/scripts/determine-version-utils.ts b/scripts/determine-version-utils.ts index 2a1929fe..192d27b2 100644 --- a/scripts/determine-version-utils.ts +++ b/scripts/determine-version-utils.ts @@ -68,8 +68,9 @@ export interface VersionResult { /** * The version a routine main push publishes under the `dev` dist-tag - * (operator ruling 2026-08-13: automatic repins deploy automatic dev - * versions; only a real release needs a human). The run number makes + * (operator ruling 2026-08-13: a product's new version deploys as a dev + * version without a human; only a real release needs one). The run + * number makes * the suffix monotonic per workflow run without reading the registry. */ export function devVersion(base: string, runNumber: string): string { diff --git a/scripts/determine-version.ts b/scripts/determine-version.ts index b7eff092..f99a1447 100644 --- a/scripts/determine-version.ts +++ b/scripts/determine-version.ts @@ -16,10 +16,10 @@ * keeps serving the pre-8 CLI until the * operator deliberately moves it. * Otherwise `-dev.` under the `dev` - * dist-tag: every routine main push — an - * automated family repin above all — ships an - * installable dev build automatically - * (operator ruling 2026-08-13). + * dist-tag: every routine main push — above + * all one that follows a product's new version + * — ships an installable dev build + * automatically (operator ruling 2026-08-13). * - `workflow_dispatch` → `` (no suffix), dist-tag from * `INPUT_DIST_TAG`; empty means the version's * canonical tag (`releaseDistTag`). Useful as a @@ -163,8 +163,8 @@ switch (eventName) { result = { version: baseVersion, tag: releaseDistTag(baseVersion) }; } else if (previous.available) { // Routine push: publish `-dev.` under `dev` (operator - // ruling 2026-08-13 — automatic repins from the product repos - // deploy automatically; only a real release needs a human). The + // ruling 2026-08-13 — a product's new version reaches the CLI and + // deploys without a human; only a real release needs one). The // suffix is derived here and stamped ephemerally in CI; it is // never committed, so releases remain committed-at-HEAD. const runNumber = process.env.GITHUB_RUN_NUMBER ?? ""; diff --git a/scripts/update-product-versions.mjs b/scripts/update-product-versions.mjs new file mode 100644 index 00000000..e89b4adf --- /dev/null +++ b/scripts/update-product-versions.mjs @@ -0,0 +1,265 @@ +#!/usr/bin/env node + +// Points this repo's dependencies on the product CLI packages at what +// their repos last published on a given channel (operator ruling +// 2026-08-17): a non-dev CLI depends on the latest non-dev composer and +// ORM; a dev CLI depends on their latest dev builds. +// +// Two callers, one mechanism: +// - `--channel release` (the default) runs from +// `.github/workflows/update-product-versions.yml`, on a +// repository_dispatch from a product repo's publish workflow, on a +// daily schedule as the backstop for a missed dispatch, and by hand. +// Its edits become a pull request, so CI tests the new versions +// before they ship. +// - `--channel dev` runs inside `publish.yml`'s dev stamp, alongside +// the version stamp. Nothing is committed; the build and the +// conformance checks that follow it in the same run are what stand +// between a product's dev build and a dev CLI. +// +// Which dist-tag "last published" means is per package and per channel: +// see the table below for releases; both products publish dev builds +// under `dev`. A package absent from a manifest is skipped — the table +// names candidates, not requirements. +// +// Exit codes: 0 whether or not anything changed (callers read the +// summary), 1 on any error — including a registry lookup that fails for +// any reason other than the package or tag not existing. Treating an +// outage as "not published" would leave a dev publish pinned to the +// committed release versions while calling itself a dev build. + +import { execFileSync } from "node:child_process"; +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Where each product publishes its releases, most-preferred tag first: +// the first tag that exists wins. Every one of these is a NEW package +// with no pre-8 audience to protect, so its releases go to `latest`, +// pre-release versions and all (operator, 2026-08-17). Holding `latest` +// back behind `next` matters only for the bare `prisma` name, which THIS +// repo publishes and which still serves Prisma 7 there. +// +// A list rather than a single tag name because asking for one tag is +// what made the previous version of this script silently never update +// the ORM: it asked for `next`, which no prisma/prisma package has ever +// had, and a missing tag was indistinguishable from a missing package. +const WATCHED = /** @type {const} */ ([ + { name: "@prisma/composer-cli", release: ["latest"] }, + // The library, a devDependency here: the startup probe imports it to + // prove the eager-loading detector works. It must move with the CLI + // package it ships beside, or the fixture resolves a second copy. + { name: "@prisma/composer", release: ["latest"] }, + { name: "@prisma/orm-toolchain", release: ["latest"] }, +]); + +const MANIFEST_PATHS = [ + "packages/cli/package.json", + "packages/prisma/package.json", +]; + +/** Every field whose specifier names an exact published version. */ +const FIELDS = ["dependencies", "devDependencies", "optionalDependencies"]; + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); + +/** + * The dist-tags a package publishes this channel under, most-preferred + * first. + * + * @param {{ release: readonly string[] }} entry + * @param {"release" | "dev"} channel + * @returns {readonly string[]} + */ +export function tagsFor(entry, channel) { + return channel === "dev" ? ["dev"] : entry.release; +} + +/** + * The first candidate tag the package actually publishes. Pure; + * exported for tests. + * + * @param {Record} distTags + * @param {readonly string[]} candidates + * @returns {{ tag: string; version: string } | undefined} + */ +export function selectTag(distTags, candidates) { + for (const tag of candidates) { + const version = distTags[tag]; + if (version !== undefined) return { tag, version }; + } + return undefined; +} + +/** + * @param {string} path + * @param {string} field + * @param {Record} deps + * @param {ReadonlyMap} published + */ +function updatesForField(path, field, deps, published) { + const updates = []; + for (const { name } of WATCHED) { + const from = deps[name]; + if (from === undefined) continue; + const to = published.get(name); + if (to === undefined || to === from) continue; + updates.push({ path, field, name, from, to }); + } + return updates; +} + +/** + * The edits that bring every manifest to the registry's versions. Pure; + * exported for tests. + * + * @param {ReadonlyArray<{ path: string; manifest: Record }>} manifests + * @param {ReadonlyMap} published name → version at its tag + * @returns {Array<{ path: string; field: string; name: string; from: string; to: string }>} + */ +export function computeUpdates(manifests, published) { + const updates = []; + for (const { path, manifest } of manifests) { + for (const field of FIELDS) { + const deps = /** @type {Record | undefined} */ ( + manifest[field] + ); + if (deps === undefined) continue; + updates.push(...updatesForField(path, field, deps, published)); + } + } + return updates; +} + +/** + * @param {ReadonlyArray<{ path: string; manifest: Record }>} manifests + * @param {ReadonlyArray<{ path: string; field: string; name: string; to: string }>} updates + */ +export function applyUpdates(manifests, updates) { + for (const update of updates) { + const target = manifests.find((entry) => entry.path === update.path); + if (target === undefined) continue; + const deps = /** @type {Record} */ ( + target.manifest[update.field] + ); + deps[update.name] = update.to; + } +} + +const PACKAGE_ABSENT = /E404|404 Not Found/; + +/** + * npm's answer for "no such package", the one lookup failure that means + * "nothing to move to" — a product package this repo does not depend on + * yet. Anything else — an outage, a proxy, no npm on PATH — must not be + * read as absence, or a dev publish quietly keeps the committed release + * versions and ships a dev build pinned to them. + * + * @param {string} stderr + */ +export function isPackageAbsent(stderr) { + return PACKAGE_ABSENT.test(stderr); +} + +/** + * Every dist-tag a package publishes, or undefined if the package does + * not exist. One call per package, not per tag, so a wrong tag name is + * distinguishable from a missing package. + * + * @returns {Record | undefined} + */ +function distTags(name) { + try { + const out = execFileSync("npm", ["view", name, "dist-tags", "--json"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + if (out.length === 0) throw new Error("npm printed nothing"); + return JSON.parse(out); + } catch (error) { + const stderr = typeof error?.stderr === "string" ? error.stderr : ""; + if (isPackageAbsent(stderr)) return undefined; + throw new Error( + `Could not read ${name}'s dist-tags from the registry: ${stderr.trim() || error.message}`, + ); + } +} + +/** + * A package that exists but publishes none of its candidate tags is a + * mistake in this script's table, or a product that moved where it + * publishes. Either way it must fail rather than look like "nothing to + * move to": the previous version of this script asked for `next` on the + * ORM, which has never had that tag, and silently never updated it. + * + * @param {readonly string[]} candidates + */ +function publishedVersion(name, candidates) { + const tags = distTags(name); + if (tags === undefined) return undefined; + const chosen = selectTag(tags, candidates); + if (chosen === undefined) { + const available = Object.keys(tags).join(", ") || "none"; + throw new Error( + `${name} publishes none of the dist-tags ${candidates.join(", ")} (it publishes: ${available}). Fix the table in this script, or the product moved where it publishes.`, + ); + } + return chosen.version; +} + +function main() { + const flag = process.argv.indexOf("--channel"); + const channel = flag === -1 ? "release" : process.argv[flag + 1]; + if (channel !== "release" && channel !== "dev") { + console.error(`Usage: node ${process.argv[1]} [--channel release|dev]`); + process.exit(1); + } + + const manifests = MANIFEST_PATHS.map((path) => ({ + path, + manifest: JSON.parse(readFileSync(join(rootDir, path), "utf-8")), + })); + const published = new Map( + WATCHED.map((entry) => [ + entry.name, + publishedVersion(entry.name, tagsFor(entry, channel)), + ]), + ); + const updates = computeUpdates(manifests, published); + applyUpdates(manifests, updates); + + for (const { path, name, from, to } of updates) { + console.log(`${path}: ${name} ${from} -> ${to}`); + } + if (updates.length === 0) { + console.log(`All watched versions already match the ${channel} channel.`); + } + for (const path of new Set(updates.map((update) => update.path))) { + const target = manifests.find((entry) => entry.path === path); + writeFileSync( + join(rootDir, path), + `${JSON.stringify(target.manifest, null, 2)}\n`, + ); + } + + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + // One line per package, not per manifest: both manifests carry the + // same pins, so naming each twice makes an unreadable PR title. + const summary = [ + ...new Set( + updates.map(({ name, from, to }) => `${name} ${from} -> ${to}`), + ), + ].join("; "); + appendFileSync( + outputFile, + `changed< 0)}\nEOF\n`, + ); + appendFileSync(outputFile, `summary< { + it("reads each package's own release tags on the release channel", () => { + assert.deepEqual(tagsFor({ release: ["latest"] }, "release"), ["latest"]); + assert.deepEqual(tagsFor({ release: ["next", "latest"] }, "release"), [ + "next", + "latest", + ]); + }); + + it("reads the shared dev tag on the dev channel", () => { + assert.deepEqual(tagsFor({ release: ["next", "latest"] }, "dev"), ["dev"]); + }); +}); + +describe("selectTag", () => { + it("takes the first candidate the package publishes", () => { + assert.deepEqual( + selectTag({ next: "8.0.0-rc.2", latest: "8.0.0-rc.1" }, [ + "next", + "latest", + ]), + { tag: "next", version: "8.0.0-rc.2" }, + ); + }); + + /** + * The case that makes this a list: a product that has not created the + * preferred tag must still be followed, not silently skipped. + */ + it("falls through to a later candidate when the preferred tag does not exist", () => { + assert.deepEqual( + selectTag({ latest: "8.0.0-rc.1", dev: "8.0.0-rc.1-dev.46" }, [ + "next", + "latest", + ]), + { tag: "latest", version: "8.0.0-rc.1" }, + ); + }); + + it("finds nothing when the package publishes none of them", () => { + assert.equal( + selectTag({ dev: "0.1.0-dev.1" }, ["next", "latest"]), + undefined, + ); + }); +}); + +describe("isPackageAbsent", () => { + it("reads npm's answer for a package that does not exist", () => { + assert.equal( + isPackageAbsent("npm error code E404\nnpm error 404 Not Found"), + true, + ); + }); + + /** + * The reason this is a function and not a bare `catch`: any of these + * read as "not published" would leave a dev publish pinned to the + * committed release versions while calling itself a dev build. + */ + it("refuses to read any other failure as absence", () => { + assert.equal(isPackageAbsent("npm error code ETIMEDOUT"), false); + assert.equal(isPackageAbsent("npm error code E401 Unauthorized"), false); + assert.equal(isPackageAbsent("npm error code EAI_AGAIN"), false); + assert.equal(isPackageAbsent("execFileSync ENOENT npm"), false); + assert.equal(isPackageAbsent(""), false); + }); +}); + +describe("computeUpdates", () => { + it("moves a watched dependency whose registry version moved", () => { + const updates = computeUpdates( + [ + { + path: CLI, + manifest: { + dependencies: { + "@prisma/composer-cli": "0.6.0-dev.22", + left: "1.0.0", + }, + }, + }, + ], + new Map([["@prisma/composer-cli", "0.7.0"]]), + ); + assert.deepEqual(updates, [ + { + path: CLI, + field: "dependencies", + name: "@prisma/composer-cli", + from: "0.6.0-dev.22", + to: "0.7.0", + }, + ]); + }); + + /** Both manifests carry the same pins; missing one is how they drift. */ + it("updates every manifest that declares the package", () => { + const updates = computeUpdates( + [ + { + path: CLI, + manifest: { dependencies: { "@prisma/orm-toolchain": "8.0.0-rc.1" } }, + }, + { + path: PRISMA, + manifest: { dependencies: { "@prisma/orm-toolchain": "8.0.0-rc.1" } }, + }, + ], + new Map([["@prisma/orm-toolchain", "8.0.0-rc.2"]]), + ); + assert.deepEqual( + updates.map((update) => update.path), + [CLI, PRISMA], + ); + }); + + it("repairs a manifest that has already drifted from its sibling", () => { + const updates = computeUpdates( + [ + { + path: CLI, + manifest: { dependencies: { "@prisma/composer-cli": "0.7.0" } }, + }, + { + path: PRISMA, + manifest: { + dependencies: { "@prisma/composer-cli": "0.6.0-dev.22" }, + }, + }, + ], + new Map([["@prisma/composer-cli", "0.7.0"]]), + ); + assert.deepEqual(updates, [ + { + path: PRISMA, + field: "dependencies", + name: "@prisma/composer-cli", + from: "0.6.0-dev.22", + to: "0.7.0", + }, + ]); + }); + + it("finds a watched package in devDependencies too — the library the fixture needs", () => { + const updates = computeUpdates( + [ + { + path: CLI, + manifest: { + dependencies: { "@prisma/composer-cli": "0.7.0" }, + devDependencies: { "@prisma/composer": "0.6.0-dev.22" }, + }, + }, + ], + new Map([ + ["@prisma/composer-cli", "0.7.0"], + ["@prisma/composer", "0.7.0"], + ]), + ); + assert.deepEqual(updates, [ + { + path: CLI, + field: "devDependencies", + name: "@prisma/composer", + from: "0.6.0-dev.22", + to: "0.7.0", + }, + ]); + }); + + it("skips watched packages a manifest does not declare — candidates, not requirements", () => { + const updates = computeUpdates( + [{ path: CLI, manifest: { dependencies: { left: "1.0.0" } } }], + new Map([["@prisma/composer-cli", "0.7.0"]]), + ); + assert.deepEqual(updates, []); + }); + + it("skips a package the registry has never seen at its tag", () => { + const updates = computeUpdates( + [ + { + path: CLI, + manifest: { + dependencies: { "@prisma/orm-toolchain": "8.0.0-rc.1-dev.40" }, + }, + }, + ], + new Map([["@prisma/orm-toolchain", undefined]]), + ); + assert.deepEqual(updates, []); + }); + + it("reports every moved package in one run", () => { + const updates = computeUpdates( + [ + { + path: CLI, + manifest: { + dependencies: { + "@prisma/composer-cli": "0.6.0", + "@prisma/orm-toolchain": "8.0.0-rc.1", + }, + }, + }, + ], + new Map([ + ["@prisma/composer-cli", "0.7.0"], + ["@prisma/orm-toolchain", "8.0.0-rc.2"], + ]), + ); + assert.equal(updates.length, 2); + }); +}); + +describe("applyUpdates", () => { + it("writes each update into the manifest it names", () => { + const manifests = [ + { + path: CLI, + manifest: { + dependencies: { "@prisma/composer-cli": "0.6.0-dev.22" }, + devDependencies: { "@prisma/composer": "0.6.0-dev.22" }, + }, + }, + { + path: PRISMA, + manifest: { + dependencies: { "@prisma/composer-cli": "0.6.0-dev.22" }, + }, + }, + ]; + applyUpdates( + manifests, + computeUpdates( + manifests, + new Map([ + ["@prisma/composer-cli", "0.7.0"], + ["@prisma/composer", "0.7.0"], + ]), + ), + ); + assert.deepEqual(manifests[0].manifest, { + dependencies: { "@prisma/composer-cli": "0.7.0" }, + devDependencies: { "@prisma/composer": "0.7.0" }, + }); + assert.deepEqual(manifests[1].manifest, { + dependencies: { "@prisma/composer-cli": "0.7.0" }, + }); + }); +}); diff --git a/turbo.json b/turbo.json index 7f2b7bc3..b8a5e10e 100644 --- a/turbo.json +++ b/turbo.json @@ -51,7 +51,11 @@ // package's own build is a dependency because the entry reads // dist/ before packing rebuilds it. "dependsOn": ["build"], - "cache": false + "cache": false, + // turbo passes only declared variables to a task, and the + // dev-build check answers differently per channel. Undeclared, it + // would silently read `release` in every run. + "env": ["PUBLISH_CHANNEL"] }, "typecheck": { "dependsOn": ["^build"],