Skip to content
Closed
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
36 changes: 32 additions & 4 deletions .github/workflows/cloudflare-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
107 changes: 107 additions & 0 deletions scripts/check-cloudflare-smoke.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return new Promise(resolve => setTimeout(resolve, delayMs))
}

/** Checks one public URL and reports status plus body presence. */
async function checkUrl(url: string): Promise<SmokeCheckResult> {
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<void> {
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()
165 changes: 165 additions & 0 deletions scripts/deploy-cloudflare.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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<void> {
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)
4 changes: 2 additions & 2 deletions scripts/generate-redirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
)
}
Expand All @@ -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`.',
)
}
}
Expand Down
Loading