Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
46 changes: 45 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,50 @@ jobs:
- run: pnpm --filter create-meith build
- run: node scripts/npm-publish.mjs

# MEI-137: 0.21.0 was cut to fix a board that rendered raw message keys and
# had no CSS, and shipped with only the first of the two fixes — a stacked
# pull request merged into a base that had already merged, so its commits
# never reached main. Nothing noticed: main was green, the tag was cut, the
# packages published, and the release notes described a fix that was not in
# them. Every other gate in this repository looks at the repository. This one
# looks at what a user downloads: it scaffolds from the published
# create-meith, installs from the real registry, and boots the result at both
# materialization depths. It runs after npm for that reason and gates
# `publish`, so a broken artefact stops the release being announced even
# though it cannot stop it being published.
published-board:
name: A board built from the published packages boots
needs: npm
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: published_board_smoke
ports: ['5432:5432']
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 26
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Scaffold from the registry, build and boot at both depths
env:
DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/published_board_smoke
run: |
MEITH_VERSION="${GITHUB_REF_NAME#v}" pnpm published:board:smoke

# MEI-77: the framework base image a scaffolded board's own Dockerfile
# starts FROM (docker/Dockerfile.base) — deps + framework layers only, no
# board, no secrets. Needs [build, npm] rather than just `versions`: the
Expand Down Expand Up @@ -229,7 +273,7 @@ jobs:

publish:
name: Tags, the release branch, and the draft notes
needs: [build, npm, base-image]
needs: [build, npm, base-image, published-board]
runs-on: ubuntu-24.04
steps:
# Full history, not the default shallow clone: pushing over an existing
Expand Down
18 changes: 18 additions & 0 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,28 @@ broken promise, which is why the workflow drafts rather than publishes.
does if one of those three was skipped as new-to-the-registry; re-run
the workflow once it is published by hand, same as everywhere else in
this pipeline;
- **a board is scaffolded from the packages that were just published**,
installed from the real registry rather than from anything in this
tree, and booted at both materialization depths — the same three
assertions the workspace smoke makes, that it renders `<main>`, that
no message key reaches the page as text, and that the stylesheet
carries rules for classes only the installed packages produce;
- the `release` branch is fast-forwarded to the tag — refused if the
tag is not descended from it, which is the guard against tagging a
side branch;
- the GitHub Release is drafted.

**Why a check that runs after publishing is worth having.** 0.21.0 was
cut to fix a board that rendered raw message keys and shipped with only
one of the two fixes in it: a stacked pull request had merged into a base
that had already merged, so its commits never reached `main`. Nothing
noticed — `main` was green, the tag was coherent, the packages published,
and the notes described a fix that was not there. Every other gate in
this repository examines the repository; this one examines what a user
downloads. It cannot un-publish a bad version, and it is not meant to:
`publish` waits on it, so a broken artefact stops the release being
announced and tells you within minutes rather than after somebody
deploys it.
4. **Finish the draft.** Fill in the migration line, trim the generated
notes to what an operator needs, publish.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"vercel-template:gen": "tsx scripts/vercel-template-gen.mts",
"vercel-template:gen:check": "tsx scripts/vercel-template-gen.mts --check",
"board:workspace:smoke": "tsx scripts/board-workspace-smoke.mts",
"published:board:smoke": "tsx scripts/published-board-smoke.mts",
"board:deploy-kit:smoke": "tsx scripts/board-deploy-kit-smoke.mts",
"board:eject:smoke": "tsx scripts/board-eject-smoke.mts",
"perf": "tsx packages/testkit/src/load/run.ts",
Expand Down
91 changes: 91 additions & 0 deletions scripts/board-boot-check.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { spawn } from 'node:child_process'
import { join } from 'node:path'

import defaultEnMessages from '../themes/default/src/messages/en.json' with { type: 'json' }
import {
assertBoardAssetsServe,
assertMessagesResolve,
assertStylesResolve,
} from './board-smoke-assets.mts'

export const AT_ROOT_FLAG = '--at-root'
export const AUTH_SECRET = 'smoke-test-auth-secret-32-bytes-min'
export const TICK_SECRET = 'smoke-test-tick-secret-32-bytes-min'

async function waitForResponse(url: string, attempts: number): Promise<Response> {
let lastError: unknown
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
return await fetch(url)
} catch (error) {
lastError = error
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
throw new Error(`board-workspace-smoke: ${url} never answered: ${String(lastError)}`)
}

export async function bootAndCheck(
boardDir: string,
port: string,
atRoot: boolean,
databaseUrl: string,
) {
const label = atRoot ? 'at the project root' : 'at .meith/app'
const flag = atRoot ? [AT_ROOT_FLAG] : []

console.log(`== forum-web start ${label} ==`)
const server = spawn(join(boardDir, 'node_modules/.bin/forum-web'), ['start', ...flag], {
cwd: boardDir,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
PORT: port,
DATABASE_URL: databaseUrl,
DATA_SOURCE: 'postgres',
AUTH_SECRET,
TICK_SECRET,
APP_URL: `http://127.0.0.1:${port}`,
},
})
server.stdout?.on('data', (chunk) => process.stdout.write(chunk))
server.stderr?.on('data', (chunk) => process.stderr.write(chunk))

function stopServer() {
if (server.pid === undefined) return
try {
process.kill(-server.pid, 'SIGTERM')
} catch {}
setTimeout(() => {
if (server.pid === undefined) return
try {
process.kill(-server.pid, 'SIGKILL')
} catch {}
}, 5000).unref()
}

