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.
Getting the Reed CLI
Section titled “Getting the Reed CLI”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:
- Check out the Reed source repository next to your docs.
- Install its dependencies and build it.
- 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.
The workflow
Section titled “The workflow”Save this as .github/workflows/docs.yml:
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."What each part is doing
Section titled “What each part is doing”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.
Stricter checks
Section titled “Stricter checks”reed check fails only on errors. To fail pull requests on warnings as well, pass both
strict flags:
node "$REED_BIN" check --strict-components --strict-tokens--strict-components escalates the DS-D* design warnings. --strict-tokens escalates
every other warning. See Diagnostics.
Deploying
Section titled “Deploying”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.jsonsetsbase, 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.
Proving the build is offline
Section titled “Proving the build is offline”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:
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.
Other platforms
Section titled “Other platforms”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.