Skip to content

Commit be848fe

Browse files
fix(ci): verify live deployment readiness before release checks
1 parent 2a1f0e0 commit be848fe

5 files changed

Lines changed: 91 additions & 5 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ jobs:
4141
- name: Unit tests
4242
run: npm test
4343

44+
- name: Deployment gate tests
45+
run: node --test scripts/*.test.mjs
46+
4447
- name: Production build
4548
run: npm run build
4649

‎public/fonts/OFL.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ with others.
1818

1919
The OFL allows the licensed fonts to be used, studied, modified and
2020
redistributed freely as long as they are not sold by themselves. The
21-
fonts, including any derivative works, can be bundled, embedded,
21+
fonts, including any derivative works, can be bundled, embedded,
2222
redistributed and/or sold with any software provided that any reserved
2323
names are not used by derivative works. The fonts and derivatives,
2424
however, cannot be released under any other type of license. The

‎scripts/deploy-vercel.mjs‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process";
44
import fs from "node:fs";
55
import os from "node:os";
66
import path from "node:path";
7+
import { waitForDeployment } from "./deployment-status.mjs";
78

89
const root = path.resolve(import.meta.dirname, "..");
910
const localStaging = process.argv.includes("--local-staging");
@@ -56,6 +57,28 @@ function authentication(tokenKey) {
5657
return requireValue(tokenKey);
5758
}
5859

60+
async function confirmReady(result, cwd, project, token, label) {
61+
return waitForDeployment(result.deployment?.id, {
62+
label,
63+
read: async id => {
64+
if (!token) {
65+
const inspected = vercel(["inspect", id, "--json"], cwd, project);
66+
return inspected.deployment ?? inspected;
67+
}
68+
const endpoint = new URL(`https://api.vercel.com/v13/deployments/${id}`);
69+
endpoint.searchParams.set("teamId", requireValue("VERCEL_ORG_ID"));
70+
const response = await fetch(endpoint, {
71+
headers: { Authorization: `Bearer ${token}` },
72+
signal: AbortSignal.timeout(30_000),
73+
});
74+
if (!response.ok) throw new Error(`${label} status lookup failed (${response.status}).`);
75+
const deployment = await response.json();
76+
if (deployment.projectId !== project) throw new Error(`${label} deployment belongs to a different project.`);
77+
return { id: deployment.id, readyState: deployment.readyState };
78+
},
79+
});
80+
}
81+
5982
function deploySource() {
6083
const result = spawnSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8", windowsHide: true });
6184
if (result.status !== 0) throw new Error("Cannot enumerate tracked deployment source.");
@@ -103,8 +126,9 @@ async function main() {
103126
const frontendSource = deploySource();
104127
fs.copyFileSync(path.join(backendSource, "vercel.backend.json"), path.join(backendSource, "vercel.json"));
105128
const deployArgs = ["deploy", "--prod", "--yes", "--json", "--meta", `sourceCommit=${sha}`, "--meta", `sourceBranch=${branch}`];
106-
const backend = vercel([...deployArgs, "--project", backendProject], backendSource, backendProject, authentication("VERCEL_BACKEND_TOKEN"));
107-
if (backend.status === "error" || backend.deployment?.readyState !== "READY") throw new Error("Backend deployment did not become ready.");
129+
const backendToken = authentication("VERCEL_BACKEND_TOKEN");
130+
const backend = vercel([...deployArgs, "--project", backendProject], backendSource, backendProject, backendToken);
131+
await confirmReady(backend, backendSource, backendProject, backendToken, "Backend");
108132
await check(`${backendOrigin}/api/platform/health`, async r => r.ok && (await r.json()).status === "ok");
109133
await check(`${backendOrigin}/api/platform/auth/session`, async r => {
110134
if (!r.ok) return false;
@@ -113,8 +137,9 @@ async function main() {
113137
});
114138
console.log("Backend is ready; database-backed session check passed.");
115139

116-
const frontend = vercel([...deployArgs, "--project", frontendProject], frontendSource, frontendProject, authentication("VERCEL_FRONTEND_TOKEN"));
117-
if (frontend.status === "error" || frontend.deployment?.readyState !== "READY") throw new Error("Frontend deployment did not become ready.");
140+
const frontendToken = authentication("VERCEL_FRONTEND_TOKEN");
141+
const frontend = vercel([...deployArgs, "--project", frontendProject], frontendSource, frontendProject, frontendToken);
142+
await confirmReady(frontend, frontendSource, frontendProject, frontendToken, "Frontend");
118143
await check(publicOrigin, async r => r.ok && (branch === "main" || r.headers.get("x-robots-tag")?.includes("noindex")));
119144
console.log(`Frontend is ready: ${publicOrigin}`);
120145
const summary = `### ${branch === "main" ? "Production" : "Staging"} deployment\n\nCommit: \`${sha}\`\n\n- Frontend: ${publicOrigin}\n- Backend: ${backendOrigin}\n- Health and database-backed session checks passed.\n`;

‎scripts/deployment-status.mjs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/** Confirm provider state instead of trusting the CLI's deployment snapshot. */
2+
export async function waitForDeployment(id, {
3+
read,
4+
label,
5+
maxAttempts = 60,
6+
intervalMs = 5000,
7+
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
8+
}) {
9+
if (!/^dpl_[a-zA-Z0-9]+$/.test(id ?? "")) throw new Error(`${label} deployment ID is missing or invalid.`);
10+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
11+
const deployment = await read(id);
12+
if (deployment.id !== id) throw new Error(`${label} status returned a different deployment.`);
13+
if (deployment.readyState === "READY") return deployment;
14+
if (["ERROR", "CANCELED", "CANCELLED"].includes(deployment.readyState)) {
15+
throw new Error(`${label} deployment failed (${deployment.readyState}).`);
16+
}
17+
if (attempt + 1 < maxAttempts) await sleep(intervalMs);
18+
}
19+
throw new Error(`${label} deployment did not become ready before the timeout.`);
20+
}

