Skip to content
Merged
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
19 changes: 16 additions & 3 deletions .github/workflows/deploy-public-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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"
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
199 changes: 184 additions & 15 deletions scripts/publish-public-site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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"]
Expand All @@ -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" }
Expand All @@ -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) => {
Expand All @@ -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 <dir>")
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)
Expand Down
Loading