Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/canary.yml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
26 changes: 26 additions & 0 deletions main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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] : [])]
Expand Down
42 changes: 41 additions & 1 deletion tests/cli.test.mjs
Original file line number Diff line number Diff line change
@@ -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"]);
Expand Down Expand Up @@ -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);
});
Loading