From f50235eb7a362bf539f67c9e2ba0fa4544173895 Mon Sep 17 00:00:00 2001 From: Meredith McGee Date: Fri, 28 Aug 2026 14:34:28 -0400 Subject: [PATCH 1/5] feat: smoke trusted immutable Pages deployment --- .github/workflows/post-deploy-smoke.yml | 14 +- .planning/codebase/INTEGRATIONS.md | 12 +- docs/runbooks/release-smoke.md | 31 +++- scripts/pages-deployment-url.ts | 33 ++++ src/lib/__tests__/pagesDeployment.test.ts | 91 +++++++++++ src/lib/__tests__/workflowContracts.test.ts | 29 +++- src/lib/pagesDeployment.ts | 172 ++++++++++++++++++++ 7 files changed, 359 insertions(+), 23 deletions(-) create mode 100644 scripts/pages-deployment-url.ts create mode 100644 src/lib/__tests__/pagesDeployment.test.ts create mode 100644 src/lib/pagesDeployment.ts diff --git a/.github/workflows/post-deploy-smoke.yml b/.github/workflows/post-deploy-smoke.yml index 92b4430..005d80a 100644 --- a/.github/workflows/post-deploy-smoke.yml +++ b/.github/workflows/post-deploy-smoke.yml @@ -7,6 +7,7 @@ on: permissions: contents: read + checks: read jobs: sentinel: @@ -50,10 +51,9 @@ jobs: cache: npm - name: Install locked dependencies run: npm ci - - name: Wait for deployed SHA and smoke production - run: >- - npm run smoke -- - --environment production - --base-url https://ratemyplace.org - --expected-release ${{ github.event.workflow_run.head_sha }} - --wait-for-release-ms 600000 + - name: Resolve trusted immutable Pages deployment and smoke it + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + deployment_origin="$(npx tsx scripts/pages-deployment-url.ts --repository "${{ github.repository }}" --sha "${{ github.event.workflow_run.head_sha }}" --wait-ms 600000)" + npm run smoke -- --environment preview --base-url "$deployment_origin" --expected-release ${{ github.event.workflow_run.head_sha }} --wait-for-release-ms 600000 diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md index ff8ea2a..3657843 100644 --- a/.planning/codebase/INTEGRATIONS.md +++ b/.planning/codebase/INTEGRATIONS.md @@ -133,8 +133,16 @@ explicitly fails red when `quality` did not succeed and explicitly passes on success; it never checks out, installs dependencies, or runs smoke. The separate, success-only `smoke` job needs that sentinel and alone owns cancellable `production-smoke` - concurrency. It then waits for Cloudflare Pages to serve the exact commit SHA and runs - the read-only production smoke suite. + concurrency. With only `contents: read`, `checks: read`, and the built-in GitHub token, + it resolves the exact commit's single successful `Cloudflare Pages` check from + `cloudflare-workers-and-pages`, validates the advertised immutable eight-hex + `ratemyplace-64y.pages.dev` deployment origin, and runs the read-only preview smoke suite + against that exact release SHA. The canonical `ratemyplace.org` smoke remains a separate + manual check: Free-plan Bot Fight Mode can managed-challenge GitHub-hosted runners at its + health route, and this workflow neither changes Bot Fight Mode nor uses a Cloudflare API + credential. Future Cloudflare synthetic monitoring, if configured, is auxiliary + availability monitoring rather than an exact-release gate; a future machine-health + boundary must stay separately specified from exact-release verification. - The repository workflows do not deploy or roll back Cloudflare Pages. A `main` branch ruleset/required-check activation is not asserted here; Task 7 must verify that external configuration separately. diff --git a/docs/runbooks/release-smoke.md b/docs/runbooks/release-smoke.md index 4bf838c..6e726cb 100644 --- a/docs/runbooks/release-smoke.md +++ b/docs/runbooks/release-smoke.md @@ -25,7 +25,9 @@ $prHeadSha = (git rev-parse HEAD).Trim() npm run smoke -- --environment preview --base-url $previewOrigin --expected-release $prHeadSha ``` -For production, use the merged `main` SHA and the canonical production origin. +For an independent manual check of the canonical production domain, use the merged `main` +SHA and the canonical production origin. This is deliberately separate from the automated +exact-release gate. ```powershell npm ci @@ -76,10 +78,29 @@ red; a successful completion explicitly passes it. The sentinel has no checkout, dependency installation, or smoke step, so failed CI never performs those actions. Only the separate, success-gated `smoke` job needs the passing sentinel. It alone owns the -cancellable `production-smoke` concurrency group, then waits for Cloudflare Pages, -verifies that `/api/health` reports the triggering release SHA, and runs the full -read-only smoke suite. A failed post-deploy smoke means the release must not be called -healthy. It does not cause an automatic rollback. +cancellable `production-smoke` concurrency group. Using the built-in GitHub token with +read-only `checks: read`, it polls the triggering commit's GitHub check runs for exactly +one successful `Cloudflare Pages` check from `cloudflare-workers-and-pages`. The resolver +accepts only the immutable `https://<8-hex>.ratemyplace-64y.pages.dev` origin advertised by +that check, then verifies `/api/health` reports the triggering release SHA and runs the +full read-only smoke suite there. It fails closed for an untrusted, ambiguous, incomplete, +or malformed resolution and never deploys, rolls back, mutates Cloudflare configuration, or +uses a Cloudflare credential. + +Cloudflare Free-plan Bot Fight Mode may managed-challenge GitHub-hosted runners at the +canonical `/api/health` route. It remains unchanged: the automated exact-release gate uses +the trusted immutable Pages deployment instead. After an automated pass, perform the +separate manual canonical-domain command above from an independent network when the +canonical routing check is needed. Cloudflare synthetic monitoring may be added later as +auxiliary availability monitoring, but it is not an exact-release gate. + +Keep the machine-health boundary narrow: this release smoke proves public responses from a +specific immutable deployment. Any future machine-health endpoint or synthetic monitor must +be designed and documented as separate availability telemetry, not treated as evidence that +the canonical domain served a particular release. + +A failed post-deploy smoke means the release must not be called healthy. It does not cause +an automatic rollback. ## Failure triage and approval boundary diff --git a/scripts/pages-deployment-url.ts b/scripts/pages-deployment-url.ts new file mode 100644 index 0000000..ed02712 --- /dev/null +++ b/scripts/pages-deployment-url.ts @@ -0,0 +1,33 @@ +import { resolvePagesDeploymentOrigin, type PagesDeploymentOptions } from '../src/lib/pagesDeployment'; + +const flags = new Set(['--repository', '--sha', '--wait-ms']); + +function parseArgs(args: string[]): Omit { + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if (!flags.has(flag)) throw new Error('Unknown argument'); + if (!value || value.startsWith('--') || values.has(flag)) throw new Error('Invalid argument'); + values.set(flag, value); + } + const repository = values.get('--repository'); + const sha = values.get('--sha'); + const waitMs = values.get('--wait-ms'); + if (!repository || !sha || !waitMs || values.size !== flags.size) throw new Error('Missing required argument'); + if (!/^\d+$/.test(waitMs)) throw new Error('Invalid wait'); + return { repository, sha, waitMs: Number(waitMs) }; +} + +async function main(): Promise { + try { + const origin = await resolvePagesDeploymentOrigin({ ...parseArgs(process.argv.slice(2)), token: process.env.GITHUB_TOKEN ?? '' }); + console.log(origin); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to resolve Pages deployment'; + console.error(`Pages deployment resolver error: ${message}`); + process.exitCode = 1; + } +} + +void main(); diff --git a/src/lib/__tests__/pagesDeployment.test.ts b/src/lib/__tests__/pagesDeployment.test.ts new file mode 100644 index 0000000..5d87da1 --- /dev/null +++ b/src/lib/__tests__/pagesDeployment.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest'; +import { resolvePagesDeploymentOrigin, type PagesDeploymentDependencies } from '../pagesDeployment'; + +const SHA = '3c0350327bef3ed8bf6afa34a3723294cf49b59d'; +const ORIGIN = 'https://a1b2c3d4.ratemyplace-64y.pages.dev'; + +interface CheckRunFixture { + app?: { slug?: unknown }; + name?: unknown; + head_sha?: unknown; + status?: unknown; + conclusion?: unknown; + output?: { summary?: unknown }; +} + +const apiResponse = (checkRuns: CheckRunFixture[], totalCount = checkRuns.length) => new Response(JSON.stringify({ + total_count: totalCount, + check_runs: checkRuns, +}), { status: 200, headers: { 'content-type': 'application/json' } }); + +const trustedCheck = (overrides: CheckRunFixture = {}): CheckRunFixture => ({ + app: { slug: 'cloudflare-workers-and-pages' }, + name: 'Cloudflare Pages', + head_sha: SHA, + status: 'completed', + conclusion: 'success', + output: { summary: `Deployment URL: ${ORIGIN}` }, + ...overrides, +}); + +const dependencies = (responses: Array, start = 0): PagesDeploymentDependencies => { + let now = start; + return { + fetch: vi.fn(async () => { + const next = responses.shift(); + if (!next) throw new Error('unexpected request'); + if (next instanceof Error) throw next; + return next; + }), + now: () => now, + sleep: vi.fn(async (milliseconds: number) => { now += milliseconds; }), + }; +}; + +const resolve = (deps: PagesDeploymentDependencies, waitMs = 600_000) => resolvePagesDeploymentOrigin({ + repository: 'example/ratemyplace', + sha: SHA, + token: 'test-token', + waitMs, +}, deps); + +describe('resolvePagesDeploymentOrigin', () => { + it('returns the single trusted immutable deployment origin for the requested SHA', async () => { + await expect(resolve(dependencies([apiResponse([trustedCheck()])]))).resolves.toBe(ORIGIN); + }); + + it.each([ + ['missing trusted check', [apiResponse([]), apiResponse([trustedCheck()])]], + ['queued trusted check', [apiResponse([trustedCheck({ status: 'queued', conclusion: null })]), apiResponse([trustedCheck()])]], + ['in-progress trusted check', [apiResponse([trustedCheck({ status: 'in_progress', conclusion: null })]), apiResponse([trustedCheck()])]], + ])('retries %s inside the wait budget', async (_name, responses) => { + await expect(resolve(dependencies(responses), 20_000)).resolves.toBe(ORIGIN); + }); + + it('ignores untrusted checks while waiting for the Cloudflare Pages check', async () => { + const untrusted = trustedCheck({ app: { slug: 'someone-else' } }); + await expect(resolve(dependencies([apiResponse([untrusted]), apiResponse([trustedCheck()])]), 20_000)).resolves.toBe(ORIGIN); + }); + + it.each([ + ['incomplete pagination', apiResponse([trustedCheck()], 2), /incomplete check-run pagination/i], + ['wrong trusted head SHA', apiResponse([trustedCheck({ head_sha: 'a'.repeat(40) })]), /head SHA/i], + ['unsuccessful completion', apiResponse([trustedCheck({ conclusion: 'failure' })]), /did not succeed/i], + ['ambiguous trusted matches', apiResponse([trustedCheck(), trustedCheck()]), /multiple trusted/i], + ['missing summary', apiResponse([trustedCheck({ output: {} })]), /missing check summary/i], + ['malformed summary', apiResponse([trustedCheck({ output: { summary: 7 } })]), /malformed check summary/i], + ['invalid project hostname', apiResponse([trustedCheck({ output: { summary: 'https://a1b2c3d4.wrong-project.pages.dev' } })]), /invalid Pages hostname/i], + ['multiple deployment origins', apiResponse([trustedCheck({ output: { summary: `${ORIGIN} https://b2c3d4e5.ratemyplace-64y.pages.dev` } })]), /multiple immutable deployment origins/i], + ])('fails closed for %s', async (_name, response, expected) => { + await expect(resolve(dependencies([response]))).rejects.toThrow(expected); + }); + + it('fails safely when the GitHub API request fails', async () => { + await expect(resolve(dependencies([new Error('network unavailable')]))).rejects.toThrow(/GitHub check-run request failed/i); + }); + + it('fails after the bounded deadline while a trusted check remains pending', async () => { + const pending = apiResponse([trustedCheck({ status: 'queued', conclusion: null })]); + await expect(resolve(dependencies([pending]), 10_000)).rejects.toThrow(/deadline/i); + }); +}); diff --git a/src/lib/__tests__/workflowContracts.test.ts b/src/lib/__tests__/workflowContracts.test.ts index f312805..e541f98 100644 --- a/src/lib/__tests__/workflowContracts.test.ts +++ b/src/lib/__tests__/workflowContracts.test.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; const workflowPath = (...parts: string[]) => resolve(process.cwd(), '.github', 'workflows', ...parts); -const readWorkflow = (name: string) => readFileSync(workflowPath(name), 'utf8'); +const readWorkflow = (name: string) => readFileSync(workflowPath(name), 'utf8').replace(/\r\n/g, '\n'); const getWorkflowJob = (workflow: string, name: string) => { const jobsStart = workflow.indexOf('jobs:\n'); @@ -50,7 +50,7 @@ const findPermissionsDeclarations = (lines: string[]): PermissionsDeclaration[] return declarations; }; -const assertReadOnlyPermissionsBlock = (workflow: string) => { +const assertReadOnlyPermissionsBlock = (workflow: string, allowedReadPermissions: string[] = ['contents']) => { const lines = workflow.split(/\r?\n/); const declarations = findPermissionsDeclarations(lines); @@ -71,12 +71,16 @@ const assertReadOnlyPermissionsBlock = (workflow: string) => { blockEntries.push(line); } - expect(blockEntries).toHaveLength(1); - expect(blockEntries[0]).toMatch(/^[ \t]+contents[ \t]*:[ \t]*read[ \t]*(?:#.*)?$/); + expect(blockEntries).toHaveLength(allowedReadPermissions.length); + expect(blockEntries).toEqual(expect.arrayContaining( + allowedReadPermissions.map((permission) => expect.stringMatching( + new RegExp(`^[ \\t]+${permission}[ \\t]*:[ \\t]*read[ \\t]*(?:#.*)?$`), + )), + )); }; -const assertLeastPrivilege = (workflow: string) => { - assertReadOnlyPermissionsBlock(workflow); +const assertLeastPrivilege = (workflow: string, allowedReadPermissions?: string[]) => { + assertReadOnlyPermissionsBlock(workflow, allowedReadPermissions); expect(workflow).not.toMatch(/\bwrangler\b/i); expect(workflow).not.toMatch(/\b(?:d1|r2)\b/i); expect(workflow).not.toMatch(/(?:CLOUDFLARE|CF)_[A-Z_]*TOKEN/i); @@ -113,7 +117,7 @@ describe('release workflow contracts', () => { expect(workflow).toMatch(/^name: Post-deploy smoke$/m); expect(workflow).toMatch(/^ workflow_run:\n workflows: \[CI\]\n types: \[completed\]$/m); - assertLeastPrivilege(workflow); + assertLeastPrivilege(workflow, ['contents', 'checks']); expect(beforeJobs).not.toMatch(/^concurrency:/m); expect(sentinel).toMatch(/github\.event\.workflow_run\.event == 'push'/); @@ -139,14 +143,21 @@ describe('release workflow contracts', () => { expect(smoke).toMatch(/uses: actions\/setup-node@v7\n with:\n node-version-file: \.node-version\n cache: npm/); expect(smoke.indexOf('run: npm ci')).toBeGreaterThan(checkout); expect(smoke.indexOf('run: npm ci')).toBeLessThan(smokeCommand); - expect(smoke).toMatch(/--base-url https:\/\/ratemyplace\.org/); + expect(smoke).toMatch(/GITHUB_TOKEN: \$\{\{ github\.token \}\}/); + expect(smoke).toMatch(/scripts\/pages-deployment-url\.ts/); + expect(smoke).toMatch(/--repository \"\$\{\{ github\.repository \}\}\"/); + expect(smoke).toMatch(/--sha \"\$\{\{ github\.event\.workflow_run\.head_sha \}\}\"/); + expect(smoke).toMatch(/--wait-ms 600000/); + expect(smoke).toMatch(/--environment preview/); + expect(smoke).toMatch(/--base-url "\$deployment_origin"/); + expect(smoke).not.toMatch(/--base-url https:\/\/ratemyplace\.org/); expect(smoke).toMatch(/--expected-release \$\{\{ github\.event\.workflow_run\.head_sha \}\}/); expect(smoke).toMatch(/--wait-for-release-ms 600000/); }); it('keeps Cloudflare deployment and privileged operations out of repository workflows', () => { for (const name of ['ci.yml', 'post-deploy-smoke.yml']) { - assertLeastPrivilege(readWorkflow(name)); + assertLeastPrivilege(readWorkflow(name), name === 'post-deploy-smoke.yml' ? ['contents', 'checks'] : undefined); } }); diff --git a/src/lib/pagesDeployment.ts b/src/lib/pagesDeployment.ts new file mode 100644 index 0000000..6d7df73 --- /dev/null +++ b/src/lib/pagesDeployment.ts @@ -0,0 +1,172 @@ +import { validateSmokeTarget } from './smoke'; + +const API_ORIGIN = 'https://api.github.com'; +const FULL_SHA = /^[0-9a-f]{40}$/i; +const REPOSITORY = /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/; +const MAX_WAIT_MS = 600_000; +const RETRY_INTERVAL_MS = 10_000; +const TRUSTED_APP_SLUG = 'cloudflare-workers-and-pages'; +const TRUSTED_CHECK_NAME = 'Cloudflare Pages'; +const URL_IN_SUMMARY = /https:\/\/[^\s<>()[\]{}"']+/gi; + +export interface PagesDeploymentOptions { + repository: string; + sha: string; + token: string; + waitMs: number; +} + +export interface PagesDeploymentDependencies { + fetch: typeof fetch; + now: () => number; + sleep: (milliseconds: number) => Promise; +} + +interface CheckRun { + appSlug: string; + name: string; + headSha: string; + status: string; + conclusion: string | null; + summary: unknown; +} + +interface CheckRunsPage { + totalCount: number; + checkRuns: CheckRun[]; +} + +const defaultDependencies: PagesDeploymentDependencies = { + fetch, + now: Date.now, + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const optionalString = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined; + +function parseCheckRun(value: unknown): CheckRun | undefined { + if (!isRecord(value) || !isRecord(value.app)) return undefined; + const appSlug = optionalString(value.app.slug); + const name = optionalString(value.name); + const headSha = optionalString(value.head_sha); + const status = optionalString(value.status); + const conclusion = value.conclusion === null ? null : optionalString(value.conclusion); + const output = isRecord(value.output) ? value.output : undefined; + if (!appSlug || !name || !headSha || !status || conclusion === undefined) return undefined; + return { appSlug, name, headSha, status, conclusion, summary: output?.summary }; +} + +function parseCheckRunsPage(value: unknown): CheckRunsPage { + if (!isRecord(value) || !Number.isInteger(value.total_count) || (value.total_count as number) < 0 || !Array.isArray(value.check_runs)) { + throw new Error('Malformed GitHub check-run response'); + } + const checkRuns = value.check_runs.map(parseCheckRun).filter((run): run is CheckRun => run !== undefined); + if ((value.total_count as number) > value.check_runs.length) { + throw new Error('Incomplete check-run pagination'); + } + return { totalCount: value.total_count as number, checkRuns }; +} + +function validateOptions(options: PagesDeploymentOptions): void { + if (!REPOSITORY.test(options.repository)) throw new Error('Invalid repository'); + if (!FULL_SHA.test(options.sha)) throw new Error('Invalid commit SHA'); + if (!options.token) throw new Error('Missing GITHUB_TOKEN'); + if (!Number.isInteger(options.waitMs) || options.waitMs < 0 || options.waitMs > MAX_WAIT_MS) { + throw new Error('Wait must be an integer between 0 and 600000 milliseconds'); + } +} + +async function fetchCheckRuns( + options: PagesDeploymentOptions, + dependencies: PagesDeploymentDependencies, +): Promise { + const url = new URL(`/repos/${options.repository}/commits/${options.sha}/check-runs?per_page=100`, API_ORIGIN); + let response: Response; + try { + response = await dependencies.fetch(url, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${options.token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + } catch { + throw new Error('GitHub check-run request failed'); + } + if (!response.ok) throw new Error('GitHub check-run request failed'); + let body: unknown; + try { + body = await response.json(); + } catch { + throw new Error('Malformed GitHub check-run response'); + } + return parseCheckRunsPage(body); +} + +function isTrusted(run: CheckRun): boolean { + return run.appSlug === TRUSTED_APP_SLUG && run.name === TRUSTED_CHECK_NAME; +} + +function extractOrigin(summary: unknown): string { + if (summary === undefined) throw new Error('Missing check summary'); + if (typeof summary !== 'string') throw new Error('Malformed check summary'); + if (!summary.trim()) throw new Error('Malformed check summary'); + + const origins = new Set(); + for (const match of summary.matchAll(URL_IN_SUMMARY)) { + let url: URL; + try { + url = new URL(match[0]); + } catch { + throw new Error('Malformed check summary'); + } + if (!url.hostname.endsWith('.pages.dev')) continue; + if (url.pathname !== '/' || url.search || url.hash || url.username || url.password) { + throw new Error('Malformed Pages deployment origin'); + } + try { + origins.add(validateSmokeTarget('preview', url.origin).origin); + } catch { + throw new Error('Invalid Pages hostname'); + } + } + if (origins.size === 0) throw new Error('Missing immutable Pages deployment origin'); + if (origins.size > 1) throw new Error('Multiple immutable deployment origins'); + return [...origins][0]; +} + +function resolvePage(page: CheckRunsPage, sha: string): { origin?: string; retry: boolean } { + const trusted = page.checkRuns.filter(isTrusted); + if (trusted.some((run) => run.headSha.toLowerCase() !== sha.toLowerCase())) { + throw new Error('Trusted Cloudflare Pages check has the wrong head SHA'); + } + if (trusted.length === 0) return { retry: true }; + if (trusted.length > 1) throw new Error('Multiple trusted Cloudflare Pages checks found'); + + const [check] = trusted; + if (check.status === 'queued' || check.status === 'in_progress') return { retry: true }; + if (check.status !== 'completed') throw new Error('Trusted Cloudflare Pages check has an invalid status'); + if (check.conclusion !== 'success') throw new Error('Trusted Cloudflare Pages check did not succeed'); + return { origin: extractOrigin(check.summary), retry: false }; +} + +export async function resolvePagesDeploymentOrigin( + options: PagesDeploymentOptions, + injected?: PagesDeploymentDependencies, +): Promise { + validateOptions(options); + const dependencies = injected ?? defaultDependencies; + const deadline = dependencies.now() + options.waitMs; + + while (true) { + const resolved = resolvePage(await fetchCheckRuns(options, dependencies), options.sha); + if (resolved.origin) return resolved.origin; + const remaining = deadline - dependencies.now(); + if (!resolved.retry || remaining <= 0) throw new Error('Trusted Cloudflare Pages check deadline reached'); + await dependencies.sleep(Math.min(RETRY_INTERVAL_MS, remaining)); + if (dependencies.now() >= deadline) throw new Error('Trusted Cloudflare Pages check deadline reached'); + } +} From 4f546a3e9d722a08f3c0fedf0e017e7d1c531071 Mon Sep 17 00:00:00 2001 From: Meredith McGee Date: Fri, 28 Aug 2026 14:43:10 -0400 Subject: [PATCH 2/5] fix: bound Pages deployment resolution --- src/lib/__tests__/pagesDeployment.test.ts | 51 +++++++++++++ src/lib/pagesDeployment.ts | 87 +++++++++++++++++------ 2 files changed, 115 insertions(+), 23 deletions(-) diff --git a/src/lib/__tests__/pagesDeployment.test.ts b/src/lib/__tests__/pagesDeployment.test.ts index 5d87da1..0d50449 100644 --- a/src/lib/__tests__/pagesDeployment.test.ts +++ b/src/lib/__tests__/pagesDeployment.test.ts @@ -84,6 +84,57 @@ describe('resolvePagesDeploymentOrigin', () => { await expect(resolve(dependencies([new Error('network unavailable')]))).rejects.toThrow(/GitHub check-run request failed/i); }); + it('rejects a trusted response delivered after the resolver deadline', async () => { + let now = 0; + const delayedResponse: PagesDeploymentDependencies = { + fetch: vi.fn(async () => { + now = 11; + return apiResponse([trustedCheck()]); + }), + now: () => now, + sleep: vi.fn(async () => undefined), + }; + + await expect(resolve(delayedResponse, 10)).rejects.toThrow(/deadline/i); + }); + + it('aborts an unresolved GitHub request when the resolver deadline expires', async () => { + vi.useFakeTimers(); + try { + let now = 0; + let outcome: unknown; + const unresolvedRequest: PagesDeploymentDependencies = { + fetch: vi.fn((_input, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + now = 10; + reject(new Error('request aborted')); + }); + })), + now: () => now, + sleep: vi.fn(async () => undefined), + }; + void resolve(unresolvedRequest, 10).then( + () => { outcome = 'resolved'; }, + (error: unknown) => { outcome = error; }, + ); + + await vi.advanceTimersByTimeAsync(10); + + expect(outcome).toBeInstanceOf(Error); + expect((outcome as Error).message).toMatch(/deadline/i); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + ['missing trusted head SHA', trustedCheck({ head_sha: undefined })], + ['missing trusted status', trustedCheck({ status: undefined })], + ])('fails immediately for %s', async (_name, malformedTrustedCheck) => { + await expect(resolve(dependencies([apiResponse([malformedTrustedCheck])]), 10_000)) + .rejects.toThrow(/malformed trusted Cloudflare Pages check/i); + }); + it('fails after the bounded deadline while a trusted check remains pending', async () => { const pending = apiResponse([trustedCheck({ status: 'queued', conclusion: null })]); await expect(resolve(dependencies([pending]), 10_000)).rejects.toThrow(/deadline/i); diff --git a/src/lib/pagesDeployment.ts b/src/lib/pagesDeployment.ts index 6d7df73..0be0665 100644 --- a/src/lib/pagesDeployment.ts +++ b/src/lib/pagesDeployment.ts @@ -34,6 +34,11 @@ interface CheckRun { interface CheckRunsPage { totalCount: number; checkRuns: CheckRun[]; + hasMalformedTrustedCheck: boolean; +} + +interface MalformedTrustedCheck { + malformedTrustedCheck: true; } const defaultDependencies: PagesDeploymentDependencies = { @@ -47,7 +52,7 @@ const isRecord = (value: unknown): value is Record => const optionalString = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined; -function parseCheckRun(value: unknown): CheckRun | undefined { +function parseCheckRun(value: unknown): CheckRun | MalformedTrustedCheck | undefined { if (!isRecord(value) || !isRecord(value.app)) return undefined; const appSlug = optionalString(value.app.slug); const name = optionalString(value.name); @@ -55,7 +60,10 @@ function parseCheckRun(value: unknown): CheckRun | undefined { const status = optionalString(value.status); const conclusion = value.conclusion === null ? null : optionalString(value.conclusion); const output = isRecord(value.output) ? value.output : undefined; - if (!appSlug || !name || !headSha || !status || conclusion === undefined) return undefined; + const identifiesTrustedCheck = appSlug === TRUSTED_APP_SLUG && name === TRUSTED_CHECK_NAME; + if (!appSlug || !name || !headSha || !status || conclusion === undefined) { + return identifiesTrustedCheck ? { malformedTrustedCheck: true } : undefined; + } return { appSlug, name, headSha, status, conclusion, summary: output?.summary }; } @@ -63,11 +71,16 @@ function parseCheckRunsPage(value: unknown): CheckRunsPage { if (!isRecord(value) || !Number.isInteger(value.total_count) || (value.total_count as number) < 0 || !Array.isArray(value.check_runs)) { throw new Error('Malformed GitHub check-run response'); } - const checkRuns = value.check_runs.map(parseCheckRun).filter((run): run is CheckRun => run !== undefined); + const parsedCheckRuns = value.check_runs.map(parseCheckRun); + const checkRuns = parsedCheckRuns.filter((run): run is CheckRun => run !== undefined && !('malformedTrustedCheck' in run)); if ((value.total_count as number) > value.check_runs.length) { throw new Error('Incomplete check-run pagination'); } - return { totalCount: value.total_count as number, checkRuns }; + return { + totalCount: value.total_count as number, + checkRuns, + hasMalformedTrustedCheck: parsedCheckRuns.some((run) => run !== undefined && 'malformedTrustedCheck' in run), + }; } function validateOptions(options: PagesDeploymentOptions): void { @@ -82,28 +95,55 @@ function validateOptions(options: PagesDeploymentOptions): void { async function fetchCheckRuns( options: PagesDeploymentOptions, dependencies: PagesDeploymentDependencies, + deadline: number, ): Promise { + const remaining = deadline - dependencies.now(); + if (remaining <= 0) throw new Error('Trusted Cloudflare Pages check deadline reached'); const url = new URL(`/repos/${options.repository}/commits/${options.sha}/check-runs?per_page=100`, API_ORIGIN); - let response: Response; - try { - response = await dependencies.fetch(url, { - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${options.token}`, - 'X-GitHub-Api-Version': '2022-11-28', - }, - }); - } catch { - throw new Error('GitHub check-run request failed'); - } - if (!response.ok) throw new Error('GitHub check-run request failed'); - let body: unknown; + const controller = new AbortController(); + let timedOut = false; + let timer: ReturnType | undefined; + const request = (async (): Promise => { + let response: Response; + try { + response = await dependencies.fetch(url, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${options.token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + signal: controller.signal, + }); + } catch { + throw new Error('GitHub check-run request failed'); + } + if (!response.ok) throw new Error('GitHub check-run request failed'); + let body: unknown; + try { + body = await response.json(); + } catch { + throw new Error('Malformed GitHub check-run response'); + } + return parseCheckRunsPage(body); + })(); + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + timedOut = true; + controller.abort(); + reject(new Error('Trusted Cloudflare Pages check deadline reached')); + }, remaining); + }); + try { - body = await response.json(); - } catch { - throw new Error('Malformed GitHub check-run response'); + const page = await Promise.race([request, timeout]); + if (dependencies.now() >= deadline) throw new Error('Trusted Cloudflare Pages check deadline reached'); + return page; + } catch (error) { + if (timedOut) throw new Error('Trusted Cloudflare Pages check deadline reached'); + throw error; + } finally { + if (timer !== undefined) clearTimeout(timer); } - return parseCheckRunsPage(body); } function isTrusted(run: CheckRun): boolean { @@ -139,6 +179,7 @@ function extractOrigin(summary: unknown): string { } function resolvePage(page: CheckRunsPage, sha: string): { origin?: string; retry: boolean } { + if (page.hasMalformedTrustedCheck) throw new Error('Malformed trusted Cloudflare Pages check'); const trusted = page.checkRuns.filter(isTrusted); if (trusted.some((run) => run.headSha.toLowerCase() !== sha.toLowerCase())) { throw new Error('Trusted Cloudflare Pages check has the wrong head SHA'); @@ -162,7 +203,7 @@ export async function resolvePagesDeploymentOrigin( const deadline = dependencies.now() + options.waitMs; while (true) { - const resolved = resolvePage(await fetchCheckRuns(options, dependencies), options.sha); + const resolved = resolvePage(await fetchCheckRuns(options, dependencies, deadline), options.sha); if (resolved.origin) return resolved.origin; const remaining = deadline - dependencies.now(); if (!resolved.retry || remaining <= 0) throw new Error('Trusted Cloudflare Pages check deadline reached'); From 155a0da279ece4062b3384689c2479539bf11482 Mon Sep 17 00:00:00 2001 From: Meredith McGee Date: Fri, 28 Aug 2026 14:59:21 -0400 Subject: [PATCH 3/5] fix: reject normalized Pages targets --- src/lib/__tests__/pagesDeployment.test.ts | 1 + src/lib/__tests__/workflowContracts.test.ts | 11 ++++++++++- src/lib/pagesDeployment.ts | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/lib/__tests__/pagesDeployment.test.ts b/src/lib/__tests__/pagesDeployment.test.ts index 0d50449..7d2575c 100644 --- a/src/lib/__tests__/pagesDeployment.test.ts +++ b/src/lib/__tests__/pagesDeployment.test.ts @@ -75,6 +75,7 @@ describe('resolvePagesDeploymentOrigin', () => { ['missing summary', apiResponse([trustedCheck({ output: {} })]), /missing check summary/i], ['malformed summary', apiResponse([trustedCheck({ output: { summary: 7 } })]), /malformed check summary/i], ['invalid project hostname', apiResponse([trustedCheck({ output: { summary: 'https://a1b2c3d4.wrong-project.pages.dev' } })]), /invalid Pages hostname/i], + ['explicit default Pages port', apiResponse([trustedCheck({ output: { summary: 'https://a1b2c3d4.ratemyplace-64y.pages.dev:443' } })]), /invalid Pages hostname/i], ['multiple deployment origins', apiResponse([trustedCheck({ output: { summary: `${ORIGIN} https://b2c3d4e5.ratemyplace-64y.pages.dev` } })]), /multiple immutable deployment origins/i], ])('fails closed for %s', async (_name, response, expected) => { await expect(resolve(dependencies([response]))).rejects.toThrow(expected); diff --git a/src/lib/__tests__/workflowContracts.test.ts b/src/lib/__tests__/workflowContracts.test.ts index e541f98..0d7a69f 100644 --- a/src/lib/__tests__/workflowContracts.test.ts +++ b/src/lib/__tests__/workflowContracts.test.ts @@ -83,7 +83,7 @@ const assertLeastPrivilege = (workflow: string, allowedReadPermissions?: string[ assertReadOnlyPermissionsBlock(workflow, allowedReadPermissions); expect(workflow).not.toMatch(/\bwrangler\b/i); expect(workflow).not.toMatch(/\b(?:d1|r2)\b/i); - expect(workflow).not.toMatch(/(?:CLOUDFLARE|CF)_[A-Z_]*TOKEN/i); + expect(workflow).not.toMatch(/(?:CLOUDFLARE|CF)_[A-Z_]*(?:TOKEN|KEY)\b/i); expect(workflow).not.toMatch(/secrets\./i); expect(workflow).not.toMatch(/pull_request_target/i); expect(workflow).not.toMatch(/permissions:\s*write-all/i); @@ -211,6 +211,15 @@ describe('release workflow contracts', () => { expect(() => assertLeastPrivilege(dangerousWorkflow)).toThrow(); }); + it('rejects a Cloudflare API key credential', () => { + const dangerousWorkflow = readWorkflow('ci.yml').replace( + ' quality:\n', + ' quality:\n env:\n CLOUDFLARE_API_KEY: unsafe\n', + ); + + expect(() => assertLeastPrivilege(dangerousWorkflow)).toThrow(); + }); + it('allows harmless write-like shell text inside a run block', () => { const harmlessWorkflow = readWorkflow('ci.yml').replace( 'run: npm ci', diff --git a/src/lib/pagesDeployment.ts b/src/lib/pagesDeployment.ts index 0be0665..12597fc 100644 --- a/src/lib/pagesDeployment.ts +++ b/src/lib/pagesDeployment.ts @@ -168,7 +168,7 @@ function extractOrigin(summary: unknown): string { throw new Error('Malformed Pages deployment origin'); } try { - origins.add(validateSmokeTarget('preview', url.origin).origin); + origins.add(validateSmokeTarget('preview', match[0]).origin); } catch { throw new Error('Invalid Pages hostname'); } From 41b39fb393a24e781ef743682f87889afbe19eaa Mon Sep 17 00:00:00 2001 From: Meredith McGee Date: Fri, 28 Aug 2026 15:10:22 -0400 Subject: [PATCH 4/5] fix: accept Pages branch summary alias --- src/lib/__tests__/pagesDeployment.test.ts | 13 +++++++++++++ src/lib/pagesDeployment.ts | 2 ++ 2 files changed, 15 insertions(+) diff --git a/src/lib/__tests__/pagesDeployment.test.ts b/src/lib/__tests__/pagesDeployment.test.ts index 7d2575c..e4c06da 100644 --- a/src/lib/__tests__/pagesDeployment.test.ts +++ b/src/lib/__tests__/pagesDeployment.test.ts @@ -54,6 +54,17 @@ describe('resolvePagesDeploymentOrigin', () => { await expect(resolve(dependencies([apiResponse([trustedCheck()])]))).resolves.toBe(ORIGIN); }); + it('selects the immutable URL from a trusted Pages summary that also advertises its branch alias', async () => { + const summary = [ + 'Preview URL: https://0d4541c6.ratemyplace-64y.pages.dev', + 'Branch Preview URL: https://codex-phase-22a-smoke-delive.ratemyplace-64y.pages.dev', + ].join('\n'); + + await expect(resolve(dependencies([apiResponse([trustedCheck({ output: { summary } })])]))).resolves.toBe( + 'https://0d4541c6.ratemyplace-64y.pages.dev', + ); + }); + it.each([ ['missing trusted check', [apiResponse([]), apiResponse([trustedCheck()])]], ['queued trusted check', [apiResponse([trustedCheck({ status: 'queued', conclusion: null })]), apiResponse([trustedCheck()])]], @@ -76,6 +87,8 @@ describe('resolvePagesDeploymentOrigin', () => { ['malformed summary', apiResponse([trustedCheck({ output: { summary: 7 } })]), /malformed check summary/i], ['invalid project hostname', apiResponse([trustedCheck({ output: { summary: 'https://a1b2c3d4.wrong-project.pages.dev' } })]), /invalid Pages hostname/i], ['explicit default Pages port', apiResponse([trustedCheck({ output: { summary: 'https://a1b2c3d4.ratemyplace-64y.pages.dev:443' } })]), /invalid Pages hostname/i], + ['wrong-project branch alias', apiResponse([trustedCheck({ output: { summary: 'https://codex-phase-22a-smoke-delive.wrong-project.pages.dev' } })]), /invalid Pages hostname/i], + ['branch alias with explicit default port', apiResponse([trustedCheck({ output: { summary: 'https://codex-phase-22a-smoke-delive.ratemyplace-64y.pages.dev:443' } })]), /invalid Pages hostname/i], ['multiple deployment origins', apiResponse([trustedCheck({ output: { summary: `${ORIGIN} https://b2c3d4e5.ratemyplace-64y.pages.dev` } })]), /multiple immutable deployment origins/i], ])('fails closed for %s', async (_name, response, expected) => { await expect(resolve(dependencies([response]))).rejects.toThrow(expected); diff --git a/src/lib/pagesDeployment.ts b/src/lib/pagesDeployment.ts index 12597fc..0aece25 100644 --- a/src/lib/pagesDeployment.ts +++ b/src/lib/pagesDeployment.ts @@ -8,6 +8,7 @@ const RETRY_INTERVAL_MS = 10_000; const TRUSTED_APP_SLUG = 'cloudflare-workers-and-pages'; const TRUSTED_CHECK_NAME = 'Cloudflare Pages'; const URL_IN_SUMMARY = /https:\/\/[^\s<>()[\]{}"']+/gi; +const SAME_PROJECT_BRANCH_ALIAS = /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,62})\.ratemyplace-64y\.pages\.dev$/i; export interface PagesDeploymentOptions { repository: string; @@ -170,6 +171,7 @@ function extractOrigin(summary: unknown): string { try { origins.add(validateSmokeTarget('preview', match[0]).origin); } catch { + if (SAME_PROJECT_BRANCH_ALIAS.test(match[0])) continue; throw new Error('Invalid Pages hostname'); } } From a9735f19bb7a31d5953018290120beb7e2a69e6d Mon Sep 17 00:00:00 2001 From: Meredith McGee Date: Fri, 28 Aug 2026 15:16:25 -0400 Subject: [PATCH 5/5] fix: validate Pages branch alias labels --- src/lib/__tests__/pagesDeployment.test.ts | 8 ++++++++ src/lib/pagesDeployment.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib/__tests__/pagesDeployment.test.ts b/src/lib/__tests__/pagesDeployment.test.ts index e4c06da..d9ee260 100644 --- a/src/lib/__tests__/pagesDeployment.test.ts +++ b/src/lib/__tests__/pagesDeployment.test.ts @@ -65,6 +65,14 @@ describe('resolvePagesDeploymentOrigin', () => { ); }); + it('rejects a malformed branch alias even when the same summary has a valid immutable URL', async () => { + const summary = `${ORIGIN} https://branch-.ratemyplace-64y.pages.dev`; + + await expect(resolve(dependencies([apiResponse([trustedCheck({ output: { summary } })])]))).rejects.toThrow( + /invalid Pages hostname/i, + ); + }); + it.each([ ['missing trusted check', [apiResponse([]), apiResponse([trustedCheck()])]], ['queued trusted check', [apiResponse([trustedCheck({ status: 'queued', conclusion: null })]), apiResponse([trustedCheck()])]], diff --git a/src/lib/pagesDeployment.ts b/src/lib/pagesDeployment.ts index 0aece25..6cbac58 100644 --- a/src/lib/pagesDeployment.ts +++ b/src/lib/pagesDeployment.ts @@ -8,7 +8,7 @@ const RETRY_INTERVAL_MS = 10_000; const TRUSTED_APP_SLUG = 'cloudflare-workers-and-pages'; const TRUSTED_CHECK_NAME = 'Cloudflare Pages'; const URL_IN_SUMMARY = /https:\/\/[^\s<>()[\]{}"']+/gi; -const SAME_PROJECT_BRANCH_ALIAS = /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,62})\.ratemyplace-64y\.pages\.dev$/i; +const SAME_PROJECT_BRANCH_ALIAS = /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.ratemyplace-64y\.pages\.dev$/i; export interface PagesDeploymentOptions { repository: string;