Deploy site #202
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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." |