diff --git a/.github/workflows/deploy-public-site.yml b/.github/workflows/deploy-public-site.yml index b0ba3325..0e54819c 100644 --- a/.github/workflows/deploy-public-site.yml +++ b/.github/workflows/deploy-public-site.yml @@ -9,6 +9,19 @@ name: Deploy public site # fixes (H2-after-H1 on /promptly/support/, JSON-LD on /contact/, real 404s, # portfolio-only homepage). # +# The lane is fail-closed and reversible: +# - Missing required secrets FAIL the run loudly. A skipped publish must +# never produce a green deploy status: green means "deployed and +# verified", nothing else. The old "dormant" green step hid the missing +# token behind green runs. +# - Before upload the lane captures the current production deployment +# identity as the rollback target and refuses to deploy without one +# (scripts/publish-public-site.mjs). +# - After upload the lane runs the live acceptance; on failure it restores +# the exact previous Pages deployment via the Cloudflare rollback API, +# re-verifies the restored identity, re-runs acceptance, and fails +# loudly if the rollback itself fails. +# # Required secrets (fail-closed when missing - a skipped publish must never # produce a green deploy status): # CLOUDFLARE_API_TOKEN - Cloudflare API token with Cloudflare Pages:Edit @@ -19,8 +32,8 @@ name: Deploy public site # # Missing wiring FAILS the run loudly instead of skipping the publish under # a green "dormant" status: green means "deployed and verified", nothing -# else. Until both secrets exist every run of this lane is red with the -# provisioning steps printed, and the nightly live-site-check.yml is the +# else. Until both secrets exist the lane fails every main merge loudly with +# the provisioning steps printed; the nightly live-site-check.yml is the # staleness alarm while the site is stale. on: @@ -78,6 +91,6 @@ jobs: if: env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_ACCOUNT_ID != '' run: node scripts/publish-public-site.mjs --prepare-only --output "$RUNNER_TEMP/tinystudio-public-bundle" - - name: Publish to Cloudflare Pages and verify live + - name: Publish to Cloudflare Pages and verify live (auto-rollback on acceptance failure) if: env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_ACCOUNT_ID != '' run: node scripts/publish-public-site.mjs --deploy --bundle "$RUNNER_TEMP/tinystudio-public-bundle" diff --git a/package.json b/package.json index e2ee7056..c9b7c4d8 100644 --- a/package.json +++ b/package.json @@ -93,9 +93,9 @@ "product:truth": "node scripts/check-product-truth.mjs", "mobbin:run": "node scripts/run-design-system-proving-lab.mjs", "mobbin:check": "node scripts/run-design-system-proving-lab.mjs --dry-run", - "ci": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/test-operator-check-strictness.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-brand-disambiguation.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && node scripts/test-public-social-preview.mjs && node scripts/test-public-soft-404.mjs && node scripts/test-public-deploy-bundle.mjs && node scripts/test-deploy-public-site-workflow.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check", + "ci": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/test-operator-check-strictness.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-brand-disambiguation.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && node scripts/test-public-social-preview.mjs && node scripts/test-public-soft-404.mjs && node scripts/test-public-deploy-bundle.mjs && node scripts/test-deploy-public-site-workflow.mjs && node scripts/test-pages-release.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check", "check": "npm test", - "test": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/test-operator-check-strictness.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-brand-disambiguation.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && node scripts/test-public-social-preview.mjs && node scripts/test-public-soft-404.mjs && node scripts/test-public-deploy-bundle.mjs && node scripts/test-deploy-public-site-workflow.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check", + "test": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/test-operator-check-strictness.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-brand-disambiguation.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && node scripts/test-public-social-preview.mjs && node scripts/test-public-soft-404.mjs && node scripts/test-public-deploy-bundle.mjs && node scripts/test-deploy-public-site-workflow.mjs && node scripts/test-pages-release.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check", "site:publish": "node scripts/publish-public-site.mjs", "site:publish:check": "node scripts/publish-public-site.mjs --prepare-only" }, diff --git a/scripts/publish-public-site.mjs b/scripts/publish-public-site.mjs index efe378db..4e9dac8f 100644 --- a/scripts/publish-public-site.mjs +++ b/scripts/publish-public-site.mjs @@ -6,6 +6,20 @@ // (independent of the Cloudflare GitHub integration), then verify the live // site against the neutral merged fixes. // +// Truthful and reversible release contract: +// 1. Missing required credential wiring fails loudly. A skipped publish +// must never produce a green run (the GitHub workflow fails closed +// too, see .github/workflows/deploy-public-site.yml). +// 2. Before upload the lane captures the current production deployment +// identity (canonical deployment id/url/source commit) as the rollback +// target and REFUSES to deploy when no safe rollback target can be +// proven. +// 3. After upload the lane runs the live acceptance +// (scripts/check-public-live-deploy.mjs). On failure it restores the +// exact previous production deployment via the supported Cloudflare +// Pages rollback API, re-verifies the restored identity, re-runs the +// acceptance against it, and surfaces any rollback failure loudly. +// // Credentials (same contract as the 0509 production lane): // CLOUDFLARE_API_TOKEN - must include Cloudflare Pages:Edit on the account // CLOUDFLARE_ACCOUNT_ID - f670a698e17bf160c8e4679823e68916 @@ -30,6 +44,7 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") export const PAGES_PROJECT = "tiny-studio-3f5" export const PAGES_ACCOUNT_ID = "f670a698e17bf160c8e4679823e68916" export const LIVE_BASE = "https://tinystudio.in" +export const CF_API_BASE = "https://api.cloudflare.com/api/v4" const PROVISION_MESSAGE = ` The release lane cannot publish yet: it needs a Cloudflare API token with @@ -48,7 +63,10 @@ One-time provisioning (dashboard, ~2 minutes): 5. Re-run this lane (or wait for the next main merge); no code change needed. ` -const run = (command, args, env = {}) => +const defaultWranglerBin = () => join(ROOT, "node_modules", ".bin", "wrangler") +const defaultAcceptanceCmd = () => [process.execPath, join(ROOT, "scripts", "check-public-live-deploy.mjs")] + +const defaultRunCommand = (command, args, env = {}) => new Promise((resolvePromise, reject) => { const child = spawn(command, args, { cwd: ROOT, @@ -61,18 +79,113 @@ const run = (command, args, env = {}) => ) }) -const wranglerBin = join(ROOT, "node_modules", ".bin", "wrangler") +// Injectable seams for hermetic tests (fake fetch, fake wrangler, fake +// acceptance); production uses the defaults below. +export const defaultDeps = () => ({ + fetchImpl: globalThis.fetch, + runCommand: defaultRunCommand, + wranglerBin: defaultWranglerBin(), + acceptanceCmd: defaultAcceptanceCmd(), +}) -const deployWithWrangler = async (bundleDir) => { - if (!existsSync(wranglerBin)) { - throw new Error("wrangler is not installed - run npm ci first") - } +export const requireDeployCredentials = () => { if (!process.env.CLOUDFLARE_API_TOKEN) { throw new Error(`Missing CLOUDFLARE_API_TOKEN.${PROVISION_MESSAGE}`) } if (!process.env.CLOUDFLARE_ACCOUNT_ID) { throw new Error(`Missing CLOUDFLARE_ACCOUNT_ID (set it to ${PAGES_ACCOUNT_ID}).${PROVISION_MESSAGE}`) } +} + +const cfHeaders = () => ({ Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}` }) + +// GET the Pages project. The documented `canonical_deployment` field is the +// most recent production deployment - the immutable rollback target. +export const fetchPagesProject = async (deps = {}) => { + const { fetchImpl = globalThis.fetch } = deps + const url = `${CF_API_BASE}/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/pages/projects/${PAGES_PROJECT}` + const res = await fetchImpl(url, { headers: cfHeaders() }) + if (!res.ok) { + throw new Error( + `Cloudflare Pages project lookup failed (HTTP ${res.status}) - no safe rollback target can be proven, refusing to deploy.${PROVISION_MESSAGE}` + ) + } + const json = await res.json() + if (!json || json.success !== true || !json.result) { + throw new Error( + `Cloudflare Pages project lookup returned an unexpected payload - no safe rollback target can be proven, refusing to deploy.` + ) + } + return json.result +} + +export const captureProductionIdentity = async (deps = {}) => { + const project = await fetchPagesProject(deps) + const deployment = project.canonical_deployment + if (!deployment || !deployment.id || !deployment.url) { + throw new Error( + `No current production deployment on Pages project ${PAGES_PROJECT} - there is no safe rollback target, refusing to deploy.${PROVISION_MESSAGE}` + ) + } + const metadata = (deployment.deployment_trigger && deployment.deployment_trigger.metadata) || {} + return { + deploymentId: deployment.id, + url: deployment.url, + commitHash: metadata.commit_hash || null, + createdOn: deployment.created_on || null, + } +} + +export const currentProductionIdentity = async (deps = {}) => { + const project = await fetchPagesProject(deps) + const deployment = project.canonical_deployment + if (!deployment || !deployment.id) return null + const metadata = (deployment.deployment_trigger && deployment.deployment_trigger.metadata) || {} + return { + deploymentId: deployment.id, + url: deployment.url, + commitHash: metadata.commit_hash || null, + createdOn: deployment.created_on || null, + } +} + +export const identitiesMatch = (a, b) => { + if (!a || !b) return false + if (a.deploymentId !== b.deploymentId || a.url !== b.url) return false + if (a.commitHash || b.commitHash) return a.commitHash === b.commitHash + return true +} + +// Restore the exact previous production deployment via the supported +// Cloudflare Pages rollback API (POST .../deployments/{id}/rollback). +export const rollbackTo = async (identity, deps = {}) => { + const { fetchImpl = globalThis.fetch } = deps + const url = `${CF_API_BASE}/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/pages/projects/${PAGES_PROJECT}/deployments/${encodeURIComponent(identity.deploymentId)}/rollback` + const res = await fetchImpl(url, { method: "POST", headers: cfHeaders() }) + if (!res.ok) { + let detail = "" + try { + detail = (await res.text()).slice(0, 300) + } catch { + // body unreadable; report the status alone + } + throw new Error(`Cloudflare Pages rollback API rejected the request (HTTP ${res.status}): ${detail}`) + } + const json = await res.json() + if (!json || json.success !== true || !json.result || json.result.id !== identity.deploymentId) { + throw new Error( + `Cloudflare Pages rollback API did not confirm deployment ${identity.deploymentId} (success=${json && json.success})` + ) + } + return json.result +} + +export const deployWithWrangler = async (bundleDir, deps = {}) => { + const wranglerBin = deps.wranglerBin || defaultWranglerBin() + if (!existsSync(wranglerBin)) { + throw new Error("wrangler is not installed - run npm ci first") + } + requireDeployCredentials() // Attach the source commit to the Pages deployment for dashboard provenance. const manifest = JSON.parse(readFileSync(join(bundleDir, "deploy-manifest.json"), "utf8")) const args = ["pages", "deploy", bundleDir, "--project-name", PAGES_PROJECT, "--branch", "main"] @@ -86,8 +199,9 @@ const deployWithWrangler = async (bundleDir) => { "true" ) } + const { runCommand = defaultRunCommand } = deps try { - await run( + await runCommand( wranglerBin, args, { CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV: "false" } @@ -101,9 +215,67 @@ const deployWithWrangler = async (bundleDir) => { } } -const verifyLive = async () => { - const check = join(ROOT, "scripts", "check-public-live-deploy.mjs") - await run(process.execPath, [check], {}) +// Live verification of the deployed site after upload: runs the acceptance +// checker (scripts/check-public-live-deploy.mjs), which probes the live +// site against the neutral merged fixes. A failed verification triggers the +// rollback path. Name kept as `verifyLive` to match the fail-closed lane +// contract asserted by test-deploy-public-site-workflow.mjs. +export const verifyLive = async (deps = {}) => { + const { runCommand = defaultRunCommand, acceptanceCmd = defaultAcceptanceCmd() } = deps + await runCommand(acceptanceCmd[0], acceptanceCmd.slice(1), {}) +} + +// The release pipeline: credentials -> capture rollback target -> upload -> +// acceptance -> rollback + re-verify on failure. Throws on any failure; the +// CLI turns that into a loud non-zero exit. +export const releasePipeline = async ({ bundleDir, deps = {}, log = console.log, err = console.error }) => { + requireDeployCredentials() + log(`[publish] capturing current production deployment identity as rollback target (project ${PAGES_PROJECT})`) + const previous = await captureProductionIdentity(deps) + log(`[publish] rollback target: ${previous.deploymentId} url=${previous.url} source=${previous.commitHash || "unknown"}`) + log("[publish] uploading bundle") + await deployWithWrangler(bundleDir, deps) + log("[publish] running live acceptance against the neutral merged fixes") + try { + await verifyLive(deps) + } catch (error) { + err(`[publish] acceptance FAILED: ${error.message}`) + err(`[publish] restoring the previous production deployment ${previous.deploymentId}`) + try { + await rollbackTo(previous, deps) + } catch (rollbackError) { + throw new Error( + `ROLLBACK FAILED: the release did not pass acceptance and the previous production deployment ${previous.deploymentId} could NOT be restored (${rollbackError.message}). Manual intervention required; the live site may be broken.` + ) + } + log("[publish] verifying the restored deployment identity") + let restored = null + try { + restored = await currentProductionIdentity(deps) + } catch (verifyError) { + throw new Error( + `ROLLBACK VERIFICATION FAILED: could not re-read the production identity after rollback (${verifyError.message}). Manual intervention required.` + ) + } + if (!identitiesMatch(previous, restored)) { + throw new Error( + `ROLLBACK VERIFICATION FAILED: expected production ${previous.deploymentId} (${previous.url}) but got ${restored ? restored.deploymentId : "none"} (${restored ? restored.url : "none"}). Manual intervention required.` + ) + } + log(`[publish] rollback verified: production is ${restored.deploymentId} (${restored.url})`) + err(`[publish] re-running acceptance against the restored identity ${restored.deploymentId}`) + try { + await verifyLive(deps) + } catch (restoredError) { + err( + `[publish] NOTE: the restored deployment ${restored.deploymentId} also fails acceptance (${restoredError.message}); this is the pre-release state - the rollback itself was verified.` + ) + } + throw new Error( + `release failed acceptance; production was rolled back to ${restored.deploymentId} (${restored.url}) and the restored identity was re-verified` + ) + } + log("[publish] done: live site matches the verified bundle") } const parseArgs = (argv) => { @@ -127,17 +299,14 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1 await preparePublicDeployBundle({ sourceDir: join(ROOT, "public"), outputDir }) console.log(`[publish] bundle ready: ${outputDir}`) if (args.mode === "prepare") process.exit(0) - await deployWithWrangler(outputDir) + await releasePipeline({ bundleDir: outputDir }) } else if (args.mode === "deploy") { if (!args.bundle) throw new Error("--deploy requires --bundle ") if (!existsSync(join(args.bundle, "deploy-manifest.json"))) { throw new Error(`not a deploy bundle (missing deploy-manifest.json): ${args.bundle}`) } - await deployWithWrangler(args.bundle) + await releasePipeline({ bundleDir: args.bundle }) } - console.log("[publish] verifying live site against neutral merged fixes") - await verifyLive() - console.log("[publish] done: live site matches the verified bundle") } catch (error) { console.error(`[publish] ${error.message}`) process.exit(typeof error.exitCode === "number" ? error.exitCode : 1) diff --git a/scripts/test-pages-release.mjs b/scripts/test-pages-release.mjs new file mode 100644 index 00000000..35486166 --- /dev/null +++ b/scripts/test-pages-release.mjs @@ -0,0 +1,430 @@ +// Guard the tinystudio.in release lane contract: fail truthfully when the +// credential wiring is missing, capture a provable rollback target before +// upload, and restore the exact previous production Pages deployment (then +// re-verify it) when live acceptance fails after upload. +// +// Hermetic: no network, no real child processes. Cloudflare is faked with a +// scripted fetch; wrangler and the live acceptance are faked with a scripted +// runCommand. These tests FAIL on the pre-change script (it has no +// releasePipeline/capture/rollback surface at all). +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { dirname } from "node:path" + +import { + CF_API_BASE, + PAGES_ACCOUNT_ID, + PAGES_PROJECT, + captureProductionIdentity, + currentProductionIdentity, + identitiesMatch, + releasePipeline, + requireDeployCredentials, + rollbackTo, +} from "./publish-public-site.mjs" + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") + +let failures = 0 +let checks = 0 +const ok = (cond, msg) => { + checks++ + if (cond) console.log(` ok ${msg}`) + else { + failures++ + console.error(` FAIL ${msg}`) + } +} + +const projectUrl = () => `${CF_API_BASE}/accounts/${PAGES_ACCOUNT_ID}/pages/projects/${PAGES_PROJECT}` +const rollbackUrl = (id) => `${CF_API_BASE}/accounts/${PAGES_ACCOUNT_ID}/pages/projects/${PAGES_PROJECT}/deployments/${id}/rollback` + +const savedEnv = { + token: process.env.CLOUDFLARE_API_TOKEN, + account: process.env.CLOUDFLARE_ACCOUNT_ID, +} + +// Pass token: null / account: null to REMOVE the variable (fail-closed +// wiring tests); omit or pass a string to set it. +const withEnv = async (fn, { token = "fake-token", account = PAGES_ACCOUNT_ID } = {}) => { + const before = { token: process.env.CLOUDFLARE_API_TOKEN, account: process.env.CLOUDFLARE_ACCOUNT_ID } + if (token === null) delete process.env.CLOUDFLARE_API_TOKEN + else process.env.CLOUDFLARE_API_TOKEN = token + if (account === null) delete process.env.CLOUDFLARE_ACCOUNT_ID + else process.env.CLOUDFLARE_ACCOUNT_ID = account + try { + return await fn() + } finally { + if (before.token === undefined) delete process.env.CLOUDFLARE_API_TOKEN + else process.env.CLOUDFLARE_API_TOKEN = before.token + if (before.account === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID + else process.env.CLOUDFLARE_ACCOUNT_ID = before.account + } +} + +const deployment = (id, commitHash = null) => ({ + id, + url: `https://${id}.tiny-studio-3f5.pages.dev`, + created_on: "2026-08-01T00:00:00Z", + deployment_trigger: { + type: "ad_hoc", + metadata: { + branch: "main", + commit_hash: commitHash, + commit_message: commitHash ? `tinystudio-in ${commitHash}` : null, + }, + }, +}) + +// Fake Cloudflare state: deployments by id, canonical = current production. +const makeState = (canonicalId, all = [canonicalId]) => { + const deployments = Object.fromEntries(all.map((id) => [id, deployment(id, `commit-${id}`)])) + return { deployments, canonical: deployments[canonicalId], rollbacks: [] } +} + +// Fake fetch: GET project (canonical identity) + POST rollback. +const makeFakeFetch = (state, { projectStatus = 200, rollbackStatus = 200, rollbackApplies = true } = {}) => { + const calls = [] + const fetchImpl = async (url, init = {}) => { + calls.push({ url, method: init.method || "GET" }) + if (url === projectUrl() && (!init.method || init.method === "GET")) { + if (projectStatus !== 200) { + return new Response(JSON.stringify({ success: false, errors: [{ code: projectStatus, message: "boom" }] }), { status: projectStatus }) + } + return new Response( + JSON.stringify({ success: true, result: { name: PAGES_PROJECT, canonical_deployment: state.canonical || null } }), + { status: 200, headers: { "content-type": "application/json" } } + ) + } + const match = url.match(/\/deployments\/([^/]+)\/rollback$/) + if (init.method === "POST" && match) { + const targetId = match[1] + state.rollbacks.push(targetId) + if (rollbackStatus !== 200) { + return new Response(JSON.stringify({ success: false, errors: [{ code: 9999, message: "rollback rejected" }] }), { status: rollbackStatus }) + } + const target = state.deployments[targetId] + if (rollbackApplies && target) state.canonical = target + return new Response(JSON.stringify({ success: true, result: target }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + throw new Error(`unexpected fake fetch: ${init.method || "GET"} ${url}`) + } + return { fetchImpl, calls } +} + +// Fake wrangler + acceptance runner: wrangler deploys create a fresh +// production deployment; acceptance can be scripted to fail. +const makeFakeRun = (state, { acceptanceFail = false, wranglerFail = false } = {}) => { + const calls = [] + const runCommand = async (command, args) => { + calls.push({ command, args }) + const isWrangler = args[0] === "pages" && args[1] === "deploy" + if (isWrangler) { + if (wranglerFail) throw Object.assign(new Error("wrangler pages deploy exited 1"), { exitCode: 1 }) + const fresh = deployment("fresh-deployment", "fresh-commit") + state.deployments[fresh.id] = fresh + state.canonical = fresh + return + } + if (args.some((a) => a.includes("check-public-live-deploy.mjs"))) { + if (acceptanceFail) { + throw Object.assign(new Error("check-public-live-deploy.mjs exited 1 (acceptance failure)"), { exitCode: 1 }) + } + return + } + throw new Error(`unexpected fake command: ${command} ${args.join(" ")}`) + } + return { runCommand, calls } +} + +const makeBundle = () => { + const dir = mkdtempSync(join(tmpdir(), "tsin-release-test-")) + writeFileSync(join(dir, "deploy-manifest.json"), JSON.stringify({ source_commit: "test-source-commit" })) + const fakeWrangler = join(dir, "fake-wrangler") + writeFileSync(fakeWrangler, "#!/bin/sh\nexit 0\n") + return { dir, fakeWrangler } +} + +const quiet = { log: () => {}, err: () => {} } + +const cleanup = [] +const cleanupAll = () => cleanup.splice(0).forEach((fn) => fn()) + +console.log("test-pages-release: the release lane is truthful (fails closed) and reversible (capture + rollback + re-verify)") + +// --------------------------------------------------------------------------- +console.log("A. missing required credential wiring fails loudly (no green skip)") +{ + const { dir } = makeBundle() + cleanup.push(() => rmSync(dir, { recursive: true, force: true })) + const state = makeState("previous-deployment") + const { fetchImpl, calls: fetchCalls } = makeFakeFetch(state) + const { runCommand, calls: runCalls } = makeFakeRun(state) + + let threw = "" + await withEnv( + async () => { + try { + await releasePipeline({ bundleDir: dir, deps: { fetchImpl, runCommand }, ...quiet }) + } catch (error) { + threw = error.message + } + }, + { token: null, account: null } + ) + ok(threw.includes("CLOUDFLARE_API_TOKEN"), "both secrets missing: pipeline throws about CLOUDFLARE_API_TOKEN") + ok(fetchCalls.length === 0, "both secrets missing: no Cloudflare call was made") + ok(runCalls.length === 0, "both secrets missing: no deploy/acceptance command was run") + + threw = "" + await withEnv( + async () => { + try { + await releasePipeline({ bundleDir: dir, deps: { fetchImpl, runCommand }, ...quiet }) + } catch (error) { + threw = error.message + } + }, + { token: null, account: PAGES_ACCOUNT_ID } + ) + ok(threw.includes("CLOUDFLARE_API_TOKEN"), "only account id present: pipeline throws about CLOUDFLARE_API_TOKEN") + + threw = "" + await withEnv( + async () => { + try { + await releasePipeline({ bundleDir: dir, deps: { fetchImpl, runCommand }, ...quiet }) + } catch (error) { + threw = error.message + } + }, + { token: "fake-token", account: null } + ) + ok(threw.includes("CLOUDFLARE_ACCOUNT_ID"), "only token present: pipeline throws about CLOUDFLARE_ACCOUNT_ID") + + let threwMissing = "" + await withEnv( + async () => { + try { + requireDeployCredentials() + } catch (error) { + threwMissing = error.message + } + }, + { token: null, account: null } + ) + ok(threwMissing.includes("CLOUDFLARE_API_TOKEN"), "requireDeployCredentials throws when wiring is missing") +} + +// --------------------------------------------------------------------------- +console.log("B. previous production identity is captured before upload; no target => refuse to deploy") +{ + const { dir, fakeWrangler } = makeBundle() + cleanup.push(() => rmSync(dir, { recursive: true, force: true })) + + // Identity capture assertions run against a pristine state (before any + // pipeline run mutates the fake store). + const pristineState = makeState("previous-deployment") + const { fetchImpl: pristineFetch } = makeFakeFetch(pristineState) + let captured = null + await withEnv(async () => { + captured = await captureProductionIdentity({ fetchImpl: pristineFetch }) + }) + ok(captured.deploymentId === "previous-deployment", "captured identity has the previous deployment id") + ok(captured.url.includes("previous-deployment"), "captured identity carries the immutable deployment url") + ok(captured.commitHash === "commit-previous-deployment", "captured identity carries the source commit") + + const state = makeState("previous-deployment") + const { fetchImpl, calls: fetchCalls } = makeFakeFetch(state) + const { runCommand, calls: runCalls } = makeFakeRun(state) + + await withEnv(async () => { + await releasePipeline({ + bundleDir: dir, + deps: { fetchImpl, runCommand, wranglerBin: fakeWrangler }, + ...quiet, + }) + }) + + ok(fetchCalls.length >= 1 && fetchCalls[0].url === projectUrl() && fetchCalls[0].method === "GET", + "the current production identity is captured (project GET) before anything else") + const wranglerRuns = runCalls.filter((c) => c.args[0] === "pages" && c.args[1] === "deploy") + ok(wranglerRuns.length === 1 && wranglerRuns[0].args.includes(dir) && wranglerRuns[0].args.includes(PAGES_PROJECT), + "wrangler deploy runs after identity capture with the bundle dir and project name") + ok(runCalls[0].args[0] === "pages", "the first command run is the wrangler deploy (capture happens before upload)") + + // No canonical production deployment => refuse, deploy never runs. + const emptyState = makeState("previous-deployment") + emptyState.canonical = null + const { fetchImpl: f2, calls: c2 } = makeFakeFetch(emptyState) + const { runCommand: r2, calls: rc2 } = makeFakeRun(emptyState) + let threw = "" + await withEnv(async () => { + try { + await releasePipeline({ bundleDir: dir, deps: { fetchImpl: f2, runCommand: r2, wranglerBin: fakeWrangler }, ...quiet }) + } catch (error) { + threw = error.message + } + }) + ok(threw.includes("no safe rollback target"), "no production deployment: pipeline refuses with a rollback-target error") + ok(rc2.length === 0, "no production deployment: wrangler deploy never runs") + + // Project lookup fails => refuse, deploy never runs. + const { fetchImpl: f3, calls: c3 } = makeFakeFetch(state, { projectStatus: 500 }) + const { runCommand: r3, calls: rc3 } = makeFakeRun(state) + threw = "" + await withEnv(async () => { + try { + await releasePipeline({ bundleDir: dir, deps: { fetchImpl: f3, runCommand: r3, wranglerBin: fakeWrangler }, ...quiet }) + } catch (error) { + threw = error.message + } + }) + ok(threw.includes("refusing to deploy"), "project lookup failure: pipeline refuses to deploy") + ok(rc3.length === 0, "project lookup failure: wrangler deploy never runs") + ok(c3.length === 1, "project lookup failure: only the capture call was attempted") +} + +// --------------------------------------------------------------------------- +console.log("C. failed acceptance after upload invokes rollback to the previous deployment") +{ + const { dir, fakeWrangler } = makeBundle() + cleanup.push(() => rmSync(dir, { recursive: true, force: true })) + const state = makeState("previous-deployment") + const { fetchImpl, calls: fetchCalls } = makeFakeFetch(state) + const { runCommand, calls: runCalls } = makeFakeRun(state, { acceptanceFail: true }) + + let threw = "" + await withEnv(async () => { + try { + await releasePipeline({ bundleDir: dir, deps: { fetchImpl, runCommand, wranglerBin: fakeWrangler }, ...quiet }) + } catch (error) { + threw = error.message + } + }) + ok(threw.includes("failed acceptance"), "pipeline reports the release failure after acceptance failure") + ok(state.rollbacks.length === 1 && state.rollbacks[0] === "previous-deployment", + "rollback to the exact previous deployment id was invoked") + const wranglerRunsC = runCalls.filter((c) => c.args[0] === "pages" && c.args[1] === "deploy") + const acceptanceRunsC = runCalls.filter((c) => c.args.some((a) => a.includes("check-public-live-deploy.mjs"))) + ok(wranglerRunsC.length === 1, "the new bundle was uploaded once") + ok(acceptanceRunsC.length === 2, "the same acceptance ran once after upload and once more after rollback") + ok(fetchCalls.some((c) => c.url === rollbackUrl("previous-deployment") && c.method === "POST"), + "the rollback used the supported Cloudflare Pages rollback API endpoint") +} + +// --------------------------------------------------------------------------- +console.log("D. restored identity is re-verified and the same acceptance reruns against it") +{ + const { dir, fakeWrangler } = makeBundle() + cleanup.push(() => rmSync(dir, { recursive: true, force: true })) + const state = makeState("previous-deployment") + const { fetchImpl } = makeFakeFetch(state) + const { runCommand, calls: runCalls } = makeFakeRun(state, { acceptanceFail: true }) + + let threw = "" + await withEnv(async () => { + try { + await releasePipeline({ bundleDir: dir, deps: { fetchImpl, runCommand, wranglerBin: fakeWrangler }, ...quiet }) + } catch (error) { + threw = error.message + } + }) + ok(threw.includes("rolled back"), "pipeline message says production was rolled back") + let restored = null + await withEnv(async () => { + restored = await currentProductionIdentity({ fetchImpl }) + }) + ok(restored && restored.deploymentId === "previous-deployment" && restored.url.includes("previous-deployment"), + "after rollback the current production identity equals the captured previous identity") + ok(identitiesMatch({ deploymentId: "previous-deployment", url: restored.url, commitHash: restored.commitHash }, restored), + "identitiesMatch confirms the restored identity") + const acceptanceRuns = runCalls.filter((c) => c.args.some((a) => a.includes("check-public-live-deploy.mjs"))) + ok(acceptanceRuns.length === 2, "the same acceptance script ran after upload and against the restored identity") + + // Rollback API failure surfaces loudly. + const state2 = makeState("previous-deployment") + const { fetchImpl: f2 } = makeFakeFetch(state2, { rollbackStatus: 500 }) + const { runCommand: r2 } = makeFakeRun(state2, { acceptanceFail: true }) + let threw2 = "" + await withEnv(async () => { + try { + await releasePipeline({ bundleDir: dir, deps: { fetchImpl: f2, runCommand: r2, wranglerBin: fakeWrangler }, ...quiet }) + } catch (error) { + threw2 = error.message + } + }) + ok(threw2.includes("ROLLBACK FAILED"), "rollback API rejection surfaces loudly as ROLLBACK FAILED") + + // Identity not restored after rollback surfaces loudly. + const state3 = makeState("previous-deployment") + const { fetchImpl: f3 } = makeFakeFetch(state3, { rollbackApplies: false }) + const { runCommand: r3 } = makeFakeRun(state3, { acceptanceFail: true }) + let threw3 = "" + await withEnv(async () => { + try { + await releasePipeline({ bundleDir: dir, deps: { fetchImpl: f3, runCommand: r3, wranglerBin: fakeWrangler }, ...quiet }) + } catch (error) { + threw3 = error.message + } + }) + ok(threw3.includes("ROLLBACK VERIFICATION FAILED"), "identity mismatch after rollback surfaces loudly as ROLLBACK VERIFICATION FAILED") +} + +// --------------------------------------------------------------------------- +console.log("E. success path does not roll back") +{ + const { dir, fakeWrangler } = makeBundle() + cleanup.push(() => rmSync(dir, { recursive: true, force: true })) + const state = makeState("previous-deployment") + const { fetchImpl } = makeFakeFetch(state) + const { runCommand } = makeFakeRun(state, { acceptanceFail: false }) + + let resolved = false + await withEnv(async () => { + await releasePipeline({ bundleDir: dir, deps: { fetchImpl, runCommand, wranglerBin: fakeWrangler }, ...quiet }) + resolved = true + }) + ok(resolved, "success path resolves") + ok(state.rollbacks.length === 0, "success path never invokes rollback") + ok(state.canonical.id === "fresh-deployment", "success path leaves the fresh deployment live") +} + +// --------------------------------------------------------------------------- +console.log("F. identity helpers behave") +{ + const a = { deploymentId: "d1", url: "https://d1.example.dev", commitHash: "abc" } + ok(identitiesMatch(a, { ...a }), "identical identities match") + ok(!identitiesMatch(a, { ...a, deploymentId: "d2" }), "different deployment id does not match") + ok(!identitiesMatch(a, { ...a, url: "https://d2.example.dev" }), "different url does not match") + ok(!identitiesMatch(a, { ...a, commitHash: null }), "missing source commit does not match a captured source commit") + ok(identitiesMatch({ ...a, commitHash: null }, { deploymentId: "d1", url: "https://d1.example.dev", commitHash: null }), + "both-null source commits still match (legacy deployments)") + ok(!identitiesMatch(a, null), "null restored identity does not match") +} + +// --------------------------------------------------------------------------- +console.log("G. rollbackTo rejects a bad rollback response") +{ + const state = makeState("previous-deployment") + const { fetchImpl } = makeFakeFetch(state, { rollbackStatus: 500 }) + let threw = "" + await withEnv(async () => { + try { + await rollbackTo({ deploymentId: "previous-deployment", url: "x" }, { fetchImpl }) + } catch (error) { + threw = error.message + } + }) + ok(threw.includes("rollback") && threw.includes("HTTP 500"), "rollbackTo surfaces the API rejection") +} + +cleanupAll() + +console.log(`\n${checks} checks, ${failures} failures`) +process.exit(failures === 0 ? 0 : 1)