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 7 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
14 changes: 8 additions & 6 deletions .github/workflows/live-site-check.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
name: Live Site Check

# Guards the deployed tinystudio.in against soft-404s and stale bundles
# without blocking pull requests. The deploy lane (deploy-public-site.yml)
# verifies the live site after every publish; this workflow is the nightly
# net that catches a stale or misconfigured deployment within a day even
# when nothing merges. It is deliberately NOT part of `npm run ci` - that
# blocking chain must stay green on repo state alone.
# Guards the deployed tinystudio.in against soft-404s, stale bundles, and
# regressed heading hierarchies without blocking pull requests. The deploy
# lane (deploy-public-site.yml) verifies the live site after every publish;
# this workflow is the nightly net that catches a stale or misconfigured
# deployment within a day even when nothing merges. It is deliberately NOT
# part of `npm run ci` - that blocking chain must stay green on repo state
# alone.
#
# This job is expected to FAIL while the deployment is stale (currently the
# June-20 bundle); the red run is the loud signal to re-deploy from
Expand Down Expand Up @@ -36,6 +37,7 @@ jobs:
cache: npm
- run: npm ci --ignore-scripts
- run: node scripts/check-public-live-soft-404.mjs
- run: node scripts/test-public-live-heading-hierarchy.mjs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the heading guard when the soft-404 check fails

In the workflow's documented stale-deployment state, the preceding check-public-live-soft-404.mjs step exits nonzero, so GitHub Actions' implicit success() condition skips this new step entirely. The job is still red, but the heading assertions and their diagnostics never run precisely while the stale bundle is deployed; run the two guards independently or otherwise aggregate their exit statuses so both execute while preserving a failed job result.

Useful? React with 👍 / 👎.

live-contact-heading-hierarchy:
runs-on: [self-hosted, linux, x64, vps-verify]
steps:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"retention:automation-check": "node scripts/check-retention-automation.mjs",
"site:prepare": "node scripts/prepare-static-site-bundle.mjs",
"site:check-live": "node scripts/check-public-live-soft-404.mjs",
"site:check-live-heading-hierarchy": "node scripts/test-public-live-heading-hierarchy.mjs",
"site:check-live-contact-heading-hierarchy": "node scripts/test-public-live-contact-heading-hierarchy.mjs",
"claims:check": "node scripts/check-outbound-claim-safety.mjs",
"config:check": "node scripts/check-agency-defaults.mjs",
Expand Down
12 changes: 11 additions & 1 deletion scripts/check-public-live-deploy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
// 3. unknown URLs get a real 404, not the homepage (PR #34)
// 4. homepage stays portfolio-only: brand-disambiguation copy (#29) live,
// and no managed-service buyer-path content (#10/#11, snoozed).
// Proof 2b covers the 2026-08-08 dogfood finding page:
// The fifth proof covers the 2026-08-08 dogfood finding pages:
// 5. /drishti/support/ and /privacy-choices/ render H2 after H1
// (the skipped-heading-levels repair, PR #23).
// Proof 2b covers the contact finding page:
// 2b. /contact/ renders H2 after H1 (the heading-hierarchy repair, PR #18).
import { join } from "node:path"
import { fileURLToPath } from "node:url"
Expand Down Expand Up @@ -66,6 +69,13 @@ try {
ok(h2AfterFirstH1(body), "H2 follows the first H1 before any H3")
}

console.log("A2. finding pages render H2 after H1 (skipped-heading-levels repair, PR #23)")
for (const path of ["/drishti/support/", "/privacy-choices/"]) {
const { status, body } = await get(path)
ok(status === 200, `${path} returns 200 (got ${status})`)
ok(h2AfterFirstH1(body), `${path} has H2 following the first H1 before any H3`)
}
Comment on lines +104 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Require H2 to be the next heading after the first H1.

