From a8d1d065a11b585bea28b8287c9077ecf3fe96c5 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 12:04:25 +0200 Subject: [PATCH 1/2] ci(website): print the deployed docs URL and smoke-check it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy workflow could report success while telling you nothing about where it published, and a green deploy is not a serving site — PRO-200 is exactly that failure: a compute service returns a successful deploy and a permanently 404ing domain. The URL is also a generated service id, so without printing it a run gives no way to find what it just shipped. Adds scripts/verify-deployed.ts, run as the last step of the deploy: it resolves the service through the Management API with the credentials CI already has, prints the URL (also to the run summary), then polls the landing page and a guide route until both serve the docs, failing if they never do. - The app and service names come from module.ts / src/service.ts rather than string literals, so renaming either cannot silently break this. - More than one matching service is a hard failure, not a guess: the API reports branchId: null for every compute service, production and stage alike, so a second one makes "which is production?" unanswerable. It prints the candidates and says how to clear them. - @prisma/management-api-sdk is a devDependency, matching pn-widgets: it is CI-only and never reaches the deployed bundle. Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/deploy-docs.yml | 5 ++ pnpm-lock.yaml | 3 + website/package.json | 2 + website/scripts/verify-deployed.ts | 125 +++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 website/scripts/verify-deployed.ts diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index b8a2868b..f280b9b7 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -54,3 +54,8 @@ jobs: - name: Deploy to production working-directory: website run: bun node_modules/.bin/prisma-composer deploy module.ts + # A green deploy is not a serving site (PRO-200), and the URL is a + # generated service id — so resolve it, print it, and prove it answers. + - name: Print the URL and smoke-check the site + working-directory: website + run: pnpm run verify:deployed diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c103caa..869aa6dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1009,6 +1009,9 @@ importers: specifier: 2.0.0-beta.59 version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.93(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) devDependencies: + '@prisma/management-api-sdk': + specifier: ^1.47.0 + version: 1.47.0 '@types/bun': specifier: ^1.3.13 version: 1.3.14 diff --git a/website/package.json b/website/package.json index f9420193..f7e4765f 100644 --- a/website/package.json +++ b/website/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "render": "bun run scripts/render-content.ts", + "verify:deployed": "bun run scripts/verify-deployed.ts", "build": "pnpm run render && tsdown", "typecheck": "pnpm run render && tsc --noEmit", "test": "pnpm run render && bun test tests", @@ -20,6 +21,7 @@ "devDependencies": { "@prisma/composer": "workspace:0.1.0", "@prisma/composer-prisma-cloud": "workspace:0.1.0", + "@prisma/management-api-sdk": "^1.47.0", "@types/bun": "^1.3.13", "@types/markdown-it": "^14.1.2", "markdown-it": "^14.1.0", diff --git a/website/scripts/verify-deployed.ts b/website/scripts/verify-deployed.ts new file mode 100644 index 00000000..1cdd921c --- /dev/null +++ b/website/scripts/verify-deployed.ts @@ -0,0 +1,125 @@ +#!/usr/bin/env bun +/** + * Resolves the deployed docs site's URL and proves it actually serves. + * + * Two reasons this exists rather than trusting the deploy's exit code. A green + * deploy does not mean a serving site (PRO-200: a compute service can report a + * successful deploy and a permanently 404ing domain). And the URL is a + * generated service id, so without printing it a CI run gives no way to tell + * where it just published. + * + * The app and service names come from the app itself, not string literals, so + * renaming either can't silently break this. + */ +import { appendFile } from 'node:fs/promises'; +import { createManagementApiClient } from '@prisma/management-api-sdk'; +import app from '../module.ts'; +import siteService from '../src/service.ts'; + +const POLL_DEADLINE_MS = 120_000; +const POLL_INTERVAL_MS = 5_000; +const REQUEST_TIMEOUT_MS = 20_000; + +function fail(message: string): never { + console.error(message); + process.exit(1); +} + +const token = process.env['PRISMA_SERVICE_TOKEN']; +if (token === undefined || token.length === 0) { + fail('PRISMA_SERVICE_TOKEN is required (in CI it is mapped from CI_SITE_DEPLOY_TOKEN).'); +} + +const client = createManagementApiClient({ token }); + +async function findProjectId(name: string): Promise { + let cursor: string | undefined; + for (;;) { + const { data, error } = await client.GET('/v1/projects', { + params: { query: cursor === undefined ? {} : { cursor } }, + }); + if (error !== undefined || data === undefined) { + fail(`GET /v1/projects failed: ${JSON.stringify(error)}`); + } + const match = data.data.find((p) => p.name === name); + if (match !== undefined) return match.id; + if (!data.pagination.hasMore || data.pagination.nextCursor === null) return undefined; + cursor = data.pagination.nextCursor; + } +} + +const projectId = await findProjectId(app.name); +if (projectId === undefined) { + fail(`No project named '${app.name}' in this workspace — nothing has been deployed here.`); +} + +const { data: services, error } = await client.GET('/v1/projects/{projectId}/compute-services', { + params: { path: { projectId } }, +}); +if (error !== undefined || services === undefined) { + fail(`GET /v1/projects/${projectId}/compute-services failed: ${JSON.stringify(error)}`); +} + +const matches = services.data.filter((s) => s.name === siteService.name); +if (matches.length === 0) { + fail(`Project '${app.name}' has no '${siteService.name}' compute service.`); +} +if (matches.length > 1) { + // The API reports branchId: null for every compute service — production and + // stage alike — so a second one makes "which is production?" unanswerable + // here. Fail loudly rather than pick one and verify the wrong environment. + fail( + `Project '${app.name}' has ${matches.length} '${siteService.name}' compute services, and the API ` + + 'reports branchId: null for each, so production cannot be identified. Tear down the stray ' + + 'stage(s) (`prisma-composer destroy module.ts --stage `) and re-run.\n' + + matches.map((s) => ` - ${s.id} ${s.serviceEndpointDomain}`).join('\n'), + ); +} + +const domain = matches[0]?.serviceEndpointDomain; +if (domain === undefined || domain.length === 0) { + fail(`The '${siteService.name}' compute service has no endpoint domain yet.`); +} + +// serviceEndpointDomain may arrive with or without the scheme; tolerate either. +const url = /^https?:\/\//.test(domain) ? domain.replace(/\/$/, '') : `https://${domain}`; +console.log(`Docs site: ${url}`); + +/** Returns undefined when the route is healthy, else why it isn't. */ +async function probe(path: string): Promise { + try { + const res = await fetch(`${url}${path}`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + if (!res.ok) return `${path} -> HTTP ${res.status}`; + const body = await res.text(); + // The brand is in every page's header; its absence means something other + // than the docs answered (an edge 404 page, a placeholder region). + if (!body.includes('Prisma Composer')) return `${path} -> 200 but did not serve the docs`; + return undefined; + } catch (err) { + return `${path} -> ${err instanceof Error ? err.message : String(err)}`; + } +} + +const deadline = Date.now() + POLL_DEADLINE_MS; +let last = ''; +for (;;) { + // The landing page and a guide route exercise both of the server's routes. + const problems = (await Promise.all([probe('/'), probe('/guides/getting-started')])).filter( + (p): p is string => p !== undefined, + ); + + if (problems.length === 0) { + console.log('Smoke check passed — the landing page and a guide route both serve the docs.'); + const summaryFile = process.env['GITHUB_STEP_SUMMARY']; + if (summaryFile !== undefined && summaryFile.length > 0) { + await appendFile(summaryFile, `### Docs site deployed\n\n<${url}>\n`); + } + process.exit(0); + } + + last = problems.join('; '); + if (Date.now() >= deadline) break; + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); +} + +fail(`The site did not serve within ${POLL_DEADLINE_MS / 1000}s. Last attempt: ${last}`); From ef080280c63048c85db29d6fc4bf77be81e8a9c8 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 12:38:18 +0200 Subject: [PATCH 2/2] docs(website): correct two false claims in the verify script comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both comments described the code as something it is not. The probe comment said the marker string comes from every page header. It does not: the header renders `Prisma Composer`, so the raw HTML never contains those two words together. The string it actually matches is in the , the meta description and the footer. The check is sound and the conclusion holds — all three come from template.ts s shell, not from a guide s markdown, so editing the docs cannot break it — but the stated reason was wrong. The header comment claimed reading the names off module.ts / src/service.ts means renaming cannot "silently" break the check. Hardcoded names would fail loudly, not silently. The actual benefit is narrower: one definition per name instead of a second copy to keep in sync. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> --- website/scripts/verify-deployed.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/website/scripts/verify-deployed.ts b/website/scripts/verify-deployed.ts index 1cdd921c..8a7ddcac 100644 --- a/website/scripts/verify-deployed.ts +++ b/website/scripts/verify-deployed.ts @@ -8,8 +8,9 @@ * generated service id, so without printing it a CI run gives no way to tell * where it just published. * - * The app and service names come from the app itself, not string literals, so - * renaming either can't silently break this. + * The project and service names are read off module.ts / src/service.ts rather + * than repeated here as strings, so each name has one definition instead of a + * second copy to keep in sync. */ import { appendFile } from 'node:fs/promises'; import { createManagementApiClient } from '@prisma/management-api-sdk'; @@ -91,8 +92,10 @@ async function probe(path: string): Promise<string | undefined> { const res = await fetch(`${url}${path}`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); if (!res.ok) return `${path} -> HTTP ${res.status}`; const body = await res.text(); - // The brand is in every page's header; its absence means something other - // than the docs answered (an edge 404 page, a placeholder region). + // Every page carries this in its <title>, meta description and footer — + // all from template.ts's shell, never from a guide's markdown, so editing + // the docs cannot break this check. Its absence means something other than + // the site answered: an edge 404 page, or a placeholder region (PRO-200). if (!body.includes('Prisma Composer')) return `${path} -> 200 but did not serve the docs`; return undefined; } catch (err) {