diff --git a/README.md b/README.md index 13427a7..dd33500 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ The credential resolves in order: an explicit `PRISMA_SERVICE_TOKEN` from the en 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. +Every request the action sends to the Prisma API also carries three headers for Prisma's deploy analytics: `x-prisma-client-name: cloud-deploy-action`, `x-prisma-client-version` (the action ref your workflow uses, such as `v1`), and `x-prisma-deploy-source: github-action`. They hold no repository or user data, and the API behaves the same without them. + Progress phases map to two server-side labels: `build` and `deploy`. The install and build commands both fall under `build`; the Composer deploy or destroy step falls under `deploy`. Build states are `running` (stamped when the first phase starts), `succeeded`, `failed`, or `cancelled` (sent by the post step when the runner is interrupted mid-flight). 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 reads the address — Composer's `https://..prisma.build` line — from the deploy report, anchored to the `.prisma.build` suffix; an app with several public services reports the first. Reporting the URL is best-effort like every other report: a missing address or a failed report call leaves the deploy successful. diff --git a/credentials.mjs b/credentials.mjs index 9d4ca80..a885fa1 100644 --- a/credentials.mjs +++ b/credentials.mjs @@ -2,6 +2,8 @@ // otherwise a repository connected through the Prisma Console exchanges the // GitHub OIDC token of this run for a short-lived workspace token. +import { clientHeaders } from "./report.mjs"; + const EXCHANGE_PATH = "/v1/auth/github-actions/token"; const OIDC_AUDIENCE = "prisma-cloud"; const TIMEOUT_MS = 15_000; @@ -59,7 +61,7 @@ export async function resolveCredential(env, apiUrl, { fetchImpl = fetch } = {}) `${apiUrl.replace(/\/$/, "")}${EXCHANGE_PATH}`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { ...clientHeaders(env), "content-type": "application/json" }, body: JSON.stringify({ token: oidcToken }), }, ); diff --git a/report.mjs b/report.mjs index eb86a42..0e0cbf9 100644 --- a/report.mjs +++ b/report.mjs @@ -26,6 +26,21 @@ async function fetchWithRetry(fetchImpl, url, init) { throw lastError; } +/** + * Headers that tell the Prisma API which tool sent a request, for its deploy + * analytics. The API behaves the same without them. + */ +export function clientHeaders(env) { + return { + "x-prisma-client-name": "cloud-deploy-action", + // The ref the workflow pinned: v1, v1.7.0, or a commit SHA. + ...(env.GITHUB_ACTION_REF + ? { "x-prisma-client-version": env.GITHUB_ACTION_REF } + : {}), + "x-prisma-deploy-source": "github-action", + }; +} + /** * Maps an action phase name to the server-side phase vocabulary. * "install" runs the user's toolchain and maps to "build". @@ -71,13 +86,19 @@ export async function guardReport(fn, label, log) { * Returns a reporter bound to an API base URL and bearer token. * Pass fetchImpl to stub network calls in tests. */ -export function makeReporter({ apiUrl, token, fetchImpl = fetch }) { +export function makeReporter({ + apiUrl, + token, + fetchImpl = fetch, + env = process.env, +}) { const base = apiUrl.replace(/\/$/, ""); async function call(method, path, body) { const response = await fetchWithRetry(fetchImpl, `${base}${path}`, { method, headers: { + ...clientHeaders(env), "content-type": "application/json", authorization: `Bearer ${token}`, }, diff --git a/tests/credentials.test.mjs b/tests/credentials.test.mjs index ba85bcd..befd122 100644 --- a/tests/credentials.test.mjs +++ b/tests/credentials.test.mjs @@ -53,6 +53,23 @@ test("exchanges the OIDC token with the prisma-cloud audience", async () => { assert.equal(seen.length, 2); }); +test("the exchange names the action, and GitHub's own token request does not", async () => { + const headersByHost = {}; + const fetchImpl = async (url, init) => { + headersByHost[new URL(url).host] = init.headers; + return url.startsWith("https://token.actions.test/") + ? jsonResponse(200, { value: "oidc-jwt" }) + : jsonResponse(200, { data: { value: "tok-short", workspaceId: "ws_1" } }); + }; + await resolveCredential({ ...githubEnv, GITHUB_ACTION_REF: "v1" }, API_URL, { + fetchImpl, + }); + assert.equal(headersByHost["api.example.test"]["x-prisma-client-name"], "cloud-deploy-action"); + assert.equal(headersByHost["api.example.test"]["x-prisma-client-version"], "v1"); + assert.equal(headersByHost["api.example.test"]["x-prisma-deploy-source"], "github-action"); + assert.equal(headersByHost["token.actions.test"]["x-prisma-client-name"], undefined); +}); + test("a 401 from the exchange resolves to denied", async () => { const fetchImpl = async (url) => url.startsWith("https://token.actions.test/") diff --git a/tests/report.test.mjs b/tests/report.test.mjs index 7456314..2fc88b7 100644 --- a/tests/report.test.mjs +++ b/tests/report.test.mjs @@ -1,6 +1,12 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { failurePatch, guardReport, makeReporter, mapPhase } from "../report.mjs"; +import { + clientHeaders, + failurePatch, + guardReport, + makeReporter, + mapPhase, +} from "../report.mjs"; const API_URL = "https://api.example.test"; const TOKEN = "tok-test"; @@ -89,6 +95,35 @@ test("create uses Bearer authorization", async () => { assert.equal(authHeader, "Bearer tok-secret"); }); +test("reports name the action, its ref, and GitHub Actions as the deploy source", async () => { + const sentHeaders = []; + const fetchImpl = async (url, init) => { + sentHeaders.push(init.headers); + return jsonResponse(201, { data: { id: BUILD_ID } }); + }; + const reporter = makeReporter({ + apiUrl: API_URL, + token: TOKEN, + fetchImpl, + env: { GITHUB_ACTION_REF: "v1.8.0" }, + }); + await reporter.create({ source: "ci" }); + await reporter.update(BUILD_ID, { phase: "build" }); + for (const headers of sentHeaders) { + assert.equal(headers["x-prisma-client-name"], "cloud-deploy-action"); + assert.equal(headers["x-prisma-client-version"], "v1.8.0"); + assert.equal(headers["x-prisma-deploy-source"], "github-action"); + } + assert.equal(sentHeaders.length, 2); +}); + +test("clientHeaders leaves out the version when the runner sets no action ref", () => { + assert.deepEqual(clientHeaders({}), { + "x-prisma-client-name": "cloud-deploy-action", + "x-prisma-deploy-source": "github-action", + }); +}); + // --- reporter.update --- test("update sends the patch body to PATCH /v1/builds/{id}", async () => {