The current predicate ignores H4-H6 and additional H1 tags. A sequence such as H1 → H4 → H2 → H3 passes this check. Parse the heading sequence and require the heading immediately after the first H1 to be H2.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check-public-live-deploy.mjs` around lines 70 - 75, Update
h2AfterFirstH1 to parse the document’s heading sequence and require the heading
immediately following the first H1 to be H2. Ensure additional H1 tags and
intervening H4–H6 headings cause the predicate to fail, while preserving the
existing check usage in the page-render validation loop.


console.log("B. /contact/ carries structured data (PR #19)")
{
const { status, body } = await get("/contact/")
Expand Down
145 changes: 145 additions & 0 deletions scripts/test-public-live-heading-hierarchy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Guard the LIVE public site against skipped heading levels on the pages
// that carry the repaired outline in source.
//
// The 2026-08-08 dogfood finding "Repair skipped heading levels on Drishti
// support and Privacy Choices pages" was repaired in source by PR #23 (card
// headings promoted from H3 to H2, outline H1 -> H2s -> H3s with no jumps),
// and scripts/test-public-heading-hierarchy.mjs guards the worktree HTML.
// This check re-asserts the repaired outline against the pages the live
// site actually serves, so a stale deployment (like the June-20 bundle
// still serving H3 cards) fails loudly instead of silently re-opening the
// finding.
//
// It runs as `npm run site:check-live-heading-hierarchy`, from the nightly
// live-site-check workflow (the loud staleness alarm while the site is
// stale) and on demand. It is deliberately NOT part of `npm run test` /
// `npm run ci`: those blocking chains must stay green on repo state alone,
// while the live site is deployed by an external mechanism (Cloudflare
// Pages). Blocking CI on the live site would keep every pull request red
// whenever the deployment is stale, and would deadlock the deploy lane's
// pre-deploy `npm run check` gate.
//
// Network-tolerant: a page or stylesheet that is unreachable is skipped
// (ok, no assertion); a page that is reachable but stale fails loudly.
//
// Escape hatch for machines without network access:
// SKIP_LIVE_CHECKS=1 npm run site:check-live-heading-hierarchy
//
// Only public tinystudio.in URLs and the local source files are referenced
// here; there is no per-environment configuration.
import { readFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { dirname, join } from "node:path"

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
const read = (p) => readFileSync(join(ROOT, p), "utf8")

if (process.env.SKIP_LIVE_CHECKS === "1") {
console.log("test-public-live-heading-hierarchy: SKIP_LIVE_CHECKS=1, skipping live site checks")
process.exit(0)
}

let failures = 0
let checks = 0
const ok = (cond, msg) => {
checks++
if (cond) console.log(` ok ${msg}`)
else {
failures++
console.error(` FAIL ${msg}`)
}
}

// The deployed pages must keep the repaired heading outline (H1 -> H2 cards
// -> H2 footer -> H3 footer columns, no skipped levels). The local suite
// (test-public-heading-hierarchy.mjs) asserts the same outline against the
// worktree HTML; this guard re-asserts it against what the live site
// serves.
const LIVE_PAGES = [
{
name: "Drishti support",
url: "https://tinystudio.in/drishti/support/",
source: "public/drishti/support/index.html"
},
{
name: "Privacy Choices",
url: "https://tinystudio.in/privacy-choices/",
source: "public/privacy-choices/index.html"
}
]
const LIVE_CSS_URL = "https://tinystudio.in/styles.css"
const FETCH_TIMEOUT_MS = 10_000

// Heading levels in document order, e.g. [1, 2, 2, 2, 2, 3, 3, 3].
const headingLevelsOf = (html) =>
[...html.matchAll(/<h([1-6])\b[^>]*>/gi)].map((m) => Number(m[1]))

// Count <h2> used as .info-card card titles (inside <article class="info-card">).
const infoCardTitleCount = (html) =>
(html.match(/<article class="info-card[^"]*"[^>]*>[\s\S]*?<h2\b/gi) || []).length

const fetchLive = async (url) => {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
if (!res.ok) {
console.log(` ok skipped: ${url} answered ${res.status}, deployment not reachable - no assertions run for it`)
return null
Comment on lines +84 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail when a monitored live resource returns non-2xx

When either monitored page or /styles.css is accidentally removed or starts returning 4xx/5xx, this branch treats the response as an acceptable skip and the script can exit 0 without checking that resource. The preceding soft-404 workflow step only verifies the homepage, /promptly/, /404.html, and an unknown URL, so it can remain green while both pages guarded here return 404; non-success HTTP responses should therefore count as failures rather than as network-unreachable skips.

Useful? React with 👍 / 👎.

}
return await res.text()
} catch (err) {
console.log(` ok skipped: ${url} unreachable (${err?.cause?.code ?? err?.name ?? "network error"}) - no assertions run for it`)
return null
}
}
Comment on lines +81 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fail when a live resource returns a non-2xx response.

A reachable 404 or 500 enters this branch and is logged as a successful skip. The guard can then exit with status 0 while a monitored page or stylesheet is absent. Skip only transport failures and timeouts. Record non-2xx responses as failures.

Proposed fix
     if (!res.ok) {
-      console.log(`  ok skipped: ${url} answered ${res.status}, deployment not reachable - no assertions run for it`)
+      ok(false, `${url} returned HTTP ${res.status}`)
       return null
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const fetchLive = async (url) => {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
if (!res.ok) {
console.log(` ok skipped: ${url} answered ${res.status}, deployment not reachable - no assertions run for it`)
return null
}
return await res.text()
} catch (err) {
console.log(` ok skipped: ${url} unreachable (${err?.cause?.code ?? err?.name ?? "network error"}) - no assertions run for it`)
return null
}
}
const fetchLive = async (url) => {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
if (!res.ok) {
ok(false, `${url} returned HTTP ${res.status}`)
return null
}
return await res.text()
} catch (err) {
console.log(` ok skipped: ${url} unreachable (${err?.cause?.code ?? err?.name ?? "network error"}) - no assertions run for it`)
return null
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-public-live-heading-hierarchy.mjs` around lines 81 - 93, Update
fetchLive so transport failures and timeouts remain skipped, but reachable
non-2xx responses are recorded as failures and cause the script to exit nonzero.
Replace the current successful-skip handling in fetchLive’s !res.ok branch with
the script’s existing failure-reporting mechanism, preserving the successful
response text path.


