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/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 dee872f..0e1bcca 100644 --- a/main.mjs +++ b/main.mjs @@ -8,11 +8,13 @@ import { join, resolve } from "node:path"; import { selectBuildCommand } from "./build.mjs"; import { MINIMUM_BUN_VERSION, + composerArguments, + installedPrismaVersion, isSupportedBunVersion, 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"; @@ -287,21 +289,29 @@ 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); 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/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"); +}); 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 }); +});