Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<team-slug> EXPECTED_DEPLOYMENT_ID=<dpl_...> pnpm verify:live
Expand Down
37 changes: 37 additions & 0 deletions scripts/check-live-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = [];
const recordingFetch = async (_url: string, init: RequestInit) => {
seen.push({ ...(init.headers as Record<string, string>) });
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",
);
});
});
32 changes: 30 additions & 2 deletions scripts/check-live-parity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,17 +203,45 @@ function inspectDeployment(url: string): Deployment {

type Fetcher = (url: string, init: RequestInit) => Promise<Response>;

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<string, string | undefined> = process.env,
): Record<string, string> {
const secret = environment[PROTECTION_BYPASS_ENV]?.trim();
return secret ? { [PROTECTION_BYPASS_HEADER]: secret } : {};
}

export async function fetchBytes(
url: string,
fetcher: Fetcher = fetch,
environment: Record<string, string | undefined> = 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) {
Expand Down