diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5830bfd..46ecfca 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,7 +50,10 @@ jobs: DEPLOYMENT_ALIAS_URL: ${{ steps.deploy.outputs.pages-deployment-alias-url }} EXPECTED_COMMIT: ${{ github.sha }} EXPECTED_BRANCH: ${{ github.ref_name }} - EXPECT_NOINDEX: ${{ github.ref != 'refs/heads/main' }} + # Wrangler returns an immutable deployment URL. Cloudflare treats + # that URL as a preview even when the source branch is main. + EXPECT_NOINDEX: "true" + PRODUCTION_URL: ${{ github.ref == 'refs/heads/main' && format('https://{0}.pages.dev', vars.CLOUDFLARE_PAGES_PROJECT || 'ldsa-website') || '' }} EXPECT_ANALYTICS: ${{ vars.CLOUDFLARE_WEB_ANALYTICS_TOKEN != '' }} CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} diff --git a/docs/operations/cloudflare.md b/docs/operations/cloudflare.md index 3f9c193..f9e6e02 100644 --- a/docs/operations/cloudflare.md +++ b/docs/operations/cloudflare.md @@ -50,7 +50,7 @@ In Cloudflare Zero Trust, protect preview deployments with Access. Complete the The checker sends the service credentials only in Cloudflare Access request headers. Never put either value in repository variables, source files, logs, or chat. Record the token expiry and rotate the GitHub secrets before it expires. -Cloudflare preview deployments should also send `X-Robots-Tag: noindex`. Verify the live response header before sharing the preview URL. +Cloudflare's immutable deployment URLs, including immutable URLs created from `main`, should send `X-Robots-Tag: noindex`. The public production alias (`ldsa-website.pages.dev`, followed by the custom production domain after cutover) must not send `noindex`. The deployment workflow verifies both sides of this contract. ## 4. Production-only observability diff --git a/scripts/check-deployment.mjs b/scripts/check-deployment.mjs index 6e38164..6625066 100644 --- a/scripts/check-deployment.mjs +++ b/scripts/check-deployment.mjs @@ -1,5 +1,6 @@ const baseArgument = process.argv[2] ?? process.env.DEPLOYMENT_URL; const aliasArgument = process.env.DEPLOYMENT_ALIAS_URL; +const productionArgument = process.env.PRODUCTION_URL; const expectedCommit = process.env.EXPECTED_COMMIT?.toLowerCase(); const expectedBranch = process.env.EXPECTED_BRANCH; const expectNoindex = process.env.EXPECT_NOINDEX === "true"; @@ -29,11 +30,15 @@ const normalizeBaseUrl = (value, label) => { let baseUrl; let aliasUrl; +let productionUrl; try { baseUrl = normalizeBaseUrl(baseArgument, "Deployment URL"); aliasUrl = aliasArgument ? normalizeBaseUrl(aliasArgument, "Deployment alias URL") : undefined; + productionUrl = productionArgument + ? normalizeBaseUrl(productionArgument, "Production URL") + : undefined; } catch (error) { console.error(error.message); process.exit(1); @@ -63,6 +68,13 @@ const request = (path, options = {}) => ...options, }); +const requestProduction = (path, options = {}) => + fetch(new URL(path, productionUrl), { + redirect: "manual", + signal: AbortSignal.timeout(15_000), + ...options, + }); + const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); @@ -215,6 +227,49 @@ if (aliasUrl && expectedBranch && expectedBranch !== "main") { } } +if (expectedBranch === "main") { + if (!productionUrl) { + errors.push("Main deployment verification requires PRODUCTION_URL."); + } else { + try { + const response = await requestProduction("/"); + if (response.status !== 200) { + errors.push(`Production homepage returned ${response.status}.`); + } else { + const robotsHeader = response.headers.get("x-robots-tag") ?? ""; + if (/\bnoindex\b/i.test(robotsHeader)) { + errors.push("Public production alias unexpectedly sends noindex."); + } + } + } catch (error) { + errors.push(`Production homepage request failed: ${error.message}`); + } + + try { + const response = await requestProduction("/deployment.json"); + if (response.status !== 200) { + errors.push( + `Production deployment metadata returned ${response.status}.`, + ); + } else { + const metadata = await response.json(); + if (expectedCommit && metadata.commit !== expectedCommit) { + errors.push( + `Expected production commit ${expectedCommit}, production alias reports ${metadata.commit}.`, + ); + } + if (metadata.branch !== "main") { + errors.push( + `Expected production branch main, production alias reports ${metadata.branch}.`, + ); + } + } + } catch (error) { + errors.push(`Production metadata check failed: ${error.message}`); + } + } +} + if (errors.length > 0) { console.error(`Deployment contract checks failed:\n- ${errors.join("\n- ")}`); process.exit(1);