Docs #1081
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' }} |