ci(docs): blame-scoped CLI docs freshness gate + fix patch artifact (#4301)

The doc freshness gate regenerated all generated doc artifacts and failed
the PR on any diff, even drift the contributor did not cause: staleness
inherited from the base branch, or local regeneration environments that
do not match CI's canonical build. Contributors ended up futzing with
regeneration loops for docs unrelated to their change (#4300, #4301).

New scripts/check-cli-docs-drift.sh keeps the strict regenerate-and-diff
probe as the fast path, but on failure in a PR it attributes the drift
before failing anyone: it regenerates the artifacts at HEAD and at the
merge-base in scratch worktrees, each with the canonical pinned build
(CGO_ENABLED=0 -tags gms_pure_go). If the PR neither changed the
regenerated CLI surface nor touched generated files, the drift is
inherited: warn and pass. Contributors only own docs for code they
actually touched.

When the drift IS attributable, CI now writes the exact regenerated fix
as a patch and uploads it as the cli-docs-freshness-patch artifact, so
the fix never depends on the contributor's local environment: download,
git apply, push.

Strict behavior is preserved wherever there is no PR base ref: pushes to
main, releases, and plain local runs. The pr-policy wrapper picks up the
same logic through check-doc-flags.sh.

Refs #4301, #4203. Complements #4312 (docgen binary pinning).

Agent-Signature: claude-unknown-model-high on behalf of matt wilkie
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
matt wilkie
2026-06-10 09:41:27 -07:00
parent 605f7fcc35
commit 1de37278ad
3 changed files with 250 additions and 2 deletions
+17
View File
@@ -147,6 +147,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
# Full history so check-cli-docs-drift.sh can regenerate docs at the
# merge-base and only fail PRs for drift they actually introduced.
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -157,10 +161,23 @@ jobs:
run: CGO_ENABLED=0 go build -tags gms_pure_go -o bd ./cmd/bd/
- name: Validate docs against CLI
env:
DOC_DRIFT_PATCH_OUT: ${{ runner.temp }}/cli-docs-freshness.patch
run: |
./scripts/check-doc-flags.sh ./bd
./scripts/check-doc-freshness.sh
# When the drift check fails, it writes the exact regenerated-docs fix
# (produced with CI's canonical build) so contributors can apply it with
# `git apply` instead of reproducing CI's environment locally.
- name: Upload docs fix patch
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: cli-docs-freshness-patch
path: ${{ runner.temp }}/cli-docs-freshness.patch
if-no-files-found: ignore
# Fast check to catch accidental .beads/issues.jsonl changes from contributors
check-no-beads-changes:
name: Check for .beads changes
+231
View File
@@ -0,0 +1,231 @@
#!/bin/bash
# check-cli-docs-drift.sh — Blame-scoped freshness check for generated CLI docs.
#
# Strict probe first: regenerate the generated doc artifacts and diff against
# the committed copies, exactly like generate-cli-docs.sh --check. When the
# probe fails AND a diff base is known (a PR), the drift is attributed before
# failing anyone:
#
# * regenerate the same artifacts at HEAD and at the merge-base, each in a
# scratch worktree with the canonical pinned build
# (CGO_ENABLED=0 go build -tags gms_pure_go);
# * if the change neither alters the regenerated CLI surface nor touches the
# generated files, the drift was inherited from the base branch: warn and
# pass — contributors only own docs for the code they actually touched;
# * otherwise fail, and write the exact regeneration diff as a patch
# (--patch-out / $DOC_DRIFT_PATCH_OUT) so the fix never depends on the
# contributor's local build environment.
#
# Without a diff base (push to main, releases, plain local runs) the strict
# probe result stands, preserving the historical behavior.
#
# Usage: check-cli-docs-drift.sh [--base <ref>] [--patch-out <file>] [bd-binary]
# --base diff base ref (default: $BD_DOCS_DIFF_BASE, then
# origin/$GITHUB_BASE_REF when set, as in GitHub PR CI)
# --patch-out where to write the regeneration patch on attributable drift
# (default: $DOC_DRIFT_PATCH_OUT when set)
#
# Attribution compares committed states; commit local changes before relying
# on it outside CI.
set -euo pipefail
export LC_ALL=C
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
BASE_REF=""
PATCH_OUT="${DOC_DRIFT_PATCH_OUT:-}"
BD_ARG=""
while [ "$#" -gt 0 ]; do
case "$1" in
--base)
[ "$#" -ge 2 ] || { echo "Error: --base needs a ref" >&2; exit 2; }
BASE_REF="$2"
shift 2
;;
--patch-out)
[ "$#" -ge 2 ] || { echo "Error: --patch-out needs a path" >&2; exit 2; }
PATCH_OUT="$2"
shift 2
;;
-h|--help)
sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
BD_ARG="$1"
shift
;;
esac
done
if [ -z "$BASE_REF" ]; then
BASE_REF="${BD_DOCS_DIFF_BASE:-}"
fi
if [ -z "$BASE_REF" ] && [ -n "${GITHUB_BASE_REF:-}" ]; then
BASE_REF="origin/${GITHUB_BASE_REF}"
fi
# The complete set of generated doc artifacts, as git pathspecs.
GEN_PATHSPECS=(
"docs/CLI_REFERENCE.md"
"website/docs/cli-reference"
":(glob)website/versioned_docs/*/cli-reference/**"
"website/static/llms-full.txt"
)
print_fix_help() {
echo ""
echo "To fix in any environment (build a canonical bd, then regenerate):"
echo " CGO_ENABLED=0 go build -tags gms_pure_go -o ./bd-docs ./cmd/bd/"
echo " ./scripts/generate-cli-docs.sh ./bd-docs"
echo " ./scripts/generate-llms-full.sh"
echo " rm ./bd-docs"
}
# Check out a commit into a scratch worktree and regenerate every generated
# doc artifact there with the canonical pinned build.
regen_worktree() {
local sha="$1" dir="$2"
git -C "$PROJECT_ROOT" worktree add --detach --quiet "$dir" "$sha"
(
cd "$dir"
CGO_ENABLED=0 go build -tags gms_pure_go -o "$dir/.docs-bd" ./cmd/bd/
./scripts/generate-cli-docs.sh "$dir/.docs-bd" >/dev/null
if [ -x ./scripts/generate-llms-full.sh ]; then
./scripts/generate-llms-full.sh >/dev/null
fi
rm -f "$dir/.docs-bd"
)
}
# Stable content hash of the regenerated artifacts in a worktree, so two
# regenerations can be compared without caring about temp paths.
surface_fingerprint() {
local dir="$1"
(
cd "$dir"
local f d
for f in docs/CLI_REFERENCE.md website/static/llms-full.txt; do
if [ -f "$f" ]; then
printf '== %s\n' "$f"
cat "$f"
fi
done
local dirs=()
for d in website/docs/cli-reference website/versioned_docs/*/cli-reference; do
[ -d "$d" ] && dirs+=("$d")
done
if [ "${#dirs[@]}" -gt 0 ]; then
find "${dirs[@]}" -type f -name '*.md' | sort | while IFS= read -r f; do
printf '== %s\n' "$f"
cat "$f"
done
fi
) | sha256sum | awk '{print $1}'
}
# --- Strict probe ------------------------------------------------------------
if "$PROJECT_ROOT/scripts/generate-cli-docs.sh" --check ${BD_ARG:+"$BD_ARG"}; then
exit 0
fi
echo ""
echo "Strict CLI docs freshness probe failed; attributing the drift..."
strict_stands() {
echo "$1"
echo "FAIL: generated CLI docs are out of sync (strict result stands)."
print_fix_help
exit 1
}
if [ -z "$BASE_REF" ]; then
strict_stands "No diff base available (not a PR build)."
fi
if ! command -v go >/dev/null 2>&1; then
strict_stands "Go toolchain unavailable; cannot regenerate for attribution."
fi
if ! git -C "$PROJECT_ROOT" rev-parse --verify --quiet "$BASE_REF" >/dev/null; then
strict_stands "Diff base $BASE_REF is not available locally (shallow clone? use fetch-depth: 0)."
fi
HEAD_SHA="$(git -C "$PROJECT_ROOT" rev-parse HEAD)"
if ! MERGE_BASE="$(git -C "$PROJECT_ROOT" merge-base "$HEAD_SHA" "$BASE_REF")"; then
strict_stands "Cannot compute merge-base with $BASE_REF (shallow clone? use fetch-depth: 0)."
fi
if [ -n "$(git -C "$PROJECT_ROOT" status --porcelain)" ]; then
echo "Note: working tree is dirty; attribution uses committed state only."
fi
SCRATCH="$(mktemp -d)"
W_HEAD="$SCRATCH/head"
W_BASE="$SCRATCH/base"
cleanup() {
git -C "$PROJECT_ROOT" worktree remove --force "$W_HEAD" >/dev/null 2>&1 || true
git -C "$PROJECT_ROOT" worktree remove --force "$W_BASE" >/dev/null 2>&1 || true
rm -rf "$SCRATCH"
}
trap cleanup EXIT
echo "Regenerating docs at HEAD (${HEAD_SHA:0:12}) with the canonical pinned build..."
if ! regen_worktree "$HEAD_SHA" "$W_HEAD"; then
strict_stands "Regeneration at HEAD failed."
fi
if git -C "$W_HEAD" diff --quiet; then
echo "PASS: committed docs are fresh under the canonical pinned build."
echo "(The strict probe failed only because the supplied bd binary differs"
echo " from the canonical one. Build with: CGO_ENABLED=0 go build -tags gms_pure_go)"
exit 0
fi
echo "Regenerating docs at merge-base (${MERGE_BASE:0:12})..."
if ! regen_worktree "$MERGE_BASE" "$W_BASE"; then
strict_stands "Regeneration at the merge-base failed."
fi
PR_TOUCHED="$(git -C "$PROJECT_ROOT" diff --name-only "$MERGE_BASE" "$HEAD_SHA" -- "${GEN_PATHSPECS[@]}")"
FP_HEAD="$(surface_fingerprint "$W_HEAD")"
FP_BASE="$(surface_fingerprint "$W_BASE")"
if [ -z "$PR_TOUCHED" ] && [ "$FP_HEAD" = "$FP_BASE" ]; then
echo ""
echo "WARN: generated docs are stale, but the drift is inherited from the base branch:"
echo "this change neither alters the regenerated CLI surface nor touches generated files."
git -C "$W_HEAD" diff --stat | sed 's/^/ /'
echo "Not failing this PR. The base branch needs a docs regeneration:"
echo " ./scripts/generate-cli-docs.sh && ./scripts/generate-llms-full.sh"
if [ -n "${GITHUB_ACTIONS:-}" ]; then
echo "::warning::Generated CLI docs are stale on the base branch (inherited drift, not introduced by this PR)."
fi
echo "PASS: no doc drift attributable to this change"
exit 0
fi
echo ""
echo "FAIL: this change affects the generated CLI docs and the committed copies are stale."
if [ "$FP_HEAD" != "$FP_BASE" ]; then
echo " - the regenerated CLI surface differs from the merge-base"
fi
if [ -n "$PR_TOUCHED" ]; then
echo " - generated doc files were modified by this change:"
echo "$PR_TOUCHED" | sed 's/^/ /'
fi
echo ""
git -C "$W_HEAD" diff --stat | sed 's/^/ /'
if [ -n "$PATCH_OUT" ]; then
git -C "$W_HEAD" diff > "$PATCH_OUT"
echo ""
echo "The exact fix (regenerated with CI's canonical build) was written to:"
echo " $PATCH_OUT"
echo "In CI it is uploaded as the 'cli-docs-freshness-patch' artifact;"
echo "download it and run: git apply cli-docs-freshness.patch"
fi
print_fix_help
exit 1
+2 -2
View File
@@ -164,8 +164,8 @@ if [ -f "$CLI_REF" ]; then
if [ -z "$BD_FOR_GEN" ]; then
echo "FAIL: Could not resolve bd binary path for CLI docs freshness check"
ERRORS=$((ERRORS + 1))
elif "$PROJECT_ROOT/scripts/generate-cli-docs.sh" --check "$BD_FOR_GEN"; then
echo "PASS: Generated CLI docs are fresh"
elif "$PROJECT_ROOT/scripts/check-cli-docs-drift.sh" "$BD_FOR_GEN"; then
echo "PASS: CLI docs freshness gate (see drift check output above)"
else
ERRORS=$((ERRORS + 1))
fi