Public build follows the production branch, and rebuilds itself when production moves
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

main is the shared sandbox the dev server follows; /cdn is built only from PRODUCTION_BRANCH (default
production), which moves when someone promotes: git push origin main:production. The container watches
that branch and rebuilds /cdn once it has held a new commit for CDN_AUTOBUILD_MINUTES (default 2; 0 = the
admin button only); a failed commit is not retried automatically. The build fetches the branch into its
own ref (never touching the dev server's FETCH_HEAD) and uses that commit's own exclusion list, so
loosening it takes a promotion too. DEPLOY.md documents the promote step.
This commit is contained in:
Doxa Maps 2026-09-28 03:35:25 +00:00
parent 53363ba80d
commit febeff4990
4 changed files with 87 additions and 22 deletions

View file

@ -66,6 +66,8 @@ there. Only then does it swap the new build in.
| `OIDC_ADMIN_GROUPS` | comma-separated groups allowed to build and to open PocketBase's dashboard |
| `PB_SUPERUSER_EMAIL`, `PB_SUPERUSER_PASSWORD` | PocketBase's superuser, set on start in both instances |
| `PB_OIDC_CLIENT_SECRET` (+ `PB_OIDC_CLIENT_ID`, default `dx-maps-pb-dev`) | turns pb-dev into the team backend: laptops use it directly (`DX_PB_URL=https://<host>/pb`), and people sign in to PocketBase through the same OIDC provider, with its own client whose redirect URI is `https://<host>/pb/api/oauth2-redirect`. Only members of `OIDC_ALLOWED_GROUPS` get in (a sign-in hook baked into the image and pinned; the repo's own hooks never run). Password sign-in is off, new collections are superuser-only until a migration opens them, and data never leaves `/data` |
| `PRODUCTION_BRANCH` | the branch `/cdn/` is built from (default `production`); the dev server keeps following `BRANCH`. Promote with `git push origin main:production` |
| `CDN_AUTOBUILD_MINUTES` | rebuild `/cdn/` by itself once `PRODUCTION_BRANCH` has held a new commit this long (default `2`; `0` = only the admin button) |
| `PUBLIC_API_BASE` | the public API URL written into the build's `runtime-config.js` (e.g. `https://<host>/api-prod`) |
| `BUILD_MAX_OLD_SPACE_MB` | Node heap cap for a build (default `1536`) |
| `RUDDER_ENGINE` | `1` runs Rudder's voice-to-text engine here, fed by the browser's microphone (about 1 GB more; its runtime and model, ~742 MB, are baked into the image at `/opt/rudder`, and `/data/rudder` wins when it holds a model). The site serves at once; Rudder shows "starting" until its engine is built, then the dev server restarts once to start it. Give the container at least 2 vCPUs, or transcription falls behind speech |

View file

@ -3,7 +3,8 @@
// /api-prod/* PocketBase PROD, PUBLIC; only /api/ passes, never the /_/ dashboard
// /pb/* PocketBase DEV, signed-in; its /_/ dashboard only for OIDC_ADMIN_GROUPS. With PB_OIDC_CLIENT_SECRET,
// its /api/ is PUBLIC (PocketBase signs people in itself), except _superusers and _system collections
// /admin/build POST: start a build (OIDC_ADMIN_GROUPS only); /admin/status GET
// /admin/build POST: build PRODUCTION_BRANCH now (OIDC_ADMIN_GROUPS only); /admin/status GET.
// The public build also follows PRODUCTION_BRANCH by itself (CDN_AUTOBUILD_MINUTES, default 2).
// /admin/pb-signin-check a page that signs in to the team PocketBase and shows who it thinks you are
// Groups come from oauth2-proxy's X-Forwarded-Groups, which it sets on signed-in routes only.
import { spawn } from 'node:child_process'
@ -25,6 +26,45 @@ async function proxy(req, url, prefix, port) {
return new Response(r.body, { status: r.status, headers: r.headers })
}
// One public build at a time (build-cdn.sh also holds a lock), started by an admin or by the watcher below.
function startBuild(by) {
last = { state: 'building', by, branch: PROD, started: new Date().toISOString() }
const child = spawn('bash', ['/app/repo/deployment/cloud/build-cdn.sh'], { stdio: ['ignore', 'pipe', 'pipe'] })
let log = ''
child.stdout.on('data', (d) => { log = (log + d).slice(-4000) })
child.stderr.on('data', (d) => { log = (log + d).slice(-4000) })
child.on('exit', (code) => {
last = { state: code === 0 ? 'done' : code === 75 ? 'busy' : 'failed', by, branch: PROD, code, finished: new Date().toISOString(), tail: log.slice(-1500),
commit: publishedCommit() }
})
}
const publishedCommit = () => (existsSync('/data/cdn/COMMIT') ? readFileSync('/data/cdn/COMMIT', 'utf8').trim() : null)
// AUTO-BUILD: the public build follows PRODUCTION_BRANCH by itself. main is the shared sandbox; production
// moves only when someone promotes (git push origin main:production), so rebuilding on every move is
// the point. Debounced: build once the branch has held the same commit for CDN_AUTOBUILD_MINUTES
// (default 2; 0 = off, admin button only). A commit whose build failed is not retried automatically.
const PROD = env.PRODUCTION_BRANCH || 'production'
const AUTO_MIN = env.CDN_AUTOBUILD_MINUTES === undefined || env.CDN_AUTOBUILD_MINUTES === '' ? 2 : Number(env.CDN_AUTOBUILD_MINUTES)
const watch = { seen: null, since: 0, failed: null, error: null }
async function checkProduction() {
const r = Bun.spawnSync(['git', '-C', '/app/repo', 'ls-remote', 'origin', `refs/heads/${PROD}`], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
const sha = r.success ? String(r.stdout).split(/\s/)[0] : ''
watch.error = sha ? null : `cannot read branch '${PROD}' on origin`
if (!sha) return
if (sha !== watch.seen) { watch.seen = sha; watch.since = Date.now() }
const published = publishedCommit()
if (published && sha.startsWith(published)) return
if (last.state === 'building' || watch.failed === sha) return
if (Date.now() - watch.since < AUTO_MIN * 60000) return
startBuild('auto')
const poll = setInterval(() => { if (last.state !== 'building') { clearInterval(poll); if (last.state === 'failed') watch.failed = sha } }, 5000)
}
if (AUTO_MIN > 0) {
setInterval(() => checkProduction().catch(() => {}), 30000)
console.log(`[admin] public build follows ${PROD}, ${AUTO_MIN} min after it moves`)
}
Bun.serve({
hostname: '127.0.0.1',
port: 5175,
@ -54,20 +94,12 @@ Bun.serve({
}
// A one-click check of the team PocketBase sign-in (behind the site sign-in like every /admin/ path).
if (p === '/admin/pb-signin-check') return new Response(Bun.file('/app/repo/deployment/cloud/pb-signin-check.html'), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
if (p === '/admin/status') return Response.json(last)
if (p === '/admin/status') return Response.json({ ...last, production: { branch: PROD, autoBuildMinutes: AUTO_MIN, head: watch.seen, published: publishedCommit(), error: watch.error } })
if (p === '/admin/build') {
if (req.method !== 'POST') return text('POST only', 405)
if (!isAdmin(req)) return text('builds are for team admins', 403)
if (last.state === 'building') return text('a build is already running', 409)
last = { state: 'building', started: new Date().toISOString() }
const child = spawn('bash', ['/app/repo/deployment/cloud/build-cdn.sh'], { stdio: ['ignore', 'pipe', 'pipe'] })
let log = ''
child.stdout.on('data', (d) => { log = (log + d).slice(-4000) })
child.stderr.on('data', (d) => { log = (log + d).slice(-4000) })
child.on('exit', (code) => {
last = { state: code === 0 ? 'done' : code === 75 ? 'busy' : 'failed', code, finished: new Date().toISOString(), tail: log.slice(-1500),
commit: existsSync('/data/cdn/COMMIT') ? readFileSync('/data/cdn/COMMIT', 'utf8').trim() : null }
})
startBuild('admin')
return text('build started', 202)
}
return text('not found', 404)

View file

@ -1,6 +1,8 @@
#!/bin/bash
# build-cdn.sh — one production build of the pulled commit into /data/cdn, one at a time.
# snapshot (so the pull loop can't change files mid-build) -> apply the public exclusions ->
# build-cdn.sh — one public build of the PRODUCTION branch into /data/cdn, one at a time.
# main is the shared sandbox the dev server follows; the public build comes only from
# PRODUCTION_BRANCH (default production), which moves when someone promotes main to it.
# fetch that branch into its own ref -> snapshot it -> apply ITS public exclusions ->
# the build steps WITHOUT the gates (memory-capped) -> check the output -> the exclusion check again
# (refuses the swap) -> atomic swap of /data/cdn/current.
# No gates here: they already passed before the commit reached main, and a full gate run on a
@ -12,11 +14,26 @@ exec 9>/tmp/build-cdn.lock
flock -n 9 || { echo "another build is running"; exit 75; }
echo $$ > /tmp/build-cdn.pid
cd /app/repo
SHA=$(git rev-parse --short HEAD); SNAP=/tmp/build-$SHA
PB="${PRODUCTION_BRANCH:-production}"
# Its own ref, and no FETCH_HEAD: the dev server's pull loop uses FETCH_HEAD for main at the same time.
git fetch -q --no-write-fetch-head --depth 1 origin "+refs/heads/$PB:refs/cdn/$PB" \
|| { echo "cannot fetch branch '$PB' (create it with: git push origin main:$PB)"; exit 1; }
SHA=$(git rev-parse --short "refs/cdn/$PB"); SNAP=/tmp/build-$SHA
rm -rf "$SNAP"; mkdir -p "$SNAP"
git archive HEAD | tar -x -C "$SNAP" || { echo "snapshot failed"; exit 1; }
bash /app/repo/deployment/cloud/apply-exclusions.sh "$SNAP"
ln -s /app/repo/node_modules "$SNAP/node_modules"
git archive "refs/cdn/$PB" | tar -x -C "$SNAP" || { echo "snapshot failed"; exit 1; }
# The exclusion list and checker come from the branch being published, so loosening them takes a
# promotion too (falls back to the dev server's copy for a branch older than the list).
SRC="$SNAP/deployment/cloud"; [ -f "$SRC/apply-exclusions.sh" ] && [ -f "$SRC/public-exclusions.txt" ] || SRC=/app/repo/deployment/cloud
EXDIR=/tmp/exclusions-$SHA; rm -rf "$EXDIR"; mkdir -p "$EXDIR"
cp "$SRC/apply-exclusions.sh" "$SRC/public-exclusions.txt" "$EXDIR/" # the script reads the list beside it
bash "$EXDIR/apply-exclusions.sh" "$SNAP"
# Dependencies: reuse the dev server's install when the lockfile is the same, else install for this commit.
if cmp -s "$SNAP/bun.lock" /app/repo/bun.lock; then
ln -s /app/repo/node_modules "$SNAP/node_modules"
else
echo "$(date -u +%FT%TZ) $PB's lockfile differs from main's: installing its dependencies"
(cd "$SNAP" && bun install --frozen-lockfile >/dev/null) || { echo "dependency install failed"; rm -rf "$SNAP"; exit 1; }
fi
cd "$SNAP"
echo "$(date -u +%FT%TZ) building $SHA"
export NODE_OPTIONS="--max-old-space-size=${BUILD_MAX_OLD_SPACE_MB:-1536}"
@ -28,9 +45,9 @@ OUT="$SNAP/public/dx-maps"
bun -e 'console.log("window.MAP_API_BASE=" + JSON.stringify(process.env.PUBLIC_API_BASE || "") + ";")' > "$OUT/runtime-config.js"
NEW=/data/cdn/build-$SHA
rm -rf "$NEW"; mkdir -p /data/cdn; mv "$SNAP/public" "$NEW"
bash /app/repo/deployment/cloud/apply-exclusions.sh --check "$NEW" || { echo "REFUSED: excluded content in the build; not published"; rm -rf "$NEW" "$SNAP"; exit 1; }
bash "$EXDIR/apply-exclusions.sh" --check "$NEW" || { echo "REFUSED: excluded content in the build; not published"; rm -rf "$NEW" "$SNAP" "$EXDIR"; exit 1; }
ln -sfn "$NEW" /data/cdn/current.tmp && mv -T /data/cdn/current.tmp /data/cdn/current
echo "$SHA" > /data/cdn/COMMIT
find /data/cdn -maxdepth 1 -name 'build-*' ! -path "$NEW" -exec rm -rf {} +
rm -rf "$SNAP"
echo "$(date -u +%FT%TZ) published $SHA at /cdn/dx-maps/"
rm -rf "$SNAP" "$EXDIR"
echo "$(date -u +%FT%TZ) published $SHA ($PB) at /cdn/dx-maps/"

View file

@ -64,10 +64,24 @@ Details: [deployment/cloud/README.md](../deployment/cloud/README.md) and
The built folder, `dx-maps/`, is what other sites embed. Every link inside it is relative, so it works
from any address.
- **From the cloud dev server:** an admin runs a build (`POST /admin/build`; about 2 minutes). It builds
the current commit, removes private files, refuses to publish if any remain, then swaps
- **From the cloud dev server:** `/cdn/` serves the **`production`** branch, never `main`. `main` is the
shared sandbox where everyone lands all day; `production` moves only when you decide the current state
is ready to be public. The container notices when `production` moves and rebuilds `/cdn` by itself
(about 2 minutes after the move, then about 2 minutes to build). Each build removes private files,
refuses to publish if any remain, keeps the last good build if anything fails, and swaps
`/cdn/dx-maps/` over atomically. `/cdn/` is public (no sign-in) and sends
`Access-Control-Allow-Origin: *`, so both iframes and JS embeds work on other sites.
**To publish (promote main to production)**, once the checks pass on the commit you want public:
```bash
git fetch origin && git checkout main && git merge --ff-only origin/main
bun run gates:pre # the checks must pass on exactly this commit
git push origin main:production # /cdn follows within a few minutes
```
An admin can also rebuild at once (`POST /admin/build`), and `/admin/status` shows what `/cdn` serves
and what `production` points at.
- **On any static host:** build locally with `BUILD_ALLOW=1 bun run build` and upload `public/dx-maps/`.
For JS embeds on other sites, the host must send `Access-Control-Allow-Origin: *`.