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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ jobs:
node --check main.mjs
node --check post.mjs
node --check credentials.mjs
- name: Unit-test credential resolution
run: node --test tests/credentials.test.mjs
- name: Unit-test the action scripts
run: node --test tests/*.test.mjs
- name: Sanity-check action.yml
run: |
node -e '
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,16 @@ The action reports build progress and outcomes to the Prisma API so your deploys

Progress phases map to two server-side labels: `build` and `deploy`. The install and build commands both fall under `build`; the Composer deploy or destroy step falls under `deploy`. Build states are `running` (stamped when the first phase starts), `succeeded`, `failed`, or `cancelled` (sent by the post step when the runner is interrupted mid-flight).

On a successful deploy, the action also reports the deployed preview URL (`deployedUrl`) so the Console can link the live preview from the build. It reads the address — Composer's `https://<hash>.<region>.prisma.build` line — from the deploy report, anchored to the `.prisma.build` suffix; an app with several public services reports the first. Reporting the URL is best-effort like every other report: a missing address or a failed report call leaves the deploy successful.

When a run has no credential, no `[report-stub]` log lines appear; the run is silent on reporting.

## Known limitations

- Keep the workflow on Node 22. prisma-composer 0.6.0 crashes on Node 24, even though the action itself runs on the runner's Node 24.
- Install detection covers npm and bun lockfiles. Repositories using pnpm or yarn need an explicit `install-command`, and deploys are not tested against them yet.
- Workflow runs triggered from forks receive no OIDC token from GitHub, so they skip deploying unless a `PRISMA_SERVICE_TOKEN` secret is provided.
- The deployed preview URL is read from Composer's human deploy output, because released Composer (0.6.0) does not expose it as data. When Composer emits the deploy result in a machine-readable form — a `--json` result carrying each deployed service's public URL — the action should read the URL from there rather than from the printed report.

## Security

Expand Down
11 changes: 11 additions & 0 deletions deployment.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const DEPLOYED_URL_RE = /https?:\/\/[a-z0-9.-]+\.prisma\.build(?:\/[^\s]*)?/i;

// Built from the escape byte so the source carries no literal control character.
const ANSI_SGR_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");

/** The first `.prisma.build` address in the deploy output, or null when it carries none. */
export function deployedUrlFromOutput(output) {
if (typeof output !== "string" || output.length === 0) return null;
const match = output.replace(ANSI_SGR_RE, "").match(DEPLOYED_URL_RE);
return match ? match[0] : null;
}
23 changes: 18 additions & 5 deletions main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process";
import { appendFileSync, existsSync, readFileSync, writeSync } from "node:fs";
import { join, resolve } from "node:path";
import { resolveCredential } from "./credentials.mjs";
import { deployedUrlFromOutput } from "./deployment.mjs";
import { guardReport, makeReporter, mapPhase } from "./report.mjs";

// Synchronous stdout keeps ::group:: markers ordered around child output:
Expand Down Expand Up @@ -61,7 +62,7 @@ function failEarly(failingStep, errorText) {
process.exit(1);
}

async function runPhase(phase, command, args) {
async function runPhase(phase, command, args, capture = false) {
const serverPhase = mapPhase(phase);
log(`::group::${phase}`);
if (reporter && buildId) {
Expand All @@ -76,9 +77,16 @@ async function runPhase(phase, command, args) {
// String commands come from the consuming repo's own workflow inputs and
// run through a shell verbatim; argv arrays never touch a shell, so
// event-controlled values like the stage name cannot inject.
const stdio = capture ? ["inherit", "pipe", "inherit"] : "inherit";
// maxBuffer raised so a large but successful deploy is not misreported as a spawn failure.
const options = capture
? { cwd: workdir, stdio, maxBuffer: 64 * 1024 * 1024 }
: { cwd: workdir, stdio };
const result = args
? spawnSync(command, args, { cwd: workdir, stdio: "inherit" })
: spawnSync(command, { cwd: workdir, stdio: "inherit", shell: true });
? spawnSync(command, args, options)
: spawnSync(command, { ...options, shell: true });
const captured = capture && result.stdout ? result.stdout.toString() : "";
if (captured) writeSync(1, captured);
log("::endgroup::");
if (result.status !== 0) {
const error =
Expand All @@ -88,6 +96,7 @@ async function runPhase(phase, command, args) {
: `${printable} exited with status ${result.status}`);
await fail(phase, error);
}
return captured;
}

const mode = input("mode") || "deploy";
Expand Down Expand Up @@ -232,9 +241,13 @@ const composerArgs = [
: ["destroy", modulePath, "--stage", stage]),
];

await runPhase(mode, composerCmd, composerArgs);
const deployOutput = await runPhase(mode, composerCmd, composerArgs, mode === "deploy");

if (reporter && buildId) {
await reportUpdate({ state: "succeeded" }, "succeeded");
const deployedUrl = mode === "deploy" ? deployedUrlFromOutput(deployOutput) : null;
await reportUpdate(
deployedUrl ? { state: "succeeded", deployedUrl } : { state: "succeeded" },
"succeeded",
);
}
finish("succeeded");
70 changes: 70 additions & 0 deletions tests/deployment.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { deployedUrlFromOutput } from "../deployment.mjs";

const ESC = String.fromCharCode(27);

const DEPLOY_REPORT = [
"menu-board",
"└─ menuboard compute-service cps_wfzg31o86hblgngtaz2lh4mw",
" https://wfzg31o86hblgngtaz2lh4mw.ewr.prisma.build",
"",
"Done: 12 succeeded",
].join("\n");

test("reads the .prisma.build address from a deploy report", () => {
assert.equal(
deployedUrlFromOutput(DEPLOY_REPORT),
"https://wfzg31o86hblgngtaz2lh4mw.ewr.prisma.build",
);
});

test("returns the first address when the app deployed several services", () => {
const report = [
"shop",
"├─ web compute-service cps_web",
" https://web123.ewr.prisma.build",
"└─ admin compute-service cps_admin",
" https://admin456.ewr.prisma.build",
].join("\n");
assert.equal(deployedUrlFromOutput(report), "https://web123.ewr.prisma.build");
});

test("ignores the Actions run URL and the API host in the same log", () => {
const log = [
"report: created bld_abc",
"$ https://api.prisma.io/v1/builds",
"see https://github.com/org/repo/actions/runs/123",
" https://wfzg31o86hblgngtaz2lh4mw.ewr.prisma.build",
].join("\n");
assert.equal(
deployedUrlFromOutput(log),
"https://wfzg31o86hblgngtaz2lh4mw.ewr.prisma.build",
);
});

test("matches through surrounding SGR color escapes", () => {
const colored = ` ${ESC}[36mhttps://abc123.ewr.prisma.build${ESC}[0m`;
assert.equal(deployedUrlFromOutput(colored), "https://abc123.ewr.prisma.build");
});

test("keeps a path suffix on the address", () => {
assert.equal(
deployedUrlFromOutput("https://abc123.ewr.prisma.build/health"),
"https://abc123.ewr.prisma.build/health",
);
});

test("does not match a lookalike host that only ends in .build", () => {
assert.equal(deployedUrlFromOutput("https://notprisma.build/x"), null);
});

test("returns null when the output carries no .prisma.build address", () => {
assert.equal(deployedUrlFromOutput("Done: 12 succeeded\nno url here"), null);
});

test("returns null for empty or non-string input", () => {
assert.equal(deployedUrlFromOutput(""), null);
assert.equal(deployedUrlFromOutput(undefined), null);
assert.equal(deployedUrlFromOutput(null), null);
});
Loading