‎scripts/deployment-status.test.mjs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { waitForDeployment } from "./deployment-status.mjs";
4+
5+
const id = "dpl_test123";
6+
const options = { label: "Backend", intervalMs: 0, maxAttempts: 3 };
7+
8+
test("queued and building deployments are checked until the provider reports ready", async () => {
9+
const states = ["QUEUED", "BUILDING", "READY"];
10+
const seen = [];
11+
const result = await waitForDeployment(id, { ...options, read: async requested => {
12+
seen.push(requested);
13+
return { id, readyState: states.shift() };
14+
} });
15+
assert.equal(result.readyState, "READY");
16+
assert.deepEqual(seen, [id, id, id]);
17+
});
18+
19+
test("failed or canceled builds cannot pass the readiness gate", async () => {
20+
for (const readyState of ["ERROR", "CANCELED", "CANCELLED"]) {
21+
await assert.rejects(waitForDeployment(id, { ...options, read: async () => ({ id, readyState }) }), /deployment failed/);
22+
}
23+
});
24+
25+
test("a deployment that stays queued fails after bounded polling", async () => {
26+
let reads = 0;
27+
await assert.rejects(waitForDeployment(id, { ...options, read: async () => {
28+
reads++;
29+
return { id, readyState: "QUEUED" };
30+
} }), /timeout/);
31+
assert.equal(reads, options.maxAttempts);
32+
});
33+
34+
test("missing IDs, mismatched records and unavailable status lookups fail closed", async () => {
35+
await assert.rejects(waitForDeployment(undefined, { ...options, read: async () => assert.fail("unexpected lookup") }), /ID is missing/);
36+
await assert.rejects(waitForDeployment(id, { ...options, read: async () => ({ id: "dpl_other", readyState: "READY" }) }), /different deployment/);
37+
await assert.rejects(waitForDeployment(id, { ...options, read: async () => { throw new Error("Status unavailable"); } }), /Status unavailable/);
38+
});

0 commit comments

Comments
 (0)