// This short-lived CI command deliberately runs operations in sequence and writes progress to its log. /* oxlint-disable node/no-sync, no-console */ import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { previewName } from "./deployment.ts"; import { commentOnPreview } from "./preview-comment.ts"; const require = createRequire(import.meta.url); const cf = join(dirname(require.resolve("cf/package.json")), "bin", "cf"); function remoteHead(branch: string): string | undefined { return ( execFileSync("git", ["ls-remote", "--heads", "origin", `refs/heads/${branch}`], { encoding: "utf8" }) .trim() .split(/\s+/)[0] || undefined ); } async function deploy(branch: string, sha: string, preview: boolean): Promise { if (remoteHead(branch) !== sha) { console.info("Skipping deployment: the branch was deleted or has a newer commit."); return; } const mode = preview ? "preview" : "production"; execFileSync("pnpm", ["build", "--mode", mode], { stdio: "inherit", env: { ...process.env, CLOUDFLARE_PREVIEW_BUILD: String(preview) } }); if (remoteHead(branch) !== sha) { console.info("Skipping deployment: the branch changed during the build."); return; } if (preview) { const output = execFileSync( process.execPath, [ cf, "previews", "deploy", previewName(branch), "--prebuilt", "--mode", mode, "--worker", "crosscredit" ], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] } ); await commentOnPreview(output, branch, sha); return; } // JSON avoids dotenv escaping issues, and the file is never part of the build. const token = process.env.TMDB_READ_ACCESS_TOKEN; if (!token) { throw new Error("Configure the TMDB_READ_ACCESS_TOKEN repository secret."); } mkdirSync(".cloudflare", { recursive: true }); const directory = mkdtempSync(".cloudflare/ci-secrets-"); const secrets = join(directory, "secrets.json"); try { writeFileSync(secrets, JSON.stringify({ TMDB_READ_ACCESS_TOKEN: token }), { mode: 0o600 }); execFileSync( process.execPath, [cf, "deploy", "--prebuilt", "--mode", "production", "--secrets-file", secrets], { stdio: "inherit" } ); } finally { unlinkSync(secrets); rmdirSync(directory); } } async function deletePreview(branch: string, account: string, token: string): Promise { const name = previewName(branch); // Use the native endpoint directly so an absent Preview is safe to clean up again. const response = await fetch( `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(account)}/workers/workers/crosscredit/previews/${name}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(30_000), redirect: "error" } ); if (response.status === 404) { const body: unknown = await response.json(); if ( typeof body === "object" && body !== null && "errors" in body && Array.isArray(body.errors) && body.errors.some( (error: unknown) => typeof error === "object" && error !== null && "code" in error && error.code === 10_025 ) ) { console.info(`Preview ${name} is already absent.`); return; } } if (!response.ok) { throw new Error(`Preview deletion failed (HTTP ${response.status}).`); } console.info(`Deleted Preview ${name}.`); } async function run(): Promise { const preview = process.env.TANGLED_PIPELINE_KIND === "pull_request"; const action = process.env.CROSSCREDIT_CI_ACTION; if (action && action !== "delete-preview") { throw new Error("Unknown CI action."); } const cleanup = action === "delete-preview"; const branch = preview ? process.env.TANGLED_PR_SOURCE_BRANCH : process.env.TANGLED_REF_NAME; const sha = preview ? process.env.TANGLED_PR_SOURCE_SHA : process.env.TANGLED_SHA; if ( (preview ? process.env.TANGLED_PR_TARGET_BRANCH !== "main" || branch === "main" : process.env.TANGLED_PIPELINE_KIND !== "push" || process.env.TANGLED_REF_TYPE !== "branch" || branch !== "main" || cleanup) || !branch || !sha ) { throw new Error( "Expected a Tangled push to main or a pull request targeting main with a source SHA." ); } if (preview && !process.env.CLOUDFLARE_API_TOKEN) { console.info("Skipping preview operation: Tangled withheld deployment secrets for this PR."); return; } const token = process.env.CLOUDFLARE_API_TOKEN; const account = process.env.CLOUDFLARE_ACCOUNT_ID; if (!token || !account) { throw new Error("Configure CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID repository secrets."); } if (cleanup) { await deletePreview(branch, account, token); return; } for (const command of ["check", "typecheck", "test"]) { execFileSync("pnpm", [command], { stdio: "inherit" }); } await deploy(branch, sha, preview); } // CI entrypoint: finish the requested operation before exiting, including Preview cleanup. // oxlint-disable-next-line node/no-top-level-await await run();