From 6435013386a0a411265029e8405b3ceef8907b49 Mon Sep 17 00:00:00 2001 From: operator-os-explainer Date: Wed, 2 Sep 2026 18:57:31 -0700 Subject: [PATCH] feat(release): send the Deployment Protection bypass header in live parity checks Protected preview URLs answer with a 302 to Vercel SSO, which the parity check reports as a bare redirect. Send VERCEL_AUTOMATION_BYPASS_SECRET as x-vercel-protection-bypass when the environment carries it, name the variable in the failure when the redirect is Vercel SSO, and document the step in RELEASE.md. --- RELEASE.md | 7 ++++++ scripts/check-live-parity.test.ts | 37 +++++++++++++++++++++++++++++++ scripts/check-live-parity.ts | 32 ++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 524c2c5..506e494 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -99,6 +99,13 @@ A real publish is the same sequence, run deliberately by hand: fetchable versioned assets. Only after this proof may the two public aliases be pointed at that exact preview deployment. + Deployment Protection answers every generated `*.vercel.app` URL with a + redirect to Vercel SSO, and the check reports that as a failure that names + the fix: export `VERCEL_AUTOMATION_BYPASS_SECRET` with the value from the + project's Protection Bypass for Automation setting and rerun. The secret + travels only as a request header; never commit it or print it. The public + aliases in step 9 are not protected and need no secret. + 9. **Read back both live aliases.** With the Vercel CLI authenticated, run: VERCEL_TEAM_SCOPE= EXPECTED_DEPLOYMENT_ID= pnpm verify:live diff --git a/scripts/check-live-parity.test.ts b/scripts/check-live-parity.test.ts index 2550732..6ac5cf1 100644 --- a/scripts/check-live-parity.test.ts +++ b/scripts/check-live-parity.test.ts @@ -103,4 +103,41 @@ describe("live parity contract", () => { ).rejects.toThrow("redirected with HTTP 302"); expect(redirectMode).toBe("manual"); }); + + it("sends the protection bypass secret only when the environment carries one", async () => { + const seen: Array> = []; + const recordingFetch = async (_url: string, init: RequestInit) => { + seen.push({ ...(init.headers as Record) }); + return new Response("ok", { + status: 200, + headers: { "content-type": "text/plain" }, + }); + }; + + await fetchBytes("https://example.test/", recordingFetch, {}); + await fetchBytes("https://example.test/", recordingFetch, { + VERCEL_AUTOMATION_BYPASS_SECRET: " secret-value ", + }); + + expect(seen[0]).not.toHaveProperty("x-vercel-protection-bypass"); + expect(seen[1]["x-vercel-protection-bypass"]).toBe("secret-value"); + expect(seen[1]["user-agent"]).toBe("operator-os-explainer-live-parity/1"); + }); + + it("names the bypass variable when a deployment redirects to Vercel SSO", async () => { + const ssoFetch = async () => + new Response(null, { + status: 302, + headers: { + location: + "https://vercel.com/sso-api?url=https%3A%2F%2Fexample.vercel.app%2F&nonce=abc", + }, + }); + + await expect( + fetchBytes("https://example.vercel.app/", ssoFetch, {}), + ).rejects.toThrow( + "behind Vercel Deployment Protection (HTTP 302 to Vercel SSO); set VERCEL_AUTOMATION_BYPASS_SECRET", + ); + }); }); diff --git a/scripts/check-live-parity.ts b/scripts/check-live-parity.ts index 31a7477..789919f 100644 --- a/scripts/check-live-parity.ts +++ b/scripts/check-live-parity.ts @@ -203,17 +203,45 @@ function inspectDeployment(url: string): Deployment { type Fetcher = (url: string, init: RequestInit) => Promise; +const PROTECTION_BYPASS_HEADER = "x-vercel-protection-bypass"; +const PROTECTION_BYPASS_ENV = "VERCEL_AUTOMATION_BYPASS_SECRET"; +const VERCEL_SSO_REDIRECT = "https://vercel.com/sso-api"; + +/** + * Deployment Protection answers every generated *.vercel.app URL with a 302 + * to Vercel SSO, which this check treats as a failure. The project's + * "Protection Bypass for Automation" secret, when present in the + * environment, is sent as a header so a protected preview can be read. + * Custom-domain aliases are never protected and need no secret. + */ +export function protectionBypassHeaders( + environment: Record = process.env, +): Record { + const secret = environment[PROTECTION_BYPASS_ENV]?.trim(); + return secret ? { [PROTECTION_BYPASS_HEADER]: secret } : {}; +} + export async function fetchBytes( url: string, fetcher: Fetcher = fetch, + environment: Record = process.env, ): Promise<{ bytes: ArrayBuffer; type: string }> { const response = await fetcher(url, { redirect: "manual", - headers: { "user-agent": "operator-os-explainer-live-parity/1" }, + headers: { + "user-agent": "operator-os-explainer-live-parity/1", + ...protectionBypassHeaders(environment), + }, }); if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location") ?? "an unknown location"; + if (location.startsWith(VERCEL_SSO_REDIRECT)) { + throw new Error( + `${url} is behind Vercel Deployment Protection (HTTP ${response.status} to Vercel SSO); set ${PROTECTION_BYPASS_ENV} from the project's Protection Bypass for Automation setting`, + ); + } throw new Error( - `${url} redirected with HTTP ${response.status} to ${response.headers.get("location") ?? "an unknown location"}`, + `${url} redirected with HTTP ${response.status} to ${location}`, ); } if (!response.ok) {