From 1d901eb7162f50fa799fc5aeea870133eec1e994 Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Mon, 17 Aug 2026 13:11:37 +0200 Subject: [PATCH 1/2] fix: report the deployed URL so the Console can show the live preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A successful Prisma Cloud deploy reported its build as succeeded but never recorded the deployed preview URL, so the Console left its provisional project card on a deploying spinner forever — the card links the live preview from Build.deployedUrl, which stayed null. The action reported create, phase updates, and { state: "succeeded" }, but never captured the deployed address or PATCHed deployedUrl. On a successful deploy it now reads the compute service's https://..prisma.build address from Composer's deploy report and fills deployedUrl on the succeeded report, guarded like every other report (warn-only, never fails the deploy). Released Composer (0.6.0) prints the address only as human text, so the URL is read from the captured deploy output, anchored to the .prisma.build suffix. The deploy phase's stdout is now captured and re-emitted at phase end so the report can be parsed. When Composer emits the deploy result as data, the action should read the URL from there instead. Signed-off-by: Kristof Siket Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 4 +-- README.md | 3 ++ deployment.mjs | 32 +++++++++++++++++ main.mjs | 31 ++++++++++++++--- tests/deployment.test.mjs | 72 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 deployment.mjs create mode 100644 tests/deployment.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9009c4d..7a72f26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 ' diff --git a/README.md b/README.md index cef88fc..6c90cdd 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ 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://..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 @@ -114,6 +116,7 @@ When a run has no credential, no `[report-stub]` log lines appear; the run is si - 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 diff --git a/deployment.mjs b/deployment.mjs new file mode 100644 index 0000000..f9edbe4 --- /dev/null +++ b/deployment.mjs @@ -0,0 +1,32 @@ +// prisma/cloud-deploy-action: reads the deployed preview URL out of Composer's +// deploy output so the action can report it to the Builds API. +// +// The Console shows a build's live preview from Build.deployedUrl. Composer's +// deploy prints the deployed compute service's public address — +// https://..prisma.build — on its own line in the deployment +// report. Released Composer (0.6.0) exposes that address only as human text, so +// the action reads it back from the captured output. The machine-readable +// source this should move to is documented in ./README.md (Build status +// reporting, and Known limitations). + +// Anchored to the .prisma.build address: the scheme and the literal suffix pin +// both ends, so nothing else in a deploy log (the Actions run URL, the API +// host, a connection string) can match. A single-service app — the shape the +// Console setup PR creates — prints exactly one, and the first is reported. +const DEPLOYED_URL_RE = /https?:\/\/[a-z0-9.-]+\.prisma\.build(?:\/[^\s]*)?/i; + +// Composer may color the report; strip SGR escapes before matching so codes +// around the address never break the pin. Built from the escape byte directly +// 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 Composer's deploy output, or null when + * the output carries none (an app with no publicly reachable service, or a + * Composer that did not print one). + */ +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; +} diff --git a/main.mjs b/main.mjs index 88c4b06..745ecc4 100644 --- a/main.mjs +++ b/main.mjs @@ -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: @@ -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) { @@ -76,9 +77,21 @@ 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. + // + // Capturing pipes stdout so the deploy report can be read for the preview + // URL; the buffered output is written back below, so the phase still appears + // in the log (as a block at phase end, not streamed). stderr stays inherited. + // The generous maxBuffer keeps a large-but-successful deploy from tripping the + // default 1 MB cap, which would surface as a spawn error and fail the deploy. + const stdio = capture ? ["inherit", "pipe", "inherit"] : "inherit"; + 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 = @@ -88,6 +101,7 @@ async function runPhase(phase, command, args) { : `${printable} exited with status ${result.status}`); await fail(phase, error); } + return captured; } const mode = input("mode") || "deploy"; @@ -232,9 +246,16 @@ 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"); + // Fill the deployed preview URL from the deploy report so the Console can + // link the live preview. Destroy has no URL; a deploy that surfaced none + // reports the success alone. deployedUrl is fill-only on the Builds API. + const deployedUrl = mode === "deploy" ? deployedUrlFromOutput(deployOutput) : null; + await reportUpdate( + deployedUrl ? { state: "succeeded", deployedUrl } : { state: "succeeded" }, + "succeeded", + ); } finish("succeeded"); diff --git a/tests/deployment.test.mjs b/tests/deployment.test.mjs new file mode 100644 index 0000000..78bdea7 --- /dev/null +++ b/tests/deployment.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { deployedUrlFromOutput } from "../deployment.mjs"; + +const ESC = String.fromCharCode(27); + +// A deploy report as Composer prints it: the app, its node tree, and the +// deployed service's public address on its own indented line. +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); +}); From 74799d15dac2819ee4957863af1a0377ddd4e96d Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Mon, 17 Aug 2026 13:25:52 +0200 Subject: [PATCH 2/2] chore: drop narrating comments Signed-off-by: Kristof Siket Co-Authored-By: Claude Fable 5 --- deployment.mjs | 25 ++----------------------- main.mjs | 10 +--------- tests/deployment.test.mjs | 2 -- 3 files changed, 3 insertions(+), 34 deletions(-) diff --git a/deployment.mjs b/deployment.mjs index f9edbe4..4447336 100644 --- a/deployment.mjs +++ b/deployment.mjs @@ -1,30 +1,9 @@ -// prisma/cloud-deploy-action: reads the deployed preview URL out of Composer's -// deploy output so the action can report it to the Builds API. -// -// The Console shows a build's live preview from Build.deployedUrl. Composer's -// deploy prints the deployed compute service's public address — -// https://..prisma.build — on its own line in the deployment -// report. Released Composer (0.6.0) exposes that address only as human text, so -// the action reads it back from the captured output. The machine-readable -// source this should move to is documented in ./README.md (Build status -// reporting, and Known limitations). - -// Anchored to the .prisma.build address: the scheme and the literal suffix pin -// both ends, so nothing else in a deploy log (the Actions run URL, the API -// host, a connection string) can match. A single-service app — the shape the -// Console setup PR creates — prints exactly one, and the first is reported. const DEPLOYED_URL_RE = /https?:\/\/[a-z0-9.-]+\.prisma\.build(?:\/[^\s]*)?/i; -// Composer may color the report; strip SGR escapes before matching so codes -// around the address never break the pin. Built from the escape byte directly -// so the source carries no literal control character. +// 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 Composer's deploy output, or null when - * the output carries none (an app with no publicly reachable service, or a - * Composer that did not print one). - */ +/** 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); diff --git a/main.mjs b/main.mjs index 745ecc4..d8262c0 100644 --- a/main.mjs +++ b/main.mjs @@ -77,13 +77,8 @@ async function runPhase(phase, command, args, capture = false) { // 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. - // - // Capturing pipes stdout so the deploy report can be read for the preview - // URL; the buffered output is written back below, so the phase still appears - // in the log (as a block at phase end, not streamed). stderr stays inherited. - // The generous maxBuffer keeps a large-but-successful deploy from tripping the - // default 1 MB cap, which would surface as a spawn error and fail the deploy. 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 }; @@ -249,9 +244,6 @@ const composerArgs = [ const deployOutput = await runPhase(mode, composerCmd, composerArgs, mode === "deploy"); if (reporter && buildId) { - // Fill the deployed preview URL from the deploy report so the Console can - // link the live preview. Destroy has no URL; a deploy that surfaced none - // reports the success alone. deployedUrl is fill-only on the Builds API. const deployedUrl = mode === "deploy" ? deployedUrlFromOutput(deployOutput) : null; await reportUpdate( deployedUrl ? { state: "succeeded", deployedUrl } : { state: "succeeded" }, diff --git a/tests/deployment.test.mjs b/tests/deployment.test.mjs index 78bdea7..1a29245 100644 --- a/tests/deployment.test.mjs +++ b/tests/deployment.test.mjs @@ -4,8 +4,6 @@ import { deployedUrlFromOutput } from "../deployment.mjs"; const ESC = String.fromCharCode(27); -// A deploy report as Composer prints it: the app, its node tree, and the -// deployed service's public address on its own indented line. const DEPLOY_REPORT = [ "menu-board", "└─ menuboard compute-service cps_wfzg31o86hblgngtaz2lh4mw",