diff --git a/.github/workflows/cloudflare-deploy.yml b/.github/workflows/cloudflare-deploy.yml index 28d7b64..8fbbf4a 100644 --- a/.github/workflows/cloudflare-deploy.yml +++ b/.github/workflows/cloudflare-deploy.yml @@ -54,8 +54,36 @@ jobs: NUXT_PUBLIC_LEGAL_VAT_ID: ${{ vars.NUXT_PUBLIC_LEGAL_VAT_ID }} - name: Deploy to Cloudflare Pages - uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd + run: vp run deploy:cloudflare:page + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_PAGES_PROJECT_NAME: ${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME }} + + post-deploy-smoke-test: + name: Post-Deploy Smoke Test + needs: + - build-and-deploy + runs-on: ubuntu-24.04 + timeout-minutes: 6 + steps: + - name: Checkout the Codebase + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy .output/public --project-name=todde-tv + version: 0.1.19 + node-version: "24" + cache: true + run-install: false + + - name: Install dependencies + run: vp install --frozen-lockfile --prefer-offline + + - name: Verify public routes + env: + BASE_URL: https://todde.tv + run: vp run verify:cloudflare:smoke diff --git a/package.json b/package.json index 0ee05ab..7372e40 100644 --- a/package.json +++ b/package.json @@ -46,10 +46,11 @@ "pnpm": "10.x" }, "scripts": { - "build:ssg": "cross-env NODE_OPTIONS=--max-old-space-size=8192 nuxt generate && node scripts/generate-redirects.ts && node scripts/generate-redirects.ts --check", + "build:ssg": "cross-env NODE_OPTIONS=--max-old-space-size=8192 nuxt generate && vp dlx tsx@4.21.0 scripts/generate-redirects.ts && vp dlx tsx@4.21.0 scripts/generate-redirects.ts --check", "build:ssr": "cross-env NODE_OPTIONS=--max-old-space-size=8192 nuxt build", "dev": "nuxt dev", "dev:preset": "nuxt dev --port 3000 --public", + "deploy:cloudflare:page": "vp dlx tsx@4.21.0 scripts/deploy-cloudflare.ts", "fix:lint": "run-s \"test:lint --fix\"", "nuxi": "nuxi", "nuxt": "nuxt", @@ -59,8 +60,9 @@ "reset": "rimraf node_modules .nuxt .output dist .data", "test": "run-s test:*", "test:lint": "eslint .", - "test:redirects": "node scripts/generate-redirects.ts && node scripts/generate-redirects.ts --check", - "test:types": "nuxt typecheck" + "test:redirects": "vp dlx tsx@4.21.0 scripts/generate-redirects.ts && vp dlx tsx@4.21.0 scripts/generate-redirects.ts --check", + "test:types": "nuxt typecheck", + "verify:cloudflare:smoke": "vp dlx tsx@4.21.0 scripts/check-cloudflare-smoke.ts" }, "dependencies": { "nuxt": "~4.4.6", diff --git a/scripts/check-cloudflare-smoke.ts b/scripts/check-cloudflare-smoke.ts new file mode 100644 index 0000000..6e988dc --- /dev/null +++ b/scripts/check-cloudflare-smoke.ts @@ -0,0 +1,107 @@ +const DEFAULT_BASE_URL = 'https://todde.tv' +const DEFAULT_SMOKE_PATHS = [ + '/', + '/humans.txt', +] as const +const REQUEST_TIMEOUT_MS = 10_000 +const RETRY_DELAY_MS = 10_000 +const TOTAL_TIMEOUT_MS = 300_000 + +interface SmokeCheckResult { + ok: boolean + message: string +} + +/** Resolves the public base URL used for smoke checks. */ +function resolveBaseUrl(): URL { + const rawBaseUrl = process.env.BASE_URL?.trim() || DEFAULT_BASE_URL + + try { + return new URL(rawBaseUrl) + } + catch (error) { + throw new Error(`Invalid BASE_URL: ${rawBaseUrl}`, { cause: error }) + } +} + +/** Waits between smoke-check attempts. */ +function wait(delayMs: number): Promise { + return new Promise(resolve => setTimeout(resolve, delayMs)) +} + +/** Checks one public URL and reports status plus body presence. */ +async function checkUrl(url: string): Promise { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + const body = await response.text() + + if (response.status !== 200) { + return { + ok: false, + message: `${url} returned ${response.status}.`, + } + } + + if (body.length === 0) { + return { + ok: false, + message: `${url} returned an empty body.`, + } + } + + return { + ok: true, + message: `${url} returned 200.`, + } + } + catch (error) { + return { + ok: false, + message: `${url} failed: ${(error as Error).message}`, + } + } +} + +/** Builds the full set of URLs checked after the Pages deploy. */ +function buildSmokeUrls(baseUrl: URL): string[] { + return DEFAULT_SMOKE_PATHS.map(pathname => new URL(pathname, baseUrl).toString()) +} + +/** Retries the public smoke checks until success or timeout. */ +async function main(): Promise { + const baseUrl = resolveBaseUrl() + const smokeUrls = buildSmokeUrls(baseUrl) + const deadline = Date.now() + TOTAL_TIMEOUT_MS + let attempt = 1 + + while (true) { + const results = await Promise.all(smokeUrls.map(checkUrl)) + + if (results.every(result => result.ok)) { + for (const result of results) { + console.log(result.message) + } + return + } + + for (const result of results) { + if (!result.ok) { + console.log(`Attempt ${attempt}: ${result.message}`) + } + } + + if (Date.now() >= deadline) { + throw new Error( + `Public smoke check failed for ${smokeUrls.join(' and ')} after 5 minutes.`, + ) + } + + console.log(`Retrying in ${RETRY_DELAY_MS / 1000} seconds.`) + attempt += 1 + await wait(RETRY_DELAY_MS) + } +} + +await main() diff --git a/scripts/deploy-cloudflare.ts b/scripts/deploy-cloudflare.ts new file mode 100644 index 0000000..c494ff3 --- /dev/null +++ b/scripts/deploy-cloudflare.ts @@ -0,0 +1,165 @@ +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' + +const DEFAULT_PAGES_PROJECT_NAME = 'todde-tv' +const DEFAULT_ARTIFACT_PATH = '.output/public' +const PROJECT_LOOKUP_TIMEOUT_MS = 60_000 +const WRANGLER_PACKAGE = 'wrangler@4.86.0' + +const requiredEnvVars = [ + 'CLOUDFLARE_API_TOKEN', + 'CLOUDFLARE_ACCOUNT_ID', +] as const + +interface CloudflarePagesProjectApiResponse { + errors?: Array<{ + message?: string + }> + messages?: Array<{ + message?: string + }> + result?: Record + success?: boolean +} + +/** Verifies that all required Cloudflare credentials exist before deployment. */ +function assertRequiredEnvVars(): void { + for (const envVar of requiredEnvVars) { + if (!process.env[envVar]) { + throw new Error(`Missing ${envVar} in process environment.`) + } + } +} + +/** Resolves the Pages project name from the environment or repository default. */ +function resolveProjectName(): string { + const projectName = process.env.CLOUDFLARE_PAGES_PROJECT_NAME?.trim() || DEFAULT_PAGES_PROJECT_NAME + + if (projectName.length === 0) { + throw new Error('CLOUDFLARE_PAGES_PROJECT_NAME resolved to an empty value.') + } + + return projectName +} + +/** Fails fast when the static deployment artifact is missing. */ +function assertDeployArtifactExists(artifactPath: string): void { + if (!existsSync(artifactPath)) { + throw new Error( + `Cloudflare Pages artifact is missing at ${artifactPath}. Run \`vp run build:ssg\` first.`, + ) + } +} + +/** Normalizes Cloudflare API error and message payloads into a readable string. */ +function formatCloudflareApiMessages(payload: CloudflarePagesProjectApiResponse): string { + return [ + ...(payload.errors ?? []), + ...(payload.messages ?? []), + ] + .flatMap(item => item.message?.trim() ? [item.message.trim()] : []) + .join('\n') +} + +/** Checks that the configured Pages project exists before calling Wrangler. */ +async function assertPagesProjectExists(projectName: string): Promise { + const accountId = process.env.CLOUDFLARE_ACCOUNT_ID! + const apiToken = process.env.CLOUDFLARE_API_TOKEN! + + let response: Response + + try { + response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/${encodeURIComponent(projectName)}`, + { + signal: AbortSignal.timeout(PROJECT_LOOKUP_TIMEOUT_MS), + headers: { + Authorization: `Bearer ${apiToken}`, + }, + }, + ) + } + catch (error) { + if ( + error instanceof Error + && (error.name === 'AbortError' || error.name === 'TimeoutError') + ) { + throw new Error( + 'Cloudflare Pages project lookup timed out after ' + + `${PROJECT_LOOKUP_TIMEOUT_MS}ms for "${projectName}".`, + ) + } + + throw error + } + + const responseBody = await response.text() + let payload: CloudflarePagesProjectApiResponse + + try { + payload = JSON.parse(responseBody) as CloudflarePagesProjectApiResponse + } + catch { + throw new Error( + 'Cloudflare Pages project lookup returned invalid JSON. ' + + `Response status: ${response.status}.\n${responseBody}`, + ) + } + + if (!response.ok || payload.success === false || !payload.result) { + const apiMessages = formatCloudflareApiMessages(payload) + + throw new Error( + `Cloudflare Pages project lookup failed for "${projectName}". ` + + `Response status: ${response.status}.` + + (apiMessages.length > 0 ? `\n${apiMessages}` : ''), + ) + } +} + +/** Calls the pinned Wrangler CLI through `vp dlx` with repo-safe defaults. */ +function runWranglerDeploy(projectName: string, artifactPath: string, passthroughArgs: string[]): void { + const deployEnv = { + ...process.env, + CLOUDFLARE_PAGES_PROJECT_NAME: projectName, + WRANGLER_SEND_ERROR_REPORTS: 'false', + WRANGLER_SEND_METRICS: 'false', + } + + delete deployEnv.VP_COMMAND + delete deployEnv.VP_PACKAGE_NAME + + execFileSync(process.env.VP_CLI_BIN ?? 'vp', [ + 'dlx', + WRANGLER_PACKAGE, + 'pages', + 'deploy', + artifactPath, + '--project-name', + projectName, + ...passthroughArgs, + ], { + env: deployEnv, + shell: process.platform === 'win32', + stdio: 'inherit', + }) +} + +const passthroughArgs = process.argv.slice(2) + +if (passthroughArgs.some(arg => arg === '--project-name' || arg === '-p' || arg.startsWith('--project-name='))) { + throw new Error( + 'Do not pass --project-name to this script. ' + + 'Use CLOUDFLARE_PAGES_PROJECT_NAME instead.', + ) +} + +assertRequiredEnvVars() + +const projectName = resolveProjectName() +const artifactPath = resolve(process.cwd(), DEFAULT_ARTIFACT_PATH) + +assertDeployArtifactExists(artifactPath) +await assertPagesProjectExists(projectName) +runWranglerDeploy(projectName, artifactPath, passthroughArgs) diff --git a/scripts/generate-redirects.ts b/scripts/generate-redirects.ts index 4d97997..8ec5943 100644 --- a/scripts/generate-redirects.ts +++ b/scripts/generate-redirects.ts @@ -65,7 +65,7 @@ async function assertRedirectsFile(outputPath: string, expectedContent: string): if ((error as NodeJS.ErrnoException).code === 'ENOENT') { throw new Error( `Redirect artifact is missing at ${relativeOutputPath}. ` - + 'Run `node scripts/generate-redirects.ts` first.', + + 'Run `vp dlx tsx@4.21.0 scripts/generate-redirects.ts` first.', { cause: error }, ) } @@ -80,7 +80,7 @@ async function assertRedirectsFile(outputPath: string, expectedContent: string): const relativeOutputPath = relative(process.cwd(), outputPath) throw new Error( `Redirect artifact drift detected at ${relativeOutputPath}. ` - + 'Regenerate it with `node scripts/generate-redirects.ts`.', + + 'Regenerate it with `vp dlx tsx@4.21.0 scripts/generate-redirects.ts`.', ) } }