Nightly Documentation Update #162
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: 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 |