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/publish.yml b/.github/workflows/publish.yml index 75807876..06ed13ac 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,7 +11,7 @@ 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 +# 2026-08-13: automated product-version updates deploy automatically; only a # real release needs a human). The dev suffix is stamped # ephemerally below and never committed. # - push to `main` with the root `version` changed → publish `` diff --git a/.github/workflows/update-product-versions.yml b/.github/workflows/update-product-versions.yml new file mode 100644 index 00000000..41f3c414 --- /dev/null +++ b/.github/workflows/update-product-versions.yml @@ -0,0 +1,114 @@ +name: Update product CLI versions + +# When composer or prisma/prisma publish a family package, this repo +# updates its pinned version automatically, and the merged update 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 an update +# by at most a day. +# - workflow_dispatch: run it by hand. +# +# The PR is opened with DEPLOY_GITHUB_TOKEN — the deploy automation's +# own account, not a person's — rather than 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: update-product-versions + cancel-in-progress: false + +jobs: + update: + name: Update pinned versions 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: compare + run: node scripts/update-product-versions.mjs + + # `pnpm install --no-frozen-lockfile` scoped to the lockfile + # refresh: the version update 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.compare.outputs.changed == 'true' }} + run: | + corepack enable + pnpm install --lockfile-only --no-frozen-lockfile + + # A second publish arriving before the first update PR merges must + # not open a duplicate: the dispatch's 204 only says GitHub accepted + # the event, not that anything was processed. An open PR from this + # workflow already carries whatever the registry now says (the + # comparison is against the registry, not against the event), so the + # right move is to leave it alone and let its own merge pick up both + # publishes. + - name: Skip if an update PR is already open + id: existing + if: ${{ steps.compare.outputs.changed == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + OPEN=$(gh pr list --state open --json headRefName \ + --jq '[.[] | select(.headRefName | startswith("auto/update-product-versions-"))] | length') + if [ "$OPEN" != "0" ]; then + echo "An update PR is already open; leaving it to pick up this publish too." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Open the version-update PR with auto-merge + if: ${{ steps.compare.outputs.changed == 'true' && steps.existing.outputs.skip == 'false' }} + 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; cannot open a CI-triggering PR. Add the deploy automation's GitHub token as the DEPLOY_GITHUB_TOKEN repository secret (see docs/oss/release-automation.md)." + exit 1 + fi + BRANCH="auto/update-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 pnpm-lock.yaml + git commit -s --trailer "Signed-off-by: Will Madden " \ + -m "Update product CLI packages to their latest published 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 "Update product CLI packages: $SUMMARY" \ + --body "Automated version update 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 update-product-versions.yml." \ + --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..1dfec85a 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 with it an obligation for every family to update its pin — 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 cross-repo update train runs only when there is something to update for. Mechanically: the engine is excluded from `set-version.ts`'s lockstep (alongside `@prisma/compute`), follows honest pre-1.0 semver (breaking bumps the minor), and publishes at its own manifest version, with an already-published version treated as a no-op by the publish workflow. ## Enforcement diff --git a/docs/oss/versioning.md b/docs/oss/versioning.md index 22b988d0..a08cd5b6 100644 --- a/docs/oss/versioning.md +++ b/docs/oss/versioning.md @@ -16,7 +16,7 @@ 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 update that pin 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. @@ -30,7 +30,7 @@ The npm registry exposes the CLI packages under these dist-tags: - **`next`** — the Prisma 8 RC line (`8.0.0-rc.N`). A merged release PR on the RC line publishes here automatically. - **`beta`** — reserved for hand-cut previews ahead of significant changes, published by dispatching the workflow with that dist-tag. Routine releases do not use this tag. -- **`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 automated product-version updates ([`update-product-versions.yml`](../../.github/workflows/update-product-versions.yml)) deploy without a human: composer's and prisma/prisma's publish workflows dispatch to this repo, an auto-merge version-update PR runs the full quality and conformance checks, and its merge ships the dev build. Only a real release — an `rc.N` bump under `next`, or moving `latest` — is a human act. PR previews go through [`pkg.pr.new`](https://pkg.pr.new) ([`preview-cli-package.yml`](../../.github/workflows/preview-cli-package.yml)); they carry the committed base version and install via per-commit URLs, not dist-tags. diff --git a/package.json b/package.json index 67ad2c09..c8bf9fd7 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/scripts/determine-version-utils.ts b/scripts/determine-version-utils.ts index 2a1929fe..8ea0f7fc 100644 --- a/scripts/determine-version-utils.ts +++ b/scripts/determine-version-utils.ts @@ -68,7 +68,7 @@ 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 + * (operator ruling 2026-08-13: automatic product-version updates deploy dev * versions; only a real release needs a human). The run number makes * the suffix monotonic per workflow run without reading the registry. */ diff --git a/scripts/determine-version.ts b/scripts/determine-version.ts index b7eff092..fac82724 100644 --- a/scripts/determine-version.ts +++ b/scripts/determine-version.ts @@ -17,7 +17,7 @@ * operator deliberately moves it. * Otherwise `-dev.` under the `dev` * dist-tag: every routine main push — an - * automated family repin above all — ships an + * automated product-version update above all — ships an * installable dev build automatically * (operator ruling 2026-08-13). * - `workflow_dispatch` → `` (no suffix), dist-tag from @@ -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 — automatic product-version updates deploy + // automatically; only a real release needs a human). The // suffix is derived here and stamped ephemerally in CI; it is // never committed, so releases remain committed-at-HEAD. const runNumber = process.env.GITHUB_RUN_NUMBER ?? ""; diff --git a/scripts/auto-repin.mjs b/scripts/update-product-versions.mjs similarity index 90% rename from scripts/auto-repin.mjs rename to scripts/update-product-versions.mjs index fb093755..d4e8b551 100644 --- a/scripts/auto-repin.mjs +++ b/scripts/update-product-versions.mjs @@ -1,10 +1,10 @@ #!/usr/bin/env node -// Repins the shell's exact dependencies on the product CLI packages to +// Updates 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 +// `.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 missed dispatches, and by hand via workflow_dispatch. // @@ -34,14 +34,14 @@ 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 + * Computes the version-update 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) { +export function computeVersionUpdates(manifest, published) { const changes = []; const deps = manifest.dependencies ?? {}; for (const { name } of WATCHED) { @@ -61,7 +61,7 @@ function publishedVersion(name, tag) { }).trim(); return out.length > 0 ? out : undefined; } catch { - // Not published yet (or the tag does not exist) — nothing to repin to. + // Not published yet (or the tag does not exist) — nothing to update to. return undefined; } } @@ -71,7 +71,7 @@ function main() { const published = new Map( WATCHED.map(({ name, tag }) => [name, publishedVersion(name, tag)]), ); - const changes = computeRepins(manifest, published); + const changes = computeVersionUpdates(manifest, published); for (const { name, from, to } of changes) { manifest.dependencies[name] = to; diff --git a/scripts/auto-repin.test.mjs b/scripts/update-product-versions.test.mjs similarity index 78% rename from scripts/auto-repin.test.mjs rename to scripts/update-product-versions.test.mjs index 91320d6a..ae29565b 100644 --- a/scripts/auto-repin.test.mjs +++ b/scripts/update-product-versions.test.mjs @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { computeRepins } from "./auto-repin.mjs"; +import { computeVersionUpdates } from "./update-product-versions.mjs"; -describe("computeRepins", () => { - it("repins a watched dependency whose registry version moved", () => { - const changes = computeRepins( +describe("computeVersionUpdates", () => { + it("updates a watched dependency whose registry version moved", () => { + const changes = computeVersionUpdates( { dependencies: { "@prisma/composer": "0.6.0-dev.16", left: "1.0.0" } }, new Map([["@prisma/composer", "0.7.0"]]), ); @@ -14,7 +14,7 @@ describe("computeRepins", () => { }); it("skips watched packages the shell does not depend on — candidates, not requirements", () => { - const changes = computeRepins( + const changes = computeVersionUpdates( { dependencies: { "@prisma/composer": "0.6.0-dev.16" } }, new Map([ ["@prisma/composer", "0.6.0-dev.16"], @@ -25,7 +25,7 @@ describe("computeRepins", () => { }); it("skips a package the registry has never seen at its watched tag", () => { - const changes = computeRepins( + const changes = computeVersionUpdates( { dependencies: { "@prisma/orm-toolchain": "8.0.0-rc.1-dev.40" } }, new Map([["@prisma/orm-toolchain", undefined]]), ); @@ -33,7 +33,7 @@ describe("computeRepins", () => { }); it("reports every drifted pin in one run", () => { - const changes = computeRepins( + const changes = computeVersionUpdates( { dependencies: { "@prisma/composer": "0.6.0",