---
title: Build in GitLab CI
description: The Reed pipeline as .gitlab-ci.yml, with both caches, dist/ as an artifact, and a main-only deploy job.
---

This page is the GitLab version of [Build in GitHub Actions](/docs/ship/ci/github-actions):
check, prefetch on a cache miss, build, keep `dist/` as an artifact, and deploy from the
default branch only.

It was written from GitLab's CI/CD YAML reference and Reed's cache layout, not from a
pipeline that has run on GitLab. Read the checklist at the end before you trust the first
green run.

## Getting the Reed CLI

Reed is not published to npm yet. The job clones the Reed source, builds it, and runs
`node .ci-reed/packages/cli/dist/bin.js`. Two inputs control what it builds:

| Name | Where | Value |
|---|---|---|
| `REED_REPO_URL` | CI/CD variable, in project settings | Clone URL of the Reed source. Mark it masked if it carries a token. |
| `.reed-ref` | a file committed to your repository | One line: the Reed commit SHA to build. |

The pin lives in a file rather than a variable for a reason. GitLab's `cache:key:files`
hashes file contents into the cache key, so changing the pin automatically starts a
fresh cache. The Reed revision is what decides what `reed prefetch` stores. Your content
does not.

## The pipeline

Save this as `.gitlab-ci.yml`:

```yaml
stages:
  - build
  - deploy

variables:
  # Both caches that `reed prefetch` fills. GitLab only caches paths inside the project
  # directory, so both live under it. REED_COREPACK_HOME points Reed's Corepack calls at
  # the same directory as COREPACK_HOME.
  REED_STORE_DIR: "$CI_PROJECT_DIR/.ci-cache/reed-pnpm-store"
  COREPACK_HOME: "$CI_PROJECT_DIR/.ci-cache/corepack"
  REED_COREPACK_HOME: "$CI_PROJECT_DIR/.ci-cache/corepack"

build-docs:
  stage: build
  # Node 22, not "latest": prefetch and build shell out to Corepack, and Node 25 and
  # newer no longer bundle it.
  image: node:22-bookworm
  cache:
    key:
      files:
        - .reed-ref
      prefix: reed-node22-bookworm
    paths:
      - .ci-cache/reed-pnpm-store
      - .ci-cache/corepack
  script:
    - |
      set -eu
      if [ -z "${REED_REPO_URL:-}" ]; then
        echo "error: set the CI/CD variable REED_REPO_URL to the Reed source repository." >&2
        exit 1
      fi

      # Build the Reed CLI from source. A throwaway COREPACK_HOME keeps this from
      # warming the cache the job is about to depend on.
      git clone --quiet "$REED_REPO_URL" .ci-reed
      git -C .ci-reed checkout --quiet "$(cat .reed-ref)"
      (
        export COREPACK_HOME="$(mktemp -d)"
        cd .ci-reed
        corepack enable pnpm
        pnpm install --frozen-lockfile
        pnpm run build
      )
      REED_BIN="$CI_PROJECT_DIR/.ci-reed/packages/cli/dist/bin.js"
      test -f "$REED_BIN"

      # Offline, no prerequisites.
      node "$REED_BIN" check

      # GitLab has no cache-hit flag. A missing cache directory is the miss.
      if [ -d "$REED_STORE_DIR" ] && [ -d "$COREPACK_HOME" ]; then
        echo "Reed caches restored; skipping prefetch."
      else
        node "$REED_BIN" prefetch
      fi

      node "$REED_BIN" build --out dist
  artifacts:
    paths:
      - dist/
    expire_in: 1 week

deploy-docs:
  stage: deploy
  image: node:22-bookworm
  needs:
    - job: build-docs
      artifacts: true
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual # REPLACE ME: switch to `when: on_success` once the script below uploads dist/
  script:
    - |
      # REPLACE ME with your host's upload command for dist/. The job stays manual
      # until you do, so a merge never looks deployed when nothing was uploaded.
      echo "no deploy command configured yet; upload dist/ to your host here"
```

## What each part is doing

**Both caches in one `cache:` block.** `reed prefetch` fills the dependency store at
`REED_STORE_DIR` and Corepack's cache of the pinned package-manager binary at
`COREPACK_HOME`. Cache only one and the build still passes on a runner with network
access. It fetches the other directory silently, every run.

**Paths inside the project.** GitLab resolves `cache:paths` and `artifacts:paths`
relative to `$CI_PROJECT_DIR` and does not reach outside it. Reed's default store
location, `~/.reed/pnpm-store`, would never be cached. That is why the variables move
both directories under `.ci-cache/`.

**Prefetch only on a miss.** When the key changes, nothing is restored, the directories
do not exist, and the job runs `reed prefetch`. GitLab saves the cache only when the job
succeeds, so a failed prefetch is never stored as a warm cache.

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

**No pipes.** Each Reed command runs on its own line. Piping one into `tee` reports the
exit status of `tee`, and a failed build passes. Redirect with `>` if you need a log file.

**Artifacts, not a rebuild.** `deploy-docs` uses `needs` with `artifacts: true`, so it
receives the exact `dist/` that `build-docs` produced and checked.

## Deploy from the default branch only

The `rules:` entry adds `deploy-docs` to a pipeline only when the commit is on the
project's default branch. Every other branch still runs `build-docs`, so each push is
checked and built, and nothing but the default branch reaches your host.

What replaces the placeholder depends on your host. Reed has no deploy command. Keep the
host's token in a masked CI/CD variable that only this job uses. If `docs.json` sets
`base`, stage `dist/` under a matching directory before uploading, and keep `_headers` at
the root of the served tree. See [Deploy](/docs/ship/deploy) and
[Response headers](/docs/ship/headers).

## Stricter checks

To fail on warnings too, change the check line:

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

`--strict-components` escalates `DS-D*` design warnings. `--strict-tokens` escalates every
other warning. See [Diagnostics](/docs/reference/diagnostics).

## Check these on the first run

This pipeline has not been run on GitLab by the people who wrote it. Verify it once:

1. **Prefetch wrote inside the project.** The first run logs
   `[reed] prefetch complete → store:` followed by a path. It must end in
   `.ci-cache/reed-pnpm-store`. A path under `~/.reed` means the variables did not reach
   the job, and nothing will be cached.
2. **Both paths were uploaded.** The end of the job log shows the cache being created for
   both `.ci-cache` paths. A warning that a path matched no files means that cache is
   empty.
3. **The second run skips prefetch.** It logs `Reed caches restored; skipping prefetch.`
   and still builds. If it fails with a Reed diagnostic naming `reed prefetch`, the
   restored cache is stale or came from a different platform.
4. **The artifact is the site.** Browse the `build-docs` job artifacts and confirm
   `dist/index.html` and `dist/llms.txt` are there.
5. **Deploy is gated.** A pipeline on a feature branch shows no `deploy-docs` job.

Two cache behaviors will show up as extra prefetches. A cache is stored where the runner is
installed unless distributed cache is enabled, so a job on a different runner sees a miss.
And by default protected and unprotected branches do not share caches, so your default
branch and your feature branches each warm their own. Both cost time, not correctness.

## One key per platform

Prefetch covers exactly the platform, architecture and libc it ran on. The cache `prefix`
names the image for that reason. If you add a job on an Alpine image, an ARM runner or a
macOS runner, give it a different prefix so the two never share a store.
