Skip to content

Commit a4fbc6d

Browse files
jsollycursoragent
andauthored
fix(ops): set x-release-id via Edge Middleware (#94)
Vercel serves committed vercel.json headers as-is, so build-time stamps left production at "dev". Use Edge Middleware (or Astro middleware) reading VERCEL_GIT_COMMIT_SHA instead. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6097a8d commit a4fbc6d

7 files changed

Lines changed: 63 additions & 93 deletions

File tree

middleware.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { next } from "@vercel/edge";
2+
3+
/**
4+
* Request-time release id for static Vercel sites.
5+
* Vercel applies committed vercel.json headers as-is (build-time stamps do not stick),
6+
* so Edge Middleware reads the deployment's VERCEL_GIT_COMMIT_SHA instead.
7+
*/
8+
export default function middleware() {
9+
const response = next();
10+
const sha = process.env.VERCEL_GIT_COMMIT_SHA || process.env.GITHUB_SHA || "";
11+
const releaseId = sha ? sha.slice(0, 12) : "dev";
12+
response.headers.set("x-release-id", releaseId);
13+
return response;
14+
}

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@astrojs/sitemap": "^3.7.3",
2525
"@astrojs/svelte": "^9.0.0",
2626
"@tailwindcss/vite": "^4.3.1",
27+
"@vercel/edge": "^1.3.1",
2728
"astro": "^7.0.3",
2829
"canvas-confetti": "^1.9.4",
2930
"echarts": "^6.1.0",

scripts/check-release-id.mjs

Lines changed: 33 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,43 @@
11
#!/usr/bin/env node
22
/**
3-
* Contract: vercel.json wires x-release-id; production-like stamp ≠ "dev".
3+
* Contract: Edge Middleware (or Astro middleware) wires x-release-id;
4+
* production-like module stamp ≠ "dev".
45
*/
56
import { execFileSync } from "node:child_process";
6-
import { readFileSync } from "node:fs";
7+
import { existsSync, readFileSync } from "node:fs";
78
import { dirname, resolve } from "node:path";
89
import { fileURLToPath } from "node:url";
910

1011
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
11-
const vercelPath = resolve(repoRoot, "vercel.json");
1212

13-
function headerValue(config) {
14-
for (const block of config.headers ?? []) {
15-
for (const h of block.headers ?? []) {
16-
if (String(h.key).toLowerCase() === "x-release-id") return h.value;
17-
}
13+
const edgeMw = resolve(repoRoot, "middleware.ts");
14+
const astroMw = resolve(repoRoot, "src/middleware.ts");
15+
if (existsSync(edgeMw)) {
16+
const src = readFileSync(edgeMw, "utf8");
17+
if (!src.includes("x-release-id") || !src.includes("VERCEL_GIT_COMMIT_SHA")) {
18+
console.error("FAIL: root middleware.ts must set x-release-id from VERCEL_GIT_COMMIT_SHA");
19+
process.exit(1);
1820
}
19-
return undefined;
20-
}
21-
22-
const committed = JSON.parse(readFileSync(vercelPath, "utf8"));
23-
const committedVal = headerValue(committed);
24-
if (committedVal === undefined) {
25-
console.error("FAIL: vercel.json missing x-release-id header");
21+
} else if (existsSync(astroMw)) {
22+
const src = readFileSync(astroMw, "utf8");
23+
if (!src.includes("x-release-id")) {
24+
console.error("FAIL: src/middleware.ts must set x-release-id");
25+
process.exit(1);
26+
}
27+
} else {
28+
console.error("FAIL: missing middleware.ts (Edge) or src/middleware.ts (Astro)");
2629
process.exit(1);
2730
}
28-
if (committedVal !== "dev") {
29-
console.error(`FAIL: committed vercel.json x-release-id must be "dev" (got ${committedVal})`);
30-
process.exit(1);
31+
32+
const vercelPath = resolve(repoRoot, "vercel.json");
33+
if (existsSync(vercelPath)) {
34+
const vercel = readFileSync(vercelPath, "utf8");
35+
if (/x-release-id/i.test(vercel)) {
36+
console.error(
37+
"FAIL: do not put x-release-id in vercel.json (committed value sticks; use Edge Middleware)",
38+
);
39+
process.exit(1);
40+
}
3141
}
3242

3343
const sha = "abcdef1234567890deadbeef";
@@ -36,34 +46,18 @@ execFileSync(process.execPath, [resolve(repoRoot, "scripts/gen-release-id.mjs")]
3646
env: { ...process.env, VERCEL_GIT_COMMIT_SHA: sha },
3747
stdio: "inherit",
3848
});
39-
40-
const stamped = JSON.parse(readFileSync(vercelPath, "utf8"));
41-
const stampedVal = headerValue(stamped);
42-
const expected = sha.slice(0, 12);
43-
if (stampedVal !== expected) {
44-
console.error(`FAIL: stamped x-release-id expected ${expected}, got ${stampedVal}`);
45-
process.exit(1);
46-
}
47-
if (stampedVal === "dev") {
48-
console.error("FAIL: production-like stamp must not be dev");
49+
const stamped = readFileSync(resolve(repoRoot, "src/release-id.ts"), "utf8");
50+
if (!stamped.includes(`RELEASE_ID = "${sha.slice(0, 12)}"`)) {
51+
console.error("FAIL: module stamp missing expected SHA");
4952
process.exit(1);
5053
}
5154

5255
execFileSync(process.execPath, [resolve(repoRoot, "scripts/restore-release-stub.mjs")], {
5356
cwd: repoRoot,
54-
env: { ...process.env, VERCEL: "" },
5557
stdio: "inherit",
5658
});
57-
58-
const restored = JSON.parse(readFileSync(vercelPath, "utf8"));
59-
if (headerValue(restored) !== "dev") {
60-
console.error("FAIL: restore did not reset x-release-id to dev");
61-
process.exit(1);
62-
}
63-
64-
// Ensure module stub is back
65-
const mod = readFileSync(resolve(repoRoot, "src/release-id.ts"), "utf8");
66-
if (!mod.includes('RELEASE_ID = "dev"')) {
59+
const restored = readFileSync(resolve(repoRoot, "src/release-id.ts"), "utf8");
60+
if (!restored.includes('RELEASE_ID = "dev"')) {
6761
console.error("FAIL: release-id.ts stub not restored");
6862
process.exit(1);
6963
}

scripts/gen-release-id.mjs

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
#!/usr/bin/env node
22
/**
3-
* Build-time release id for static Vercel sites.
4-
* Stamps x-release-id into vercel.json headers and writes src/release-id.ts.
5-
* SHA: VERCEL_GIT_COMMIT_SHA → GITHUB_SHA → git rev-parse HEAD → "dev"
3+
* Writes src/release-id.ts with the current git commit SHA (12-char) for HTML meta.
4+
* Response header x-release-id is set by Edge Middleware (VERCEL_GIT_COMMIT_SHA) —
5+
* do not stamp vercel.json; Vercel serves committed headers as-is.
66
*/
77
import { execSync } from "node:child_process";
8-
import { readFileSync, writeFileSync } from "node:fs";
8+
import { writeFileSync } from "node:fs";
99
import { dirname, resolve } from "node:path";
1010
import { fileURLToPath } from "node:url";
1111

@@ -31,28 +31,8 @@ if (sha && !fromEnv) {
3131
if (dirty) releaseId += "-dirty";
3232
}
3333

34-
const modulePath = resolve(repoRoot, "src/release-id.ts");
3534
writeFileSync(
36-
modulePath,
35+
resolve(repoRoot, "src/release-id.ts"),
3736
`// Generated by scripts/gen-release-id.mjs — do not commit the generated version.\nexport const RELEASE_ID = ${JSON.stringify(releaseId)};\n`,
3837
);
39-
40-
const vercelPath = resolve(repoRoot, "vercel.json");
41-
const config = JSON.parse(readFileSync(vercelPath, "utf8"));
42-
if (!Array.isArray(config.headers)) config.headers = [];
43-
let catchAll = config.headers.find((h) => h.source === "/(.*)");
44-
if (!catchAll) {
45-
catchAll = { source: "/(.*)", headers: [] };
46-
config.headers.push(catchAll);
47-
}
48-
if (!Array.isArray(catchAll.headers)) catchAll.headers = [];
49-
const existing = catchAll.headers.find(
50-
(h) => String(h.key).toLowerCase() === "x-release-id",
51-
);
52-
if (existing) {
53-
existing.value = releaseId;
54-
} else {
55-
catchAll.headers.push({ key: "x-release-id", value: releaseId });
56-
}
57-
writeFileSync(vercelPath, `${JSON.stringify(config, null, "\t")}\n`);
5838
process.stdout.write(`release-id: ${releaseId}\n`);

scripts/restore-release-stub.mjs

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,12 @@
11
#!/usr/bin/env node
2-
/**
3-
* Restore committed stubs after build so generated release ids are never committed.
4-
* On Vercel, leave vercel.json stamped (headers are read from the build tree);
5-
* always restore src/release-id.ts so a failed local build cannot leave a dirty module.
6-
*/
7-
import { writeFileSync, readFileSync } from "node:fs";
2+
/** Restore src/release-id.ts stub after build. */
3+
import { writeFileSync } from "node:fs";
84
import { dirname, resolve } from "node:path";
95
import { fileURLToPath } from "node:url";
106

117
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
12-
138
writeFileSync(
149
resolve(repoRoot, "src/release-id.ts"),
1510
'// Stub — overwritten at build time by scripts/gen-release-id.mjs. Do not commit the generated version.\nexport const RELEASE_ID = "dev";\n',
1611
);
17-
18-
// Vercel serves headers from the post-build vercel.json — do not wipe the stamp there.
19-
if (process.env.VERCEL === "1") {
20-
process.stdout.write("release-id module stub restored (vercel.json left stamped)\n");
21-
process.exit(0);
22-
}
23-
24-
const vercelPath = resolve(repoRoot, "vercel.json");
25-
const config = JSON.parse(readFileSync(vercelPath, "utf8"));
26-
for (const block of config.headers ?? []) {
27-
for (const h of block.headers ?? []) {
28-
if (String(h.key).toLowerCase() === "x-release-id") {
29-
h.value = "dev";
30-
}
31-
}
32-
}
33-
writeFileSync(vercelPath, `${JSON.stringify(config, null, "\t")}\n`);
34-
process.stdout.write("release-id stubs restored\n");
12+
process.stdout.write("release-id stub restored\n");

vercel.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,6 @@
2727
{
2828
"key": "X-Content-Type-Options",
2929
"value": "nosniff"
30-
},
31-
{
32-
"key": "x-release-id",
33-
"value": "dev"
3430
}
3531
]
3632
}

0 commit comments

Comments
 (0)