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..3e62a0c 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,46 @@ 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". 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({ + 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 existing = await guardReport(() => reporter.get(buildId), "read before failed", log); + const result = await guardReport( + () => reporter.update(buildId, failurePatch(existing, 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..f67212f 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,152 @@ 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, existingBuild = {}) { + const calls = []; + const fetchImpl = async (url, init) => { + 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 }; +} + +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`, + `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[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({ + 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::"))); +}); 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(); + } +});