const assertRepairedOutline = (name, source, html) => {
const levels = headingLevelsOf(html)
ok(levels.length > 0, `live ${name} page contains at least one heading`)
ok(levels.filter((l) => l === 1).length === 1, `live ${name} page has exactly one H1`)
ok(levels[0] === 1, `live ${name} page has the H1 as the first heading in the outline`)
ok(infoCardTitleCount(html) === 3, `live ${name} page has the three card headings as H2s inside .info-card articles`)
const cardH2s = levels.filter((l) => l === 2).length
ok(cardH2s >= 4, `live ${name} page keeps the flat H2 band (card H2s plus the footer H2) before the footer H3s`)
let jumps = 0
for (let i = 1; i < levels.length; i++) {
if (levels[i] - levels[i - 1] > 1) {
jumps++
console.error(` bad transition H${levels[i - 1]} -> H${levels[i]} on ${name}`)
}
}
ok(jumps === 0, `live ${name} page has no heading-level jump greater than one (no H1 -> H3 skip)`)
if (failures > 0) {
console.error(` the deployed ${source} is stale: it misses the heading-hierarchy repair that the worktree copy of ${source} already has. Refresh the live deployment from origin/main.`)
Comment on lines +111 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope stale diagnostics to the resource that failed

When an earlier page increments the global failures counter, this condition remains true for every later page and for the stylesheet branch, even if those resources pass all their assertions. During a partial deployment—for example, stale Drishti HTML but current Privacy Choices HTML and CSS—the output therefore falsely labels the passing resources as stale and misdirects release triage; compare the failure count before and after each resource's assertions instead.

Useful? React with 👍 / 👎.

}
Comment on lines +111 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report stale diagnostics only for the resource that failed.

Both checks use the global failures counter. If the first page fails, a passing second page and a passing stylesheet are also reported as stale. Capture the failure count before each resource check and compare it after that resource completes.

  • scripts/test-public-live-heading-hierarchy.mjs#L111-L113: compare against a failure count captured at the start of assertRepairedOutline.
  • scripts/test-public-live-heading-hierarchy.mjs#L138-L140: compare against a failure count captured before the stylesheet assertions.
📍 Affects 1 file
  • scripts/test-public-live-heading-hierarchy.mjs#L111-L113 (this comment)
  • scripts/test-public-live-heading-hierarchy.mjs#L138-L140
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-public-live-heading-hierarchy.mjs` around lines 111 - 113, Track
failures per resource instead of using the cumulative failures count: capture
the count at the start of assertRepairedOutline and compare the post-check count
against that baseline at the stale-diagnostic site
(scripts/test-public-live-heading-hierarchy.mjs:111-113); likewise capture a
baseline immediately before the stylesheet assertions and use it for the
stylesheet diagnostic (scripts/test-public-live-heading-hierarchy.mjs:138-140),
so only the resource’s own failures trigger reporting.

}

console.log("test-public-live-heading-hierarchy: the deployed tinystudio.in pages keep the repaired heading outline (no skipped levels)")

console.log("A. live pages carry the repaired heading hierarchy")
for (const page of LIVE_PAGES) {
const html = await fetchLive(page.url)
if (html !== null) assertRepairedOutline(page.name, page.source, html)
}

console.log("B. live stylesheet keeps the shared card-heading rule at the former card scale")
const css = await fetchLive(LIVE_CSS_URL)
if (css !== null) {
const ruleStart = css.indexOf(".info-card :is(h2, h3) {")
ok(ruleStart !== -1, `live styles.css defines .info-card :is(h2, h3) {`)
const ruleEnd = ruleStart === -1 ? -1 : css.indexOf("}", ruleStart)
const ruleBody = ruleStart === -1 ? "" : css.slice(ruleStart, ruleEnd)
for (const decl of ["margin-top: 12px", "font-size: clamp(1.65rem, 2vw, 2.35rem)", "max-width: none"]) {
ok(ruleBody.includes(decl), `live card rule keeps ${decl}`)
}
ok(
!/\.info-card\s+h3\s*{/.test(css),
"live styles.css replaced the old .info-card h3-only rule with the shared :is(h2, h3) rule"
)
if (failures > 0) {
console.error(" the deployed stylesheet is stale: it misses the card-heading pairing that public/styles.css already has. Refresh the live deployment from origin/main.")
}
}

console.log("\nLive heading-hierarchy guard result: the finding stays open against tinystudio.in until a refresh of the live deployment lands on origin/main.")
console.log(`\n${checks} checks, ${failures} failures`)
process.exit(failures === 0 ? 0 : 1)