From eea4fcd210a14d6fa184384b4f1a418279455efc Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Tue, 25 Aug 2026 11:39:36 +0200 Subject: [PATCH] Add a CLI compatibility contract: version guard, canary, matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 8.0.0-rc.8 command rename broke every released action version at once because releases float against whatever CLI they find, with no compatibility contract. Three guardrails: - A version guard: before deploying with a repository's own prisma devDependency, check its version and fail with an error naming the supported range (>= 8.0.0-rc.8) instead of CLI.UNKNOWN_COMMAND. An unreadable or unparseable version only warns — the guard must never break a working deploy over output formatting. - A daily canary workflow probing prisma@next for the command shape the action invokes. `--help` exits 0 even for unknown commands (the CLI falls back to the general help), so the probe greps for the deploy-specific usage line. It also checks the version guard accepts prisma@next, and posts a notice the day a destroy command appears. - A compatibility matrix and the policy, documented in the README. Co-Authored-By: Claude Fable 5 --- .github/workflows/canary.yml | 65 ++++++++++++++++++++++++++++++++++++ README.md | 13 ++++++++ cli.mjs | 40 ++++++++++++++++++++++ main.mjs | 26 +++++++++++++++ tests/cli.test.mjs | 42 ++++++++++++++++++++++- 5 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/canary.yml diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml new file mode 100644 index 0000000..b2fb135 --- /dev/null +++ b/.github/workflows/canary.yml @@ -0,0 +1,65 @@ +# Canary: probes prisma@next daily so a CLI command rename or removal is +# caught in this repository when it ships, not by users' broken deploys. +# The 8.0.0-rc.8 rename (`prisma composer deploy` → `prisma deploy`) broke +# every released action version at once; this workflow exists so that cannot +# happen silently again. +name: canary + +on: + schedule: + # Daily, after the CLI's nightly publish window. + - cron: "0 6 * * *" + workflow_dispatch: + +jobs: + cli-shape: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + # Match runs.using in action.yml. + node-version: 24 + - uses: oven-sh/setup-bun@v2 + - name: Probe the top-level deploy command on prisma@next + # `--help` exits 0 even for an unknown command (the CLI falls back + # to the general help), so the probe greps for the deploy-specific + # usage line instead of trusting the exit code. The general help's + # Examples section shows a bare `$ prisma deploy`, so the marker + # includes `[options]`, which only the command's own help prints. + run: | + out="$(bunx -p prisma@next prisma deploy --help 2>&1)" + echo "$out" + echo "$out" | grep -F '$ prisma deploy [options]' + - name: Version guard accepts prisma@next + # The guard the action applies to a repository's local CLI must + # parse prisma@next's --version output and place it in the + # supported range; a guard that rejects the upcoming release would + # block users the day it ships. + run: | + PROBE_OUTPUT="$(bunx -p prisma@next prisma --version 2>&1)" \ + node --input-type=module -e ' + import { extractPrismaVersion, isSupportedPrismaVersion, SUPPORTED_PRISMA_RANGE } from "./cli.mjs"; + const output = process.env.PROBE_OUTPUT; + console.log(output); + const version = extractPrismaVersion(output); + if (version === null) { + console.error("no version found in prisma --version output — extractPrismaVersion needs updating"); + process.exit(1); + } + if (!isSupportedPrismaVersion(version)) { + console.error(`version guard rejects prisma@next (${version}); supported range is ${SUPPORTED_PRISMA_RANGE}`); + process.exit(1); + } + console.log(`version guard accepts prisma@next (${version})`); + ' + - name: Watch for a teardown command returning (informational) + # mode: destroy is a documented known limitation while the CLI has + # no teardown command. This step never fails; it surfaces a notice + # the day a destroy command appears so the action can wire it up. + run: | + if bunx -p prisma@next prisma destroy --help 2>&1 | grep -qF '$ prisma destroy'; then + echo "::notice::prisma@next has a top-level destroy command — wire mode: destroy back up" + else + echo "destroy still absent on prisma@next (documented known limitation)" + fi diff --git a/README.md b/README.md index 85fe6ac..0525259 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,19 @@ When a run has no credential, no `[report-stub]` log lines appear; the run is si Bun `1.3.10` or newer must be on the runner PATH. Add `oven-sh/setup-bun@v2` before this action step; it installs the latest Bun unless a pin (package.json's `packageManager`, a `.bun-version` file, or the step's `bun-version` input) says otherwise. Bun `1.3.9` and older omit Content-Length on the deploy's artifact upload, failing it with HTTP 411, so the action refuses to run on them with an error naming that bug. The generated Prisma deploy workflow adds the setup step for every project. +## CLI compatibility + +The action drives the unified `prisma` CLI, so every action release supports a stated CLI range and enforces it. Three mechanisms keep a CLI change from silently breaking deploys: + +- **Pinned fallback.** Each release ships a `prisma-version` default known to work with that release's invocation. +- **Version guard.** Before deploying with a repository's own `prisma` devDependency, the action checks its version and fails with an error naming the supported range — instead of an opaque `CLI.UNKNOWN_COMMAND` — when it falls outside. An unparseable version is a warning, never a blocker. +- **Canary.** A daily workflow probes `prisma@next` for the command shape the action invokes, so an upcoming rename is caught in this repository when it ships, not by user deploys. + +| Action release | Supported `prisma` CLI | Command shape | +| --- | --- | --- | +| unreleased (main) | `>= 8.0.0-rc.8` | top-level `prisma deploy` | +| v1.0.0 – v1.5.0 | `<= 8.0.0-rc.7` | `prisma composer deploy` (removed in rc.8 — these releases fail against current CLIs) | + ## Known limitations - `mode: destroy` does not work against the current CLI: `8.0.0-rc.9` has no top-level `destroy` command (and no `branch delete`), so the destroy invocation fails. The action keeps the mode and its invocation shape while the CLI's teardown story is settled. In practice, repositories connected through the Prisma Console get preview teardown from the platform's branch automation when a branch is deleted, without running this action. diff --git a/cli.mjs b/cli.mjs index f134ecf..8686b1c 100644 --- a/cli.mjs +++ b/cli.mjs @@ -40,6 +40,46 @@ export function isSupportedBunVersion(version) { return patch >= minPatch; } +// The CLI range this action release speaks: 8.0.0-rc.8 made the deploy +// commands top-level and removed the `prisma composer` prefix. Stated in +// error messages and in the README's compatibility matrix. +export const SUPPORTED_PRISMA_RANGE = ">= 8.0.0-rc.8"; + +/** + * Extracts the CLI version from `prisma --version` output. The current CLI + * prints a JSON envelope with a version field; older ones printed a human + * line. A lenient regex covers both, so an output-format change degrades to + * "version unknown" (a warning) instead of a crash or a false rejection. + * + * @param {string} output - Combined stdout+stderr of `prisma --version`. + * @returns {string|null} e.g. "8.0.0-rc.9", or null when no version is found. + */ +export function extractPrismaVersion(output) { + const match = output.match(/\b(\d+\.\d+\.\d+(?:-rc\.\d+)?)/); + return match ? match[1] : null; +} + +/** + * Whether this action release supports the given CLI version — that is, + * whether the CLI speaks the top-level command shape introduced in + * 8.0.0-rc.8 (SUPPORTED_PRISMA_RANGE). + * + * @param {string} version - e.g. "8.0.0-rc.9" from extractPrismaVersion. + * @returns {boolean} + */ +export function isSupportedPrismaVersion(version) { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?/); + if (!match) return false; + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + if (major !== 8) return major > 8; + if (minor > 0 || patch > 0) return true; + // 8.0.0: stable (no rc tag) and rc.8+ are supported; rc.7 and older speak + // the removed `prisma composer` prefix. + return match[4] === undefined || Number(match[4]) >= 8; +} + export function selectPrismaCliCommand(prismaVersion, binExists, composerArgs) { if (binExists) { return [ diff --git a/main.mjs b/main.mjs index f6d6525..976956a 100644 --- a/main.mjs +++ b/main.mjs @@ -7,7 +7,10 @@ import { join, resolve } from "node:path"; import { selectBuildCommand } from "./build.mjs"; import { MINIMUM_BUN_VERSION, + SUPPORTED_PRISMA_RANGE, + extractPrismaVersion, isSupportedBunVersion, + isSupportedPrismaVersion, selectPrismaCliCommand, } from "./cli.mjs"; import { resolveCredential } from "./credentials.mjs"; @@ -263,6 +266,29 @@ if (buildCommand === null) { // never interpolated into a shell string. // const localBin = join(workdir, "node_modules", ".bin", "prisma"); + +// Version guard: a local CLI outside the supported range fails here with an +// error naming the range, instead of an opaque CLI.UNKNOWN_COMMAND from a +// changed command shape. An unreadable or unparseable version is only a +// warning — the guard must never break a working deploy over output +// formatting. The bunx fallback is not probed: its version is this release's +// own pinned default unless overridden. +if (existsSync(localBin)) { + const probe = spawnSync("node", [localBin, "--version"], { cwd: workdir, stdio: "pipe" }); + const probeOutput = `${probe.stdout ?? ""}${probe.stderr ?? ""}`; + const localVersion = probe.error ? null : extractPrismaVersion(probeOutput); + if (localVersion === null) { + log("::warning::could not determine the local prisma CLI version; continuing without the compatibility check"); + } else if (!isSupportedPrismaVersion(localVersion)) { + await fail( + "cli-version", + `the repository's prisma CLI is ${localVersion}, but this action release requires prisma ${SUPPORTED_PRISMA_RANGE} (top-level \`prisma deploy\`). Update the prisma devDependency, or pin the action release that matches your CLI — see the README's CLI compatibility matrix.`, + ); + } else { + log(`local prisma CLI ${localVersion} is within the supported range (${SUPPORTED_PRISMA_RANGE})`); + } +} + const composerArgs = mode === "deploy" ? ["deploy", modulePath, ...(stage ? ["--stage", stage] : [])] diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index b0f74c9..b90bd9c 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { isSupportedBunVersion, selectPrismaCliCommand } from "../cli.mjs"; +import { extractPrismaVersion, isSupportedBunVersion, isSupportedPrismaVersion, selectPrismaCliCommand } from "../cli.mjs"; test("runs the local prisma CLI with bun run --bun when the bin exists", () => { const [cmd, args, label] = selectPrismaCliCommand("8.0.0-rc.7", true, ["deploy", "module.ts"]); @@ -90,3 +90,43 @@ test("rejects bun 1.3.9 and older, which omit Content-Length on uploads", () => test("rejects output that carries no version", () => { assert.equal(isSupportedBunVersion("command not found"), false); }); + +test("extracts the version from the JSON envelope of prisma --version", () => { + const output = '{"kind":"result","envelope":{"ok":true,"commandId":"version","result":{"version":"8.0.0-rc.9"},"exitCode":0}}'; + assert.equal(extractPrismaVersion(output), "8.0.0-rc.9"); +}); + +test("extracts the version from a human prisma --version line", () => { + assert.equal(extractPrismaVersion("prisma 7.9.1\n"), "7.9.1"); +}); + +test("extracts the rc version from a dev build suffix", () => { + assert.equal(extractPrismaVersion("version 8.0.0-rc.9-dev.79"), "8.0.0-rc.9"); +}); + +test("returns null when the output carries no version", () => { + assert.equal(extractPrismaVersion("command not found"), null); +}); + +test("supports rc.8 and later 8.0.0 release candidates", () => { + assert.equal(isSupportedPrismaVersion("8.0.0-rc.8"), true); + assert.equal(isSupportedPrismaVersion("8.0.0-rc.9"), true); + assert.equal(isSupportedPrismaVersion("8.0.0-rc.12"), true); +}); + +test("rejects rc.7 and older release candidates", () => { + assert.equal(isSupportedPrismaVersion("8.0.0-rc.7"), false); + assert.equal(isSupportedPrismaVersion("8.0.0-rc.1"), false); +}); + +test("supports stable 8.0.0 and every later version", () => { + assert.equal(isSupportedPrismaVersion("8.0.0"), true); + assert.equal(isSupportedPrismaVersion("8.0.1"), true); + assert.equal(isSupportedPrismaVersion("8.1.0"), true); + assert.equal(isSupportedPrismaVersion("9.0.0"), true); +}); + +test("rejects every pre-8 major", () => { + assert.equal(isSupportedPrismaVersion("7.9.1"), false); + assert.equal(isSupportedPrismaVersion("6.19.2"), false); +});