diff --git a/scripts/deploy-vercel.mjs b/scripts/deploy-vercel.mjs index 40cac86..1988e2e 100644 --- a/scripts/deploy-vercel.mjs +++ b/scripts/deploy-vercel.mjs @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; -import { activateDeployment, findDeployment, waitForDeployment } from "./deployment-status.mjs"; +import { activateDeployment, findDeployment, readProviderResult, waitForDeployment } from "./deployment-status.mjs"; const root = path.resolve(import.meta.dirname, ".."); const localStaging = process.argv.includes("--local-staging"); @@ -70,9 +70,7 @@ async function providerApi(endpoint, cwd, project, token, label, method = "GET") ...(method === "POST" ? { body: "{}" } : {}), signal: AbortSignal.timeout(30_000), }); - if (!response.ok) throw new Error(`${label} provider lookup failed (${response.status}).`); - if (response.status === 204) return {}; - return response.json(); + return readProviderResult(response, label, { empty: method === "POST" }); } async function confirmReady(cwd, project, token, label, commit, run) { diff --git a/scripts/deployment-status.mjs b/scripts/deployment-status.mjs index 906e5e9..45bcacd 100644 --- a/scripts/deployment-status.mjs +++ b/scripts/deployment-status.mjs @@ -1,4 +1,12 @@ /** Confirm provider state instead of trusting the CLI's deployment snapshot. */ +export async function readProviderResult(response, label, { empty = false } = {}) { + if (!response.ok) throw new Error(`${label} provider request failed (${response.status}).`); + // Promotion returns a successful empty 200 response, not a JSON document. + if (empty || response.status === 204) return {}; + try { return await response.json(); } + catch { throw new Error(`${label} provider returned invalid JSON.`); } +} + export async function activateDeployment(id, { project, label, read, promote, maxAttempts = 30, intervalMs = 2000, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), diff --git a/scripts/deployment-status.test.mjs b/scripts/deployment-status.test.mjs index c6d0a52..ca1034a 100644 --- a/scripts/deployment-status.test.mjs +++ b/scripts/deployment-status.test.mjs @@ -1,10 +1,18 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { activateDeployment, findDeployment, waitForDeployment } from "./deployment-status.mjs"; +import { activateDeployment, findDeployment, readProviderResult, waitForDeployment } from "./deployment-status.mjs"; const id = "dpl_test123"; const options = { label: "Backend", intervalMs: 0, maxAttempts: 3 }; +test("successful empty promotion responses are accepted while lookups require JSON", async () => { + assert.deepEqual(await readProviderResult(new Response(null, { status: 200 }), "Promotion", { empty: true }), {}); + assert.deepEqual(await readProviderResult(new Response(null, { status: 204 }), "Promotion"), {}); + assert.deepEqual(await readProviderResult(Response.json({ id }), "Lookup"), { id }); + await assert.rejects(readProviderResult(new Response(null, { status: 200 }), "Lookup"), /invalid JSON/); + await assert.rejects(readProviderResult(new Response(null, { status: 403 }), "Promotion", { empty: true }), /request failed \(403\)/); +}); + test("already-current production builds do not request a conflicting promotion", async () => { await activateDeployment(id, { ...options, project: "project", read: async () => ({ id: "project", targets: { production: { id } } }), promote: async () => assert.fail("already-current deployments must not be promoted again") });