Skip to content

ci: automate and harden doc-gardening (model pin + per-PR advisory) - #913

Merged
Roasbeef merged 7 commits into
mainfrom
doc-ci-automation
Aug 7, 2026
Merged

ci: automate and harden doc-gardening (model pin + per-PR advisory)#913
Roasbeef merged 7 commits into
mainfrom
doc-ci-automation

Conversation

@Roasbeef

Copy link
Copy Markdown
Member

In this PR, we make the doc-gardening automation more autonomous, and
more trustworthy while we're at it. Two changes, one per commit.

First, we harden the nightly sweep. It was the only Claude workflow with
no --model, so it silently rode the action's default (Sonnet) and
would drift again the moment the action bumps that default. The @claude
and code-review workflows are already pinned to Opus, so the writer
running unattended was, backwards, the least capable and least pinned of
the three. We pin it to opus-4-8, and fold in what the recent manual
sweep learned the hard way: the prompt now mirrors every CLAUDE.md to
its AGENTS.md as an explicit gate (the root pair is the one most often
missed, and doc-check fails on a byte diff), scrubs leaked tool
artifacts (stray </content>/</invoke> tags) before committing, and
forbids go build/go test so a compiled binary never gets swept into
git add -A.

Second, we add a per-PR advisory that catches drift in lockstep with the
change that caused it, instead of waiting up to 24h for the nightly.
When a PR touches Go code, we run the skill scoped to just the packages
whose .go files changed (only ones that already have a CLAUDE.md, so
we never create new docs here, that stays the nightly's job) and post the
proposed fix back as a single sticky suggested-diff comment via
--edit-last. It never commits, pushes, or opens a PR.

The advisory is advisory by design: the comment is the whole signal, and
the check exits 0 so it can't block merge. There's a marked one-line
toggle to flip it to a non-required soft-fail later if the comment alone
proves too easy to ignore. We deliberately skipped a hard "changed pkg
must update its doc" gate, since most code changes (bug fixes, refactors,
test-only) don't warrant a doc edit and the escape valve (touch the file
to bypass) just trains people to make meaningless edits.

