Skip to content

Commit 5e420e7

Browse files
bloveclaude
andauthored
fix(ci): open the protected immutable cockpit preview with its own automation bypass (#984)
With #983 the deploy job reached "Exhaustively verify immutable cockpit preview" for the first time, and it failed on its first probe: [preview] root default redirect: expected 308, received 302. The 302 is Vercel deployment protection sending every path on the unaliased artifact to vercel.com/sso-api — the same wall #974 removed for the Website preview. Bypass secrets are issued per Vercel project, so the Website secret cannot open the cockpit deployment. - deploy-smoke.ts sends `x-vercel-protection-bypass` on every probe when VERCEL_AUTOMATION_BYPASS_SECRET is set, read from the environment so the value never lands in argv or step logs. A 302 to the SSO endpoint now names deployment protection and the missing secret instead of reporting a bare status mismatch. - The workflow step supplies the secret from VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET and fails with a provisioning message when it is unset. - Unit tests cover the header on every probe (including the hostile-header case), its absence without a secret, and the SSO hint; the workflow guard asserts both preview checks carry their own project's secret. Requires the repository secret VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent ecbbbb0 commit 5e420e7

4 files changed

Lines changed: 143 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,7 +1111,17 @@ jobs:
11111111
- name: Exhaustively verify immutable cockpit preview
11121112
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true'
11131113
run: |
1114+
# Deployment protection answers every path on the unaliased artifact
1115+
# with 302 -> vercel.com/sso-api, which the smoke reports as
1116+
# "expected 308, received 302". Bypass secrets are issued per Vercel
1117+
# project, so the Website secret cannot open this one. Say so.
1118+
if [ -z "${VERCEL_AUTOMATION_BYPASS_SECRET}" ]; then
1119+
echo "::error::VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET is unset — the protected immutable cockpit preview cannot be verified. Enable 'Protection Bypass for Automation' on the Vercel threadplane-cockpit project and store the value as this repository secret."
1120+
exit 1
1121+
fi
11141122
npx tsx apps/cockpit/scripts/deploy-smoke.ts --url "${{ steps.deploy_cockpit.outputs.deployment_url }}" --mode preview --retries 20 --retry-delay-ms 5000
1123+
env:
1124+
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET }}
11151125
- name: Check this commit is still the tip before cockpit promotion
11161126
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true'
11171127
id: cockpit_promotion_freshness

apps/cockpit/scripts/deploy-smoke.spec.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,73 @@ describe('redirect deploy smoke contract', () => {
332332
).rejects.toThrow(/WAF Raw Path prerequisite/);
333333
});
334334

