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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
50 changes: 50 additions & 0 deletions cli.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -40,6 +43,53 @@ export function isSupportedBunVersion(version) {
return patch >= minPatch;
}

/**
* Whether this `prisma` CLI takes the module as `--entry <path>`. 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 [
Expand Down
29 changes: 24 additions & 5 deletions deployment.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand All @@ -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) };
}
24 changes: 17 additions & 7 deletions main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
Expand Down
61 changes: 60 additions & 1 deletion tests/cli.test.mjs
Original file line number Diff line number Diff line change
@@ -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"]);
Expand Down Expand Up @@ -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");
});
34 changes: 33 additions & 1 deletion tests/deployment.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 { deployedUrlFromOutput, deployedUrlsFromOutput } from "../deployment.mjs";
import { deployedUrlFromOutput, deployedUrlsFromOutput, deployedUrlsFromReport } from "../deployment.mjs";

const ESC = String.fromCharCode(27);

Expand Down Expand Up @@ -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 });
});
Loading