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
26 changes: 14 additions & 12 deletions scripts/deploy-vercel.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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. */ }
Expand All @@ -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();
}

Expand All @@ -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() {
Expand Down
20 changes: 20 additions & 0 deletions scripts/deployment-status.mjs
Original file line number Diff line number Diff line change
@@ -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)),
Expand Down
25 changes: 24 additions & 1 deletion scripts/deployment-status.test.mjs
Original file line number Diff line number Diff line change
@@ -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" } };
Expand Down