Skip to content

Deploy site

Deploy site #202

Workflow file for this run

name: Deploy site
# Publishes the marketing site (site/) to GitHub Pages, plus browser demos
# staged on the machine-managed `demo-previews` branch. All demos and preview
# bundles are served below /demo/: persistent /demo/main/ and /demo/release/
# builds plus ephemeral /demo/pr-<n>/ and /demo/pr-<n>-preview/ builds. Pages
# source must be set to "GitHub Actions" in repo Settings → Pages (classic Pages
# can only serve from the repo root or /docs, not /site).
#
# GitHub Pages allows one deployment per repo and each deploy REPLACES the whole
# published tree, so previews cannot be deployed independently of the site. This
# job is the single assembler: it lays down site/ at the root, then mounts every
# directory from demo-previews below /demo/. A site update and a preview publish
# both deploy through this one path.
#
# It is also where the site's Markdown twins are generated (site/*.md and
# llms.txt, from the HTML — see scripts/sync-site-markdown.mts). They are build
# artifacts and are not committed, so this is the only place they exist.
on:
push:
branches: [release]
paths:
- 'site/**'
- '.github/workflows/pages.yml'
# Also how demo-preview.yml asks for a deploy once it has published: it
# dispatches this workflow rather than calling it, so a deploy superseded on
# the queue below cancels a run of its own instead of a PR's preview run.
workflow_dispatch:
# Invoked by demo-preview-cleanup.yml after it prunes a closed PR's preview.
# NOT a `push:` trigger on demo-previews — for either path: that branch is an
# orphan holding preview content only, with no .github/ of its own, and a push
# event runs the workflow definitions from the pushed branch, so such a trigger
# would silently never fire and previews would stop deploying.
workflow_call:
inputs:
tolerate-deploy-failure:
description: >-
Warn instead of failing when the deployment itself does not land.
demo-preview-cleanup.yml passes true: it calls this from a PR's own
closed-event run, so a failed deploy reddens a PR over a problem that
is never that PR's — the previews branch, the Pages service, or a
deploy this one raced. Site deploys from `release` and the runs
demo-preview.yml dispatches leave it false and still fail loudly,
which is the point of dispatching: there is no PR to redden.
type: boolean
default: false
permissions:
contents: read
pages: write
id-token: write
# Allow one concurrent deployment; let an in-progress run finish. Preview
# publishes and site pushes share this group and therefore serialize.
#
# Deliberately NOT `queue: max`. This job assembles whatever is on the
# demo-previews tip, not the content of whichever run called it, so two queued
# deploys publish the same tree twice and only the last one can matter. Keeping
# every one of them meant a burst of preview publishes became a backlog that
# drained no faster than an ~11-minute full-tree upload — deploys were observed
# starting six and a half hours after they were created, and the PR comment that
# waited on them landed seven hours after the push.
#
# GitHub's default single pending slot is what we want instead: a newer pending
# deploy supersedes an older one. That was previously rejected for marking the
# caller's run cancelled and painting the PR red — which it did, on 23 of the 40
# Demo preview runs before demo-preview.yml stopped calling this workflow and
# started dispatching it instead. A superseded deploy is now a cancelled "Deploy
# site" run that no PR is watching, and nothing is lost with it: the assembler
# reads the demo-previews tip, so whichever deploy wins the slot publishes every
# target the superseded ones wanted published. Publishes are also far rarer since
# the no-change guard there started working, so collisions are the exception.
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
# SELF_HOSTED_CHECKS routes to the self-hosted check fleet when set (see
# ci.yml's routing note); hosted otherwise. Trusted main-branch content
# only, and deploy-pages/OIDC work fine from self-hosted runners.
runs-on: ${{ vars.SELF_HOSTED_CHECKS || 'ubuntu-latest' }}
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
# Always publish the marketing site from `main`, even when this deploy was
# invoked (workflow_call) by a preview push on another branch. A preview
# can therefore only ever add or change its own /demo/ subtree — never the
# site — so pre-merge preview deploys can't ship unreviewed site changes.
- uses: actions/checkout@v7.0.1
with:
ref: main
# Assemble the published tree: the marketing site at the root and every
# machine-managed preview directory below /demo/. The previews branch may
# not exist yet (no demo ever published) — that is expected; we then ship
# site/ alone.
- name: Assemble site and demo previews
run: |
set -euo pipefail
rm -rf _site _previews
mkdir -p _site
cp -R site/. _site/
if git ls-remote --exit-code --heads origin demo-previews >/dev/null 2>&1; then
git fetch --depth=1 origin demo-previews
mkdir -p _previews
git archive FETCH_HEAD | tar -x -C _previews
shopt -s nullglob
# `vendor/` belongs in this list. It is not a demo, but demo-preview.yml
# stopped every demo carrying its own 34MB Monaco tree and pointed them
# at that one shared copy instead — so a build that is not published
# beside them has no editor to load. It was omitted here, which meant
# every preview built after that change requested a Monaco tree that
# was committed to demo-previews and then never deployed.
for dir in _previews/release/ _previews/main/ _previews/pr-*/ _previews/vendor/; do
[ -d "$dir" ] || continue
name="$(basename "$dir")"
echo "Adding demo: demo/$name"
mkdir -p "_site/demo/$name"
cp -R "$dir". "_site/demo/$name/"
done
else
echo "No demo-previews branch yet — publishing site only."
fi
# `package-manager-cache: false` because this job never runs the repo's own
# install. Left on, setup-node hunts for a lockfile to cache and warns
# about the pnpm `packageManager` field it cannot act on here.
- uses: actions/setup-node@v7
with: { node-version-file: '.nvmrc', package-manager-cache: false }
# The Markdown twins (site/*.md + llms.txt) are generated here rather than
# committed, so the published copy is always what the published HTML says
# and the repo carries no second copy of every page to keep in step.
#
# Deliberately NOT `.github/actions/setup`. That installs the app's whole
# dependency tree — ~590 MB, an electron-rebuild, a 2.5-3.5 min cache
# restore on a hosted runner — and this job needs four pure-JS libraries.
# The cost would land on every caller: demo-preview.yml and
# demo-preview-cleanup.yml both `uses:` this workflow, and all deploys
# serialize on the `pages` concurrency group, so a slower job here is a
# slower preview publish for markdown a preview does not use. Slowing this
# path is what the concurrency note above records going wrong before.
#
# The four ranges are copied from the repo's own `devDependencies`, so the
# deploy renders within the same bounds CI and local checkouts do and a
# turndown major cannot arrive here unreviewed. Deliberately package.json
# rather than the lockfile: the first version of this step read
# package-lock.json and broke the moment `main` moved to pnpm, because this
# job checks out `main` and so always runs against a tree the PR that wrote
# it never saw. `dependencies` is the one manifest every package manager
# keeps. A missing entry is a hard error rather than an unpinned install.
#
# npm rather than pnpm because the scratch directory is not the project —
# it has no lockfile and no workspace to honour, npm ships with the Node
# that setup-node just installed, and this needs no corepack activation on
# the deploy path. `--ignore-scripts` runs no install hooks; none of the four
# declares any (oxfmt ships its binary as a platform optionalDependency, not
# a postinstall download). The scratch prefix is what keeps the install from reconciling
# against the repo's own manifest and pulling the whole tree in anyway.
- name: Generate the site's Markdown twins
run: |
set -euo pipefail
deps="${RUNNER_TEMP}/site-md-deps"
rm -rf "$deps"
mkdir -p "$deps"
node -e '
const manifest = require("./package.json")
const need = ["jsdom", "turndown", "zod", "oxfmt"]
const dependencies = Object.fromEntries(need.map((name) => {
const range = manifest.devDependencies?.[name] ?? manifest.dependencies?.[name]
if (!range) throw new Error(name + " is not a dependency of this repo")
return [name, range]
}))
require("fs").writeFileSync(
process.argv[1] + "/package.json",
JSON.stringify({ name: "site-md-deps", private: true, dependencies }, null, 2) + "\n",
)
' "$deps"
(cd "$deps" && npm install --ignore-scripts --no-audit --no-fund)
# `mv` into an existing directory would nest rather than replace it,
# and a self-hosted runner reuses its workspace between jobs.
rm -rf node_modules
mv "$deps/node_modules" node_modules
node scripts/sync-site-markdown.mts --out _site
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
path: _site
- id: deployment
uses: actions/deploy-pages@v5
# Expression rather than a plain literal: `inputs` is null for the push
# and workflow_dispatch triggers, where this resolves to false and a
# failed site deploy still fails the run.
continue-on-error: ${{ inputs.tolerate-deploy-failure || false }}
# Tolerated does not mean silent. The job goes green, but this annotation
# puts the failure on the run summary so a genuinely broken deploy is still
# visible to anyone looking at Actions rather than at a PR's checks.
- if: steps.deployment.outcome != 'success'
run: |
echo "::warning title=Pages deploy did not land::The published site is unchanged. This build is already committed to demo-previews, so the next deploy will publish it; see the deploy-pages step above for the cause."