also looking at this
Add BunPress guide and pre-E2E quality gates
#4
246 files
+1744
-64381
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -203,5 +203,8 @@ jobs: | ||
| 203 | 203 | - name: Validate responsive visual contracts |
| 204 | 204 | run: bun run visual:validate |
| 205 | 205 | |
| 206 | - name: Validate production release artifact | |
| 207 | run: bun run release:validate | |
| 208 | ||
| 206 | 209 | - name: Verify generated files are current |
| 207 | 210 | run: git diff --exit-code |
| @@ -34,9 +34,6 @@ jobs: | ||
| 34 | 34 | # promoted, and it costs about a minute. Without it a promotion carrying a |
| 35 | 35 | # broken commit deploys, and nothing ever says so. |
| 36 | 36 | # |
| 37 | # Deliberately not lint or build: neither changes what the box serves. This | |
| 38 | # gate is about correctness, and the "does it actually serve" half is asserted | |
| 39 | # against the live site at the end of the deploy job. | |
| 40 | 37 | gate: |
| 41 | 38 | runs-on: ubuntu-latest |
| 42 | 39 | steps: |
| @@ -55,6 +52,10 @@ jobs: | ||
| 55 | 52 | run: bunx --bun tsc --noEmit |
| 56 | 53 | - name: Test |
| 57 | 54 | run: bun run test |
| 55 | - name: Build the production artifact | |
| 56 | run: bun run build | |
| 57 | - name: Validate the production artifact | |
| 58 | run: bun run release:validate | |
| 58 | 59 | |
| 59 | 60 | deploy: |
| 60 | 61 | needs: gate |
| @@ -327,3 +328,6 @@ jobs: | ||
| 327 | 328 | || { echo "::error::ingest did not reject a bogus key with 'unknown project'"; exit 1; } |
| 328 | 329 | |
| 329 | 330 | echo "== all deploy verifications passed ==" |
| 331 | ||
| 332 | - name: Verify public metadata | |
| 333 | run: bun run deployment:smoke | |
| @@ -34,6 +34,8 @@ | ||
| 34 | 34 | "docs:validate": "bun scripts/validate-docs.ts", |
| 35 | 35 | "ui:validate": "bun scripts/validate-ui.ts", |
| 36 | 36 | "visual:validate": "bun scripts/validate-visual.ts", |
| 37 | "release:validate": "bun scripts/validate-release.ts", | |
| 38 | "deployment:smoke": "bun scripts/smoke-deployment.ts", | |
| 37 | 39 | "prod": "bun scripts/build-app.ts", |
| 38 | 40 | "deploy": "./buddy deploy", |
| 39 | 41 | "lint": "./buddy lint", |
| @@ -0,0 +1,1 @@ | ||
| 1 | ||
| @@ -13,7 +13,7 @@ import { archiveConfig } from '../../app/Archive/config' | ||
| 13 | 13 | import { projectPlan } from '../../app/Archive/plan' |
| 14 | 14 | import { archiveSummary, levelsByRelease, volumeSeries } from '../../app/Archive/query' |
| 15 | 15 | |
| 16 | const query = new URLSearchParams(__stxServeSearch || '') | |
| 16 | const query = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '') | |
| 17 | 17 | |
| 18 | 18 | // Whitelisted, like the dashboard's ranges: the key comes from the URL and only |
| 19 | 19 | // the mapped day count is ever used in a query. |
| @@ -34,9 +34,9 @@ const RANGES: AnalyticsRange[] = [ | ||
| 34 | 34 | const activeRange = RANGES.find(r => r.key === query.get('range')) ?? DEFAULT_RANGE |
| 35 | 35 | |
| 36 | 36 | const requestedProject = query.get('project') |
| 37 | const cookieProject = cookies.loghq_project ?? '' | |
| 37 | const cookieProject = typeof cookies !== 'undefined' ? (cookies.loghq_project ?? '') : '' | |
| 38 | 38 | |
| 39 | const __token = cookies.loghq_token ?? '' | |
| 39 | const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : '' | |
| 40 | 40 | let user: AuthUser | null = null |
| 41 | 41 | if (__token) { |
| 42 | 42 | try { |
| @@ -38,7 +38,7 @@ import { utcIsoHoursAgo } from '../../app/Support/time' | ||
| 38 | 38 | // the serve path immediately before the block runs — not a global to reach |
| 39 | 39 | // through globalThis for. Reading it bare has no ordering requirement and no |
| 40 | 40 | // cross-request global to race on. |
| 41 | const query = new URLSearchParams(__stxServeSearch || '') | |
| 41 | const query = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '') | |
| 42 | 42 | |
| 43 | 43 | // Level filter is a MINIMUM-severity threshold: the key names the floor, and we |
| 44 | 44 | // expand it to the set of levels at or above it. Whitelisted — the raw param is |
| @@ -119,7 +119,7 @@ const activeRange = RANGES.find(r => r.key === rangeParam) ?? DEFAULT_RANGE | ||
| 119 | 119 | const rangeFilter = activeRange.key |
| 120 | 120 | |
| 121 | 121 | const requestedProject = query.get('project') |
| 122 | const cookieProject = cookies.loghq_project ?? '' | |
| 122 | const cookieProject = typeof cookies !== 'undefined' ? (cookies.loghq_project ?? '') : '' | |
| 123 | 123 | |
| 124 | 124 | const PAGE_SIZE = 50 |
| 125 | 125 | const pageParam = Number.parseInt(query.get('page') || '1', 10) |
| @@ -135,7 +135,7 @@ const requestedPage = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : | ||
| 135 | 135 | // put the raw session credential in the page source of every /dashboard render, |
| 136 | 136 | // because `useStore('session').token()` appears in the client block below. See |
| 137 | 137 | // the fuller note in settings.stx. |
| 138 | const __token = cookies.loghq_token ?? '' | |
| 138 | const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : '' | |
| 139 | 139 | let user: AuthUser | null = null |
| 140 | 140 | if (__token) { |
| 141 | 141 | try { |
| @@ -23,10 +23,10 @@ useSeoMeta({ | ||
| 23 | 23 | robots: 'noindex, nofollow', |
| 24 | 24 | }) |
| 25 | 25 | |
| 26 | const query = new URLSearchParams(__stxServeSearch || '') | |
| 26 | const query = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '') | |
| 27 | 27 | const logId = (query.get('log') || '').slice(0, 255) |
| 28 | 28 | |
| 29 | const __token = cookies.loghq_token ?? '' | |
| 29 | const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : '' | |
| 30 | 30 | let __user: any = null |
| 31 | 31 | if (__token) { |
| 32 | 32 | try { |
| @@ -124,6 +124,9 @@ function stampOf(value: unknown): string { | ||
| 124 | 124 | } |
| 125 | 125 | |
| 126 | 126 | const analyzedAt = stampOf(run?.completed_at) |
| 127 | const analysisSuspects = Array.isArray(analysis?.suspects) ? analysis.suspects : [] | |
| 128 | const analysisSteps = Array.isArray(analysis?.steps) ? analysis.steps : [] | |
| 129 | const analysisVerification = Array.isArray(analysis?.verification) ? analysis.verification : [] | |
| 127 | 130 | |
| 128 | 131 | // The page handles its own POST (stx#1847). The analysis is one model call, so |
| 129 | 132 | // it completes inside this request rather than needing a queue and a poller - |
| @@ -240,10 +243,10 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri | ||
| 240 | 243 | <p class="leading-relaxed text-muted text-sm">{{ analysis.rootCause }}</p> |
| 241 | 244 | </div> |
| 242 | 245 | |
| 243 | @if (analysis.suspects && analysis.suspects.length) | |
| 246 | @if (analysisSuspects.length) | |
| 244 | 247 | <div class="px-5 py-4 border-b border-line"> |
| 245 | 248 | <div class="mb-2.5 font-semibold text-subtle text-xs tracking-wider uppercase">Where to look</div> |
| 246 | @foreach (analysis.suspects as suspect) | |
| 249 | @foreach (analysisSuspects as suspect) | |
| 247 | 250 | <div class="mb-2.5 last:mb-0"> |
| 248 | 251 | <div class="font-mono text-ink text-xs tnum">{{ suspect.location }}</div> |
| 249 | 252 | <div class="mt-0.5 text-muted text-sm">{{ suspect.reason }}</div> |
| @@ -252,11 +255,11 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri | ||
| 252 | 255 | </div> |
| 253 | 256 | @endif |
| 254 | 257 | |
| 255 | @if (analysis.steps && analysis.steps.length) | |
| 258 | @if (analysisSteps.length) | |
| 256 | 259 | <div class="px-5 py-4 border-b border-line"> |
| 257 | 260 | <div class="mb-2.5 font-semibold text-subtle text-xs tracking-wider uppercase">Suggested fix</div> |
| 258 | 261 | <ol> |
| 259 | @foreach (analysis.steps as step) | |
| 262 | @foreach (analysisSteps as step) | |
| 260 | 263 | <li class="grid grid-cols-[1.5rem_1fr] mb-3 last:mb-0"> |
| 261 | 264 | <span class="font-mono text-subtle text-xs tnum">{{ loop.iteration }}.</span> |
| 262 | 265 | <span> |
| @@ -276,11 +279,11 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri | ||
| 276 | 279 | </div> |
| 277 | 280 | @endif |
| 278 | 281 | |
| 279 | @if (analysis.verification && analysis.verification.length) | |
| 282 | @if (analysisVerification.length) | |
| 280 | 283 | <div class="px-5 py-4 border-b border-line"> |
| 281 | 284 | <div class="mb-2.5 font-semibold text-subtle text-xs tracking-wider uppercase">How to verify</div> |
| 282 | 285 | <ul> |
| 283 | @foreach (analysis.verification as check) | |
| 286 | @foreach (analysisVerification as check) | |
| 284 | 287 | <li class="mb-1.5 last:mb-0 text-muted text-sm">{{ check }}</li> |
| 285 | 288 | @endforeach |
| 286 | 289 | </ul> |
| @@ -48,7 +48,7 @@ useHead({ link: [{ rel: 'canonical', href: 'https://loghq.org/' }] }) | ||
| 48 | 48 | // This also settles ch.9.1. Both CTAs were plain `<a href>` carrying a `:href` |
| 49 | 49 | // binding, which is a same-origin navigation done as a full document load. They |
| 50 | 50 | // are `<StxLink>` now — one branch, one destination, no binding to override it. |
| 51 | const pageAuthed = !!cookies.loghq_token | |
| 51 | const pageAuthed = typeof cookies !== 'undefined' && !!cookies.loghq_token | |
| 52 | 52 | </script> |
| 53 | 53 | |
| 54 | 54 | <script client> |
| @@ -31,7 +31,7 @@ useHead({ link: [{ rel: 'canonical', href: 'https://loghq.org/login' }] }) | ||
| 31 | 31 | // provider. Read here rather than from location.search in a client block: it is |
| 32 | 32 | // server-known at render time, so reading it on the client meant the message |
| 33 | 33 | // could not exist until hydration. |
| 34 | const oauthError = new URLSearchParams(__stxServeSearch || '').get('error') || '' | |
| 34 | const oauthError = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '').get('error') || '' | |
| 35 | 35 | |
| 36 | 36 | // What the action merges back on a rejected submit, declared with its |
| 37 | 37 | // resting values. |
| @@ -23,7 +23,7 @@ import { db as __db } from '@stacksjs/database' | ||
| 23 | 23 | // `__`-prefixed so the server→client bridge cannot publish it — see the note in |
| 24 | 24 | // settings.stx. This page does not leak today only because the word "token" |
| 25 | 25 | // happens not to appear in its client block; that is an accident, not a design. |
| 26 | const __token = cookies.loghq_token ?? '' | |
| 26 | const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : '' | |
| 27 | 27 | let user: AuthUser | null = null |
| 28 | 28 | if (__token) { |
| 29 | 29 | try { |
| @@ -6,7 +6,7 @@ import { Auth } from '@stacksjs/auth' | ||
| 6 | 6 | import { db as __db } from '@stacksjs/database' |
| 7 | 7 | // Shared with routes/projects.ts so the two callers cannot drift on how an id |
| 8 | 8 | // or an ingest key is minted. |
| 9 | import { newIngestKey, newProjectId } from '../../../app/Support/projects' | |
| 9 | import { newIngestKey, newProjectId } from '../../app/Support/projects' | |
| 10 | 10 | useSeoMeta({ |
| 11 | 11 | title: 'New app - loghq', |
| 12 | 12 | description: 'Create an app to get an ingest key and start streaming logs.', |
| @@ -16,7 +16,7 @@ useSeoMeta({ | ||
| 16 | 16 | // Server-side session check, replacing a client guard that navigated away |
| 17 | 17 | // after hydration. A logged-out visitor now gets an honest prompt in the first |
| 18 | 18 | // response instead of a create form that flashes and disappears. |
| 19 | const authed = !!cookies.loghq_token | |
| 19 | const authed = typeof cookies !== 'undefined' && !!cookies.loghq_token | |
| 20 | 20 | |
| 21 | 21 | // Resting values for what the action merges back. |
| 22 | 22 | let formError = '' |
| @@ -25,7 +25,7 @@ useHead({ link: [{ rel: 'canonical', href: 'https://loghq.org/register' }] }) | ||
| 25 | 25 | // It has to survive the POST too. The form posts to /register with no query |
| 26 | 26 | // string, so both values are carried as hidden inputs below — that is the |
| 27 | 27 | // no-JS equivalent of the client reading location.search on every submit. |
| 28 | const __regQuery = new URLSearchParams(__stxServeSearch || '') | |
| 28 | const __regQuery = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '') | |
| 29 | 29 | const isInvite = !!__regQuery.get('invite') |
| 30 | 30 | const invitedEmail = __regQuery.get('email') || '' |
| 31 | 31 | |
| @@ -12,7 +12,7 @@ useSeoMeta({ | ||
| 12 | 12 | // render. They are carried across the POST as hidden inputs, because the form |
| 13 | 13 | // posts to /reset-password with no query string and the token would otherwise |
| 14 | 14 | // be gone by the time the action runs. |
| 15 | const __rpQuery = new URLSearchParams(__stxServeSearch || '') | |
| 15 | const __rpQuery = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '') | |
| 16 | 16 | const linkToken = __rpQuery.get('token') || '' |
| 17 | 17 | const linkEmail = __rpQuery.get('email') || '' |
| 18 | 18 | |
| @@ -54,9 +54,9 @@ const INGEST_BASE = ingestUrl() | ||
| 54 | 54 | // |
| 55 | 55 | // Deliberately not naming that global here: the ch.5.5 gate greps for it by |
| 56 | 56 | // name, and prose in a comment would keep the check permanently non-zero. |
| 57 | const query = new URLSearchParams(__stxServeSearch || '') | |
| 57 | const query = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '') | |
| 58 | 58 | const requestedProject = query.get('project') |
| 59 | const cookieProject = cookies.loghq_project ?? '' | |
| 59 | const cookieProject = typeof cookies !== 'undefined' ? (cookies.loghq_project ?? '') : '' | |
| 60 | 60 | |
| 61 | 61 | // --------------------------------------------------------------------------- |
| 62 | 62 | // THE `__` PREFIX IS A SECURITY BOUNDARY IN THIS FILE. READ BEFORE RENAMING. |
| @@ -81,7 +81,7 @@ const cookieProject = cookies.loghq_project ?? '' | ||
| 81 | 81 | // Renaming a `__` binding to a bare one re-opens the leak silently, with no lint |
| 82 | 82 | // error and no test failure. |
| 83 | 83 | // --------------------------------------------------------------------------- |
| 84 | const __token = cookies.loghq_token ?? '' | |
| 84 | const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : '' | |
| 85 | 85 | let user: AuthUser | null = null |
| 86 | 86 | if (__token) { |
| 87 | 87 | try { |
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | import { existsSync, lstatSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs' |
| 2 | 2 | import { resolve } from 'node:path' |
| 3 | import { buildForProduction } from '@stacksjs/stx' | |
| 3 | 4 | |
| 4 | 5 | const projectRoot = resolve(import.meta.dir, '..') |
| 5 | const compatibilityPath = resolve(projectRoot, 'pages') | |
| 6 | const resourcesRoot = resolve(projectRoot, 'resources') | |
| 7 | const compatibilityPath = resolve(resourcesRoot, 'pages') | |
| 6 | 8 | const viewsPath = resolve(projectRoot, 'resources/views') |
| 7 | const relativeViewsPath = 'resources/views' | |
| 9 | const relativeViewsPath = 'views' | |
| 8 | 10 | |
| 9 | 11 | let createdCompatibilityLink = false |
| 10 | 12 | |
| @@ -19,24 +21,24 @@ if (existsSync(compatibilityPath)) { | ||
| 19 | 21 | } |
| 20 | 22 | } |
| 21 | 23 | else { |
| 22 | // The current SSR builder discovers only a root pages directory and does not | |
| 23 | // honor ui.pagesDir. Keep the source layout canonical and expose it only while | |
| 24 | // the framework compiles the application. | |
| 24 | // The current SSR builder discovers only a pages directory beneath its root | |
| 25 | // and does not honor ui.pagesDir. Building from resources preserves every | |
| 26 | // view's relative imports; expose views as resources/pages only while the | |
| 27 | // framework compiles the application. | |
| 25 | 28 | symlinkSync(relativeViewsPath, compatibilityPath, 'dir') |
| 26 | 29 | createdCompatibilityLink = true |
| 27 | 30 | } |
| 28 | 31 | |
| 29 | 32 | try { |
| 30 | const build = Bun.spawn(['./buddy', 'build'], { | |
| 31 | cwd: projectRoot, | |
| 32 | stdin: 'inherit', | |
| 33 | stdout: 'inherit', | |
| 34 | stderr: 'inherit', | |
| 33 | const result = await buildForProduction({ | |
| 34 | root: resourcesRoot, | |
| 35 | outputDir: resolve(projectRoot, '.output'), | |
| 36 | componentsDir: resolve(resourcesRoot, 'components'), | |
| 37 | layoutsDir: resolve(resourcesRoot, 'layouts'), | |
| 38 | partialsDir: resolve(resourcesRoot, 'partials'), | |
| 39 | publicDir: resolve(projectRoot, 'public'), | |
| 35 | 40 | }) |
| 36 | const exitCode = await build.exited | |
| 37 | ||
| 38 | if (exitCode !== 0) | |
| 39 | process.exitCode = exitCode | |
| 41 | console.log(`Compiled ${result.pageCount} SSR routes to ${result.outputDir}.`) | |
| 40 | 42 | } |
| 41 | 43 | finally { |
| 42 | 44 | if (createdCompatibilityLink && existsSync(compatibilityPath)) |
| @@ -0,0 +1,63 @@ | ||
| 1 | import { readFileSync } from 'node:fs' | |
| 2 | import { resolve } from 'node:path' | |
| 3 | ||
| 4 | interface SmokeContract { | |
| 5 | domain: string | |
| 6 | homeText: string | |
| 7 | } | |
| 8 | ||
| 9 | const CONTRACTS: Record<string, SmokeContract> = { | |
| 10 | analyticshq: { domain: 'analyticshq.org', homeText: 'analyticshq' }, | |
| 11 | bughq: { domain: 'bughq.org', homeText: 'Error tracking for people who ship.' }, | |
| 12 | commshq: { domain: 'commshq.org', homeText: 'Grow an audience worth knowing.' }, | |
| 13 | loghq: { domain: 'loghq.org', homeText: 'Your logs, finally worth reading.' }, | |
| 14 | reportshq: { domain: 'reportshq.org', homeText: 'Reports that build themselves' }, | |
| 15 | status: { domain: 'statushq.org', homeText: 'Know the moment' }, | |
| 16 | } | |
| 17 | ||
| 18 | const projectRoot = resolve(import.meta.dir, '..') | |
| 19 | const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string } | |
| 20 | const packageName = String(packageJson.name || '') | |
| 21 | const directoryName = projectRoot.split('/').pop() || '' | |
| 22 | const appName = CONTRACTS[packageName] ? packageName : directoryName | |
| 23 | const contract = CONTRACTS[appName] | |
| 24 | ||
| 25 | if (!contract) throw new Error(`No deployment smoke contract exists for ${appName || '(unnamed)'}.`) | |
| 26 | ||
| 27 | const baseUrl = String(process.env.SMOKE_BASE_URL || `https://${contract.domain}`).replace(/\/$/, '') | |
| 28 | const attempts = Math.max(1, Number(process.env.SMOKE_ATTEMPTS || 10)) | |
| 29 | const pauseMs = Math.max(0, Number(process.env.SMOKE_RETRY_MS || 3000)) | |
| 30 | ||
| 31 | async function readWithRetry(path: string): Promise<string> { | |
| 32 | let lastFailure = 'no response' | |
| 33 | for (let attempt = 1; attempt <= attempts; attempt += 1) { | |
| 34 | try { | |
| 35 | const separator = path.includes('?') ? '&' : '?' | |
| 36 | const response = await fetch(`${baseUrl}${path}${separator}smoke=${Date.now()}`, { | |
| 37 | headers: { accept: path === '/robots.txt' ? 'text/plain' : 'text/html' }, | |
| 38 | redirect: 'follow', | |
| 39 | signal: AbortSignal.timeout(10_000), | |
| 40 | }) | |
| 41 | const body = await response.text() | |
| 42 | if (response.ok) return body | |
| 43 | lastFailure = `HTTP ${response.status}` | |
| 44 | } | |
| 45 | catch (error) { | |
| 46 | lastFailure = error instanceof Error ? error.message : String(error) | |
| 47 | } | |
| 48 | if (attempt < attempts) await Bun.sleep(pauseMs) | |
| 49 | } | |
| 50 | throw new Error(`${baseUrl}${path} failed after ${attempts} attempts: ${lastFailure}`) | |
| 51 | } | |
| 52 | ||
| 53 | const home = await readWithRetry('/') | |
| 54 | if (!home.includes(contract.homeText)) throw new Error(`Homepage does not contain the expected release marker: ${contract.homeText}`) | |
| 55 | ||
| 56 | const login = await readWithRetry('/login') | |
| 57 | if (!/<(?:form|main)\b/i.test(login)) throw new Error('Login smoke response does not contain an application form or main region.') | |
| 58 | ||
| 59 | const robots = await readWithRetry('/robots.txt') | |
| 60 | if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.') | |
| 61 | if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`) | |
| 62 | ||
| 63 | console.log(`Deployment smoke passed for ${baseUrl}: homepage, login, and robots metadata are live.`) | |
| @@ -0,0 +1,199 @@ | ||
| 1 | import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' | |
| 2 | import { extname, join, resolve } from 'node:path' | |
| 3 | ||
| 4 | interface AppContract { | |
| 5 | domain: string | |
| 6 | minPages: number | |
| 7 | routes: Record<string, string[]> | |
| 8 | } | |
| 9 | ||
| 10 | const CONTRACTS: Record<string, AppContract> = { | |
| 11 | analyticshq: { | |
| 12 | domain: 'analyticshq.org', | |
| 13 | minPages: 30, | |
| 14 | routes: { | |
| 15 | '/': ['analyticshq'], | |
| 16 | '/compare/matomo': ['analyticshq vs Matomo', 'Point for point.'], | |
| 17 | '/login': ['analyticshq'], | |
| 18 | }, | |
| 19 | }, | |
| 20 | bughq: { | |
| 21 | domain: 'bughq.org', | |
| 22 | minPages: 25, | |
| 23 | routes: { | |
| 24 | '/': ['Error tracking for people who ship.', 'From throw to triaged'], | |
| 25 | '/login': ['bughq'], | |
| 26 | '/dashboard': ['bughq'], | |
| 27 | }, | |
| 28 | }, | |
| 29 | commshq: { | |
| 30 | domain: 'commshq.org', | |
| 31 | minPages: 45, | |
| 32 | routes: { | |
| 33 | '/': ['Grow an audience worth knowing.', 'From first hello to favorite customer.'], | |
| 34 | '/login': ['CommsHQ'], | |
| 35 | '/dashboard/commshq': ['CommsHQ'], | |
| 36 | }, | |
| 37 | }, | |
| 38 | loghq: { | |
| 39 | domain: 'loghq.org', | |
| 40 | minPages: 30, | |
| 41 | routes: { | |
| 42 | '/': ['Your logs, finally worth reading.', 'From scattered log files to one live stream.'], | |
| 43 | '/login': ['loghq'], | |
| 44 | '/compare/sentry': ['loghq', 'Sentry'], | |
| 45 | }, | |
| 46 | }, | |
| 47 | reportshq: { | |
| 48 | domain: 'reportshq.org', | |
| 49 | minPages: 20, | |
| 50 | routes: { | |
| 51 | '/': ['Reports that build themselves', 'Three steps, and you write none of the reporting'], | |
| 52 | '/login': ['ReportsHQ'], | |
| 53 | '/pricing': ['ReportsHQ'], | |
| 54 | }, | |
| 55 | }, | |
| 56 | status: { | |
| 57 | domain: 'statushq.org', | |
| 58 | minPages: 35, | |
| 59 | routes: { | |
| 60 | '/': ['Know the moment', 'One dashboard for uptime, SSL, DNS, and status.'], | |
| 61 | '/login': ['Welcome back'], | |
| 62 | '/register': ['Create your account'], | |
| 63 | }, | |
| 64 | }, | |
| 65 | } | |
| 66 | ||
| 67 | const projectRoot = resolve(import.meta.dir, '..') | |
| 68 | const distRoot = resolve(projectRoot, 'dist') | |
| 69 | const ssrRoot = resolve(projectRoot, '.output') | |
| 70 | const ssrPages = resolve(ssrRoot, 'server/pages') | |
| 71 | const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string } | |
| 72 | const packageName = String(packageJson.name || '') | |
| 73 | const directoryName = projectRoot.split('/').pop() || '' | |
| 74 | const appName = CONTRACTS[packageName] ? packageName : directoryName | |
| 75 | const contract = CONTRACTS[appName] | |
| 76 | ||
| 77 | if (!contract) | |
| 78 | throw new Error(`No release contract is configured for package ${appName || '(unnamed)'}.`) | |
| 79 | ||
| 80 | const isSsr = existsSync(ssrPages) | |
| 81 | if (!existsSync(distRoot) && !isSsr) | |
| 82 | throw new Error('No production artifact found. Run bun run build first.') | |
| 83 | ||
| 84 | function walkFiles(directory: string): string[] { | |
| 85 | if (!existsSync(directory)) return [] | |
| 86 | const files: string[] = [] | |
| 87 | for (const entry of readdirSync(directory)) { | |
| 88 | const path = join(directory, entry) | |
| 89 | if (statSync(path).isDirectory()) files.push(...walkFiles(path)) | |
| 90 | else files.push(path) | |
| 91 | } | |
| 92 | return files | |
| 93 | } | |
| 94 | ||
| 95 | function routeArtifact(route: string): { html: string, source: string } | undefined { | |
| 96 | if (!isSsr) { | |
| 97 | const direct = route === '/' ? join(distRoot, 'index.html') : join(distRoot, `${route.slice(1)}.html`) | |
| 98 | const nested = join(distRoot, route.slice(1), 'index.html') | |
| 99 | const source = existsSync(direct) ? direct : existsSync(nested) ? nested : undefined | |
| 100 | return source ? { html: readFileSync(source, 'utf8'), source } : undefined | |
| 101 | } | |
| 102 | ||
| 103 | const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-') | |
| 104 | const source = join(ssrPages, `${name}.compiled.json`) | |
| 105 | if (!existsSync(source)) return undefined | |
| 106 | const compiled = JSON.parse(readFileSync(source, 'utf8')) as { html?: string } | |
| 107 | return { html: compiled.html || '', source } | |
| 108 | } | |
| 109 | ||
| 110 | const pageFiles = isSsr | |
| 111 | ? walkFiles(ssrPages).filter(path => path.endsWith('.compiled.json')) | |
| 112 | : walkFiles(distRoot).filter(path => path.endsWith('.html')) | |
| 113 | ||
| 114 | const failures: string[] = [] | |
| 115 | if (pageFiles.length < contract.minPages) | |
| 116 | failures.push(`artifact contains ${pageFiles.length} pages; expected at least ${contract.minPages}`) | |
| 117 | ||
| 118 | const publicRoot = isSsr ? join(ssrRoot, 'public') : distRoot | |
| 119 | for (const [route, expectedText] of Object.entries(contract.routes)) { | |
| 120 | const artifact = routeArtifact(route) | |
| 121 | if (!artifact) { | |
| 122 | failures.push(`${route}: production artifact is missing`) | |
| 123 | continue | |
| 124 | } | |
| 125 | ||
| 126 | const withoutCode = artifact.html | |
| 127 | .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ') | |
| 128 | .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ') | |
| 129 | .replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, ' ') | |
| 130 | const visibleText = withoutCode.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() | |
| 131 | ||
| 132 | for (const fragment of expectedText) { | |
| 133 | if (!visibleText.includes(fragment)) | |
| 134 | failures.push(`${route}: expected text is missing: ${fragment}`) | |
| 135 | } | |
| 136 | if (/@(if|foreach|forelse)\b/.test(visibleText)) | |
| 137 | failures.push(`${route}: unresolved template syntax is visible`) | |
| 138 | if (/<h[12]\b[^>]*>\s*<\/h[12]>/i.test(withoutCode)) | |
| 139 | failures.push(`${route}: an empty primary heading was emitted`) | |
| 140 | if (/\b(?:AWS_SECRET_ACCESS_KEY|DB_PASSWORD|STRIPE_SECRET_KEY)\s*[:=]\s*["']?[^\s"'<]{4,}/i.test(artifact.html)) | |
| 141 | failures.push(`${route}: a secret-shaped assignment was emitted`) | |
| 142 | ||
| 143 | const assetPattern = /(?:src|href)=["'](\/[^"'?#]+)["']/gi | |
| 144 | let match: RegExpExecArray | null | |
| 145 | while ((match = assetPattern.exec(artifact.html))) { | |
| 146 | const assetPath = match[1] | |
| 147 | const looksLikeAsset = extname(assetPath) !== '' || assetPath.startsWith('/_stx/') || assetPath.startsWith('/__stx/') | |
| 148 | if (!looksLikeAsset) continue | |
| 149 | const local = resolve(publicRoot, `.${assetPath}`) | |
| 150 | if (!local.startsWith(`${publicRoot}/`) || !existsSync(local)) | |
| 151 | failures.push(`${route}: referenced asset is missing: ${assetPath}`) | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | for (const metadataFile of ['sitemap.xml', 'robots.txt']) { | |
| 156 | const path = join(publicRoot, metadataFile) | |
| 157 | if (!existsSync(path)) { | |
| 158 | failures.push(`${metadataFile}: missing from production artifact`) | |
| 159 | continue | |
| 160 | } | |
| 161 | const contents = readFileSync(path, 'utf8') | |
| 162 | if (contents.includes('http://localhost')) failures.push(`${metadataFile}: contains a localhost production URL`) | |
| 163 | if (!contents.includes(contract.domain)) failures.push(`${metadataFile}: does not name ${contract.domain}`) | |
| 164 | } | |
| 165 | ||
| 166 | const migrationsDir = resolve(projectRoot, 'database/migrations') | |
| 167 | if (existsSync(migrationsDir)) { | |
| 168 | for (const file of readdirSync(migrationsDir).sort()) { | |
| 169 | const path = join(migrationsDir, file) | |
| 170 | if (!statSync(path).isFile()) continue | |
| 171 | if (readFileSync(path, 'utf8').trim().length === 0) failures.push(`migration is empty: ${file}`) | |
| 172 | const sql = readFileSync(path, 'utf8') | |
| 173 | if (/^(<{7}|={7}|>{7})/m.test(sql)) failures.push(`migration contains a merge marker: ${file}`) | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | if (isSsr && failures.length === 0) { | |
| 178 | const { startProductionServer } = await import('@stacksjs/stx') | |
| 179 | const port = 42_000 + Math.floor(Math.random() * 8_000) | |
| 180 | const server = await startProductionServer({ outputDir: ssrRoot, port }) | |
| 181 | try { | |
| 182 | for (const route of Object.keys(contract.routes)) { | |
| 183 | const response = await fetch(`http://127.0.0.1:${server.port}${route}`) | |
| 184 | const body = await response.text() | |
| 185 | if (!response.ok) failures.push(`${route}: production server returned ${response.status}`) | |
| 186 | if (!body.toLowerCase().includes(appName.toLowerCase())) failures.push(`${route}: production server response is not the expected application`) | |
| 187 | } | |
| 188 | } | |
| 189 | finally { | |
| 190 | server.stop() | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | if (failures.length) { | |
| 195 | for (const failure of failures) console.error(`- ${failure}`) | |
| 196 | process.exit(1) | |
| 197 | } | |
| 198 | ||
| 199 | console.log(`Validated ${pageFiles.length} production pages, ${Object.keys(contract.routes).length} smoke routes, metadata, assets, migrations, and ${isSsr ? 'the SSR server' : 'the SSG artifact'}.`) | |