From 696c0f180d034a609afa4830165815ce0dab6f0e Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Tue, 27 Oct 2026 12:15:03 +0200 Subject: [PATCH 1/2] fix: report the cause of a run that fails before install A run that exits on a setup problem (Bun missing or too old, no detectable install command, invalid mode or stage) never created its build report, so the platform finalized the queued build as "workflow run finished without reporting to the platform" with no failing step. The early exit now creates the run's build, converging on the queued one through the run identity, and records the failing step and error. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + main.mjs | 66 +++++++++++++++++------------ report.mjs | 43 +++++++++++++++++++ tests/report.test.mjs | 98 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 181 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 0096582..8f4e6dd 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,8 @@ Progress phases map to two server-side labels: `build` and `deploy`. The install 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 is the `url` output: the root service's address, read from the structured `deploy` result the Prisma CLI prints to stdout. A CLI that prints no result line falls back to the first `.prisma.build` address in its output. Reporting the URL is best-effort like every other report: a missing address or a failed report call leaves the deploy successful. +A run that fails before install, such as Bun missing or too old, an install command that cannot be detected, or an invalid `mode` or `stage`, still reports its cause when the run has a credential. The build the platform queued for the run is marked `failed` with a failing step (`config` or `install`) and the error text, so the Console shows why instead of "workflow run finished without reporting to the platform". Like every report, this is best-effort and never changes the run's outcome. + On a failed deploy or destroy, the Prisma CLI reports its own failure to the same build (the action hands it the build via `PRISMA_BUILD_ID`), with the exact failing step and cause. The action and the post step defer to that report: they only send their generic messages when the build has no failure details yet. When a run has no credential, no `[report-stub]` log lines appear; the run is silent on reporting. diff --git a/main.mjs b/main.mjs index 0e1bcca..bd5c8f1 100644 --- a/main.mjs +++ b/main.mjs @@ -16,7 +16,7 @@ import { import { resolveCredential } from "./credentials.mjs"; import { deployedUrlsFromOutput, deployedUrlsFromReport } from "./deployment.mjs"; import { readRunReport, renderJobSummary } from "./summary.mjs"; -import { failurePatch, guardReport, makeReporter, mapPhase } from "./report.mjs"; +import { failurePatch, guardReport, makeReporter, mapPhase, reportEarlyFailure } from "./report.mjs"; import { runCommand } from "./run.mjs"; // Synchronous stdout keeps ::group:: markers ordered around child output, @@ -89,8 +89,22 @@ async function fail(failingStep, errorText) { process.exit(1); } -// Fails before any build report exists: no API call, just exit. -function failEarly(failingStep, errorText) { +// Fails before the action has created its build report. The platform holds +// a queued build for this run, and without a report it would only say the +// run "finished without reporting", so the cause is reported to it first. +async function failEarly(failingStep, errorText) { + const reportedId = await reportEarlyFailure({ + env: process.env, + apiUrl, + createPayload: buildReport, + failingStep, + errorText, + log, + }); + if (reportedId) { + buildId = reportedId; + setOutput("build-id", reportedId); + } finish("failed", { cause: `${failingStep} failed: ${errorText}` }); log(`::error::${failingStep} failed: ${errorText}`); process.exit(1); @@ -131,6 +145,21 @@ 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}`; +const apiUrl = input("api-url") || "https://api.prisma.io"; +// The run identity lets this report converge on the build the platform +// already queued for the run, instead of creating a second one. +const buildReport = { + source: "ci", + commitSha: process.env.GITHUB_SHA ?? "", + branchName: branch, + runIdentity: { + provider: "github", + repositoryId: process.env.GITHUB_REPOSITORY_ID ?? "", + runId: process.env.GITHUB_RUN_ID ?? "", + runAttempt: parseInt(process.env.GITHUB_RUN_ATTEMPT ?? "1", 10), + }, + externalLogUrl: runUrl, +}; // 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. @@ -153,13 +182,13 @@ const defaultBranch = (() => { // 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")`); + await 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"); + await failEarly("config", "cannot derive a stage: the event context has no branch; set the stage input"); } if (mode === "destroy" && !stage) { - failEarly( + await failEarly( "config", "destroy refuses to run without a resolved stage; the default branch derives to production, so pass the stage input explicitly", ); @@ -170,7 +199,7 @@ log(stage ? `${mode} target: --stage ${stage}` : `${mode} target: production (de // deploy workflow adds `oven-sh/setup-bun@v2` for every project. const bunVersionCheck = spawnSync("bun", ["--version"], { stdio: "pipe" }); if (bunVersionCheck.error?.code === "ENOENT") { - failEarly( + await failEarly( "config", "bun not found on PATH: add `- uses: oven-sh/setup-bun@v2` before this action step; the generated Prisma deploy workflow includes this automatically", ); @@ -183,7 +212,7 @@ const bunVersion = ((bunVersionCheck.stdout ?? "").toString().match(/\d+\.\d+\.\ if (bunVersion === null) { log("::warning::could not determine the bun version; continuing without the compatibility check"); } else if (!isSupportedBunVersion(bunVersion)) { - failEarly( + await failEarly( "config", `bun ${bunVersion} is too old: bun <= 1.3.9 omits Content-Length on the deploy's artifact upload, so it fails with HTTP 411. Use bun >= ${MINIMUM_BUN_VERSION} — setup-bun installs the latest by default, so look for an old pin in package.json's \`packageManager\`, a .bun-version file, or the setup step's bun-version input.`, ); @@ -199,7 +228,7 @@ const installCommand = (() => { return null; })(); if (!installCommand) { - failEarly( + await failEarly( "install", `install-auto-detect-failed: no bun.lock, bun.lockb, or package-lock.json in ${workdir} (pnpm/yarn are out of scope); set the install-command input`, ); @@ -209,7 +238,6 @@ if (!installCommand) { // with no credential path skip reporting entirely and exit successfully without // deploying. A transient exchange failure (outage) fails the run instead of // skipping it. -const apiUrl = input("api-url") || "https://api.prisma.io"; const credential = await (async () => { try { return await resolveCredential(process.env, apiUrl); @@ -240,23 +268,7 @@ if (credential.source === "oidc") { reporter = makeReporter({ apiUrl, token: credential.token }); // Create the build report. A failure here is a warning, not a deploy blocker. -buildId = await guardReport( - () => - reporter.create({ - source: "ci", - commitSha: process.env.GITHUB_SHA ?? "", - branchName: branch, - runIdentity: { - provider: "github", - repositoryId: process.env.GITHUB_REPOSITORY_ID ?? "", - runId: process.env.GITHUB_RUN_ID ?? "", - runAttempt: parseInt(process.env.GITHUB_RUN_ATTEMPT ?? "1", 10), - }, - externalLogUrl: runUrl, - }), - "create", - log, -); +buildId = await guardReport(() => reporter.create(buildReport), "create", log); if (buildId) { log(`report: created ${buildId}`); diff --git a/report.mjs b/report.mjs index eb86a42..357b5c1 100644 --- a/report.mjs +++ b/report.mjs @@ -1,6 +1,8 @@ // prisma/cloud-deploy-action: Builds API client. // On non-2xx or network failure this module throws; callers use guardReport // to convert those failures into warnings without aborting the deploy. +import { resolveCredential } from "./credentials.mjs"; + const TIMEOUT_MS = 10_000; // Local copy of the retry helper from credentials.mjs. Sharing it would @@ -108,3 +110,44 @@ export function makeReporter({ apiUrl, token, fetchImpl = fetch }) { }, }; } + +/** + * Reports a failure that happens before the run has a build report, such as + * a missing Bun or an undetectable install command. The platform already + * holds a queued build for this run (the workflow_run webhook creates it), + * and the create converges on it through the run identity, so the cause + * lands on that build instead of the platform's generic "workflow run + * finished without reporting to the platform". Best-effort: never throws, + * and a run without a credential reports nothing. + * Returns the build id, or null when nothing was reported. + */ +export async function reportEarlyFailure({ + env, + apiUrl, + createPayload, + failingStep, + errorText, + log, + fetchImpl = fetch, +}) { + let credential; + try { + credential = await resolveCredential(env, apiUrl, { fetchImpl }); + } catch (error) { + log(`::warning::build report failed (credential): ${error.message}`); + return null; + } + if (credential.source === "none" || credential.denied) return null; + if (credential.source === "oidc") log(`::add-mask::${credential.token}`); + + const reporter = makeReporter({ apiUrl, token: credential.token, fetchImpl }); + const buildId = await guardReport(() => reporter.create(createPayload), "create", log); + if (!buildId) return null; + const result = await guardReport( + () => reporter.update(buildId, failurePatch(null, failingStep, errorText)), + "failed", + log, + ); + if (result !== null) log("report: failed"); + return buildId; +} diff --git a/tests/report.test.mjs b/tests/report.test.mjs index 7456314..ef395bb 100644 --- a/tests/report.test.mjs +++ b/tests/report.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { failurePatch, guardReport, makeReporter, mapPhase } from "../report.mjs"; +import { failurePatch, guardReport, makeReporter, mapPhase, reportEarlyFailure } from "../report.mjs"; const API_URL = "https://api.example.test"; const TOKEN = "tok-test"; @@ -293,3 +293,99 @@ test("failurePatch sends the full report when the build has no failure details", assert.deepEqual(patch, { state: "failed", failingStep: "deploy", errorMessage: "exited with status 2" }); assert.deepEqual(failurePatch(null, "deploy", "x"), { state: "failed", failingStep: "deploy", errorMessage: "x" }); }); + +// --- reportEarlyFailure --- + +const RUN_PAYLOAD = { + source: "ci", + commitSha: "abc123def456", + branchName: "main", + runIdentity: { provider: "github", repositoryId: "98765", runId: "11111", runAttempt: 1 }, + externalLogUrl: "https://github.com/org/repo/actions/runs/11111", +}; + +function recordingFetch(createStatus = 201) { + const calls = []; + const fetchImpl = async (url, init) => { + calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined }); + if (init.method === "POST") return jsonResponse(createStatus, { data: { id: BUILD_ID } }); + return jsonResponse(200, { data: {} }); + }; + return { calls, fetchImpl }; +} + +test("reportEarlyFailure creates the run's build and records the cause on it", async () => { + const { calls, fetchImpl } = recordingFetch(); + const buildId = await reportEarlyFailure({ + env: { PRISMA_SERVICE_TOKEN: TOKEN }, + apiUrl: API_URL, + createPayload: RUN_PAYLOAD, + failingStep: "config", + errorText: "bun not found on PATH", + log: () => {}, + fetchImpl, + }); + assert.equal(buildId, BUILD_ID); + assert.deepEqual( + calls.map(({ url, method }) => `${method} ${url}`), + [`POST ${API_URL}/v1/builds`, `PATCH ${API_URL}/v1/builds/${BUILD_ID}`], + ); + assert.deepEqual(calls[0].body.runIdentity, RUN_PAYLOAD.runIdentity); + assert.deepEqual(calls[1].body, { + state: "failed", + failingStep: "config", + errorMessage: "bun not found on PATH", + }); +}); + +test("reportEarlyFailure sends nothing when the run has no credential", async () => { + const { calls, fetchImpl } = recordingFetch(); + const buildId = await reportEarlyFailure({ + env: {}, + apiUrl: API_URL, + createPayload: RUN_PAYLOAD, + failingStep: "config", + errorText: "x", + log: () => {}, + fetchImpl, + }); + assert.equal(buildId, null); + assert.equal(calls.length, 0); +}); + +test("reportEarlyFailure warns and returns null when the create is refused", async () => { + const { calls, fetchImpl } = recordingFetch(403); + const lines = []; + const buildId = await reportEarlyFailure({ + env: { PRISMA_SERVICE_TOKEN: TOKEN }, + apiUrl: API_URL, + createPayload: RUN_PAYLOAD, + failingStep: "install", + errorText: "x", + log: (line) => lines.push(line), + fetchImpl, + }); + assert.equal(buildId, null); + assert.equal(calls.length, 1); + assert.ok(lines.some((line) => line.startsWith("::warning::build report failed (create)"))); +}); + +test("reportEarlyFailure never throws when the credential exchange fails", async () => { + const lines = []; + const buildId = await reportEarlyFailure({ + env: { + ACTIONS_ID_TOKEN_REQUEST_URL: "https://token.actions.test/get?run=1", + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "runner-bearer", + }, + apiUrl: API_URL, + createPayload: RUN_PAYLOAD, + failingStep: "config", + errorText: "x", + log: (line) => lines.push(line), + fetchImpl: async () => { + throw new Error("network down"); + }, + }); + assert.equal(buildId, null); + assert.ok(lines.some((line) => line.startsWith("::warning::"))); +}); From 5eadccc3530625cd83017f416fea66a90f05aa82 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Thu, 29 Oct 2026 12:32:32 +0200 Subject: [PATCH 2/2] fix: keep a failure cause another job already recorded on early failure Jobs of one workflow run share a build, and the failing step and error are not write-once on the platform, so the early-failure report now reads the build first and lets failurePatch keep an existing cause, as the normal failure path does. Adds an entry-point test that runs main.mjs into an early failure against a local API stub. Co-Authored-By: Claude Opus 5.5 --- report.mjs | 8 ++++-- tests/report.test.mjs | 63 ++++++++++++++++++++++++++++++++++++++---- tests/summary.test.mjs | 53 ++++++++++++++++++++++++++++++++++- 3 files changed, 115 insertions(+), 9 deletions(-) diff --git a/report.mjs b/report.mjs index 357b5c1..3e62a0c 100644 --- a/report.mjs +++ b/report.mjs @@ -117,8 +117,9 @@ export function makeReporter({ apiUrl, token, fetchImpl = fetch }) { * holds a queued build for this run (the workflow_run webhook creates it), * and the create converges on it through the run identity, so the cause * lands on that build instead of the platform's generic "workflow run - * finished without reporting to the platform". Best-effort: never throws, - * and a run without a credential reports nothing. + * finished without reporting to the platform". Jobs of one run share that + * build, so a cause another job already recorded is kept (failurePatch). + * Best-effort: never throws, and a run without a credential reports nothing. * Returns the build id, or null when nothing was reported. */ export async function reportEarlyFailure({ @@ -143,8 +144,9 @@ export async function reportEarlyFailure({ const reporter = makeReporter({ apiUrl, token: credential.token, fetchImpl }); const buildId = await guardReport(() => reporter.create(createPayload), "create", log); if (!buildId) return null; + const existing = await guardReport(() => reporter.get(buildId), "read before failed", log); const result = await guardReport( - () => reporter.update(buildId, failurePatch(null, failingStep, errorText)), + () => reporter.update(buildId, failurePatch(existing, failingStep, errorText)), "failed", log, ); diff --git a/tests/report.test.mjs b/tests/report.test.mjs index ef395bb..f67212f 100644 --- a/tests/report.test.mjs +++ b/tests/report.test.mjs @@ -304,11 +304,13 @@ const RUN_PAYLOAD = { externalLogUrl: "https://github.com/org/repo/actions/runs/11111", }; -function recordingFetch(createStatus = 201) { +function recordingFetch(createStatus = 201, existingBuild = {}) { const calls = []; const fetchImpl = async (url, init) => { - calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined }); - if (init.method === "POST") return jsonResponse(createStatus, { data: { id: BUILD_ID } }); + const method = init.method ?? "GET"; + calls.push({ url, method, body: init.body ? JSON.parse(init.body) : undefined }); + if (method === "POST") return jsonResponse(createStatus, { data: { id: BUILD_ID } }); + if (method === "GET") return jsonResponse(200, { data: existingBuild }); return jsonResponse(200, { data: {} }); }; return { calls, fetchImpl }; @@ -328,16 +330,67 @@ test("reportEarlyFailure creates the run's build and records the cause on it", a assert.equal(buildId, BUILD_ID); assert.deepEqual( calls.map(({ url, method }) => `${method} ${url}`), - [`POST ${API_URL}/v1/builds`, `PATCH ${API_URL}/v1/builds/${BUILD_ID}`], + [ + `POST ${API_URL}/v1/builds`, + `GET ${API_URL}/v1/builds/${BUILD_ID}`, + `PATCH ${API_URL}/v1/builds/${BUILD_ID}`, + ], ); assert.deepEqual(calls[0].body.runIdentity, RUN_PAYLOAD.runIdentity); - assert.deepEqual(calls[1].body, { + assert.deepEqual(calls[2].body, { state: "failed", failingStep: "config", errorMessage: "bun not found on PATH", }); }); +test("reportEarlyFailure keeps a cause another job of the run already recorded", async () => { + const { calls, fetchImpl } = recordingFetch(201, { + failingStep: "install", + errorMessage: "install-auto-detect-failed", + }); + const buildId = await reportEarlyFailure({ + env: { PRISMA_SERVICE_TOKEN: TOKEN }, + apiUrl: API_URL, + createPayload: RUN_PAYLOAD, + failingStep: "config", + errorText: "bun not found on PATH", + log: () => {}, + fetchImpl, + }); + assert.equal(buildId, BUILD_ID); + assert.deepEqual(calls.at(-1), { + url: `${API_URL}/v1/builds/${BUILD_ID}`, + method: "PATCH", + body: { state: "failed" }, + }); +}); + +test("reportEarlyFailure still records the cause when the read before it fails", async () => { + const calls = []; + const fetchImpl = async (url, init) => { + const method = init.method ?? "GET"; + calls.push({ method, body: init.body ? JSON.parse(init.body) : undefined }); + if (method === "POST") return jsonResponse(201, { data: { id: BUILD_ID } }); + if (method === "GET") return jsonResponse(404, {}); + return jsonResponse(200, { data: {} }); + }; + const buildId = await reportEarlyFailure({ + env: { PRISMA_SERVICE_TOKEN: TOKEN }, + apiUrl: API_URL, + createPayload: RUN_PAYLOAD, + failingStep: "config", + errorText: "bun not found on PATH", + log: () => {}, + fetchImpl, + }); + assert.equal(buildId, BUILD_ID); + assert.deepEqual(calls.at(-1), { + method: "PATCH", + body: { state: "failed", failingStep: "config", errorMessage: "bun not found on PATH" }, + }); +}); + test("reportEarlyFailure sends nothing when the run has no credential", async () => { const { calls, fetchImpl } = recordingFetch(); const buildId = await reportEarlyFailure({ diff --git a/tests/summary.test.mjs b/tests/summary.test.mjs index 74c8145..cd96a35 100644 --- a/tests/summary.test.mjs +++ b/tests/summary.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; +import { createServer } from "node:http"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -259,3 +260,53 @@ test("main.mjs keeps its outcome and exit code when the summary cannot be writte assert.match(outputs, /^outcome=failed$/m); assert.match(result.stdout, /::warning::could not write the job summary/); }); + +test("main.mjs reports an early failure to the run's build when it has a credential", async () => { + const requests = []; + const server = createServer((req, res) => { + let raw = ""; + req.on("data", (chunk) => (raw += chunk)); + req.on("end", () => { + requests.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : undefined }); + res.writeHead(req.method === "POST" ? 201 : 200, { "content-type": "application/json" }); + res.end(JSON.stringify({ data: req.method === "POST" ? { id: "bld_early" } : {} })); + }); + }); + await new Promise((done) => server.listen(0, "127.0.0.1", done)); + const dir = mkdtempSync(join(tmpdir(), "summary-main-")); + const output = join(dir, "output"); + try { + // spawn, not spawnSync: the stub API answers on this process's event loop. + const status = await new Promise((done, reject) => { + const child = spawn(process.execPath, [new URL("../main.mjs", import.meta.url).pathname], { + env: { + PATH: process.env.PATH, + INPUT_MODE: "bogus", + "INPUT_API-URL": `http://127.0.0.1:${server.address().port}`, + PRISMA_SERVICE_TOKEN: "tok-test", + GITHUB_REF_NAME: "main", + GITHUB_SHA: "095b237a1c0ffee", + GITHUB_RUN_ID: "11111", + GITHUB_OUTPUT: output, + GITHUB_STATE: join(dir, "state"), + GITHUB_STEP_SUMMARY: join(dir, "summary.md"), + RUNNER_TEMP: dir, + }, + stdio: "ignore", + }); + child.on("error", reject); + child.on("exit", done); + }); + assert.equal(status, 1); + assert.deepEqual( + requests.map(({ method, url }) => `${method} ${url}`), + ["POST /v1/builds", "GET /v1/builds/bld_early", "PATCH /v1/builds/bld_early"], + ); + assert.equal(requests[2].body.failingStep, "config"); + const outputs = readFileSync(output, "utf8"); + assert.match(outputs, /^build-id=bld_early$/m); + assert.match(outputs, /^outcome=failed$/m); + } finally { + server.close(); + } +});