diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb004d5..1d05081 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,23 +56,26 @@ Once `staging` is verified and fit for release: gh pr create --base main --head staging --title "Promote staging to production" --fill ``` -2. The `Promotion guard` check rejects any PR into `main` whose source branch is - not `staging` (it runs when GitHub Actions is available). +2. Two guards reject any PR into `main` whose source branch is not `staging`: + the `Promotion guard` check (when GitHub Actions is available) and + `scripts/vercel-promotion-guard.mjs` in the Vercel install step (always). 3. Get the required checks green, then merge. `main` deploys to production. ## Staging environment `staging` is deployed to and runs against -Stacks **testnet**, not mainnet. Production (`main`) runs against mainnet. +Stacks **testnet**, not mainnet. All other Vercel preview deployments (work +branches) also run against testnet; only production (`main`) runs against +mainnet. -The testnet configuration is stored as branch-scoped Vercel environment -variables for the `staging` branch: +The testnet configuration is stored as preview-scoped Vercel environment +variables, so it applies to `staging` and every other preview deployment: - `NEXT_PUBLIC_NETWORK=testnet` - `NEXT_PUBLIC_CONTRACT_DEPLOYER=ST36MRYTKD8X92Q6386RA9QV62A14NJMM9D9DYM3V` -- `NEXT_PUBLIC_CHAIN_ID=2147483648` -- `NEXT_PUBLIC_RPC_URL=https://api.testnet.hiro.so` + +`NEXT_PUBLIC_CHAIN_ID` and `NEXT_PUBLIC_RPC_URL` are not read by the frontend. The testnet contracts are deployed with: @@ -94,7 +97,8 @@ through **Deployment Protection Exceptions** when the team grows. Hotfixes follow the same path: branch off `staging`, open a PR into `staging`, verify, then promote `staging` to `main`. If production is broken and an expedited release is unavoidable, an administrator may temporarily relax branch -protection, but the fix must still be merged back into `staging` afterwards. +protection for a `staging` to `main` promotion, but the fix must still pass the +Vercel promotion guard and be merged back into `staging` afterwards. ## CI @@ -108,6 +112,10 @@ The checks live in `scripts/ci.sh` and run in two places: - Vercel runs `scripts/ci.sh all` as part of every deployment through `frontend/vercel.json`, so the required `Vercel` check covers the same three groups. +- The Vercel install step (`scripts/vercel-install.sh`) runs + `scripts/vercel-promotion-guard.mjs` before installing dependencies. It fails + the build when an open PR targets `main` from a branch other than `staging`, + so the promotion rule is enforced even while GitHub Actions is unavailable. While GitHub Actions is unavailable (for example, an account billing lock), branch protection requires only the `Vercel` check. Once Actions is healthy, diff --git a/frontend/vercel.json b/frontend/vercel.json index d614c46..48e16a6 100644 --- a/frontend/vercel.json +++ b/frontend/vercel.json @@ -1,4 +1,4 @@ { - "installCommand": "cd .. && npm ci --include=dev --prefer-offline --no-audit --no-fund && npm ci --prefix frontend --include=dev --prefer-offline --no-audit --no-fund && npm ci --prefix openclaw-service --include=dev --prefer-offline --no-audit --no-fund", + "installCommand": "cd .. && ./scripts/vercel-install.sh", "buildCommand": "cd .. && ./scripts/ci.sh all" } diff --git a/scripts/vercel-install.sh b/scripts/vercel-install.sh new file mode 100755 index 0000000..913f1c1 --- /dev/null +++ b/scripts/vercel-install.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +node scripts/vercel-promotion-guard.mjs +npm ci --include=dev --prefer-offline --no-audit --no-fund +npm ci --prefix frontend --include=dev --prefer-offline --no-audit --no-fund +npm ci --prefix openclaw-service --include=dev --prefer-offline --no-audit --no-fund diff --git a/scripts/vercel-promotion-guard.mjs b/scripts/vercel-promotion-guard.mjs new file mode 100755 index 0000000..4c73529 --- /dev/null +++ b/scripts/vercel-promotion-guard.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +const owner = process.env.VERCEL_GIT_REPO_OWNER; +const repo = process.env.VERCEL_GIT_REPO_SLUG; +const ref = process.env.VERCEL_GIT_COMMIT_REF; +const commitSha = process.env.VERCEL_GIT_COMMIT_SHA; +const pullRequestId = process.env.VERCEL_GIT_PULL_REQUEST_ID; + +const fail = (message) => { + console.error(`Promotion guard: ${message}`); + process.exit(1); +}; + +const allow = (message) => { + console.log(`Promotion guard: ${message}`); + process.exit(0); +}; + +if (!owner || !repo || !ref) { + allow("not a Vercel Git build; skipping"); +} + +const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls`; +const headers = { + Accept: "application/vnd.github+json", + "User-Agent": "stackstream-promotion-guard", +}; + +const request = async (url) => { + let response; + + try { + response = await fetch(url, { + headers, + signal: AbortSignal.timeout(10_000), + }); + } catch (error) { + fail(`GitHub request failed: ${error.message}`); + } + + if (!response.ok) { + fail(`GitHub API returned ${response.status}`); + } + + return response.json(); +}; + +const isSameRepoStaging = (pr) => + pr.head?.repo?.full_name === `${owner}/${repo}` && pr.head?.ref === "staging"; + +if (pullRequestId) { + const pr = await request(`${apiUrl}/${pullRequestId}`); + + if (pr.base?.ref === "main" && !isSameRepoStaging(pr)) { + fail( + `PR #${pr.number} targets main from ${pr.head?.repo?.full_name}:${pr.head?.ref}; only ${owner}/${repo}:staging may target main` + ); + } + + allow( + pr.base?.ref === "main" + ? `PR #${pr.number} is a staging promotion` + : `PR #${pr.number} targets ${pr.base?.ref}, not main` + ); +} + +if (ref === "main" || ref === "staging") { + allow(`${ref} is a protected deployment branch`); +} + +if (!commitSha) { + fail("missing VERCEL_GIT_COMMIT_SHA"); +} + +const url = new URL(apiUrl); +url.searchParams.set("state", "open"); +url.searchParams.set("base", "main"); +url.searchParams.set("per_page", "100"); + +const openPullRequests = await request(url); +const matchingPullRequests = openPullRequests.filter( + (pr) => pr.head?.sha === commitSha +); +const blockedPullRequest = matchingPullRequests.find( + (pr) => !isSameRepoStaging(pr) +); + +if (blockedPullRequest) { + fail( + `PR #${blockedPullRequest.number} targets main from ${blockedPullRequest.head?.repo?.full_name}:${blockedPullRequest.head?.ref}; only ${owner}/${repo}:staging may target main` + ); +} + +if (matchingPullRequests.length > 0) { + allow("commit is part of a staging promotion"); +} + +allow(`${ref} has no open promotion PR to main`);