Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

production-readiness.mjs

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

production-readiness.mjsBlame219 lines · 1 contributor
38de014ccantynz-alt1#!/usr/bin/env node
2/**
3 * Production-readiness gate.
4 *
5 * Answers one question: "would launching right now expose a class of fault we
6 * have already seen?" Every HARD gate below exists because it caught a real
7 * defect on 2026-07-26/27 — none are hypothetical.
8 *
9 * privacy GET /api/repos/:o/:n returned a PRIVATE repo's full row
10 * (isPrivate, ownerId, on-disk path) to an anonymous caller.
11 * api-json /api/* 404s rendered a 126KB HTML page instead of JSON, so
12 * API and MCP clients failed opaquely.
13 * provenance The container never received a build SHA, so /api/version
14 * reported "unknown" forever: the auto-update banner could
15 * never fire and the PWA cache key never rotated.
16 * render Catches JS exceptions / broken images on real pages.
17 * overflow /docs/agents was 3406px wide in a 1440 viewport; /login
18 * overflowed at 390px.
19 * auth-gate Authenticated-only paths must not render to anonymous users.
20 *
21 * SOFT gates warn but do not block.
22 *
23 * Usage:
24 * node scripts/production-readiness.mjs [--base https://gluecron.com]
25 * [--expect-sha <sha>]
26 * [--private-repo owner/name]
27 * [--json out.json]
28 *
29 * Exit codes: 0 = all HARD gates pass · 1 = a HARD gate failed · 2 = crashed.
30 *
31 * Deliberately does NOT authenticate. Everything here must hold for an
32 * anonymous visitor, which is exactly the threat model for opening signups.
33 */
34
35import { chromium } from '@playwright/test';
36import { writeFileSync } from 'fs';
37
38const argv = process.argv.slice(2);
39const arg = (name, fallback = null) => {
40 const i = argv.indexOf(`--${name}`);
41 return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
42};
43
44const BASE = (arg('base', process.env.SMOKE_HOST || 'https://gluecron.com')).replace(/\/$/, '');
45const EXPECT_SHA = arg('expect-sha', process.env.EXPECT_SHA);
46const PRIVATE_REPO = arg('private-repo', process.env.PRIVATE_REPO); // "owner/name"
47const JSON_OUT = arg('json');
48
49// Pages sampled for render/overflow gates. Keep this list small and
50// representative — it runs on every deploy.
51const PAGES = [
52 '/', '/explore', '/pricing', '/docs', '/docs/agents', '/docs/api',
53 '/login', '/register', '/marketplace', '/enterprise',
54];
55// Must never render for an anonymous caller.
56const AUTH_ONLY = ['/dashboard', '/settings', '/admin', '/settings/tokens'];
57const PERF_BUDGET_MS = 3000; // soft
58
59const results = [];
60const add = (gate, hard, ok, detail) => results.push({ gate, hard, ok, detail });
61
62async function main() {
63 console.log(`[readiness] target: ${BASE}\n`);
64
65 // ── HARD: build provenance ────────────────────────────────────────────
66 try {
67 const r = await fetch(`${BASE}/api/version`);
68 const v = await r.json();
69 const sha = String(v.sha || '');
70 const bad = !sha || sha === 'unknown' || sha === 'dev';
71 if (bad) {
72 add('provenance', true, false,
73 `/api/version reports sha="${sha}" — the build SHA never reached the running process, so deploy detection and PWA cache-busting are both inert`);
74 } else if (EXPECT_SHA && !EXPECT_SHA.startsWith(v.shaFull) && !v.shaFull?.startsWith(sha)) {
75 add('provenance', true, false, `live sha ${v.shaFull} != expected ${EXPECT_SHA} — deploy did not land`);
76 } else {
77 add('provenance', true, true, `sha=${sha} builtAt=${v.builtAt}`);
78 }
79 } catch (e) {
80 add('provenance', true, false, `/api/version unreachable: ${e.message}`);
81 }
82
83 // ── HARD: /api/* must speak JSON, never HTML ──────────────────────────
84 try {
85 const r = await fetch(`${BASE}/api/definitely-not-a-real-endpoint-${Date.now()}`);
86 const ct = r.headers.get('content-type') || '';
87 const body = (await r.text()).slice(0, 400);
88 const isHtml = ct.includes('text/html') || body.trimStart().startsWith('<');
89 add('api-json', true, !isHtml,
90 isHtml
91 ? `unknown /api/* path returned ${r.status} as ${ct || 'HTML'} — API and MCP clients cannot parse this`
92 : `unknown /api/* path returned ${r.status} as ${ct}`);
93 } catch (e) {
94 add('api-json', true, false, `probe failed: ${e.message}`);
95 }
96
97 // ── HARD: a private repo must not be disclosed anonymously ────────────
98 if (PRIVATE_REPO) {
99 const [o, n] = PRIVATE_REPO.split('/');
100 const surfaces = [`/api/repos/${o}/${n}`, `/${o}/${n}`, `/api/v2/repos/${o}/${n}`];
101 const leaks = [];
102 for (const path of surfaces) {
103 try {
104 const r = await fetch(`${BASE}${path}`);
105 if (r.status !== 200) continue;
106 const body = await r.text();
107 // A 200 alone is not proof; look for fields only the real row carries.
108 if (/"isPrivate"|"diskPath"|"ownerId"/.test(body)) {
109 leaks.push(`${path} -> 200 disclosing repo metadata`);
110 }
111 } catch { /* unreachable surface is not a leak */ }
112 }
113 add('privacy', true, leaks.length === 0,
114 leaks.length ? leaks.join('; ') : `no anonymous disclosure across ${surfaces.length} surfaces`);
115 } else {
116 add('privacy', true, true, 'SKIPPED — pass --private-repo owner/name to enable (strongly recommended)');
117 }
118
119 // ── HARD: auth-only pages must not render anonymously ─────────────────
120 const rendered = [];
121 for (const p of AUTH_ONLY) {
122 try {
123 const r = await fetch(`${BASE}${p}`, { redirect: 'manual' });
124 // 2xx that actually renders the page is the failure. 3xx/401/404 are fine.
125 if (r.status >= 200 && r.status < 300) {
126 const body = await r.text();
127 if (!/sign in|log ?in|password/i.test(body.slice(0, 4000))) rendered.push(`${p} -> ${r.status}`);
128 }
129 } catch { /* ignore */ }
130 }
131 add('auth-gate', true, rendered.length === 0,
132 rendered.length ? `rendered to anonymous caller: ${rendered.join(', ')}` : `${AUTH_ONLY.length} paths correctly gated`);
133
134 // ── Render + overflow gates (real browser) ────────────────────────────
135 const browser = await chromium.launch();
136 const jsErrors = [], overflows = [], brokenImgs = [], slow = [], noH1 = [];
137
138 for (const vp of [{ w: 1440, h: 900 }, { w: 390, h: 844 }]) {
139 const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } });
140 for (const path of PAGES) {
141 const page = await ctx.newPage();
142 const errs = [];
143 page.on('pageerror', (e) => errs.push(String(e).slice(0, 200)));
144 const t0 = Date.now();
145 try {
146 await page.goto(BASE + path, { waitUntil: 'domcontentloaded', timeout: 45000 });
147 try { await page.waitForLoadState('networkidle', { timeout: 8000 }); } catch {}
148 const ms = Date.now() - t0;
149 const probe = await page.evaluate(() => ({
150 scrollW: document.documentElement.scrollWidth,
151 innerW: window.innerWidth,
152 h1: document.querySelectorAll('h1').length,
153 broken: Array.from(document.images)
154 .filter((im) => im.complete && im.naturalWidth === 0)
155 .map((im) => im.src.slice(0, 120)),
156 }));
157 if (probe.scrollW > probe.innerW + 2) {
158 overflows.push(`${path} @${vp.w}: ${probe.scrollW}px (+${probe.scrollW - probe.innerW})`);
159 }
160 if (probe.broken.length) brokenImgs.push(`${path}: ${probe.broken.length}`);
161 if (vp.w === 1440) {
162 if (probe.h1 === 0) noH1.push(path);
163 if (ms > PERF_BUDGET_MS) slow.push(`${path} ${ms}ms`);
164 }
165 } catch (e) {
166 errs.push(`navigation failed: ${String(e).slice(0, 160)}`);
167 }
168 if (errs.length) jsErrors.push(`${path} @${vp.w}: ${errs[0]}`);
169 await page.close();
170 }
171 await ctx.close();
172 }
173 await browser.close();
174
175 add('render', true, jsErrors.length === 0,
176 jsErrors.length ? jsErrors.slice(0, 5).join(' | ') : `${PAGES.length} pages x2 viewports, no uncaught JS errors`);
177 add('overflow', true, overflows.length === 0,
178 overflows.length ? overflows.slice(0, 6).join(' | ') : 'no horizontal overflow at 1440 or 390');
179 add('images', false, brokenImgs.length === 0,
180 brokenImgs.length ? brokenImgs.join(' | ') : 'no broken images');
181 add('headings', false, noH1.length === 0,
182 noH1.length ? `${noH1.length} page(s) with no <h1>: ${noH1.slice(0, 6).join(', ')}` : 'every sampled page has an h1');
183 add('perf', false, slow.length === 0,
184 slow.length ? `over ${PERF_BUDGET_MS}ms: ${slow.join(', ')}` : `all sampled pages under ${PERF_BUDGET_MS}ms`);
185
186 // ── Report ────────────────────────────────────────────────────────────
187 const pad = (s, n) => String(s).padEnd(n);
188 console.log(pad('GATE', 13) + pad('KIND', 7) + pad('RESULT', 8) + 'DETAIL');
189 console.log('-'.repeat(100));
190 for (const r of results) {
191 console.log(
192 pad(r.gate, 13) + pad(r.hard ? 'HARD' : 'soft', 7) +
193 pad(r.ok ? 'PASS' : 'FAIL', 8) + r.detail
194 );
195 }
196
197 const hardFails = results.filter((r) => r.hard && !r.ok);
198 const softFails = results.filter((r) => !r.hard && !r.ok);
199 console.log('');
200 console.log(`[readiness] HARD ${results.filter(r => r.hard && r.ok).length}/${results.filter(r => r.hard).length} passed · soft warnings: ${softFails.length}`);
201
202 if (JSON_OUT) {
203 writeFileSync(JSON_OUT, JSON.stringify({ base: BASE, results }, null, 2));
204 console.log(`[readiness] wrote ${JSON_OUT}`);
205 }
206
207 if (hardFails.length) {
208 console.error(`\n[readiness] NOT READY — ${hardFails.length} hard gate(s) failed:`);
209 for (const f of hardFails) console.error(` - ${f.gate}: ${f.detail}`);
210 process.exit(1);
211 }
212 console.log('\n[readiness] all hard gates passed');
213 process.exit(0);
214}
215
216main().catch((e) => {
217 console.error('[readiness] crashed:', e);
218 process.exit(2);
219});