From 42987aa24fba536b950c801c465bfb7ca4698222 Mon Sep 17 00:00:00 2001 From: Nish <257724087+nish3451@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:13:38 +0530 Subject: [PATCH] fix(ci): fail the tinystudio.in deploy lane loudly when Pages secrets are missing Replace the green 'dormant' step (which exited 0 when CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID were absent, making every main merge report a deploy while production stayed stale) with a fail-closed step that prints the provisioning steps and exits 1. Green now means 'deployed and verified': the publish step still runs check-public-live-deploy.mjs after upload and fails the run when the live acceptance fails, and the run can no longer succeed without running it. Add scripts/test-deploy-public-site-workflow.mjs (hermetic, wired into npm test and npm run ci) asserting the fail-closed contract: no dormant step, a fail-loud step gated on missing secrets, deploy steps gated on both secrets, acceptance script in the deploy path. Red on the old workflow (dormant step), green on the new one. --- .github/workflows/deploy-public-site.yml | 19 +-- package.json | 4 +- scripts/test-deploy-public-site-workflow.mjs | 120 +++++++++++++++++++ 3 files changed, 132 insertions(+), 11 deletions(-) create mode 100644 scripts/test-deploy-public-site-workflow.mjs diff --git a/.github/workflows/deploy-public-site.yml b/.github/workflows/deploy-public-site.yml index 618ff032..b0ba3325 100644 --- a/.github/workflows/deploy-public-site.yml +++ b/.github/workflows/deploy-public-site.yml @@ -9,19 +9,19 @@ name: Deploy public site # fixes (H2-after-H1 on /promptly/support/, JSON-LD on /contact/, real 404s, # portfolio-only homepage). # -# Required secrets (fail-closed when present): +# 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 # on the tinystudio.in account # CLOUDFLARE_ACCOUNT_ID - f670a698e17bf160c8e4679823e68916 # The fleet Workers token (fleet-console/cf.env) does NOT have Pages:Edit; # provisioning a Pages-scoped token is a one-time dashboard step. # -# While BOTH secrets are absent the lane runs a seconds-long "dormant" -# notice step that prints the exact provisioning steps and exits green, so -# main merges are not blocked by the missing credential. The moment both -# secrets exist, every main merge runs the full publish-and-verify pipeline -# and any failure fails the run loudly (fail-closed). The nightly -# live-site-check.yml is the loud staleness alarm while the site is stale. +# 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 +# staleness alarm while the site is stale. on: push: @@ -49,16 +49,17 @@ jobs: with: persist-credentials: false - - name: Release lane dormant - Pages token not provisioned yet + - name: Required Pages secrets not provisioned - fail loudly if: env.CLOUDFLARE_API_TOKEN == '' || env.CLOUDFLARE_ACCOUNT_ID == '' run: | - echo "::warning title=Release lane dormant::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID are not set in repo secrets, so the publish step is skipped. The lane will deploy automatically on the next main merge once both secrets are provisioned; until then the live site stays on its current bundle and the nightly live-site-check.yml reports it." + echo "::error title=Release lane cannot run::CLOUDFLARE_API_TOKEN and/or CLOUDFLARE_ACCOUNT_ID are not set in repo secrets. The lane fails closed instead of skipping the publish: a skipped publish must never show a green deploy status." echo "One-time provisioning (dashboard, ~2 minutes):" echo " 1. https://dash.cloudflare.com/profile/api-tokens -> Create Token" echo " 2. Use the 'Cloudflare Pages: Edit' template, scope it to the account (account id f670a698e17bf160c8e4679823e68916), create, copy the token." echo " 3. gh secret set CLOUDFLARE_API_TOKEN -R nish3451/tinystudio-in" echo " 4. gh secret set CLOUDFLARE_ACCOUNT_ID -R nish3451/tinystudio-in -b f670a698e17bf160c8e4679823e68916" echo " 5. Re-run this lane (or wait for the next main merge); no code change needed." + exit 1 - uses: actions/setup-node@v7 if: env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_ACCOUNT_ID != '' diff --git a/package.json b/package.json index 87ba3b7d..114bd475 100644 --- a/package.json +++ b/package.json @@ -87,9 +87,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/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 && 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/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", "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/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 && 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/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", "site:publish": "node scripts/publish-public-site.mjs", "site:publish:check": "node scripts/publish-public-site.mjs --prepare-only" }, diff --git a/scripts/test-deploy-public-site-workflow.mjs b/scripts/test-deploy-public-site-workflow.mjs new file mode 100644 index 00000000..059f9846 --- /dev/null +++ b/scripts/test-deploy-public-site-workflow.mjs @@ -0,0 +1,120 @@ +// Regression for the tinystudio.in deploy lane fail-closed contract +// (sol-sweep finding: tinystudio-in-dormant-deploy-reports-green-while-live-is-stale). +// +// The release lane must NEVER produce a green run while the required Pages +// wiring is missing. The old workflow ran a "dormant" notice step that +// printed provisioning steps and exited 0 when CLOUDFLARE_API_TOKEN / +// CLOUDFLARE_ACCOUNT_ID were absent, so every main merge reported a green +// deploy while production stayed stale. +// +// This test asserts the fail-closed contract structurally, on the workflow +// file alone - hermetic, no network, no real workflow execution: +// 1. No "dormant" step exists (a skipped publish must not exit 0). +// 2. A fail-loud step exists: gated on missing secrets and its run exits 1. +// 3. Every deploy/verify step is gated on BOTH secrets being present. +// 4. The deploy path actually runs the live acceptance script +// (check-public-live-deploy.mjs) so green requires verified live state. +// +// RED on the pre-change workflow (dormant step present, no exit-1 step), +// GREEN on the changed workflow. +import { readFileSync } from "node:fs" +import { join, dirname } from "node:path" +import { fileURLToPath } from "node:url" + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") +const WORKFLOW = process.env.WORKFLOW_PATH || join(ROOT, ".github", "workflows", "deploy-public-site.yml") + +let failures = 0 +let checks = 0 +const ok = (cond, msg) => { + checks++ + if (cond) console.log(` ok ${msg}`) + else { + failures++ + console.error(` FAIL ${msg}`) + } +} + +// Extract the steps of the single `publish` job as a list of blocks, each +// block = { name, uses, if, run }. The workflow has a flat two-level +// structure (`jobs.publish.steps`), so a line-based scan at step indent is +// deterministic for this file. +const parseSteps = (text) => { + const lines = text.split("\n") + const steps = [] + let current = null + let inSteps = false + for (const line of lines) { + // Only the `publish` job's `steps:` block contains deploy steps; the + // `on:` block also has `- main` entries that are not steps. + if (/^ {4}steps:$/.test(line)) { + inSteps = true + continue + } + if (!inSteps) continue + const m = line.match(/^( {6}- )(.*)$/) + if (m) { + if (current) steps.push(current) + current = {} + const rest = m[2] + const name = rest.match(/^name: (.*)$/) + if (name) current.name = name[1] + const uses = rest.match(/^uses: (.*)$/) + if (uses) current.uses = uses[1] + const runInline = rest.match(/^run: (.*)$/) + if (runInline) current.run = [runInline[1]] + continue + } + if (!current) continue + const name = line.match(/^ {8}name: (.*)$/) + if (name) current.name = name[1] + const uses = line.match(/^ {8}uses: (.*)$/) + if (uses) current.uses = uses[1] + const cond = line.match(/^ {8}if: (.*)$/) + if (cond) current.if = cond[1] + const runBlock = line.match(/^ {8}run: \|$/) + const runInline = line.match(/^ {8}run: (.*)$/) + if (runBlock) current.run = [] + else if (runInline && !current.run) current.run = [runInline[1]] + else if (current.run && line.startsWith(" ")) current.run.push(line.trim()) + } + if (current) steps.push(current) + return steps.map((s) => ({ ...s, run: (s.run || []).join("\n") })) +} + +const GATE = "env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_ACCOUNT_ID != ''" +const MISSING = "env.CLOUDFLARE_API_TOKEN == '' || env.CLOUDFLARE_ACCOUNT_ID == ''" + +console.log("test-deploy-public-site-workflow: release lane must fail closed on missing Pages wiring") + +const text = readFileSync(WORKFLOW, "utf8") +const steps = parseSteps(text) +console.log(` parsed ${steps.length} steps from ${WORKFLOW.replace(ROOT, "")}`) + +// 1. No dormant / green-on-missing-secrets step. +const dormant = steps.filter((s) => /dormant/i.test(s.name || "")) +ok(dormant.length === 0, "no 'dormant' step exists (skipped publish must not exit green)") +ok(!/::warning title=Release lane dormant/.test(text), "no dormant warning annotation remains") + +// 2. A fail-loud step exists: gated on missing secrets, run exits 1. +const failLoud = steps.filter((s) => /fail loudly/i.test(s.name || "") && (s.if || "").includes(MISSING)) +ok(failLoud.length >= 1, "a step named '*fail loudly*' gated on missing secrets exists") +ok(failLoud.every((s) => /exit 1/.test(s.run)), "the fail-loud step's run exits 1") + +// 3. Every step that touches the deploy pipeline is gated on BOTH secrets. +const deploySteps = steps.filter((s) => { + const blob = `${s.name || ""} ${s.uses || ""} ${s.run}` + return /setup-node|npm ci|Pre-deploy repository gate|Prepare the filtered deploy bundle|Publish to Cloudflare Pages/.test(blob) +}) +ok(deploySteps.length >= 5, `all deploy-pipeline steps present (found ${deploySteps.length})`) +ok(deploySteps.every((s) => (s.if || "").includes(GATE)), "every deploy-pipeline step is gated on both secrets") + +// 4. The deploy path runs the live acceptance script (green requires verified live state). +const publish = steps.find((s) => /Publish to Cloudflare Pages/.test(s.name || "")) +ok(!!publish, "publish-and-verify step exists") +ok(!!publish && /publish-public-site\.mjs --deploy/.test(publish.run), "deploy runs publish-public-site.mjs --deploy") +ok(/check-public-live-deploy\.mjs/.test(readFileSync(join(ROOT, "scripts", "publish-public-site.mjs"), "utf8")), "publish script runs check-public-live-deploy.mjs after upload") +ok(/verifyLive/.test(readFileSync(join(ROOT, "scripts", "publish-public-site.mjs"), "utf8")), "publish script verifies the live site after deploy (verifyLive)") + +console.log(`\n${checks} checks, ${failures} failures`) +process.exit(failures === 0 ? 0 : 1) \ No newline at end of file