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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ The credential resolves in order: an explicit `PRISMA_SERVICE_TOKEN` from the en
| `build-id` | The build id assigned by the Prisma API when reporting is active. Holds a stable placeholder value when the run has no credential, so downstream steps always receive a value. |
| `url` | After a deploy, the public URL of the app's root service (the service declared in the root module, e.g. `app`). Empty when the deploy exposed no service. |
| `urls` | After a deploy, a JSON object mapping every deployed service's Composer address to its public URL, e.g. `{"app":"https://….prisma.build","login.service":"https://…"}`. `{}` on every other outcome (skipped, failed, destroy). |
| `report-path` | The path of the JSON run report the deploy wrote during this run, on success and on failure. Not set when the deploy wrote no report. |

When the CLI prints its structured deploy result, the job summary also lists every service URL. To get a "View deployment" button on pull requests, point the job's environment at the `url` output:
To get a "View deployment" button on pull requests, point the job's environment at the `url` output:

```yaml
deploy:
Expand All @@ -122,6 +123,12 @@ When the CLI prints its structured deploy result, the job summary also lists eve

Other services are reachable with `fromJSON(steps.deploy.outputs.urls)['login.service']`.

## Job summary

Every run writes a job summary: the outcome, the commit, the ref, and the build id. After a deploy it lists each service with its URL, the databases by id, and the optional inputs each service left unset. After a failure it shows the cause. Writing the summary never changes the run's outcome.

The summary comes from the run report the Prisma CLI writes to `PRISMA_COMPOSER_REPORT_FILE`. The action sets that variable to a file under `$RUNNER_TEMP` unless your workflow already sets it, and exposes the path as the `report-path` output for later steps. Without a report, the summary still shows the outcome and any service URLs from the CLI's structured deploy result.

## Build status reporting

The action reports build progress and outcomes to the Prisma API so your deploys show up in the Prisma Console. Reporting is active when the run has a credential (service token or OIDC exchange). When no credential is available, no reports are sent.
Expand Down
2 changes: 2 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ outputs:
description: Public URL of the app's root service after a deploy (empty when none was found)
urls:
description: 'JSON map of every deployed service address to its public URL, e.g. {"app":"https://….prisma.build","mail.service":"https://…"}'
report-path:
description: Path of the JSON run report the deploy wrote during this run (empty when it wrote none)
runs:
using: node24
main: main.mjs
Expand Down
58 changes: 37 additions & 21 deletions main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// credential is available. Interface and behaviors are frozen by ./README.md.
import { spawnSync } from "node:child_process";
import { appendFileSync, existsSync, readFileSync, writeSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { selectBuildCommand } from "./build.mjs";
import {
Expand All @@ -12,6 +13,7 @@ import {
} from "./cli.mjs";
import { resolveCredential } from "./credentials.mjs";
import { deployedUrlsFromOutput } from "./deployment.mjs";
import { readRunReport, renderJobSummary } from "./summary.mjs";
import { failurePatch, guardReport, makeReporter, mapPhase } from "./report.mjs";
import { runCommand } from "./run.mjs";

Expand All @@ -24,14 +26,37 @@ const setOutput = (name, value) => appendFileSync(process.env.GITHUB_OUTPUT, `${
const saveState = (name, value) => appendFileSync(process.env.GITHUB_STATE, `${name}=${value}\n`);

const STUB_BUILD_ID = `build_stub_${process.env.GITHUB_RUN_ID}`;
const startedAt = Date.now();

let reporter = null;
let buildId = null;
let firstPhaseReported = false;

function finish(outcome) {
// Called once on every exit path. A job summary error is logged, never fatal.
function finish(outcome, { cause, urls } = {}) {
setOutput("outcome", outcome);
saveState("finalOutcome", outcome);
try {
const report = readRunReport(reportPath, startedAt);
if (report) setOutput("report-path", reportPath);
if (process.env.GITHUB_STEP_SUMMARY) {
const summary = renderJobSummary({
outcome,
mode,
stage,
sha: process.env.GITHUB_SHA,
ref: branch,
buildId,
report,
reportPath,
urls,
cause,
});
appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
}
} catch (error) {
log(`::warning::could not write the job summary: ${error.message}`);
}
}

async function reportUpdate(patch, label) {
Expand All @@ -57,14 +82,14 @@ async function fail(failingStep, errorText) {
);
if (result !== null) log("report: failed");
}
finish("failed");
finish("failed", { cause: `${failingStep} failed: ${errorText}` });
log(`::error::${failingStep} failed: ${errorText}`);
process.exit(1);
}

// Fails before any build report exists: no API call, just exit.
function failEarly(failingStep, errorText) {
finish("failed");
finish("failed", { cause: `${failingStep} failed: ${errorText}` });
log(`::error::${failingStep} failed: ${errorText}`);
process.exit(1);
}
Expand Down Expand Up @@ -104,15 +129,15 @@ const workdir = resolve(process.env.GITHUB_WORKSPACE ?? ".", input("working-dire
const repository = process.env.GITHUB_REPOSITORY ?? "";
const branch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || "";
const runUrl = `${process.env.GITHUB_SERVER_URL ?? "https://github.com"}/${repository}/actions/runs/${process.env.GITHUB_RUN_ID}`;
// The deploy writes its run report here; a path the workflow already set is kept.
process.env.PRISMA_COMPOSER_REPORT_FILE ||= join(process.env.RUNNER_TEMP || tmpdir(), "prisma-deploy-report.json");
// The CLI resolves a relative path against its cwd, which is workdir.
const reportPath = resolve(workdir, process.env.PRISMA_COMPOSER_REPORT_FILE);

log(`prisma-deploy: mode=${mode} module=${modulePath} working-directory=${workdir}`);
// Every exit path leaves valid JSON for fromJSON(); a deploy overwrites it (last write wins).
setOutput("urls", "{}");

if (mode !== "deploy" && mode !== "destroy") {
failEarly("config", `unknown mode "${mode}" (expected "deploy" or "destroy")`);
}

const defaultBranch = (() => {
try {
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
Expand All @@ -125,6 +150,9 @@ const defaultBranch = (() => {
// The default branch deploys to production, which has no stage name; every
// other branch deploys to a stage named after it.
const stage = input("stage") || (branch === defaultBranch ? "" : branch);
if (mode !== "deploy" && mode !== "destroy") {
failEarly("config", `unknown mode "${mode}" (expected "deploy" or "destroy")`);
}
if (!input("stage") && !branch) {
failEarly("config", "cannot derive a stage: the event context has no branch; set the stage input");
}
Expand Down Expand Up @@ -184,7 +212,7 @@ const credential = await (async () => {
try {
return await resolveCredential(process.env, apiUrl);
} catch (error) {
finish("failed");
finish("failed", { cause: `credential failed: ${error.message}` });
log(`::error::credential failed: ${error.message}`);
process.exit(1);
}
Expand Down Expand Up @@ -277,18 +305,6 @@ const { urls, url: deployedUrl } =
if (mode === "deploy") {
setOutput("url", deployedUrl ?? "");
setOutput("urls", JSON.stringify(urls));
const rows = Object.entries(urls).map(([address, u]) => `| \`${address}\` | ${u} |`);
if (rows.length > 0 && process.env.GITHUB_STEP_SUMMARY) {
const target = stage ? `stage \`${stage}\`` : "production";
try {
appendFileSync(
process.env.GITHUB_STEP_SUMMARY,
`### Deployed to ${target}\n\n| Service | URL |\n| --- | --- |\n${rows.join("\n")}\n`,
);
} catch (error) {
log(`::warning::could not write the job summary: ${error.message}`);
}
}
}

if (reporter && buildId) {
Expand All @@ -297,4 +313,4 @@ if (reporter && buildId) {
"succeeded",
);
}
finish("succeeded");
finish("succeeded", { urls });
128 changes: 128 additions & 0 deletions summary.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// prisma/cloud-deploy-action: the job summary, rendered from the run report
// the deploy writes to PRISMA_COMPOSER_REPORT_FILE.
import { readFileSync, statSync } from "node:fs";

const isObject = (value) => typeof value === "object" && value !== null;

/** Whether `report` has the version 1 run report shape that renderJobSummary reads. */
function isRunReportV1(report) {
return (
isObject(report) &&
report.version === 1 &&
(report.outcome === "succeeded" || report.outcome === "failed") &&
(report.app === null || typeof report.app === "string") &&
(report.failure === null || isObject(report.failure)) &&
Array.isArray(report.nodes) &&
report.nodes.every(
(node) =>
isObject(node) &&
typeof node.address === "string" &&
Array.isArray(node.entities) &&
node.entities.every(
(entity) =>
isObject(entity) && (entity.details?.absent === undefined || typeof entity.details.absent === "string"),
),
)
);
}

/**
* The run report the deploy wrote during this run, or null when there is
* none. A file older than `notBefore` (epoch ms) is left over from an earlier
* run, so it counts as none; so does a file that is not valid JSON or not a
* version 1 run report.
*/
export function readRunReport(path, notBefore) {
try {
if (statSync(path).mtimeMs < notBefore) return null;
const report = JSON.parse(readFileSync(path, "utf8"));
return isRunReportV1(report) ? report : null;
} catch {
return null;
}
}

/**
* The job summary markdown for one run. Without a report it still names the
* outcome, the commit, and any service URLs the deploy output carried.
*
* @param {object} run
* @param {"succeeded" | "failed" | "skipped-no-credential"} run.outcome
* @param {"deploy" | "destroy"} run.mode
* @param {string} run.stage - Empty for production.
* @param {string} [run.sha]
* @param {string} [run.ref]
* @param {string | null} [run.buildId]
* @param {object | null} [run.report] - The parsed run report.
* @param {string} [run.reportPath]
* @param {Record<string, string>} [run.urls] - Service URLs from the deploy output, used when there is no report.
* @param {string} [run.cause] - The action's own error text, used when the report names no failure.
*/
export function renderJobSummary({ outcome, mode, stage, sha, ref, buildId, report, reportPath, urls = {}, cause }) {
const target = stage ? `\`${stage}\`` : "production";
const app = report?.app ? `\`${report.app}\` ` : "";
const heading =
outcome === "skipped-no-credential"
? "⏭ Skipped: no credential"
: outcome !== "succeeded"
? `❌ ${mode === "destroy" ? "Destroy" : "Deploy"} failed`
: mode === "destroy"
? `✅ Destroyed stage ${target}`
: `✅ Deployed ${app}to ${target}`;
const lines = [`### ${heading}`, ""];

const context = [
sha && `Commit \`${sha.slice(0, 7)}\``,
ref && `on \`${ref}\``,
buildId && `· build \`${buildId}\``,
].filter(Boolean);
if (context.length > 0) lines.push(context.join(" "), "");

const nodes = report?.nodes ?? [];
const entities = (kind) =>
nodes.flatMap((node) => (node.entities ?? []).filter((e) => e.kind === kind).map((e) => ({ ...e, address: node.address })));
const services = report
? entities("compute-service")
: Object.entries(urls).map(([address, url]) => ({ address, url }));
if (services.length > 0) {
lines.push("#### Services", "", "| Service | URL |", "| --- | --- |");
for (const s of services) lines.push(`| \`${s.address}\` | ${s.url ?? ""} |`);
lines.push("");
}
const databases = entities("postgres-database").map((d) => `\`${d.address}\` (\`${d.id}\`)`);
if (databases.length > 0) lines.push(`Databases: ${databases.join(", ")}.`, "");

// Phase timings belong here, once the deploy reports a duration per phase.

// `absent` holds one "input path → variable" line per optional input whose variable was unset.
const warnings = services.flatMap((s) => {
const names = (s.details?.absent ?? "")
.split("\n")
.map((line) => line.split(" → ").pop().trim())
.filter(Boolean)
.map((name) => `\`${name}\``);
if (names.length === 0) return [];
return names.length === 1
? [`- \`${s.address}\`: optional input ${names[0]} is not set.`]
: [`- \`${s.address}\`: optional inputs ${names.join(", ")} are not set.`];
});
if (warnings.length > 0) lines.push(`#### ⚠️ Warnings (${warnings.length})`, "", ...warnings, "");

if (outcome === "failed") {
const failure = report?.failure;
const text = failure ? `${failure.code}: ${failure.message}` : cause;
if (text) lines.push("#### Cause", "", "```text", text, "```", "");
}

if (report) {
lines.push(
"<details><summary>For tools and agents</summary>",
"",
`- Report: \`${reportPath}\` (step output \`report-path\`, run report version ${report.version})`,
"",
"</details>",
"",
);
}
return `${lines.join("\n")}\n`;
}
Loading
Loading