Skip to content

Commit 6d1ed12

Browse files
authored
Merge pull request #99 from Coding-Professional/fix/idempotent-release-promotion
fix(ci): avoid conflicting promotion of current deployments
2 parents c933a9a + e1c9c4d commit 6d1ed12

3 files changed

Lines changed: 58 additions & 13 deletions

File tree

‎scripts/deploy-vercel.mjs‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import fs from "node:fs";
55
import os from "node:os";
66
import path from "node:path";
77
import { randomUUID } from "node:crypto";
8-
import { findDeployment, waitForDeployment } from "./deployment-status.mjs";
8+
import { activateDeployment, findDeployment, waitForDeployment } from "./deployment-status.mjs";
99

1010
const root = path.resolve(import.meta.dirname, "..");
1111
const localStaging = process.argv.includes("--local-staging");
@@ -46,7 +46,7 @@ function vercel(args, cwd, project, token, json = true) {
4646
encoding: "utf8", windowsHide: true, timeout: 12 * 60 * 1000,
4747
maxBuffer: 8 * 1024 * 1024,
4848
});
49-
if (result.status !== 0) throw new Error("Vercel command failed. Inspect the deployment in the project dashboard.");
49+
if (result.status !== 0) throw new Error(`Vercel ${args[0]} command failed. Inspect the deployment in the project dashboard.`);
5050
if (!json) return;
5151
let data;
5252
try { data = JSON.parse(result.stdout); } catch { /* Never dump raw provider logs. */ }
@@ -60,15 +60,18 @@ function authentication(tokenKey) {
6060
return requireValue(tokenKey);
6161
}
6262

63-
async function providerApi(endpoint, cwd, project, token, label) {
63+
async function providerApi(endpoint, cwd, project, token, label, method = "GET") {
6464
const url = new URL(endpoint, "https://api.vercel.com");
6565
url.searchParams.set("teamId", requireValue("VERCEL_ORG_ID"));
6666
if (!token) return vercel(["api", `${url.pathname}${url.search}`, "--method", "GET"], cwd, project);
6767
const response = await fetch(url, {
68-
headers: { Authorization: `Bearer ${token}` },
68+
method,
69+
headers: { Authorization: `Bearer ${token}`, ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
70+
...(method === "POST" ? { body: "{}" } : {}),
6971
signal: AbortSignal.timeout(30_000),
7072
});
7173
if (!response.ok) throw new Error(`${label} provider lookup failed (${response.status}).`);
74+
if (response.status === 204) return {};
7275
return response.json();
7376
}
7477

@@ -92,14 +95,13 @@ async function confirmReady(cwd, project, token, label, commit, run) {
9295
async function promote(deployment, cwd, project, token, label) {
9396
// A previous rollback can leave domains pinned to an older build. Activate only
9497
// this invocation's verified production build, then confirm the canonical target.
95-
vercel(["promote", deployment.id, "--yes"], cwd, project, token, false);
96-
for (let attempt = 0; attempt < 5; attempt++) {
97-
const current = await providerApi(`/v9/projects/${project}`, cwd, project, token, label);
98-
if (current.id !== project) throw new Error(`${label} target belongs to a different project.`);
99-
if (current.targets?.production?.id === deployment.id) return;
100-
if (attempt < 4) await new Promise(resolve => setTimeout(resolve, 2000));
101-
}
102-
throw new Error(`${label} canonical target does not serve the checked deployment.`);
98+
await activateDeployment(deployment.id, {
99+
project, label,
100+
read: () => providerApi(`/v9/projects/${project}`, cwd, project, token, label),
101+
promote: () => token
102+
? providerApi(`/v10/projects/${project}/promote/${deployment.id}`, cwd, project, token, `${label} promotion`, "POST")
103+
: vercel(["promote", deployment.id, "--yes"], cwd, project, token, false),
104+
});
103105
}
104106

105107
function deploySource() {

‎scripts/deployment-status.mjs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,24 @@
11
/** Confirm provider state instead of trusting the CLI's deployment snapshot. */
2+
export async function activateDeployment(id, {
3+
project, label, read, promote, maxAttempts = 30, intervalMs = 2000,
4+
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
5+
}) {
6+
const current = async () => {
7+
const record = await read();
8+
if (record.id !== project) throw new Error(`${label} target belongs to a different project.`);
9+
return record.targets?.production?.id === id;
10+
};
11+
// Some deployment runtimes assign production domains despite --skip-domain.
12+
// Promoting an already-current deployment returns 409; verify and finish instead.
13+
if (await current()) return;
14+
await promote();
15+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
16+
if (await current()) return;
17+
if (attempt + 1 < maxAttempts) await sleep(intervalMs);
18+
}
19+
throw new Error(`${label} canonical target does not serve the checked deployment.`);
20+
}
21+
222
export async function findDeployment({
323
read, commit, run, label, maxAttempts = 10, intervalMs = 1000,
424
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),

‎scripts/deployment-status.test.mjs‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,33 @@
11
import assert from "node:assert/strict";
22
import test from "node:test";
3-
import { findDeployment, waitForDeployment } from "./deployment-status.mjs";
3+
import { activateDeployment, findDeployment, waitForDeployment } from "./deployment-status.mjs";
44

55
const id = "dpl_test123";
66
const options = { label: "Backend", intervalMs: 0, maxAttempts: 3 };
77

8+
test("already-current production builds do not request a conflicting promotion", async () => {
9+
await activateDeployment(id, { ...options, project: "project", read: async () => ({ id: "project", targets: { production: { id } } }),
10+
promote: async () => assert.fail("already-current deployments must not be promoted again") });
11+
});
12+
13+
test("promotion waits until the checked deployment becomes the project's current target", async () => {
14+
let promoted = false;
15+
let reads = 0;
16+
await activateDeployment(id, { ...options, project: "project",
17+
read: async () => ({ id: "project", targets: { production: { id: ++reads < 3 ? "dpl_old" : id } } }),
18+
promote: async () => { promoted = true; } });
19+
assert.equal(promoted, true);
20+
assert.equal(reads, 3);
21+
});
22+
23+
test("wrong projects, failed promotions and stale canonical targets cannot pass", async () => {
24+
await assert.rejects(activateDeployment(id, { ...options, project: "project", read: async () => ({ id: "other" }),
25+
promote: async () => assert.fail("wrong project must not be promoted") }), /different project/);
26+
const input = { ...options, project: "project", read: async () => ({ id: "project" }) };
27+
await assert.rejects(activateDeployment(id, { ...input, promote: async () => { throw Error("Promotion denied"); } }), /Promotion denied/);
28+
await assert.rejects(activateDeployment(id, { ...input, promote: async () => {} }), /canonical target/);
29+
});
30+
831
test("deployment discovery ignores old runs and waits for the exact invocation to be indexed", async () => {
932
let reads = 0;
1033
const older = { uid: "dpl_old", meta: { sourceCommit: "commit", sourceRun: "older" } };

0 commit comments

Comments
 (0)