Skip to content

Build in GitHub Actions

A complete workflow that checks, prefetches on a cache miss, builds, uploads dist/ and hands off to your host.

This page gives you one workflow file that validates your docs on every pull request and builds the deployable site. Copy it, set two repository variables, and replace the deploy step with your host’s upload command.

It follows the same conventions as the workflow that builds this site: Node 22, no pipes, no publish step, and a CLI built from source. It is a general-purpose pipeline, not a copy of that workflow. The reasoning behind each step is on Offline builds in CI.

Reed is not published to npm yet. There is no package to install. In CI you build the CLI from source, the same way this site’s own workflow does:

  1. Check out the Reed source repository next to your docs.
  2. Install its dependencies and build it.
  3. Run node <reed-source>/packages/cli/dist/bin.js.

The workflow reads two repository variables and one optional secret:

Name Kind Value
REED_REPO variable The Reed source repository, as owner/name. Required.
REED_REF variable A commit SHA, tag or branch of that repository. Pin a SHA.
REED_REPO_TOKEN secret A read token, only if the source repository is private.

If REED_REPO is unset, the job fails on its first step and says why. It never reports a green build it did not run.

Save this as .github/workflows/docs.yml:

yaml
name: docs
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
env:
# Both caches that `reed prefetch` fills live inside the workspace so actions/cache can
# save and restore them. REED_COREPACK_HOME points Reed's Corepack calls at the same
# directory as COREPACK_HOME.
REED_STORE_DIR: ${{ github.workspace }}/.ci-cache/reed-pnpm-store
COREPACK_HOME: ${{ github.workspace }}/.ci-cache/corepack
REED_COREPACK_HOME: ${{ github.workspace }}/.ci-cache/corepack
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Require a Reed source
if: ${{ vars.REED_REPO == '' }}
run: |
echo "error: set the repository variable REED_REPO to the Reed source (owner/name)." >&2
exit 1
- name: Check out docs
uses: actions/checkout@v4
# Node 22, not "latest": prefetch and build shell out to Corepack, and Node 25 and
# newer no longer bundle it.
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Check out Reed source
uses: actions/checkout@v4
with:
repository: ${{ vars.REED_REPO }}
ref: ${{ vars.REED_REF }}
token: ${{ secrets.REED_REPO_TOKEN || github.token }}
path: .ci-reed
# A throwaway COREPACK_HOME here keeps building Reed itself from warming the cache
# this job is about to restore and measure.
- name: Build the Reed CLI
id: reed
env:
COREPACK_HOME: ${{ runner.temp }}/corepack-reed-source
run: |
set -euo pipefail
cd .ci-reed
corepack enable pnpm
pnpm install --frozen-lockfile
pnpm run build
test -f packages/cli/dist/bin.js
echo "REED_BIN=$PWD/packages/cli/dist/bin.js" >> "$GITHUB_ENV"
echo "rev=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# One step, both caches. The key is the Reed revision: what prefetch stores depends
# on Reed's pins and the runner, not on your content.
- name: Restore Reed caches (store + Corepack)
id: reed-cache
uses: actions/cache@v4
with:
path: |
${{ env.REED_STORE_DIR }}
${{ env.COREPACK_HOME }}
key: reed-${{ runner.os }}-${{ runner.arch }}-${{ steps.reed.outputs.rev }}
restore-keys: |
reed-${{ runner.os }}-${{ runner.arch }}-
# Offline, no prerequisites. Fails the job on any error-level diagnostic.
- name: reed check
run: node "$REED_BIN" check
# The only Reed command allowed network access. Skipped on an exact cache hit.
- name: reed prefetch (cache miss only)
if: steps.reed-cache.outputs.cache-hit != 'true'
run: node "$REED_BIN" prefetch
- name: reed build
run: node "$REED_BIN" build --out dist
- name: Upload the built site
uses: actions/upload-artifact@v4
with:
name: docs-site
path: dist/
if-no-files-found: error
deploy:
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Download the built site
uses: actions/download-artifact@v4
with:
name: docs-site
path: dist
# REPLACE ME: put your host's upload command in `run`, then delete the `if` line.
# Until then the step is skipped, and the run shows it as skipped rather than as a
# deploy: nothing is uploaded.
- name: Deploy (replace me)
if: ${{ false }}
run: |
echo "Upload dist/ to your host here."

Both caches, one step. reed prefetch fills two directories: the dependency store at REED_STORE_DIR, and Corepack’s cache of the pinned package-manager binary at COREPACK_HOME. Restore only one and the build still passes on a hosted runner. It just fetches the other one over the network, where you cannot see it.

Prefetch runs only on a miss. cache-hit is true only when the exact key matched. A partial match through restore-keys reports a miss, so prefetch runs, refreshes the store, and the cache is saved under the new key at the end of the job.

The key follows Reed, not your docs. Prefetch rejects --dir because its caches are machine-wide. One prefetch serves every project on the runner, so hashing docs.json into the key only adds cache misses. The Reed revision and the runner’s OS and architecture are what actually change the contents.

A stale cache fails loudly. If a restored store was prefetched for a different Reed pin, reed build stops with a diagnostic telling you to run reed prefetch. It does not fall back to the network.

No pipes. Every Reed command runs on its own line. Piping one into tee makes the step’s exit status the status of tee, and a failed build reports success. GitHub’s default bash shell does not set pipefail, so this matters here. If you need a log file, redirect with > instead.

reed check fails only on errors. To fail pull requests on warnings as well, pass both strict flags:

sh
node "$REED_BIN" check --strict-components --strict-tokens

--strict-components escalates the DS-D* design warnings. --strict-tokens escalates every other warning. See Diagnostics.

The deploy job runs only on a push to main, and only after build succeeds. It downloads the same dist/ that was built and checked. Nothing is rebuilt.

What goes in the deploy step is up to your host. Reed has no deploy command and no hosted runtime. Three things to get right:

  • Credentials. Put your host’s token in a repository secret and reference it only in the deploy step. The build job never needs it.
  • The mount path. If docs.json sets base, the files have to be served from a directory matching it. Stage the tree before uploading. See Deploy.
  • _headers. It belongs at the root of the served tree, even when the pages sit under a mount path. See Response headers.

After the upload, fetch a page from the live origin and look for a string from this build. A zero exit code from the upload is not proof the new site is live.

If your runner allows unprivileged user namespaces, you can run the build step with no network at all. A cache that was missing a directory then fails here instead of passing quietly:

sh
unshare --user --map-root-user --net -- \
bash -c 'ip link set lo up 2>/dev/null || true; exec node "$REED_BIN" build --out dist'

reed check can run the same way with no prior step. It never touches the network.

Prefetch covers exactly the platform, architecture and libc it ran on. If you add an Alpine or macOS job, give it its own cache. The runner.os and runner.arch parts of the key already keep hosted Linux, macOS and ARM runners apart. They do not tell glibc from musl, so an Alpine container job needs its own key prefix.

For the same pipeline on GitLab, see Build in GitLab CI.