335+
it('sends the automation bypass on every probe only when a secret is supplied', async () => {
336+
// Vercel deployment protection answers every path on an unaliased
337+
// deployment with 302 -> vercel.com/sso-api, so the immutable cockpit
338+
// artifact can only be verified with the project's automation bypass.
339+
const cases = buildRedirectSmokeCases('preview');
340+
const withSecret = vi.fn(async (request: RedirectSmokeRequest) => {
341+
const { 'x-vercel-protection-bypass': bypass, ...rest } =
342+
request.headers ?? {};
343+
if (bypass !== 'cockpit-bypass-sentinel') {
344+
throw new Error(`Missing bypass on ${request.path}`);
345+
}
346+
return responseFor(
347+
{ ...request, headers: Object.keys(rest).length ? rest : undefined },
348+
cases
349+
);
350+
});
351+
352+
await expect(
353+
runDeploySmoke({
354+
url: previewUrl,
355+
mode: 'preview',
356+
requestImpl: withSecret,
357+
bypassSecret: 'cockpit-bypass-sentinel',
358+
})
359+
).resolves.toBe(`pass:preview:${previewUrl}:${cases.length}`);
360+
expect(withSecret).toHaveBeenCalledTimes(cases.length);
361+
expect(withSecret).toHaveBeenCalledWith(
362+
expect.objectContaining({
363+
path: '/langgraph/core-capabilities/streaming/overview/python',
364+
headers: expect.objectContaining({
365+
'x-forwarded-host': 'attacker.test',
366+
'x-vercel-protection-bypass': 'cockpit-bypass-sentinel',
367+
}),
368+
})
369+
);
370+
371+
const withoutSecret = vi.fn(async (request: RedirectSmokeRequest) =>
372+
responseFor(request, cases)
373+
);
374+
await runDeploySmoke({
375+
url: previewUrl,
376+
mode: 'preview',
377+
requestImpl: withoutSecret,
378+
});
379+
for (const [request] of withoutSecret.mock.calls) {
380+
expect(request.headers ?? {}).not.toHaveProperty(
381+
'x-vercel-protection-bypass'
382+
);
383+
}
384+
});
385+
386+
it('names Vercel deployment protection when a probe lands on the SSO redirect', async () => {
387+
const requestImpl = vi.fn(async () => ({
388+
status: 302,
389+
headers: {
390+
location:
391+
'https://vercel.com/sso-api?url=https%3A%2F%2Fimmutable-preview.vercel.app%2F&nonce=abc',
392+
},
393+
}));
394+
395+
await expect(
396+
runDeploySmoke({ url: previewUrl, mode: 'preview', requestImpl })
397+
).rejects.toThrow(
398+
/expected 308, received 302.*deployment protection.*automation bypass/i
399+
);
400+
});
401+
335402
it('formats dry-run output with the selected mode and case count', async () => {
336403
await expect(
337404
runDeploySmoke({ url: previewUrl, mode: 'preview', dryRun: true })

apps/cockpit/scripts/deploy-smoke.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ export interface DeploySmokeOptions {
4646
readonly retryDelayMs?: number;
4747
readonly requestImpl?: RedirectSmokeRequestImpl;
4848
readonly sleep?: (delayMs: number) => Promise<void>;
49+
/**
50+
* Vercel "Protection Bypass for Automation" secret for the project that
51+
* owns the deployment. Deployment protection answers every path on an
52+
* unaliased deployment with 302 -> vercel.com/sso-api, so the immutable
53+
* artifact can only be probed when each request carries this header. The
54+
* secret is issued per project: the cockpit one is not the Website one.
55+
*/
56+
readonly bypassSecret?: string;
4957
}
5058

5159
export interface ParsedDeploySmokeArgs {
@@ -57,6 +65,8 @@ export interface ParsedDeploySmokeArgs {
5765
}
5866

5967
const WEBSITE_ORIGIN = 'https://threadplane.ai';
68+
const BYPASS_HEADER = 'x-vercel-protection-bypass';
69+
const BYPASS_SECRET_ENV = 'VERCEL_AUTOMATION_BYPASS_SECRET';
6070
const DEFAULT_RETRIES = 0;
6171
const DEFAULT_RETRY_DELAY_MS = 2000;
6272
const ALL_MODES: readonly WorkspaceMode[] = ['Docs', 'Run', 'Code', 'API'];
@@ -383,8 +393,15 @@ const verifyCase = (
383393
? ' Raw Path rejection failed; verify the Vercel project WAF Raw Path prerequisite before promotion.'
384394
: '';
385395
if (response.status !== smokeCase.expectedStatus) {
396+
const protectionHint =
397+
response.status === 302 &&
398+
(response.headers.location ?? '').startsWith(
399+
'https://vercel.com/sso-api'
400+
)
401+
? ` The deployment answered with Vercel deployment protection, not the redirect service; supply the owning project's automation bypass secret via ${BYPASS_SECRET_ENV}.`
402+
: '';
386403
throw new RedirectContractError(
387-
`[${mode}] ${smokeCase.name}: expected ${smokeCase.expectedStatus}, received ${response.status}.${rawGateHint}`
404+
`[${mode}] ${smokeCase.name}: expected ${smokeCase.expectedStatus}, received ${response.status}.${rawGateHint}${protectionHint}`
388405
);
389406
}
390407
const location = response.headers.location;
@@ -411,6 +428,7 @@ export const runDeploySmoke = async ({
411428
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
412429
requestImpl = requestExactTarget,
413430
sleep = defaultSleep,
431+
bypassSecret,
414432
}: DeploySmokeOptions): Promise<string> => {
415433
const target = new URL(url);
416434
if (target.pathname !== '/' || target.search || target.hash) {
@@ -420,14 +438,21 @@ export const runDeploySmoke = async ({
420438
const cases = buildRedirectSmokeCases(mode);
421439
if (dryRun) return `dry-run:${mode}:${origin}:${cases.length}`;
422440

441+
const bypassHeaders: Readonly<Record<string, string>> | undefined =
442+
bypassSecret ? { [BYPASS_HEADER]: bypassSecret } : undefined;
443+
423444
for (const smokeCase of cases) {
445+
const headers =
446+
smokeCase.headers || bypassHeaders
447+
? { ...smokeCase.headers, ...bypassHeaders }
448+
: undefined;
424449
let attempt = 0;
425450
while (true) {
426451
try {
427452
const response = await requestImpl({
428453
origin,
429454
path: smokeCase.path,
430-
...(smokeCase.headers ? { headers: smokeCase.headers } : {}),
455+
...(headers ? { headers } : {}),
431456
});
432457
verifyCase(mode, smokeCase, response);
433458
break;
@@ -454,7 +479,10 @@ if (
454479
) {
455480
try {
456481
const options = parseDeploySmokeArgs(process.argv.slice(2));
457-
runDeploySmoke(options)
482+
// Read the secret from the environment, never argv, so it stays out of
483+
// process listings and CI step logs.
484+
const bypassSecret = process.env[BYPASS_SECRET_ENV] || undefined;
485+
runDeploySmoke({ ...options, ...(bypassSecret ? { bypassSecret } : {}) })
458486
.then((result) => process.stdout.write(`${result}\n`))
459487
.catch((error: unknown) => {
460488
const message = error instanceof Error ? error.message : String(error);

scripts/ci-workflow.spec.mjs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,41 @@ describe('CI workflow', () => {
556556
);
557557
});
558558

559+
it('verifies every protected immutable preview with its own automation bypass', async () => {
560+
// Vercel deployment protection answers every path on an unaliased
561+
// deployment with 302 -> vercel.com/sso-api. Bypass secrets are issued per
562+
// project, so the Website and cockpit checks each need their own, and a
563+
// missing one must fail with a message that says what to provision rather
564+
// than as an opaque "expected 308, received 302".
565+
const deployJob = await readDeployJob();
566+
const websiteStep = readNamedStep(
567+
deployJob,
568+
'Verify Website preview runtime embedding policy'
569+
);
570+
const cockpitStep = readNamedStep(
571+
deployJob,
572+
'Exhaustively verify immutable cockpit preview'
573+
);
574+
575+
assert.match(
576+
websiteStep,
577+
/VERCEL_AUTOMATION_BYPASS_SECRET:\s*\$\{\{ secrets\.VERCEL_AUTOMATION_BYPASS_SECRET \}\}/
578+
);
579+
assert.match(websiteStep, /-z "\$\{VERCEL_AUTOMATION_BYPASS_SECRET\}"/);
580+
assert.match(
581+
cockpitStep,
582+
/VERCEL_AUTOMATION_BYPASS_SECRET:\s*\$\{\{ secrets\.VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET \}\}/
583+
);
584+
assert.match(cockpitStep, /-z "\$\{VERCEL_AUTOMATION_BYPASS_SECRET\}"/);
585+
assert.match(cockpitStep, /::error::[^\n]*threadplane-cockpit/);
586+
assert.match(cockpitStep, /exit 1/);
587+
assert.doesNotMatch(
588+
cockpitStep,
589+
/secrets\.VERCEL_AUTOMATION_BYPASS_SECRET/,
590+
'the cockpit preview must not reuse the Website project secret'
591+
);
592+
});
593+
559594
it('gates Cockpit deployment on the production Website smoke even for Cockpit-only changes', async () => {
560595
const deployJob = await readDeployJob();
561596
const websiteOrCockpit =

0 commit comments

Comments
 (0)