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
5 changes: 4 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion docs/operations/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
55 changes: 55 additions & 0 deletions scripts/check-deployment.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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);
Expand Down
Loading