From e1c9c4d02fcad33e2f0824076adf332156c20d60 Mon Sep 17 00:00:00 2001 From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:33:15 +0530 Subject: [PATCH] fix(ci): avoid conflicting promotion of current deployments --- scripts/deploy-vercel.mjs | 26 ++++++++++++++------------ scripts/deployment-status.mjs | 20 ++++++++++++++++++++ scripts/deployment-status.test.mjs | 25 ++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/scripts/deploy-vercel.mjs b/scripts/deploy-vercel.mjs index e6b1eea..40cac86 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 { findDeployment, waitForDeployment } from "./deployment-status.mjs"; +import { activateDeployment, findDeployment, waitForDeployment } from "./deployment-status.mjs"; const root = path.resolve(import.meta.dirname, ".."); const localStaging = process.argv.includes("--local-staging"); @@ -46,7 +46,7 @@ function vercel(args, cwd, project, token, json = true) { encoding: "utf8", windowsHide: true, timeout: 12 * 60 * 1000, maxBuffer: 8 * 1024 * 1024, }); - if (result.status !== 0) throw new Error("Vercel command failed. Inspect the deployment in the project dashboard."); + if (result.status !== 0) throw new Error(`Vercel ${args[0]} command failed. Inspect the deployment in the project dashboard.`); if (!json) return; let data; try { data = JSON.parse(result.stdout); } catch { /* Never dump raw provider logs. */ } @@ -60,15 +60,18 @@ function authentication(tokenKey) { return requireValue(tokenKey); } -async function providerApi(endpoint, cwd, project, token, label) { +async function providerApi(endpoint, cwd, project, token, label, method = "GET") { const url = new URL(endpoint, "https://api.vercel.com"); url.searchParams.set("teamId", requireValue("VERCEL_ORG_ID")); if (!token) return vercel(["api", `${url.pathname}${url.search}`, "--method", "GET"], cwd, project); const response = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, + method, + headers: { Authorization: `Bearer ${token}`, ...(method === "POST" ? { "Content-Type": "application/json" } : {}) }, + ...(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(); } @@ -92,14 +95,13 @@ async function confirmReady(cwd, project, token, label, commit, run) { async function promote(deployment, cwd, project, token, label) { // A previous rollback can leave domains pinned to an older build. Activate only // this invocation's verified production build, then confirm the canonical target. - vercel(["promote", deployment.id, "--yes"], cwd, project, token, false); - for (let attempt = 0; attempt < 5; attempt++) { - const current = await providerApi(`/v9/projects/${project}`, cwd, project, token, label); - if (current.id !== project) throw new Error(`${label} target belongs to a different project.`); - if (current.targets?.production?.id === deployment.id) return; - if (attempt < 4) await new Promise(resolve => setTimeout(resolve, 2000)); - } - throw new Error(`${label} canonical target does not serve the checked deployment.`); + await activateDeployment(deployment.id, { + project, label, + read: () => providerApi(`/v9/projects/${project}`, cwd, project, token, label), + promote: () => token + ? providerApi(`/v10/projects/${project}/promote/${deployment.id}`, cwd, project, token, `${label} promotion`, "POST") + : vercel(["promote", deployment.id, "--yes"], cwd, project, token, false), + }); } function deploySource() { diff --git a/scripts/deployment-status.mjs b/scripts/deployment-status.mjs index 1de95d4..906e5e9 100644 --- a/scripts/deployment-status.mjs +++ b/scripts/deployment-status.mjs @@ -1,4 +1,24 @@ /** Confirm provider state instead of trusting the CLI's deployment snapshot. */ +export async function activateDeployment(id, { + project, label, read, promote, maxAttempts = 30, intervalMs = 2000, + sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), +}) { + const current = async () => { + const record = await read(); + if (record.id !== project) throw new Error(`${label} target belongs to a different project.`); + return record.targets?.production?.id === id; + }; + // Some deployment runtimes assign production domains despite --skip-domain. + // Promoting an already-current deployment returns 409; verify and finish instead. + if (await current()) return; + await promote(); + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (await current()) return; + if (attempt + 1 < maxAttempts) await sleep(intervalMs); + } + throw new Error(`${label} canonical target does not serve the checked deployment.`); +} + export async function findDeployment({ read, commit, run, label, maxAttempts = 10, intervalMs = 1000, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), diff --git a/scripts/deployment-status.test.mjs b/scripts/deployment-status.test.mjs index c44b06c..c6d0a52 100644 --- a/scripts/deployment-status.test.mjs +++ b/scripts/deployment-status.test.mjs @@ -1,10 +1,33 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { findDeployment, waitForDeployment } from "./deployment-status.mjs"; +import { activateDeployment, findDeployment, waitForDeployment } from "./deployment-status.mjs"; const id = "dpl_test123"; const options = { label: "Backend", intervalMs: 0, maxAttempts: 3 }; +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") }); +}); + +test("promotion waits until the checked deployment becomes the project's current target", async () => { + let promoted = false; + let reads = 0; + await activateDeployment(id, { ...options, project: "project", + read: async () => ({ id: "project", targets: { production: { id: ++reads < 3 ? "dpl_old" : id } } }), + promote: async () => { promoted = true; } }); + assert.equal(promoted, true); + assert.equal(reads, 3); +}); + +test("wrong projects, failed promotions and stale canonical targets cannot pass", async () => { + await assert.rejects(activateDeployment(id, { ...options, project: "project", read: async () => ({ id: "other" }), + promote: async () => assert.fail("wrong project must not be promoted") }), /different project/); + const input = { ...options, project: "project", read: async () => ({ id: "project" }) }; + await assert.rejects(activateDeployment(id, { ...input, promote: async () => { throw Error("Promotion denied"); } }), /Promotion denied/); + await assert.rejects(activateDeployment(id, { ...input, promote: async () => {} }), /canonical target/); +}); + test("deployment discovery ignores old runs and waits for the exact invocation to be indexed", async () => { let reads = 0; const older = { uid: "dpl_old", meta: { sourceCommit: "commit", sourceRun: "older" } };