Skip to content
This repository was archived by the owner on Oct 12, 2026. It is now read-only.
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: 10 additions & 9 deletions .github/workflows/deploy-public-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 != ''
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
120 changes: 120 additions & 0 deletions scripts/test-deploy-public-site-workflow.mjs
Original file line number Diff line number Diff line change
@@ -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)