diff --git a/.github/workflows/deploy-verify.yml b/.github/workflows/deploy-verify.yml new file mode 100644 index 0000000..c4aaf82 --- /dev/null +++ b/.github/workflows/deploy-verify.yml @@ -0,0 +1,42 @@ +name: Deploy verify + +# Vercel's GitHub integration posts deployment_status events with no secrets needed to read +# them. This repo's own deployments (checked via +# `gh api repos/HomenShum/NodeProof/deployments?per_page=10` and each deployment's +# `/statuses`) show every production deploy's environment as the bare string "Production" — +# no sibling Vercel project shares this repo, so no name-contains filter is needed here +# (unlike NodeVoice's deploy-verify.yml, which does share a repo with local-collab-mvp). +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://proofloop.live/ + 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/package.json b/package.json index d132b6e..03b298c 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "doctor": "npm run proofloop:doctor", "check": "npm test && npm audit --omit=dev", "proof": "npm run proofloop:maturity", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && node scripts/stamp-build-sha.mjs", "prepublishOnly": "npm run build", "pretest": "npm run build", "test": "vitest run", diff --git a/scripts/stamp-build-sha.mjs b/scripts/stamp-build-sha.mjs new file mode 100644 index 0000000..2fbd41c --- /dev/null +++ b/scripts/stamp-build-sha.mjs @@ -0,0 +1,60 @@ +/** + * Stamps exactly one into public/index.html at build + * time, adapted from node-foyer's vite.config.ts foyer-build-sha plugin (there is no bundler + * here — public/ is a static Vercel deploy — so this runs as a `npm run build` step instead + * of a Vite transform). + * + * Precedence: VERCEL_GIT_COMMIT_SHA, then GITHUB_SHA, then `git rev-parse HEAD`, else + * "unavailable" (non-strict, matching NodeVoice's build-sha plugin: no gate here depends on + * this tag existing, so a git-less build environment ships "unavailable" rather than failing). + * + * Idempotent: strips any previously stamped tag before inserting the new one, so re-running + * `npm run build` (as `pretest` does) never produces a duplicate. + */ +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; + +const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); +const INDEX_HTML = resolve(ROOT, "public", "index.html"); +const BUILD_SHA_PATTERN = /^[0-9a-f]{40}$/u; +const META_TAG_RE = /\n?\s*]*>\r?\n?/gu; + +export function resolveBuildSha() { + 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, + cwd: ROOT, + }).trim(); + if (BUILD_SHA_PATTERN.test(sha)) return sha; + } catch { + // fall through to unavailable + } + return "unavailable"; +} + +export function stamp(sha, html) { + const provenance = sha === "unavailable" ? "unavailable" : "commit"; + const tag = ` \n`; + const stripped = html.replace(META_TAG_RE, "\n"); + return stripped.replace("", `${tag} `); +} + +function main() { + const sha = resolveBuildSha(); + const html = readFileSync(INDEX_HTML, "utf8"); + writeFileSync(INDEX_HTML, stamp(sha, html)); + console.log(`stamp-build-sha: wrote proofloop-build-sha=${sha} to public/index.html`); +} + +const invokedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]); +if (invokedDirectly) { + main(); +} diff --git a/tests/stampBuildSha.test.ts b/tests/stampBuildSha.test.ts new file mode 100644 index 0000000..7615727 --- /dev/null +++ b/tests/stampBuildSha.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { stamp } from "../scripts/stamp-build-sha.mjs"; + +const HEAD = "\n\n
\n