also looking at this
Promote main to production: signed-out chrome and the subscriber tables
#17
54 files
+4252
-1353
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.
| @@ -4,15 +4,17 @@ import { resolve } from 'node:path' | ||
| 4 | 4 | interface SmokeContract { |
| 5 | 5 | domain: string |
| 6 | 6 | homeText: string |
| 7 | /** Whether POST /api/email/subscribe is registered in that app. */ | |
| 8 | subscribe: boolean | |
| 7 | 9 | } |
| 8 | 10 | |
| 9 | 11 | 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' }, | |
| 12 | analyticshq: { domain: 'analyticshq.org', homeText: 'analyticshq', subscribe: true }, | |
| 13 | bughq: { domain: 'bughq.org', homeText: 'Error tracking for people who ship.', subscribe: false }, | |
| 14 | commshq: { domain: 'commshq.org', homeText: 'Grow an audience worth knowing.', subscribe: true }, | |
| 15 | loghq: { domain: 'loghq.org', homeText: 'Your logs, finally worth reading.', subscribe: true }, | |
| 16 | reportshq: { domain: 'reportshq.org', homeText: 'Reports that build themselves', subscribe: true }, | |
| 17 | status: { domain: 'statushq.org', homeText: 'Know the moment', subscribe: true }, | |
| 16 | 18 | } |
| 17 | 19 | |
| 18 | 20 | const projectRoot = resolve(import.meta.dir, '..') |
| @@ -89,6 +91,84 @@ for (const [label, body] of [['homepage', home], ['login', login]] as const) { | ||
| 89 | 91 | } |
| 90 | 92 | } |
| 91 | 93 | |
| 94 | /* | |
| 95 | * The newsletter endpoint, which is really a database read wearing a form. | |
| 96 | * | |
| 97 | * Added because two sites served a 500 here for an unknown length of time and | |
| 98 | * every gate stayed green: loghq had never created the `subscribers` table at | |
| 99 | * all, and reportshq deploys had never run a migration. The checks above cannot | |
| 100 | * see it. They read the homepage, the login page and robots.txt, and all three | |
| 101 | * were perfect on both sites throughout. | |
| 102 | * | |
| 103 | * THE PAYLOAD IS THE WHOLE POINT, and an invalid one would be useless here. | |
| 104 | * The framework action validates before it queries: | |
| 105 | * | |
| 106 | * if (!email || !email.includes('@')) return { success: false, ... } | |
| 107 | * | |
| 108 | * so an empty body returns 200 WITHOUT TOUCHING THE DATABASE. Measured against | |
| 109 | * the two broken sites while they were broken: `{}` gave 200 and a real address | |
| 110 | * gave 500. A smoke check built on the polite empty payload would have passed | |
| 111 | * on both. | |
| 112 | * | |
| 113 | * So it sends a real address, and a FIXED one. The action looks the address up | |
| 114 | * before it writes and returns "Already subscribed" when it finds it, so the | |
| 115 | * first run after a database is created writes one row and every run after that | |
| 116 | * is a pure read of the table this is here to prove exists. A fresh address per | |
| 117 | * run would add a row per deploy forever. | |
| 118 | * | |
| 119 | * `.invalid` is reserved by RFC 2606 and resolves nowhere, so the row can never | |
| 120 | * become mail to a real person even if someone later points a campaign at the | |
| 121 | * subscriber list. | |
| 122 | */ | |
| 123 | const SMOKE_SUBSCRIBER = 'smoke-probe@example.invalid' | |
| 124 | ||
| 125 | async function assertSubscribeWorks(): Promise<void> { | |
| 126 | let lastFailure = 'no response' | |
| 127 | for (let attempt = 1; attempt <= attempts; attempt += 1) { | |
| 128 | try { | |
| 129 | const response = await fetch(`${baseUrl}/api/email/subscribe`, { | |
| 130 | method: 'POST', | |
| 131 | headers: { 'content-type': 'application/json', 'accept': 'application/json' }, | |
| 132 | body: JSON.stringify({ email: SMOKE_SUBSCRIBER, source: 'deploy-smoke' }), | |
| 133 | signal: AbortSignal.timeout(10_000), | |
| 134 | }) | |
| 135 | const body = await response.text() | |
| 136 | ||
| 137 | // A 5xx is the failure this exists to catch, and it is worth naming | |
| 138 | // separately: it means the route resolved and the handler threw, which is | |
| 139 | // the missing-table shape rather than a missing route. | |
| 140 | if (response.status >= 500) { | |
| 141 | throw new Error( | |
| 142 | `${baseUrl}/api/email/subscribe answered ${response.status}. The route is registered and ` | |
| 143 | + `the handler threw, which is what a missing \`subscribers\` table looks like from outside. ` | |
| 144 | + `Check that migrations ran on this box. Body: ${body.slice(0, 200)}`, | |
| 145 | ) | |
| 146 | } | |
| 147 | ||
| 148 | if (!response.ok) { | |
| 149 | lastFailure = `HTTP ${response.status}` | |
| 150 | } | |
| 151 | else { | |
| 152 | const parsed = JSON.parse(body) as { success?: boolean, message?: string } | |
| 153 | if (parsed.success !== true) | |
| 154 | throw new Error(`Subscribe endpoint returned success=${parsed.success}: ${body.slice(0, 200)}`) | |
| 155 | return | |
| 156 | } | |
| 157 | } | |
| 158 | catch (error) { | |
| 159 | // A thrown assertion is a real failure and must not be retried into a | |
| 160 | // timeout; only transport problems are worth another attempt. | |
| 161 | if (error instanceof Error && error.message.startsWith(baseUrl)) throw error | |
| 162 | if (error instanceof Error && error.message.startsWith('Subscribe endpoint')) throw error | |
| 163 | lastFailure = error instanceof Error ? error.message : String(error) | |
| 164 | } | |
| 165 | if (attempt < attempts) await Bun.sleep(pauseMs) | |
| 166 | } | |
| 167 | throw new Error(`${baseUrl}/api/email/subscribe failed after ${attempts} attempts: ${lastFailure}`) | |
| 168 | } | |
| 169 | ||
| 170 | if (contract.subscribe) await assertSubscribeWorks() | |
| 171 | ||
| 92 | 172 | const robots = await readWithRetry('/robots.txt') |
| 93 | 173 | if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.') |
| 94 | 174 | if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`) |