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