From 918b95b1d5f1db92eb56be0dc2e7edbe6eb56633 Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Tue, 6 Oct 2026 14:00:01 +0200 Subject: [PATCH 1/2] feat: write a job summary from the deploy report Every run now appends a job summary built from the run report the Prisma CLI writes to PRISMA_COMPOSER_REPORT_FILE: outcome, commit, ref, build id, services with URLs, databases by id, unset optional inputs, and the failure cause. The action points the variable at a file under RUNNER_TEMP unless the workflow already set it, and exposes the path as the new report-path output. A summary error is logged and never changes the outcome or the exit code. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- action.yml | 2 + main.mjs | 58 ++++++---- summary.mjs | 102 ++++++++++++++++++ tests/summary.test.mjs | 239 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 388 insertions(+), 22 deletions(-) create mode 100644 summary.mjs create mode 100644 tests/summary.test.mjs diff --git a/README.md b/README.md index 66bbe15..2dcea05 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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. diff --git a/action.yml b/action.yml index 5cb8b01..c0621cf 100644 --- a/action.yml +++ b/action.yml @@ -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 diff --git a/main.mjs b/main.mjs index 75a1b3d..a748091 100644 --- a/main.mjs +++ b/main.mjs @@ -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 { @@ -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"; // Synchronous stdout keeps ::group:: markers ordered around child output: @@ -23,14 +25,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) { @@ -56,14 +81,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); } @@ -112,15 +137,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")); @@ -133,6 +158,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"); } @@ -192,7 +220,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); } @@ -285,18 +313,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) { @@ -305,4 +321,4 @@ if (reporter && buildId) { "succeeded", ); } -finish("succeeded"); +finish("succeeded", { urls }); diff --git a/summary.mjs b/summary.mjs new file mode 100644 index 0000000..1238fde --- /dev/null +++ b/summary.mjs @@ -0,0 +1,102 @@ +// 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"; + +/** + * 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. + */ +export function readRunReport(path, notBefore) { + try { + if (statSync(path).mtimeMs < notBefore) return null; + return JSON.parse(readFileSync(path, "utf8")); + } 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} [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( + "
For tools and agents", + "", + `- Report: \`${reportPath}\` (step output \`report-path\`, run report version ${report.version})`, + "", + "
", + "", + ); + } + return `${lines.join("\n")}\n`; +} diff --git a/tests/summary.test.mjs b/tests/summary.test.mjs new file mode 100644 index 0000000..086d6f2 --- /dev/null +++ b/tests/summary.test.mjs @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { readRunReport, renderJobSummary } from "../summary.mjs"; + +// The shape of a real production deploy's run report (five services, two +// databases, two services with unset optional inputs), with ids, URLs and +// inputs replaced. +const PRODUCTION_REPORT = { + version: 1, + outcome: "succeeded", + app: "pdp-control-plane-console", + stage: null, + nodes: [ + { address: "agentEvents.subscriptions", entities: [{ kind: "postgres-database", id: "db_subs" }] }, + { + address: "agentEvents.service", + entities: [ + { kind: "compute-service", id: "cps_events", url: "https://events.ewr.prisma.build", details: { input: "{}" } }, + ], + }, + { address: "bloom.database", entities: [{ kind: "postgres-database", id: "db_bloom" }] }, + { + address: "bloom.service", + entities: [ + { kind: "compute-service", id: "cps_bloom", url: "https://bloom.ewr.prisma.build", details: { input: "{}" } }, + ], + }, + { + address: "console", + entities: [ + { kind: "compute-service", id: "cps_console", url: "https://console.ewr.prisma.build", details: { input: "{}" } }, + ], + }, + { + address: "mcp", + entities: [ + { + kind: "compute-service", + id: "cps_mcp", + url: "https://mcp.ewr.prisma.build", + details: { input: "{}", absent: "MCP_PUBLIC_URL → MCP_PUBLIC_URL" }, + }, + ], + }, + { + address: "githubWebhook", + entities: [ + { + kind: "compute-service", + id: "cps_webhook", + url: "https://webhook.ewr.prisma.build", + details: { + input: "{}", + absent: "AXIOM_OTEL_LOGS_DATASET → AXIOM_OTEL_LOGS_DATASET\nFOUNDRY_BASE_URL → FOUNDRY_BASE_URL", + }, + }, + ], + }, + ], + failure: null, +}; + +const RUN = { + mode: "deploy", + stage: "", + sha: "095b237a1c0ffee", + ref: "main", + buildId: "bld_igci22j8zecxd2t364y9wb5j", + reportPath: "/home/runner/work/_temp/prisma-deploy-report.json", +}; + +test("a successful production deploy lists services, databases, and unset optional inputs", () => { + assert.equal( + renderJobSummary({ ...RUN, outcome: "succeeded", report: PRODUCTION_REPORT }), + `### ✅ Deployed \`pdp-control-plane-console\` to production + +Commit \`095b237\` on \`main\` · build \`bld_igci22j8zecxd2t364y9wb5j\` + +#### Services + +| Service | URL | +| --- | --- | +| \`agentEvents.service\` | https://events.ewr.prisma.build | +| \`bloom.service\` | https://bloom.ewr.prisma.build | +| \`console\` | https://console.ewr.prisma.build | +| \`mcp\` | https://mcp.ewr.prisma.build | +| \`githubWebhook\` | https://webhook.ewr.prisma.build | + +Databases: \`agentEvents.subscriptions\` (\`db_subs\`), \`bloom.database\` (\`db_bloom\`). + +#### ⚠️ Warnings (2) + +- \`mcp\`: optional input \`MCP_PUBLIC_URL\` is not set. +- \`githubWebhook\`: optional inputs \`AXIOM_OTEL_LOGS_DATASET\`, \`FOUNDRY_BASE_URL\` are not set. + +
For tools and agents + +- Report: \`/home/runner/work/_temp/prisma-deploy-report.json\` (step output \`report-path\`, run report version 1) + +
+ +`, + ); +}); + +test("a preview deploy names its stage", () => { + const summary = renderJobSummary({ ...RUN, stage: "feat-x", outcome: "succeeded", report: PRODUCTION_REPORT }); + assert.match(summary, /^### ✅ Deployed `pdp-control-plane-console` to `feat-x`\n/); +}); + +test("a failed deploy shows the report's failure cause", () => { + const report = { + version: 1, + outcome: "failed", + app: null, + stage: "feat-x", + nodes: [], + failure: { code: "DEPLOY.PREFLIGHT_FAILED", message: "1 required setting has no value: TOLT_PUBLIC_API_KEY." }, + }; + assert.equal( + renderJobSummary({ ...RUN, stage: "feat-x", outcome: "failed", report, cause: "deploy failed: exited with status 1" }), + `### ❌ Deploy failed + +Commit \`095b237\` on \`main\` · build \`bld_igci22j8zecxd2t364y9wb5j\` + +#### Cause + +\`\`\`text +DEPLOY.PREFLIGHT_FAILED: 1 required setting has no value: TOLT_PUBLIC_API_KEY. +\`\`\` + +
For tools and agents + +- Report: \`/home/runner/work/_temp/prisma-deploy-report.json\` (step output \`report-path\`, run report version 1) + +
+ +`, + ); +}); + +test("without a report, a failure shows the action's own error and no report section", () => { + assert.equal( + renderJobSummary({ ...RUN, outcome: "failed", report: null, cause: "build failed: npm run build exited with status 2" }), + `### ❌ Deploy failed + +Commit \`095b237\` on \`main\` · build \`bld_igci22j8zecxd2t364y9wb5j\` + +#### Cause + +\`\`\`text +build failed: npm run build exited with status 2 +\`\`\` + +`, + ); +}); + +test("without a report, a success lists the service URLs from the deploy output", () => { + assert.equal( + renderJobSummary({ ...RUN, outcome: "succeeded", report: null, urls: { app: "https://app.fra.prisma.build" } }), + `### ✅ Deployed to production + +Commit \`095b237\` on \`main\` · build \`bld_igci22j8zecxd2t364y9wb5j\` + +#### Services + +| Service | URL | +| --- | --- | +| \`app\` | https://app.fra.prisma.build | + +`, + ); +}); + +test("a skipped run says so and shows no build", () => { + assert.equal( + renderJobSummary({ ...RUN, buildId: null, outcome: "skipped-no-credential", report: null }), + "### ⏭ Skipped: no credential\n\nCommit `095b237` on `main`\n\n", + ); +}); + +test("a successful destroy names the stage it removed", () => { + const summary = renderJobSummary({ ...RUN, mode: "destroy", stage: "feat-x", outcome: "succeeded", report: null }); + assert.match(summary, /^### ✅ Destroyed stage `feat-x`\n/); +}); + +test("readRunReport ignores a missing file, invalid JSON, and a report older than this run", () => { + const dir = mkdtempSync(join(tmpdir(), "summary-test-")); + const path = join(dir, "report.json"); + assert.equal(readRunReport(path, 0), null); + writeFileSync(path, "not json"); + assert.equal(readRunReport(path, 0), null); + writeFileSync(path, JSON.stringify(PRODUCTION_REPORT)); + assert.deepEqual(readRunReport(path, 0), PRODUCTION_REPORT); + assert.equal(readRunReport(path, Date.now() + 60_000), null); +}); + +// An unknown mode fails before any tool runs, so main.mjs can run here end to end. +function runMainWithUnknownMode(stepSummary) { + const dir = mkdtempSync(join(tmpdir(), "summary-main-")); + const output = join(dir, "output"); + const result = spawnSync(process.execPath, [new URL("../main.mjs", import.meta.url).pathname], { + env: { + PATH: process.env.PATH, + INPUT_MODE: "bogus", + GITHUB_REF_NAME: "main", + GITHUB_SHA: "095b237a1c0ffee", + GITHUB_OUTPUT: output, + GITHUB_STATE: join(dir, "state"), + GITHUB_STEP_SUMMARY: stepSummary ?? join(dir, "summary.md"), + RUNNER_TEMP: dir, + }, + encoding: "utf8", + }); + return { dir, result, outputs: readFileSync(output, "utf8") }; +} + +test("main.mjs writes a summary on an early failure and keeps the failed outcome", () => { + const { dir, result, outputs } = runMainWithUnknownMode(); + assert.equal(result.status, 1); + assert.match(outputs, /^outcome=failed$/m); + assert.equal( + readFileSync(join(dir, "summary.md"), "utf8"), + '### ❌ Deploy failed\n\nCommit `095b237` on `main`\n\n#### Cause\n\n```text\nconfig failed: unknown mode "bogus" (expected "deploy" or "destroy")\n```\n\n', + ); +}); + +test("main.mjs keeps its outcome and exit code when the summary cannot be written", () => { + const unwritable = mkdtempSync(join(tmpdir(), "summary-dir-")); + const { result, outputs } = runMainWithUnknownMode(unwritable); + assert.equal(result.status, 1); + assert.match(outputs, /^outcome=failed$/m); + assert.match(result.stdout, /::warning::could not write the job summary/); +}); From aa48159ae635272cf1708832d30ce2928cf9c0d9 Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Tue, 6 Oct 2026 14:41:32 +0200 Subject: [PATCH 2/2] fix: treat a report that is not the version 1 shape as missing Co-Authored-By: Claude Opus 5.5 --- summary.mjs | 30 ++++++++++++++++++++++++++++-- tests/summary.test.mjs | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/summary.mjs b/summary.mjs index 1238fde..5a1c60f 100644 --- a/summary.mjs +++ b/summary.mjs @@ -2,15 +2,41 @@ // 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. + * 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; - return JSON.parse(readFileSync(path, "utf8")); + const report = JSON.parse(readFileSync(path, "utf8")); + return isRunReportV1(report) ? report : null; } catch { return null; } diff --git a/tests/summary.test.mjs b/tests/summary.test.mjs index 086d6f2..74c8145 100644 --- a/tests/summary.test.mjs +++ b/tests/summary.test.mjs @@ -200,6 +200,28 @@ test("readRunReport ignores a missing file, invalid JSON, and a report older tha assert.equal(readRunReport(path, Date.now() + 60_000), null); }); +test("readRunReport treats a report that is not the version 1 shape as missing", () => { + const dir = mkdtempSync(join(tmpdir(), "summary-test-")); + const path = join(dir, "report.json"); + const node = PRODUCTION_REPORT.nodes[5]; + const shapes = [ + null, + [], + { nodes: {} }, + { ...PRODUCTION_REPORT, version: 2 }, + { ...PRODUCTION_REPORT, outcome: "running" }, + { ...PRODUCTION_REPORT, failure: "boom" }, + { ...PRODUCTION_REPORT, nodes: [null] }, + { ...PRODUCTION_REPORT, nodes: [{ address: "app", entities: {} }] }, + { ...PRODUCTION_REPORT, nodes: [{ address: "app", entities: [null] }] }, + { ...PRODUCTION_REPORT, nodes: [{ ...node, entities: [{ ...node.entities[0], details: { absent: ["X"] } }] }] }, + ]; + for (const shape of shapes) { + writeFileSync(path, JSON.stringify(shape)); + assert.equal(readRunReport(path, 0), null, JSON.stringify(shape)); + } +}); + // An unknown mode fails before any tool runs, so main.mjs can run here end to end. function runMainWithUnknownMode(stepSummary) { const dir = mkdtempSync(join(tmpdir(), "summary-main-"));