Skip to content

Docs

Docs #1081

Workflow file for this run

name: Docs
# Least-privilege default for every job. The PR/main-push `build` and
# `verify-commands` jobs only read the repo; the elevated `pages: write` +
# `id-token: write` scopes are granted to the `deploy` job alone (AAASM-3877).
permissions:
contents: read
on:
pull_request:
branches: [main]
paths:
- 'docs/**'
- 'README.md'
- 'CONTRIBUTING.md'
- '.github/workflows/docs.yml'
# AAASM-4310: shared metadata sources feed docs/src/generated/. A drift
# in either without a matching snippet update must fail docs CI.
- 'metadata/**'
- 'scripts/generate_docs_metadata.py'
- 'Cargo.toml'
# AAASM-4911: the version-drift gate below stamps README / installation.md
# / docs.yml pins / CONTRIBUTING.md from the SoT; a change to the gate
# script itself must re-run it (a dead trigger otherwise).
- 'scripts/propagate_versions.py'
# AAASM-4922: the metadata-drift lint (ADR 0014); re-run it when it changes.
- '.ci/check-metadata-drift.sh'
# AAASM-5671: the banned-absolutes waiver-eligibility gate (ADR 0034 W10);
# a change to the gate itself must re-run it, or it is a dead trigger.
- 'scripts/check_absolutes_unwaivable.py'
# The gate also scans the root adoption record ADR 0034 Decision 4 requires of
# every repository. It does not exist here yet; listing it now means the first
# one committed is gated, rather than sitting outside the trigger unnoticed —
# the vacuous-pass guard cannot catch that, since the docs/ globs still match.
- 'TRUTH-ADOPTION.md'
push:
branches: [main]
paths:
- 'docs/**'
- 'README.md'
- 'CONTRIBUTING.md'
- '.github/workflows/docs.yml'
- 'metadata/**'
- 'scripts/generate_docs_metadata.py'
- 'Cargo.toml'
- 'scripts/propagate_versions.py'
- '.ci/check-metadata-drift.sh'
- 'scripts/check_absolutes_unwaivable.py'
- 'TRUTH-ADOPTION.md'
# Release-driven frozen-version cut (AAASM-2752). Instead of reacting to a
# raw tag push, the frozen snapshot is cut only after the Release workflow
# has *succeeded* for that tag. This guarantees we never publish docs for a
# release whose binaries/crates failed to build. The triggering run's
# head_branch is the tag name (Release fires on `push: tags`).
workflow_run:
workflows: ["Release"]
types: [completed]
jobs:
build:
name: Build mdBook
runs-on: ubuntu-latest
# PR + main-push always build. A workflow_run only proceeds when the
# upstream Release succeeded, was a TAG PUSH (not a pull_request / dispatch
# dry-run — AAASM-3714; our branch names also start with 'v', so the
# head_branch check alone is insufficient), and the tag looks like a release
# version (vX.Y.Z[-pre]).
if: >-
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Full history so the last-changed preprocessor can read per-page
# git dates; a shallow clone would collapse every page to one commit.
fetch-depth: 0
# For a release cut, build the docs from the tagged commit, not main.
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch @ 2026-06-27
- name: Cache mdBook binaries
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/bin/mdbook
~/.cargo/bin/mdbook-mermaid
key: ${{ runner.os }}-mdbook-0.5.2-mermaid-0.17.0
- name: Install mdBook
run: |
if ! command -v mdbook >/dev/null; then
cargo install --locked --version 0.5.2 mdbook
fi
- name: Install mdbook-mermaid
run: |
if ! command -v mdbook-mermaid >/dev/null; then
cargo install --locked --version 0.17.0 mdbook-mermaid
fi
# Guard the pre-release semver gate (docs/ci/channels.py): a logic
# regression here would silently mis-classify release channels, so run
# the channel-gate unit tests on every PR/push before building.
- name: Run channel-gate unit tests
run: python3 docs/ci/test_channels.py
# AAASM-4310: verify the shared metadata snippets in docs/src/generated/
# are up to date with Cargo.toml + metadata/docs.yaml. The generator is
# idempotent; a non-empty diff means someone edited a source without
# regenerating (or edited a generated file by hand). Fail early — before
# `mdbook build docs` — so the CI log points at the drift immediately.
- name: Regenerate shared docs metadata
run: python3 scripts/generate_docs_metadata.py
- name: Fail if generated docs metadata is stale
run: |
if ! git diff --exit-code -- docs/src/generated/; then
echo "::error::docs/src/generated/ is out of date with Cargo.toml / metadata/docs.yaml." >&2
echo "Run 'python3 scripts/generate_docs_metadata.py' locally and commit the result." >&2
exit 1
fi
- name: Build book
run: mdbook build docs
# --- Doc versioning (AAASM-2752) ---
# The Pages site is laid out as per-version subpaths under `_site/`:
# _site/index.html -> root redirect (stable -> pre-release -> latest)
# _site/versions.json -> manifest read by the selector + warning banner
# _site/latest/ -> live docs tracking main
# _site/<vX.Y.Z[-pre]>/ -> frozen, immutable release snapshots
#
# versions.json describes moving CHANNELS that point at a concrete
# subpath, plus immutable ARCHIVED concrete versions:
# { "channels": [ {id,title,target}, ... ],
# "archived": [ {id,title}, ... ] }
# Channels: latest (main), pre-release (newest vX.Y.Z-pre tag),
# stable (newest vX.Y.Z tag). Channel id classification:
# ^v\d+\.\d+\.\d+$ -> stable
# ^v\d+\.\d+\.\d+-.+ -> pre-release
#
# On main push we publish into latest/. On a *successful* Release
# (workflow_run) we publish into <tag>/, archive it, and repoint the
# stable or pre-release channel to it.
- name: Resolve target version subpath
if: github.event_name != 'pull_request'
id: docver
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.workflow_run.head_branch }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_run" ]; then
tag="$RELEASE_TAG"
# Classify the channel from the tag shape.
if printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
channel="stable"
elif printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+-.+'; then
channel="pre-release"
else
echo "::error::Tag '$tag' is not a release version; skipping docs cut."
exit 1
fi
echo "version=$tag" >> "$GITHUB_OUTPUT"
echo "channel=$channel" >> "$GITHUB_OUTPUT"
else
echo "version=latest" >> "$GITHUB_OUTPUT"
echo "channel=latest" >> "$GITHUB_OUTPUT"
fi
- name: Fetch prior live versions.json
# AAASM-2752 + AAASM-2827: the live manifest is the only place that
# carries the moving channel targets (pre-release -> vX.Y.Z-pre).
# archived[] is rebuilt from git tags in the next step so a missing
# or truncated prior manifest is no longer a permanent loss.
if: github.event_name != 'pull_request'
id: restore
continue-on-error: true
run: |
base_url="https://${GITHUB_REPOSITORY_OWNER}.github.io/${GITHUB_REPOSITORY##*/}"
echo "Fetching existing versions.json from ${base_url}/versions.json"
if curl -fsSL "${base_url}/versions.json" -o prior-versions.json; then
echo "prior_versions=true" >> "$GITHUB_OUTPUT"
else
echo "No existing versions.json (first deploy)."
echo "prior_versions=false" >> "$GITHUB_OUTPUT"
fi
- name: Rebuild every release tag's docs subpath from git
# AAASM-2827: the previous design mirrored prior subpaths from the
# live Pages site with `wget -r`, which silently lost versions when
# the mirror failed and only worked if the prior deploy was itself
# whole. Rebuild every archived release directly from its git tag
# instead — git is the source of truth, and this loop is idempotent
# and self-healing: if a prior release never published docs (e.g.
# because its Release workflow failed), it is recovered on the next
# successful deploy.
if: github.event_name != 'pull_request'
env:
VERSION: ${{ steps.docver.outputs.version }}
run: |
set -euo pipefail
mkdir -p _site
# Enumerate every release tag (vX.Y.Z[-pre]) reachable in the
# repository. The workflow's checkout uses fetch-depth: 0 so all
# tags are local.
mapfile -t TAGS < <(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' | sort)
: > extra-archived.txt
# Workspace for tag worktrees. We use git worktree (cheaper than
# a fresh clone per tag) and tear each one down after use.
BUILD_ROOT="$(mktemp -d)"
trap 'rm -rf "$BUILD_ROOT" || true' EXIT
for tag in "${TAGS[@]}"; do
# Skip the tag being cut this run; the freshly built docs/book
# is placed in _site/$VERSION below by the assemble step.
if [ "$tag" = "$VERSION" ]; then
echo "$tag" >> extra-archived.txt
continue
fi
# Already restored from the artifact upload of a prior step?
# (Defensive — no current step pre-populates _site, but this
# keeps the loop idempotent if one is added later.)
if [ -f "_site/$tag/index.html" ]; then
echo "Skipping $tag (already present in _site)"
echo "$tag" >> extra-archived.txt
continue
fi
workdir="$BUILD_ROOT/$tag"
echo "::group::Rebuild $tag"
# `git worktree add --detach` checks out the tag's tree without
# touching the current branch state. A tag whose docs/ subtree
# is missing or whose mdBook config has drifted past this
# workflow's mdbook version is logged and skipped — its subpath
# will fall back to the prior live mirror (if any) or 404.
if ! git worktree add --detach "$workdir" "$tag" >/dev/null 2>&1; then
echo "::warning::Could not check out $tag; skipping."
echo "::endgroup::"
continue
fi
if [ ! -f "$workdir/docs/book.toml" ]; then
echo "::warning::$tag has no docs/book.toml; skipping."
git worktree remove --force "$workdir" >/dev/null 2>&1 || true
echo "::endgroup::"
continue
fi
if ! mdbook build "$workdir/docs"; then
echo "::warning::mdbook build failed for $tag; skipping."
git worktree remove --force "$workdir" >/dev/null 2>&1 || true
echo "::endgroup::"
continue
fi
mkdir -p "_site/$tag"
cp -R "$workdir/docs/book/." "_site/$tag/"
git worktree remove --force "$workdir" >/dev/null 2>&1 || true
echo "$tag" >> extra-archived.txt
echo "::endgroup::"
done
echo "Rebuilt archive subpaths:"
find _site -maxdepth 1 -mindepth 1 -type d | sort
echo "extra-archived.txt:"
cat extra-archived.txt
- name: Assemble versioned Pages site
if: github.event_name != 'pull_request'
env:
VERSION: ${{ steps.docver.outputs.version }}
CHANNEL: ${{ steps.docver.outputs.channel }}
run: |
set -euo pipefail
mkdir -p _site
# 1. Place the freshly built book at the target version subpath.
# Archive subpaths for all *other* release tags were already
# materialised by the rebuild step above.
rm -rf "_site/$VERSION"
mkdir -p "_site/$VERSION"
cp -R docs/book/. "_site/$VERSION/"
# 1b. Always materialise the `latest/` channel directory (AAASM-3733).
# `latest` is a guaranteed channel in versions.json and the root
# redirect's final fallback target, but it was only written on a
# main-push cut (VERSION=latest). A release-only deploy (the only
# kind this site has had) left `latest/` as a dangling target, so
# the documented entry point /latest/ 404'd. On a release cut the
# freshly built book is the newest live docs, so it is the correct
# content for `latest/` too; on a main cut this is a no-op copy.
if [ "$VERSION" != "latest" ]; then
rm -rf "_site/latest"
mkdir -p "_site/latest"
cp -R docs/book/. "_site/latest/"
fi
# 2. Recompute versions.json via the testable channel module
# (docs/ci/channels.py), which applies the pre-release semver
# gate and unions extra-archived.txt (written by the rebuild
# step) into archived[]. AAASM-2827: this makes archived[]
# self-healing against a missing/truncated prior manifest.
python3 docs/ci/build_versions.py "$VERSION" "$CHANNEL" _site/versions.json
# 3. Root redirect (stable -> pre-release -> latest, resolved client-side).
cp docs/site-root-index.html _site/index.html
echo "Assembled _site:"; find _site -maxdepth 2 -type d | sort
- name: Upload Pages artifact
if: github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: _site
deploy:
name: Deploy to GitHub Pages
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
# Pages deployment needs the elevated scopes; scoped here so the
# PR-triggered build/verify jobs run with contents:read only (AAASM-3877).
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
verify-commands:
name: Verify documented commands (Linux)
# Only on PR / main push; a release cut re-verifies nothing new.
if: github.event_name != 'workflow_run'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install system dependencies (protoc, pkg-config, libssl-dev)
run: |
sudo apt-get update
sudo apt-get install -y protobuf-compiler pkg-config libssl-dev
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch @ 2026-06-27
- name: Cache cargo build
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Verify README "Getting Started" build command
run: cargo build --workspace --exclude aa-ebpf
- name: Verify api-reference.md rustdoc command
run: cargo doc --workspace --no-deps --exclude aa-ebpf
# AAASM-4911 (ADR 0013 rollout): the blocking version-drift gate. The `build`
# job above already fails on stale `docs/src/generated/` snippets, but that
# only covers the generated tier. `propagate_versions.py --check` extends the
# SoT → consumer contract to the hand-maintained literal tier the ADR audit
# flagged (README install/Project-Status lines, docs/src/quick-start/
# installation.md, this workflow's mdBook pins, CONTRIBUTING.md) plus a
# re-check of the snippet tier — so a version literal that drifts from the
# Cargo.toml / metadata/docs.yaml SoT fails the build instead of shipping.
# Kept as its own lightweight job (no Rust/mdBook toolchain) so the drift
# signal is fast and independent of the heavy book build.
version-drift:
name: Version-drift gate (propagate_versions --check)
# PR + main push only; a release-cut workflow_run rebuilds no sources.
if: github.event_name != 'workflow_run'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The propagator uses `tomllib` (Python 3.11+); pin 3.12 rather than rely
# on the runner's default `python3`, which is not guaranteed to be ≥3.11.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Fail if any version-bearing site drifts from the SoT
run: python3 scripts/propagate_versions.py --check
# AAASM-4922 (ADR 0014 rollout): blocking hardcoded-value lint for registry-owned
# canonical metadata. The `.github` registry owns the governance-doc branch and
# the canonical URL set; this gate fails the build if one of those canonical
# literals drifts back into the tree (e.g. a `.github/blob/main` governance link,
# or a `/install.sh` path on the host-root `.dev` installer alt). Pure git/bash,
# no toolchain — kept fast and independent of the book build like version-drift.
# NOTE: this job now runs two independent pure-script docs gates —
# check-metadata-drift.sh and check_absolutes_unwaivable.py (AAASM-5671). The
# display name still names only the first because it is a required status check
# and renaming it would break branch protection; the steps are the accurate list.
metadata-drift:
name: Metadata-drift gate (check-metadata-drift.sh)
if: github.event_name != 'workflow_run'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Fail if a registry-owned canonical value drifts
run: bash .ci/check-metadata-drift.sh
# AAASM-5671 (ADR 0034 Decision 10, W10): banned absolutes are unwaivable.
# ADR 0034 shipped saying both things at once, in nine places, and no gate
# could see it. This rides in the existing pure-script docs job rather than
# a job of its own — same trigger set, same no-toolchain shape, and the job
# name is left alone because it is a required check. The selftest runs first
# so a detector that has stopped detecting fails loudly instead of passing
# everything.
- name: Fail if a governance page makes banned absolutes waiver-eligible
run: |
python3 scripts/check_absolutes_unwaivable.py --selftest
python3 scripts/check_absolutes_unwaivable.py
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}