From 890259a18a9c779d6a471d0596dd2a7216ceb291 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 28 Oct 2026 16:32:09 +0100 Subject: [PATCH 1/2] fix: pass the module as --entry for prisma 8.0.0-rc.28 and later Composer 0.35.0, which prisma 8.0.0-rc.28 ships, rejects a positional module on deploy. The action reads the installed prisma version and passes --entry from rc.28 on, and the module positionally before it. Signed-off-by: willbot Signed-off-by: Will Madden --- README.md | 2 +- cli.mjs | 50 +++++++++++++++++++++++++++++++++++++ main.mjs | 15 ++++++++---- tests/cli.test.mjs | 61 +++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 121 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2dcea05..0096582 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ The credential resolves in order: an explicit `PRISMA_SERVICE_TOKEN` from the en | --- | --- | --- | | `build-command` | `npm run build` | Your build command, run verbatim. It runs in both modes, because destroy evaluates the built app to know what to remove. The exact value `none` skips the build phase in both modes, for repositories with no build script; an empty value keeps the default. | | `install-command` | auto | Detected from the lockfile: `npm ci` for `package-lock.json`, `bun install --frozen-lockfile` for a bun lockfile. Set this to override. pnpm and yarn are not supported yet. | -| `module` | `module.ts` | Path to your app's Composer module. | +| `module` | `module.ts` | Path to your app's Composer module. On `prisma` 8.0.0-rc.28 and later the action passes it as `--entry`, which overrides the `entry` in `prisma.config.ts`. | | `mode` | `deploy` | `deploy` or `destroy`. | | `stage` | derived | Empty derives the stage from the branch: the default branch deploys to production, any other branch name becomes the stage. `destroy` requires a resolved stage. | | `prisma-version` | `8.0.0-rc.9` | The `prisma` package version the action fetches via bunx — the fallback for repositories that do not carry the `prisma` devDependency. Repositories that do carry it deploy with their own installed version. | diff --git a/cli.mjs b/cli.mjs index f134ecf..65b40fd 100644 --- a/cli.mjs +++ b/cli.mjs @@ -1,3 +1,6 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + /** * Returns [cmd, args, logLabel] for running the Composer command family of * the unified `prisma` CLI under Bun. The bunx fallback covers repositories @@ -40,6 +43,53 @@ export function isSupportedBunVersion(version) { return patch >= minPatch; } +/** + * Whether this `prisma` CLI takes the module as `--entry `. From + * 8.0.0-rc.28 (Composer 0.35.0) `deploy` rejects a positional module. + * + * @param {string} version - e.g. "8.0.0-rc.28". + * @returns {boolean} + */ +export function takesEntryFlag(version) { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?/); + if (!match) return true; + const [major, minor, patch] = match.slice(1, 4).map(Number); + if (major !== 8 || minor !== 0 || patch !== 0) return major >= 8; + return match[4] === undefined || Number(match[4]) >= 28; +} + +/** + * The version of the `prisma` package installed in `workdir`, or "" when it + * cannot be read. + * + * @param {string} workdir + * @returns {string} + */ +export function installedPrismaVersion(workdir) { + try { + const manifest = JSON.parse( + readFileSync(join(workdir, "node_modules", "prisma", "package.json"), "utf8"), + ); + return typeof manifest.version === "string" ? manifest.version : ""; + } catch { + return ""; + } +} + +/** + * The arguments after `prisma` for a deploy or destroy of `modulePath`. + * + * @param {"deploy" | "destroy"} mode + * @param {string} modulePath + * @param {string} stage - Empty for production. + * @param {string} cliVersion - The version of the `prisma` CLI that will run. + * @returns {string[]} + */ +export function composerArguments(mode, modulePath, stage, cliVersion) { + const entry = takesEntryFlag(cliVersion) ? ["--entry", modulePath] : [modulePath]; + return [mode, ...entry, ...(stage ? ["--stage", stage] : [])]; +} + export function selectPrismaCliCommand(prismaVersion, binExists, composerArgs) { if (binExists) { return [ diff --git a/main.mjs b/main.mjs index dee872f..2e44389 100644 --- a/main.mjs +++ b/main.mjs @@ -8,6 +8,8 @@ import { join, resolve } from "node:path"; import { selectBuildCommand } from "./build.mjs"; import { MINIMUM_BUN_VERSION, + composerArguments, + installedPrismaVersion, isSupportedBunVersion, selectPrismaCliCommand, } from "./cli.mjs"; @@ -287,13 +289,16 @@ if (buildCommand === null) { // never interpolated into a shell string. // const localBin = join(workdir, "node_modules", ".bin", "prisma"); -const composerArgs = - mode === "deploy" - ? ["deploy", modulePath, ...(stage ? ["--stage", stage] : [])] - : ["destroy", modulePath, "--stage", stage]; +const hasLocalBin = existsSync(localBin); +const composerArgs = composerArguments( + mode, + modulePath, + stage, + hasLocalBin ? installedPrismaVersion(workdir) : prismaVersion, +); const [cliCmd, cliArgs, cliLabel] = selectPrismaCliCommand( prismaVersion, - existsSync(localBin), + hasLocalBin, composerArgs, ); log(cliLabel); diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index b0f74c9..411d64d 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -1,6 +1,15 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { isSupportedBunVersion, selectPrismaCliCommand } from "../cli.mjs"; +import { + composerArguments, + installedPrismaVersion, + isSupportedBunVersion, + selectPrismaCliCommand, + takesEntryFlag, +} from "../cli.mjs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; 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 +99,53 @@ 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("names the module with --entry for a prisma CLI from 8.0.0-rc.28", () => { + assert.deepEqual(composerArguments("deploy", "module.ts", "", "8.0.0-rc.28"), [ + "deploy", + "--entry", + "module.ts", + ]); + assert.deepEqual(composerArguments("deploy", "app/module.ts", "pr-7", "8.1.0"), [ + "deploy", + "--entry", + "app/module.ts", + "--stage", + "pr-7", + ]); +}); + +test("passes the module positionally for a prisma CLI before 8.0.0-rc.28", () => { + assert.deepEqual(composerArguments("deploy", "module.ts", "pr-7", "8.0.0-rc.27"), [ + "deploy", + "module.ts", + "--stage", + "pr-7", + ]); + assert.deepEqual(composerArguments("destroy", "module.ts", "pr-7", "8.0.0-rc.9"), [ + "destroy", + "module.ts", + "--stage", + "pr-7", + ]); +}); + +test("takes --entry from rc.28 onward and positional before it", () => { + assert.equal(takesEntryFlag("8.0.0-rc.27"), false); + assert.equal(takesEntryFlag("8.0.0-rc.28"), true); + assert.equal(takesEntryFlag("8.0.0-rc.100"), true); + assert.equal(takesEntryFlag("8.0.0"), true); + assert.equal(takesEntryFlag("8.0.1-dev.3"), true); + assert.equal(takesEntryFlag("7.10.1"), false); +}); + +test("reads the installed prisma version, or empty when there is none", () => { + const workdir = mkdtempSync(join(tmpdir(), "cli-test-")); + assert.equal(installedPrismaVersion(workdir), ""); + mkdirSync(join(workdir, "node_modules", "prisma"), { recursive: true }); + writeFileSync( + join(workdir, "node_modules", "prisma", "package.json"), + JSON.stringify({ name: "prisma", version: "8.0.0-rc.27" }), + ); + assert.equal(installedPrismaVersion(workdir), "8.0.0-rc.27"); +}); From 655630bd13539a84e7b1fd42faee9d5230a9e678 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 28 Oct 2026 17:27:12 +0100 Subject: [PATCH 2/2] fix: read the deployed URLs from the run report prisma 8.0.0-rc.28 prints no JSON result line when its output is piped, so the urls output came back empty and url fell back to the first URL in the text. The run report lists every service's URL, so the action reads it first. With app-name-prefixed addresses, url is the service nested least deeply. Signed-off-by: willbot Signed-off-by: Will Madden --- deployment.mjs | 29 ++++++++++++++++++++++++----- main.mjs | 9 +++++++-- tests/deployment.test.mjs | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/deployment.mjs b/deployment.mjs index 09c1c4a..757b551 100644 --- a/deployment.mjs +++ b/deployment.mjs @@ -10,13 +10,33 @@ export function deployedUrlFromOutput(output) { return match ? match[0] : null; } +/** + * Every deployed service's public URL, keyed by its Composer address, read + * from the run report the deploy writes to PRISMA_COMPOSER_REPORT_FILE. + * `url` is the service nested least deeply. + */ +export function deployedUrlsFromReport(report) { + const urls = {}; + for (const node of report?.nodes ?? []) { + const entity = (node.entities ?? []).find((e) => e.kind === "compute-service" && e.url); + if (entity) urls[node.address] = entity.url; + } + const root = topLevelAddress(Object.keys(urls)); + return { urls, url: root ? urls[root] : null }; +} + +/** The address nested least deeply, the first of those on a tie. */ +function topLevelAddress(addresses) { + const depth = (address) => address.split(".").length; + return addresses.reduce((best, a) => (best === undefined || depth(a) < depth(best) ? a : best), undefined); +} + /** * Every deployed service's public URL, keyed by its Composer address, read * from the `deploy` result line the Prisma CLI prints to stdout * (`{"kind":"result","envelope":{"commandId":"deploy","result":{"summary":…}}}`). - * `url` is the root module's service (an address without a dot, e.g. "app"), - * falling back to the first service, then to the first `.prisma.build` - * address in the text for CLIs that print no result line. + * `url` is the service nested least deeply, falling back to the first + * `.prisma.build` address in the text for CLIs that print no result line. */ export function deployedUrlsFromOutput(output) { const urls = {}; @@ -33,7 +53,6 @@ export function deployedUrlsFromOutput(output) { if (entity) urls[node.address] = entity.url; } } - const addresses = Object.keys(urls); - const root = addresses.find((a) => !a.includes(".")) ?? addresses[0]; + const root = topLevelAddress(Object.keys(urls)); return { urls, url: root ? urls[root] : deployedUrlFromOutput(output) }; } diff --git a/main.mjs b/main.mjs index 2e44389..0e1bcca 100644 --- a/main.mjs +++ b/main.mjs @@ -14,7 +14,7 @@ import { selectPrismaCliCommand, } from "./cli.mjs"; import { resolveCredential } from "./credentials.mjs"; -import { deployedUrlsFromOutput } from "./deployment.mjs"; +import { deployedUrlsFromOutput, deployedUrlsFromReport } from "./deployment.mjs"; import { readRunReport, renderJobSummary } from "./summary.mjs"; import { failurePatch, guardReport, makeReporter, mapPhase } from "./report.mjs"; import { runCommand } from "./run.mjs"; @@ -305,8 +305,13 @@ log(cliLabel); const deployOutput = await runPhase(mode, cliCmd, cliArgs, mode === "deploy"); +const fromReport = deployedUrlsFromReport(readRunReport(reportPath, startedAt)); const { urls, url: deployedUrl } = - mode === "deploy" ? deployedUrlsFromOutput(deployOutput) : { urls: {}, url: null }; + mode !== "deploy" + ? { urls: {}, url: null } + : Object.keys(fromReport.urls).length > 0 + ? fromReport + : deployedUrlsFromOutput(deployOutput); if (mode === "deploy") { setOutput("url", deployedUrl ?? ""); setOutput("urls", JSON.stringify(urls)); diff --git a/tests/deployment.test.mjs b/tests/deployment.test.mjs index e3924be..0c7740e 100644 --- a/tests/deployment.test.mjs +++ b/tests/deployment.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { deployedUrlFromOutput, deployedUrlsFromOutput } from "../deployment.mjs"; +import { deployedUrlFromOutput, deployedUrlsFromOutput, deployedUrlsFromReport } from "../deployment.mjs"; const ESC = String.fromCharCode(27); @@ -129,3 +129,35 @@ test("falls back to the printed report when the CLI emits no result line", () => }); assert.deepEqual(deployedUrlsFromOutput(""), { urls: {}, url: null }); }); + +test("reads every service URL from the run report, with prefixed addresses", () => { + const report = { + nodes: [ + { address: "shop.api", entities: [{ kind: "compute-service", url: "https://api.ewr.prisma.build" }] }, + { address: "shop.catalog.web", entities: [{ kind: "compute-service", url: "https://web.ewr.prisma.build" }] }, + { address: "shop.database", entities: [{ kind: "postgres-database", id: "db_1" }] }, + ], + }; + assert.deepEqual(deployedUrlsFromReport(report), { + urls: { + "shop.api": "https://api.ewr.prisma.build", + "shop.catalog.web": "https://web.ewr.prisma.build", + }, + url: "https://api.ewr.prisma.build", + }); +}); + +test("reads a single unprefixed service from the run report", () => { + const report = { + nodes: [{ address: "app", entities: [{ kind: "compute-service", url: "https://app.ewr.prisma.build" }] }], + }; + assert.deepEqual(deployedUrlsFromReport(report), { + urls: { app: "https://app.ewr.prisma.build" }, + url: "https://app.ewr.prisma.build", + }); +}); + +test("reads no URLs from a missing report or one without services", () => { + assert.deepEqual(deployedUrlsFromReport(null), { urls: {}, url: null }); + assert.deepEqual(deployedUrlsFromReport({ nodes: [] }), { urls: {}, url: null }); +});