try {
console.log('== waiting for it to answer / ==')
const response = await waitForResponse(`http://127.0.0.1:${port}/`, 40)
if (!response.ok) {
throw new Error(`board-workspace-smoke: / answered ${response.status} (${label})`)
}
const body = await response.text()
if (!body.includes('<main')) {
throw new Error(`board-workspace-smoke: / answered but did not render <main> (${label})`)
}
assertMessagesResolve(body, Object.keys(defaultEnMessages))
console.log(`== the board materialized ${label} rendered / ==`)

console.log('== confirming static assets and /sw.js actually serve ==')
await assertBoardAssetsServe(`http://127.0.0.1:${port}`, body)
console.log('== static assets and /sw.js served correctly ==')

console.log('== confirming the stylesheet actually styles what rendered ==')
await assertStylesResolve(`http://127.0.0.1:${port}`, body)
console.log('== every class the board rendered has a rule ==')
} finally {
stopServer()
}
}
89 changes: 4 additions & 85 deletions scripts/board-workspace-smoke.mts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,12 @@
* that Node's own idle-exit never clears, so the entry point below calls
* `process.exit()` itself rather than trusting the event loop to empty.
*/
import { spawn, spawnSync } from 'node:child_process'
import { spawnSync } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import defaultEnMessages from '../themes/default/src/messages/en.json' with { type: 'json' }
import {
assertBoardAssetsServe,
assertMessagesResolve,
assertStylesResolve,
} from './board-smoke-assets.mts'
import { AT_ROOT_FLAG, AUTH_SECRET, bootAndCheck, TICK_SECRET } from './board-boot-check.mts'
import { packClosure } from './pack-workspace-closure.mts'
import { ROOT } from './workspace-packages.mjs'

Expand All @@ -58,9 +53,6 @@ if (!DATABASE_URL) {

const PORT = process.env.SMOKE_PORT ?? '3999'
const AT_ROOT_PORT = process.env.SMOKE_AT_ROOT_PORT ?? String(Number(PORT) + 1)
const AT_ROOT_FLAG = '--at-root'
const AUTH_SECRET = 'smoke-test-auth-secret-32-bytes-min'
const TICK_SECRET = 'smoke-test-tick-secret-32-bytes-min'

function run(
command: string,
Expand Down Expand Up @@ -133,79 +125,6 @@ async function pointAtTarballs(boardDir: string, tarballs: ReadonlyMap<string, s
await writeFile(packageJsonPath, `${JSON.stringify(manifest, null, 2)}\n`)
}

async function waitForResponse(url: string, attempts: number): Promise<Response> {
let lastError: unknown
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
return await fetch(url)
} catch (error) {
lastError = error
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
throw new Error(`board-workspace-smoke: ${url} never answered: ${String(lastError)}`)
}

async function bootAndCheck(boardDir: string, port: string, atRoot: boolean) {
const label = atRoot ? 'at the project root' : 'at .meith/app'
const flag = atRoot ? [AT_ROOT_FLAG] : []

console.log(`== forum-web start ${label} ==`)
const server = spawn(join(boardDir, 'node_modules/.bin/forum-web'), ['start', ...flag], {
cwd: boardDir,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
PORT: port,
DATABASE_URL,
DATA_SOURCE: 'postgres',
AUTH_SECRET,
TICK_SECRET,
APP_URL: `http://127.0.0.1:${port}`,
},
})
server.stdout?.on('data', (chunk) => process.stdout.write(chunk))
server.stderr?.on('data', (chunk) => process.stderr.write(chunk))

function stopServer() {
if (server.pid === undefined) return
try {
process.kill(-server.pid, 'SIGTERM')
} catch {}
setTimeout(() => {
if (server.pid === undefined) return
try {
process.kill(-server.pid, 'SIGKILL')
} catch {}
}, 5000).unref()
}

try {
console.log('== waiting for it to answer / ==')
const response = await waitForResponse(`http://127.0.0.1:${port}/`, 40)
if (!response.ok) {
throw new Error(`board-workspace-smoke: / answered ${response.status} (${label})`)
}
const body = await response.text()
if (!body.includes('<main')) {
throw new Error(`board-workspace-smoke: / answered but did not render <main> (${label})`)
}
assertMessagesResolve(body, Object.keys(defaultEnMessages))
console.log(`== the board materialized ${label} rendered / ==`)

console.log('== confirming static assets and /sw.js actually serve ==')
await assertBoardAssetsServe(`http://127.0.0.1:${port}`, body)
console.log('== static assets and /sw.js served correctly ==')

console.log('== confirming the stylesheet actually styles what rendered ==')
await assertStylesResolve(`http://127.0.0.1:${port}`, body)
console.log('== every class the board rendered has a rule ==')
} finally {
stopServer()
}
}

async function main() {
const tarballDir = await mkdtemp(join(tmpdir(), 'board-workspace-smoke-tarballs-'))
const scaffoldParent = await mkdtemp(join(tmpdir(), 'board-workspace-smoke-board-'))
Expand Down Expand Up @@ -264,7 +183,7 @@ async function main() {
)
}

await bootAndCheck(boardDir, PORT, false)
await bootAndCheck(boardDir, PORT, false, DATABASE_URL)

console.log('== a second board, materialized the way Vercel deploys it ==')
const atRootDir = await scaffoldBoard(scaffoldParent, 'smoke-board-at-root')
Expand All @@ -274,7 +193,7 @@ async function main() {
cwd: atRootDir,
env: { ...process.env, DATABASE_URL: '', DATA_SOURCE: '' },
})
await bootAndCheck(atRootDir, AT_ROOT_PORT, true)
await bootAndCheck(atRootDir, AT_ROOT_PORT, true, DATABASE_URL)
} finally {
await rm(tarballDir, { recursive: true, force: true })
await rm(scaffoldParent, { recursive: true, force: true })
Expand Down
Loading