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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 39 additions & 27 deletions main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand All @@ -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",
);
Expand All @@ -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",
);
Expand All @@ -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.`,
);
Expand All @@ -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`,
);
Expand All @@ -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);
Expand Down Expand Up @@ -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}`);
Expand Down
45 changes: 45 additions & 0 deletions report.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);
if (result !== null) log("report: failed");
return buildId;
}
151 changes: 150 additions & 1 deletion tests/report.test.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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::")));
});
Loading
Loading