One cost knob worth flagging: the advisory burns an Opus run on every
Go-touching PR. If that gets noisy, easy dials are a cheaper model for
the scoped diff, or a paths-ignore for **/*_test.go.

The sibling client-side PR is lightninglabs/darepo-client (same two
commits, adjusted for main and no submodule).

Roasbeef added 2 commits July 9, 2026 18:35
In this commit, we make the unsupervised nightly doc sweep trustworthy.
It was the one Claude workflow with no --model, so it silently rode the
action's default (Sonnet), and would drift again the moment the action
bumps that default. The @claude and code-review workflows are already
pinned to Opus, so the writer running unattended was, backwards, the
least capable and least pinned of the three. We pin it to opus-4-8.

We also fold in what our manual sweep learned the hard way. The prompt
now mirrors every CLAUDE.md to its AGENTS.md as an explicit gate (the
root pair is the one most often missed, and doc-check fails on a byte
diff), scrubs leaked tool/formatting artifacts (stray </content> and
</invoke> tags) before committing, and forbids `go build`/`go test` so
a compiled `accounting` binary never gets swept into `git add -A`.
In this commit, we add a per-PR companion to the nightly sweep. The
nightly is batch: drift lands up to 24h late, and each sweep opens its
own PR to babysit. Here we catch drift in lockstep with the change that
caused it.

When a PR touches Go code, we run the doc-gardening skill scoped to just
the packages whose .go files changed (and only those that already have a
CLAUDE.md, so we never create new docs here, that stays the nightly's
job). If a scoped doc has gone stale, we post the proposed CLAUDE.md +
AGENTS.md fix back as a single sticky suggested-diff comment, replacing
any prior one via --edit-last so the thread never piles up. The workflow
never commits, pushes, or opens a PR.

It's advisory by design: the comment is the whole signal, and the check
exits 0 so it can't block merge. There's a marked one-line toggle to
flip it to a non-required soft-fail later if the comment alone proves
too easy to ignore. We skip fork PRs (no secrets) and the nightly's own
automation PRs.
@gemini-code-assist

Copy link
Copy Markdown

Note

Gemini is unable to generate a review for this pull request due to the file types involved not being currently supported.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f1100e3c4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/doc-gardening-pr.yml Outdated
Comment on lines +84 to +85
git diff --name-only "$BASE_SHA".."$HEAD_SHA" -- '*.go' \
| xargs -r -n1 dirname | sort -u

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare PR heads from the merge base

For PRs whose branch is behind main, this two-dot range compares the current base tip directly to the head, so packages changed only on main show up in SCOPE as deletions/changes from the PR. GitHub documents PR diffs as three-dot/merge-base comparisons focused on what the PR introduces, and step 2 repeats the same two-dot range for auditing, so the advisory can spend an Opus run and post unrelated doc fixes until the author rebases. Use $BASE_SHA...$HEAD_SHA or an explicit git merge-base for both scans.

Useful? React with 👍 / 👎.

Comment thread .github/workflows/doc-gardening-pr.yml Outdated
Comment on lines +112 to +113
4. **Compute the proposal.** Run `git diff -- '**/CLAUDE.md'
'**/AGENTS.md' '**/*.md'`. If the diff is empty, print

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include root agent docs in proposed diff

When a PR changes a root-level Go file such as lnd_boarding_wallet.go, the scope command above yields . and the advisor may update the root CLAUDE.md/AGENTS.md, but these pathspecs do not match root-level Markdown files (git ls-files -- '**/CLAUDE.md' omits CLAUDE.md). The resulting diff can be empty even after root docs were changed, so the workflow prints the no-op message and never posts the needed advisory.

Useful? React with 👍 / 👎.

In this commit, we decouple the nightly's failure canary from the bot
token it exists to watch. Both notify steps authenticated with
BOT_GITHUB_TOKEN, so when that PAT hit its 90-day expiry the checkout
step failed with a 401 and the canary failed with the very same 401,
leaving two mornings of silent red runs and no tracking issue.

The notify steps now use the workflow's own GITHUB_TOKEN, which GitHub
mints fresh per run and which cannot expire out from under us. The
workflow grants issues: write to make that possible; contents stays
read-only. The bot token remains the identity for everything the sweep
actually publishes (branch push, PR open) -- only the alarm wiring
moves off it.
@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

In this commit, we move both doc-gardening workflows -- the nightly
sweep and the per-PR advisory -- from claude-opus-4-8 to claude-opus-5,
now that Opus 5 is the strongest model available. The nightly writes
docs unattended and the advisory reasons about code-to-doc drift, so
both are exactly the kind of judgment-heavy, low-oversight job that
wants the most capable model. The pin stays explicit rather than
riding the action default, which is the property the original pin
commit was protecting.
In this commit, we close the widest prompt-injection escape in the
doc-gardening tool allowlist. awk, sed, and find were granted to Claude
but used by neither prompt, and each is a subprocess spawner that the
per-command allowlist cannot contain: awk 'BEGIN{system(...)}', GNU
sed's e command, and find -exec all launch arbitrary children, which
makes the curl/wget/gh-api entries on the disallow list reachable
anyway and the bot token in the step env exfiltrable. An injected
instruction riding in on a PR diff, go doc output, or the skill file is
the threat we care about here, not a hostile operator.

Removing the three we never call shrinks that surface to zero cost. The
tools the prompts actually use (cp for the CLAUDE.md->AGENTS.md mirror,
sort/xargs for the changed-package pipeline) stay for now; tightening
those needs a matching prompt rewrite and is tracked as follow-up.
@Roasbeef
Roasbeef force-pushed the doc-ci-automation branch from 86a18e8 to 72504cf Compare August 4, 2026 01:40
Second-pass hardening after an adversarial review of the doc-gardening
automation. None of these change the sweep's behavior on a normal run;
each shrinks what a prompt injection arriving via a PR diff, go doc
output, or the skill file could do with the tools the model is granted.

- Neutralize git hooks before the model runs, via a pre-step that sets
  core.hooksPath to /dev/null. The model holds native Write plus
  Bash(git commit:*), so without this an injection could drop a
  .git/hooks/pre-commit and have the sweep's own commit execute it with
  the bot token in env. git config is not in the allowlist, and
  git -c core.hooksPath=... commit does not match the Bash(git commit:*)
  prefix, so the model cannot re-enable hooks from inside the action.

- Scope the stage step to git add -- '*.md' rather than git add -A, so a
  stray binary or any secret-bearing file dropped into the worktree can
  never be committed and pushed onto the PR branch.

- Pin the three actions (claude-code-action, checkout, setup-go) to
  commit SHAs so a moved tag or upstream compromise cannot inject code
  into a step that holds the bot and OAuth tokens.

- Correct the show_full_output rationale: these repos are private, not
  public, and the real residual is the base64 credential checkout
  persists into .git/config.

Direct-push-to-default needs no change here: the branch ruleset already
blocks it (the bot has write, not bypass) and every auto-PR requires one
human approval to merge. Token-into-comment exfil stays bounded by
keeping BOT_GITHUB_TOKEN a fine-grained, repo-scoped PAT.
@Roasbeef

Roasbeef commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Aug 4, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 3m 10s —— View job


Doc-gardening automation review

I read both workflow files end-to-end and empirically checked the two Codex findings against this repo's actual git pathspec behavior. Both are real. A few additional items below.

  • Read doc-gardening-pr.yml and doc-gardening-nightly.yml
  • Verify Codex findings against real git ls-files behavior
  • Independent review pass

Confirmed bugs

1. pr.yml:112 — proposed diff silently drops root-level docs (Codex P2, confirmed)

The proposal is computed with git diff -- '**/CLAUDE.md' '**/AGENTS.md' '**/*.md'. In git pathspec, a leading **/ requires the literal /, so it does not match repo-root files. Verified here:

**/CLAUDE.md includes root CLAUDE.md exactly?  NO
**/*.md      includes root CLAUDE.md?          NO
*.md         includes root CLAUDE.md?          YES  (and nested too)

So when a PR touches a root-level .go file, dirname yields ., . has a CLAUDE.md, the advisor edits the root CLAUDE.md/AGENTS.md — and then step 4's diff comes up empty and the workflow prints DOC-PR NO-OP while real drift sits unposted. Note the nightly gets this right: nightly.yml:223 stages with git add -- '*.md' (single-star), which does match root. The fix is to use the same form here: git diff -- '*.md' (or add explicit CLAUDE.md AGENTS.md paths). As a bonus, '**/*.md' already subsumes '**/CLAUDE.md' and '**/AGENTS.md', so those two are redundant today.

2. pr.yml:84 — two-dot range compares base tip to head, not the PR's own changes (Codex P2, confirmed)

git diff --name-only "$BASE_SHA".."$HEAD_SHA" diffs the two endpoint trees directly. base.sha is the current tip of main, so once the PR branch falls behind, any package changed only on main shows up in SCOPE as a reversion the PR never made — burning an Opus run and posting unrelated doc "fixes" until the author rebases. Switch to three-dot ("$BASE_SHA"..."$HEAD_SHA"), which diffs from the merge-base and needs no new allowlisted tool. Same fix applies to the audit diff at pr.yml:98. (fetch-depth: 0 already fetches base.sha, so merge-base resolution will work.)


Additional findings

3. pr.yml:132--edit-last doesn't honor the `` marker

The prompt writes a hidden marker "so the comment is idempotent," but gh pr comment --edit-last edits the token identity's most recent comment on the PR regardless of marker. If BOT_GITHUB_TOKEN ever posts another comment on the same PR (nightly follow-up, another bot task), this silently overwrites the wrong comment. The marker is currently decorative. If the bot user is truly single-purpose this is fine in practice, but the idempotency guarantee the comment claims isn't actually enforced — worth a code comment acknowledging the --edit-last assumption, or a marker-based lookup via gh pr view --json comments.

4. PR description says opus-4-8, code pins claude-opus-5

pr.yml:158 and nightly.yml:295 both use --model claude-opus-5 (per the later "bump to opus 5" commit), but the PR body still says "We pin it to opus-4-8." Just stale prose — the pin itself is correct and is the right call (latest/most-capable for an unattended writer).

5. pr.yml:108 / nightly.yml:204 — artifact grep runs over ., not the changed docs

Both prompts describe scrubbing "the changed docs" but the command is grep -rn -e '</content>' -e '</invoke>' -e '<parameter' . over the whole tree. That will surface any legitimate occurrence anywhere in the repo (fixtures, vendored content, docs about tool syntax) and instruct the model to "remove any," which could touch unrelated files. Scope the grep to the docs actually edited this run.

6. nightly.yml:164 — the --merges --grep baseline anchor likely never resolves

BASE=$(git log --grep='^docs: nightly doc-gardening sweep' --merges ... origin/main) looks for a merge commit whose subject starts with the sweep title. But a merge commit's subject is Merge pull request #…, and a squash-merge isn't a merge commit at all — so this almost always falls through to the 30-day lookback. If the intent is "since the last merged sweep," drop --merges and grep the squash/commit subject instead, or match the PR title in the merge subject. (Pre-existing-ish, but it's in the diff and it undercuts the sweep's scope.)

7. Lower-confidence: paths: '**/*.go' may not trigger on root-level Go files

Same **/-needs-a-slash concern as #1, but for GitHub's path-filter engine rather than git's. If a PR only touches a root-level *.go, the advisory may not fire at all — which is exactly the root-doc case #1 also mishandles. Worth confirming with a throwaway root-file PR; if it doesn't trigger, add '*.go' alongside '**/*.go'.


What's good

The security posture is genuinely careful and well-documented: contents: read on the PR job, fork/automation-label skip, the disallow-list blocking destructive git/gh, the hooks-neutralization rationale on the nightly, the failure canary deliberately using github.token instead of the bot PAT, and the git add -- '*.md' scoping to keep stray binaries uncommittable. The go build/go test prohibition with the "stray binary into git add -A" reasoning is a nice touch. None of the findings above are blockers for the security model — #1 and #2 are correctness bugs that make the advisory quietly under- or mis-report, and both are one-line fixes.

Fix #1 and #2 →

Note: I can review but can't push fixes here — GitHub App permissions don't allow me to modify files under .github/workflows/. The changes above are small enough to apply by hand.
· branch doc-ci-automation

Three correctness bugs surfaced by the Codex and Claude review passes,
all verified against this repo before fixing.

The advisory computed its scope with a two-dot range,
"$BASE_SHA".."$HEAD_SHA". base.sha is the base branch tip at event time,
not the branch point, so a PR that has fallen behind sees every file
changed on the base branch since it forked. Measured on this very PR:
two-dot reports 1147 changed files where the PR changed 2. That burns an
Opus run and posts doc fixes for packages the PR never touched. Both
scans now use the three-dot merge-base form.

The proposal diff used '**/CLAUDE.md' '**/AGENTS.md' '**/*.md'. Git's *
already spans /, so a leading **/ requires at least one slash and never
matches a repo-root file:

    git ls-files -- '**/CLAUDE.md'  ->  root CLAUDE.md absent
    git ls-files -- '*.md'          ->  root CLAUDE.md present

A root-level .go change puts . in SCOPE, the advisor edits the root
CLAUDE.md/AGENTS.md pair, and then the empty diff makes the run print
NO-OP and drop the advisory -- silently failing on exactly the pair the
nightly calls the one most often missed. A single '*.md' matches every
depth including root, and subsumes the other two patterns.

The nightly's artifact scrub told the model to run grep, but grep was
never added to the nightly allowlist when that instruction landed (the
advisory workflow has it). Headless, the scrub is denied and leaked tags
reach the commit. Add Bash(grep:*) there.

Two robustness items while here: scope the scrub to --include='*.md' so
a legitimate <parameter in Go source is never treated as an artifact to
delete, and forbid reading .git/config or env into the advisory comment.
checkout persists the bot token into .git/config and a comment is not
log-masked, so the comment body is the one egress path that could carry
a secret off the runner.
@Roasbeef
Roasbeef merged commit 85d6f05 into main Aug 7, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants