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..4447336 --- /dev/null +++ b/deployment.mjs @@ -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; +} diff --git a/main.mjs b/main.mjs index 88c4b06..d8262c0 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,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 = @@ -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"; @@ -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"); diff --git a/tests/deployment.test.mjs b/tests/deployment.test.mjs new file mode 100644 index 0000000..1a29245 --- /dev/null +++ b/tests/deployment.test.mjs @@ -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); +});