diff --git a/.github/workflows/live-social-preview-check.yml b/.github/workflows/live-social-preview-check.yml
new file mode 100644
index 00000000..815bb24a
--- /dev/null
+++ b/.github/workflows/live-social-preview-check.yml
@@ -0,0 +1,28 @@
+name: Live Social Preview Check
+
+on:
+ schedule:
+ # Nightly: catch stale deployments that dropped social preview imagery
+ # (og:image / twitter:image) within a day even when no pull requests are
+ # open.
+ - cron: "37 3 * * *"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: live-social-preview-check
+ cancel-in-progress: false
+
+jobs:
+ live-social-preview:
+ runs-on: [self-hosted, linux, x64, vps-verify]
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 20
+ cache: npm
+ - run: npm ci --ignore-scripts
+ - run: node scripts/check-public-live-social-preview.mjs
diff --git a/package.json b/package.json
index 340ea7d9..1eb67d39 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"client:proof-review": "node scripts/review-client-proof.mjs",
"retention:automation-check": "node scripts/check-retention-automation.mjs",
"site:prepare": "node scripts/prepare-static-site-bundle.mjs",
+ "site:check-live-social-preview": "node scripts/check-public-live-social-preview.mjs",
"claims:check": "node scripts/check-outbound-claim-safety.mjs",
"config:check": "node scripts/check-agency-defaults.mjs",
"send:configure": "node scripts/configure-sender-setup.mjs",
@@ -85,9 +86,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 && 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/check-public-live-social-preview.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 && 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/check-public-live-social-preview.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check"
},
"engines": {
"node": ">=20"
diff --git a/scripts/check-public-live-social-preview.mjs b/scripts/check-public-live-social-preview.mjs
new file mode 100644
index 00000000..1018f615
--- /dev/null
+++ b/scripts/check-public-live-social-preview.mjs
@@ -0,0 +1,125 @@
+// Guard the LIVE public site against missing social preview imagery: every
+// one of the 12 public pages on tinystudio.in must declare the full
+// og:image block and the matching twitter summary_large_image card.
+//
+// The static test (test-public-social-preview.mjs) only proves the repo files
+// carry the meta tags; it cannot catch a stale deployment that still serves
+// the old pages. This check hits the deployed site so a regression is
+// detected the moment it ships. It is wired into `npm run ci` and a nightly
+// workflow.
+//
+// Escape hatch for machines without network access:
+// SKIP_LIVE_CHECKS=1 npm run ci
+//
+// Only the live site name and the local family-to-image mapping are
+// referenced here; there is no per-environment configuration.
+
+if (process.env.SKIP_LIVE_CHECKS === "1") {
+ console.log("check-public-live-social-preview: SKIP_LIVE_CHECKS=1, skipping live site checks")
+ process.exit(0)
+}
+
+const SITE = "https://tinystudio.in"
+
+// Every public page (canonical list, mirrors test-public-social-preview.mjs).
+const PUBLIC_PAGES = [
+ "/",
+ "/contact/",
+ "/promptly/",
+ "/promptly/support/",
+ "/promptly/privacy/",
+ "/drishti/",
+ "/drishti/support/",
+ "/drishti/privacy/",
+ "/support/",
+ "/privacy/",
+ "/privacy-choices/",
+ "/terms/"
+]
+
+// Which social image each page family must carry.
+const expectedImage = (page) =>
+ page.startsWith("/promptly/")
+ ? "promptly-social.png"
+ : page.startsWith("/drishti/")
+ ? "drishti-social.png"
+ : "tiny-studio-social.png"
+
+const metaOf = (html, name) => {
+ const match = html.match(new RegExp(` {
+ checks++
+ if (cond) console.log(` ok ${msg}`)
+ else {
+ failures++
+ console.error(` FAIL ${msg}`)
+ }
+}
+
+const fetchWithRetry = async (url, attempts = 2) => {
+ let lastError
+ for (let i = 0; i < attempts; i++) {
+ try {
+ const res = await fetch(url, { redirect: "follow", headers: { "user-agent": "tinystudio-social-preview-check" } })
+ return { res, body: await res.text() }
+ } catch (err) {
+ lastError = err
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, 1000))
+ }
+ }
+ throw lastError
+}
+
+console.log("check-public-live-social-preview: every live public page must declare a social preview image")
+
+let results
+try {
+ results = await Promise.all(
+ PUBLIC_PAGES.map(async (page) => ({ page, ...(await fetchWithRetry(`${SITE}${page}`)) }))
+ )
+} catch (err) {
+ console.error(` FAIL could not reach ${SITE}: ${err.message}`)
+ console.error(" (transient network problems and live deploy state both fail here; retry once manually before assuming a deploy issue)")
+ process.exit(1)
+}
+
+for (const { page, res, body } of results) {
+ ok(res.status === 200, `GET ${page} returns HTTP ${res.status}`)
+ const image = metaOf(body, "og:image")
+ ok(image !== null && image !== "", `${page} declares `)
+ if (image) {
+ const imageUrl = `${SITE}/social/${expectedImage(page)}`
+ ok(image === imageUrl, `${page} og:image points at ${expectedImage(page)}`)
+ ok(metaOf(body, "og:image:secure_url") === image, `${page} og:image:secure_url matches og:image`)
+ ok(metaOf(body, "og:image:type") === "image/png", `${page} og:image:type is image/png`)
+ ok(metaOf(body, "og:image:width") === "1200", `${page} og:image:width is 1200`)
+ ok(metaOf(body, "og:image:height") === "630", `${page} og:image:height is 630`)
+ ok((metaOf(body, "og:image:alt") ?? "") !== "", `${page} has non-empty og:image:alt`)
+ ok(metaOf(body, "twitter:card") === "summary_large_image", `${page} twitter:card is summary_large_image`)
+ ok(metaOf(body, "twitter:image") === image, `${page} twitter:image matches og:image`)
+ ok((metaOf(body, "twitter:image:alt") ?? "") !== "", `${page} has non-empty twitter:image:alt`)
+ }
+}
+
+console.log("B. every referenced social image file is served")
+try {
+ const uniqueImages = [...new Set(PUBLIC_PAGES.map((page) => `${SITE}/social/${expectedImage(page)}`))]
+ for (const url of uniqueImages) {
+ const { res } = await fetchWithRetry(url)
+ ok(res.status === 200, `GET ${url} returns HTTP ${res.status}`)
+ }
+} catch (err) {
+ console.error(` FAIL could not reach a social image: ${err.message}`)
+ process.exit(1)
+}
+
+console.log(`\n${checks} checks, ${failures} failures`)
+if (failures > 0) {
+ console.error("\nThe live site is missing social preview imagery (or serving a stale bundle). Re-deploy the public site from origin/main and re-run this check.")
+}
+process.exit(failures === 0 ? 0 : 1)