CI/CD publishing
Once documentation lives in Git, publishing should be a consequence of merging, not a task somebody performs. The goal is narrow and testable: a contributor opens a pull request, sees their change rendered at a URL, gets it approved, merges, and the change is live without anyone touching a deploy button.
Build the pipeline early — during the migration, not after it. It is how you review converted content, and a pipeline retrofitted after launch is a pipeline nobody trusts.
What does a documentation CI/CD pipeline need to do?
Five things: install from a lockfile, build the site reproducibly, run the quality checks, deploy the built output to a URL, and do the same for pull requests at a temporary URL. Everything else is refinement.
A complete workflow for GitHub Pages, which you can read top to bottom:
# .github/workflows/deploy-docs.yml
name: Deploy documentation
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Full history: last-updated timestamps are read from git log.
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: build
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
Four details that are easy to get wrong:
fetch-depth: 0. Docusaurus derives "last updated" from Git history. The default shallow checkout gives every page the same timestamp, which is worse than showing none.npm ci, nevernpm install. The lockfile is the reproducibility guarantee;installwill happily resolve a new minor version of a transitive dependency and change your output.concurrencywithoutcancel-in-progress. Cancelling a half-finished deploy leaves the site in an undefined state. Queue instead.- Least-privilege permissions, declared at the workflow level rather than inherited.
The same shape works anywhere. On GitLab CI it is a pages job publishing the
build directory as an artifact; on a static host it is a build command and an
output directory configured in the host's dashboard. What matters is that the
build runs on a clean machine from a lockfile, so "works on my laptop" stops
being a category of problem.
Preview deploys are the feature that changes behaviour
Reviewing prose as a diff is fine for a typo and useless for a restructured page. A per-pull-request preview URL is what makes non-technical reviewers able to participate, and it is the single highest-value thing in the pipeline.
Most static hosts provide previews automatically for pull requests. If yours does not, build on the pull request and upload the output as an artifact, so a reviewer can at least download and open it:
# .github/workflows/pr-checks.yml
name: Documentation checks
on:
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- run: npx lychee --offline --include-fragments 'build/**/*.html'
- uses: actions/upload-artifact@v4
with:
name: docs-preview
path: build
retention-days: 7
Note that the build here is the link check as well, because
onBrokenLinks: 'throw' makes a broken internal link a build failure. The
lychee step adds anchor and asset verification on top. The full reasoning is
in QA and link checking.
Keep the build fast enough to be used
Contributors abandon a workflow that takes fifteen minutes to tell them about a typo. Watch build duration as a real metric and act when it drifts past about five minutes:
- Cache the dependency install with
cache: npminsetup-node, which keys on the lockfile. - Limit versioned docs in preview builds with
onlyIncludeVersions, as described in versioning strategies. - Split the workflow so the fast checks — lint, frontmatter validation, vocabulary — run in a separate job that fails in under a minute, rather than behind a full build.
- Do not run external link checking on pull requests. Schedule it nightly.
Deploy, verify, and be able to go back
A deployment is not finished when the job goes green. Add a post-deploy step that fetches the live site and asserts something true about it — the homepage returns 200, a known page contains a known string, the sitemap parses:
set -euo pipefail
base="https://docs.example.com"
curl -fsS "$base/" > /dev/null
curl -fsS "$base/sitemap.xml" | grep -q '<urlset'
curl -fsS "$base/docs/webhooks/subscribe/" | grep -q 'Verify webhook signatures'
echo "smoke tests passed"
Rollback for a static site is redeploying the previous commit, which means your rollback procedure is one revert and one pipeline run. Write the two commands in the repository README and test them once, deliberately, before you need them:
git revert --no-edit <bad-commit>
git push origin main
What belongs in the pipeline, and what does not
Publishing automation is not a substitute for editorial judgement. The pipeline should enforce everything objectively checkable — links, structure, metadata, terminology — and refuse to enforce anything else, because a linter that argues about wording gets switched off. The boundary between the two is drawn in review gates and quality checks, and the human side of it is defined by ownership and governance.