mirror of
https://github.com/gastownhall/beads.git
synced 2026-09-14 20:17:24 +08:00
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:
@@ -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
|
||||
|
||||
Executable
+231
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user