Skip to content

Nightly Documentation Update #162

Nightly Documentation Update

Nightly Documentation Update #162

name: Nightly Documentation Update
# Automatically detects doc-affecting code changes, uses Claude Code CLI
# to update in-repo .md files, and creates draft PRs for human review.
# Estimated cost: ~$1-3 per run (claude-sonnet-4-6)
on:
schedule:
- cron: '0 5 * * *' # 5 AM UTC daily
workflow_dispatch:
inputs:
commits_to_analyze:
description: 'Number of recent commits to analyze'
type: number
default: 20
dry_run:
description: 'Dry run (skip PR creation)'
type: boolean
default: false
concurrency:
group: nightly-automation
cancel-in-progress: false
jobs:
detect-changes:
name: Detect Doc-Affecting Changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
affected_docs: ${{ steps.check.outputs.affected_docs }}
commits_analyzed: ${{ steps.check.outputs.commits_analyzed }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Check for existing docs PR
id: dedup
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
EXISTING_PR=$(gh pr list --state open --label "documentation" --json number,headRefName \
--jq '[.[] | select(.headRefName | startswith("docs/nightly-update"))][0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then
echo "::notice::Existing docs PR #$EXISTING_PR is still open — skipping"
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Detect doc-affecting changes
id: check
if: steps.dedup.outputs.skip != 'true'
run: |
COMMITS=${{ inputs.commits_to_analyze || 20 }}
echo "commits_analyzed=$COMMITS" >> $GITHUB_OUTPUT
# Get changed files from recent commits
CHANGED_FILES=$(git log --oneline -"$COMMITS" --name-only --pretty=format: | sort -u | grep -v '^$')
if [ -z "$CHANGED_FILES" ]; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "::notice::No files changed in last $COMMITS commits"
exit 0
fi
# Doc-affecting file patterns and their documentation targets
AFFECTED_DOCS=""
# agents/ -> README.md, AGENTS.md
if echo "$CHANGED_FILES" | grep -qE '^agents/'; then
AFFECTED_DOCS="README.md,AGENTS.md"
fi
# .claude/skills/ -> README.md
if echo "$CHANGED_FILES" | grep -qE '^\.claude/skills/'; then
for doc in "README.md"; do
if ! echo "$AFFECTED_DOCS" | grep -q "$doc"; then
AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}$doc"
fi
done
fi
# scripts/ -> README.md, TOOLS.md
if echo "$CHANGED_FILES" | grep -qE '^scripts/'; then
for doc in "README.md" "TOOLS.md"; do
if ! echo "$AFFECTED_DOCS" | grep -q "$doc"; then
AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}$doc"
fi
done
fi
# .github/workflows/ -> README.md, TOOLS.md
if echo "$CHANGED_FILES" | grep -qE '^\.github/workflows/'; then
for doc in "README.md" "TOOLS.md"; do
if ! echo "$AFFECTED_DOCS" | grep -q "$doc"; then
AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}$doc"
fi
done
fi
# .claude/rules/ -> README.md
if echo "$CHANGED_FILES" | grep -qE '^\.claude/rules/'; then
if ! echo "$AFFECTED_DOCS" | grep -q "README.md"; then
AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}README.md"
fi
fi
# tests/ -> README.md
if echo "$CHANGED_FILES" | grep -qE '^tests/'; then
if ! echo "$AFFECTED_DOCS" | grep -q "README.md"; then
AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}README.md"
fi
fi
# benchmarks/ -> README.md
if echo "$CHANGED_FILES" | grep -qE '^benchmarks/'; then
if ! echo "$AFFECTED_DOCS" | grep -q "README.md"; then
AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}README.md"
fi
fi
if [ -z "$AFFECTED_DOCS" ]; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "::notice::No doc-affecting changes in last $COMMITS commits"
else
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "affected_docs=$AFFECTED_DOCS" >> $GITHUB_OUTPUT
echo "::notice::Affected docs: $AFFECTED_DOCS"
fi
update-and-create-pr:
name: Update Docs & Create Draft PR
needs: detect-changes
if: needs.detect-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Validate required secrets
env:
HAS_ANTHROPIC_KEY: ${{ secrets.ANTHROPIC_API_KEY != '' }}
HAS_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }}
run: |
if [ "$HAS_ANTHROPIC_KEY" != "true" ] && [ "$HAS_OAUTH_TOKEN" != "true" ]; then
echo "::error::Neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN is configured"
exit 1
fi
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: 20
- name: Get current date
id: date
run: echo "date=$(date +%Y-%m-%d)" >> $GITHUB_OUTPUT
- name: Run Claude Code CLI
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
AFFECTED_DOCS: ${{ needs.detect-changes.outputs.affected_docs }}
COMMITS: ${{ needs.detect-changes.outputs.commits_analyzed }}
run: |
{
cat .github/prompts/nightly-docs-update.md
echo ""
echo "AFFECTED_DOCS: ${AFFECTED_DOCS}"
echo "COMMITS_TO_ANALYZE: ${COMMITS}"
} | npx -y @anthropic-ai/claude-code \
--print \
--model claude-sonnet-4-6-v1 \
--max-turns 40 \
--allowedTools "Read,Edit,Glob,Grep,Bash(git log:*),Bash(git diff:*)"
- name: Enforce file allowlist
id: guardrails
run: |
# ALLOWLIST: Only these specific .md files may be modified
ALLOWED_FILES=(
"README.md"
"AGENTS.md"
"TOOLS.md"
)
# Get all modified files
MODIFIED=$(git diff --name-only)
if [ -z "$MODIFIED" ]; then
echo "No files modified"
exit 0
fi
REVERTED=""
# Revert ANY file not in the allowlist
for file in $MODIFIED; do
ALLOWED=false
for allowed in "${ALLOWED_FILES[@]}"; do
if [ "$file" == "$allowed" ]; then
ALLOWED=true
break
fi
done
if [ "$ALLOWED" == "false" ]; then
echo "::warning::Reverting unauthorized modification: $file"
git checkout -- "$file"
REVERTED="${REVERTED:+$REVERTED, }$file"
fi
done
# Log if Claude attempted to modify protected files
PROTECTED=$(echo "$MODIFIED" | grep -E '(\.github/|\.env|CLAUDE\.md|\.py$|\.rs$|\.yml$|\.yaml$)' || true)
if [ -n "$PROTECTED" ]; then
echo "::error::Claude attempted to modify protected files: $PROTECTED"
fi
if [ -n "$REVERTED" ]; then
echo "::notice::Reverted unauthorized files: $REVERTED"
fi
- name: Secret scanning on diff
run: |
DIFF_CONTENT=$(git diff)
if [ -z "$DIFF_CONTENT" ]; then
exit 0
fi
if echo "$DIFF_CONTENT" | grep -iE '(sk-[a-zA-Z0-9]{20,}|AKIA[A-Z0-9]{16}|ghp_[a-zA-Z0-9]{36}|password\s*=\s*["\x27][^"\x27]+["\x27])' > /dev/null; then
echo "::error::Potential secret detected in documentation changes — aborting"
git checkout -- .
exit 1
fi
- name: Diff size gate
run: |
if git diff --quiet; then
echo "No changes to check"
exit 0
fi
INSERTIONS=$(git diff --numstat | awk '{s+=$1} END {print s+0}')
DELETIONS=$(git diff --numstat | awk '{s+=$2} END {print s+0}')
DIFF_LINES=$((INSERTIONS + DELETIONS))
echo "::notice::Diff size: +$INSERTIONS -$DELETIONS ($DIFF_LINES total lines)"
if [ "$DIFF_LINES" -gt 500 ]; then
echo "::error::Diff too large ($DIFF_LINES lines). Aborting to prevent runaway changes."
git checkout -- .
exit 1
fi
- name: Check for actual changes
id: changes
run: |
if git diff --quiet; then
echo "has_updates=false" >> $GITHUB_OUTPUT
echo "::notice::No documentation updates needed"
else
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "### Documentation Changes" >> $GITHUB_STEP_SUMMARY
echo '```diff' >> $GITHUB_STEP_SUMMARY
git diff --stat >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
- name: Format affected docs list
id: format_docs
if: steps.changes.outputs.has_updates == 'true'
env:
AFFECTED_DOCS: ${{ needs.detect-changes.outputs.affected_docs }}
run: |
BULLET_LIST=$(echo "$AFFECTED_DOCS" | tr ',' '\n' | sed 's/^ *//' | sed 's/^/- `/' | sed 's/$/`/')
echo "bullet_list<<DOCS_EOF" >> $GITHUB_OUTPUT
echo "$BULLET_LIST" >> $GITHUB_OUTPUT
echo "DOCS_EOF" >> $GITHUB_OUTPUT
- name: Create Draft Pull Request
if: steps.changes.outputs.has_updates == 'true' && inputs.dry_run != true
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: |
docs: nightly documentation update (${{ steps.date.outputs.date }})
Automated update based on ${{ needs.detect-changes.outputs.commits_analyzed }} recent commits.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
branch: docs/nightly-update-${{ steps.date.outputs.date }}
delete-branch: true
draft: true
title: "docs: nightly documentation update (${{ steps.date.outputs.date }})"
body: |
## Nightly Documentation Update
Automated update based on **${{ needs.detect-changes.outputs.commits_analyzed }}** recent commits.
### Affected Documentation
${{ steps.format_docs.outputs.bullet_list }}
### Review Checklist
- [ ] Changes are factually accurate
- [ ] No unintended content removed
- [ ] Tables and formatting preserved
- [ ] Cross-doc consistency maintained
---
Generated by Claude Code (claude-sonnet-4-6) | [Nightly Docs Workflow](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
labels: |
documentation
ai-generated
needs-human-review
skip-ci-review
- name: Dry run summary
if: steps.changes.outputs.has_updates == 'true' && inputs.dry_run == true
run: |
echo "### Dry Run — Changes NOT committed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The following changes would be included in a PR:" >> $GITHUB_STEP_SUMMARY
echo '```diff' >> $GITHUB_STEP_SUMMARY
git diff >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
notify:
name: Slack Notification
needs: [detect-changes, update-and-create-pr]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: scripts
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.12'
- name: Install dependencies
run: pip install requests
- name: Determine workflow status
id: status
run: |
if [ "${{ needs.detect-changes.outputs.has_changes }}" != "true" ]; then
echo "status=skipped" >> $GITHUB_OUTPUT
elif [ "${{ needs.update-and-create-pr.result }}" == "success" ]; then
echo "status=success" >> $GITHUB_OUTPUT
elif [ "${{ needs.update-and-create-pr.result }}" == "skipped" ]; then
echo "status=skipped" >> $GITHUB_OUTPUT
else
echo "status=failure" >> $GITHUB_OUTPUT
fi
- name: Send Slack notification
env:
RUBE_API_TOKEN: ${{ secrets.RUBE_API_TOKEN }}
SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}
RUBE_ENTITY_ID: ${{ secrets.RUBE_ENTITY_ID }}
NOTIFY_KIND: docs-update
WORKFLOW_STATUS: ${{ steps.status.outputs.status }}
AFFECTED_DOCS: ${{ needs.detect-changes.outputs.affected_docs }}
WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [ -z "$RUBE_API_TOKEN" ] || [ -z "$SLACK_CHANNEL_ID" ] || [ -z "$RUBE_ENTITY_ID" ]; then
echo "::warning::Slack secrets not configured — skipping notifications"
exit 0
fi
python scripts/notify_slack.py