---
title: Build in GitHub Actions
description: 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](/docs/ship/ci-offline).

## 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:

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.

## The workflow

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."
```

## 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

`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](/docs/reference/diagnostics).

## 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.json` sets `base`, the files have to be served from a
  directory matching it. Stage the tree before uploading. See
  [Deploy](/docs/ship/deploy).
- **`_headers`.** It belongs at the root of the served tree, even when the pages sit
  under a mount path. See [Response headers](/docs/ship/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

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.

## 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](/docs/ship/ci/gitlab).
