diff --git a/.github/workflows/deploy-verify.yml b/.github/workflows/deploy-verify.yml
new file mode 100644
index 00000000..a49c08f7
--- /dev/null
+++ b/.github/workflows/deploy-verify.yml
@@ -0,0 +1,51 @@
+name: Deploy verify
+
+# Node Foyer identity lane (copies NodeVoice PR #10's pattern). Vercel's GitHub
+# integration posts deployment_status events with no secrets needed to read them.
+# `gh api repos/HomenShum/NodeBenchAI/deployments?per_page=5` shows this repo's
+# production environment name is the bare string "Production" (a single Vercel
+# project, nodebench-ai, deploys from this repo — unlike NodeVoice's two-project
+# case, no environment-name disambiguation is needed here).
+#
+# KNOWN HAZARD (FOYER-V3 council ruling): .github/workflows/post-deploy-verify.yml
+# in this same repo runs `npx vercel rollback --yes` on a failed PRODUCTION
+# deployment_status event, and also runs on a 6-hourly schedule. This workflow is
+# a separate file with `on: deployment_status` only (no `schedule:`, no
+# `workflow_dispatch:`) and has no rollback step at all, so it can never reach
+# post-deploy-verify.yml's rollback path in either direction: this workflow never
+# fires from the schedule (it isn't triggered by one), and a failure here has no
+# rollback step to run. The two workflows run independently on the same event.
+on:
+ deployment_status:
+
+permissions:
+ contents: read
+ deployments: read
+
+jobs:
+ verify-live-identity:
+ if: >-
+ github.event.deployment_status.state == 'success' &&
+ github.event.deployment_status.environment == 'Production'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Poll the live meta tag for the deployed commit sha
+ env:
+ EXPECTED_SHA: ${{ github.event.deployment.sha }}
+ LIVE_URL: https://www.nodebenchai.com/
+ run: |
+ set -euo pipefail
+ deadline=$((SECONDS + 180))
+ found=""
+ while [ "$SECONDS" -lt "$deadline" ]; do
+ body="$(curl -fsS --max-time 10 "$LIVE_URL" || true)"
+ found="$(printf '%s' "$body" | grep -oE '}'" >&2
+ exit 1
diff --git a/vite.config.ts b/vite.config.ts
index ada9664f..3678c7e2 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -6,8 +6,52 @@ import { visualizer } from "rollup-plugin-visualizer";
import { imagetools } from "vite-imagetools";
import { VitePWA } from "vite-plugin-pwa";
import Critters from "critters";
+import { execFileSync } from "node:child_process";
///
+// Build identity, adapted from node-foyer's vite.config.ts (foyer-build-sha) via the
+// NodeVoice PR #10 pattern. Non-strict: falls back to "unavailable" rather than throwing
+// when no signal is available.
+const BUILD_SHA_PATTERN = /^[0-9a-f]{40}$/u;
+
+function resolveBuildSha(): string {
+ for (const value of [process.env.VERCEL_GIT_COMMIT_SHA, process.env.GITHUB_SHA]) {
+ const sha = value?.trim().toLowerCase();
+ if (sha && BUILD_SHA_PATTERN.test(sha)) return sha;
+ }
+ try {
+ const sha = execFileSync("git", ["rev-parse", "HEAD"], {
+ encoding: "utf8",
+ timeout: 5_000,
+ windowsHide: true,
+ }).trim();
+ if (BUILD_SHA_PATTERN.test(sha)) return sha;
+ } catch {
+ // fall through to unavailable
+ }
+ return "unavailable";
+}
+
+function buildIdentityMeta(): Plugin {
+ return {
+ name: "nodebench-build-identity",
+ transformIndexHtml() {
+ const sha = resolveBuildSha();
+ return [
+ {
+ tag: "meta",
+ attrs: {
+ name: "nodebench-build-sha",
+ content: sha,
+ "data-provenance": sha === "unavailable" ? "unavailable" : "commit",
+ },
+ injectTo: "head" as const,
+ },
+ ];
+ },
+ };
+}
+
// Critical CSS plugin using Critters
function criticalCSSPlugin(): Plugin {
return {
@@ -177,6 +221,7 @@ export default defineConfig(({ mode }) => {
envDir: __dirname,
plugins: [
react(),
+ buildIdentityMeta(),
// Service Worker + PWA for aggressive caching
VitePWA({
registerType: 'autoUpdate',