name: Nightly Documentation Update # Detects doc-affecting code changes, uses Claude Code CLI to update .md files, # and creates draft PRs for human review. # Estimated cost: ~$1-3 per run (claude-sonnet-4-6) on: # schedule: # - cron: '0 4 * * *' # 4 AM UTC daily — enable after cost validation 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-docs-update 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.0.2 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 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 AFFECTED_DOCS="" # ods/installers/ -> README.md, CLAUDE.md if echo "$CHANGED_FILES" | grep -qE '^ods/installers/'; then AFFECTED_DOCS="README.md,CLAUDE.md" fi # ods/scripts/ -> README.md, CLAUDE.md if echo "$CHANGED_FILES" | grep -qE '^ods/scripts/'; then for doc in "README.md" "CLAUDE.md"; do if ! echo "$AFFECTED_DOCS" | grep -q "$doc"; then AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}$doc" fi done fi # ods/extensions/services/dashboard-api/ -> README.md, CLAUDE.md if echo "$CHANGED_FILES" | grep -qE '^ods/extensions/services/dashboard-api/'; then for doc in "README.md" "CLAUDE.md"; do if ! echo "$AFFECTED_DOCS" | grep -q "$doc"; then AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}$doc" fi done fi # ods/extensions/services/dashboard/ -> README.md if echo "$CHANGED_FILES" | grep -qE '^ods/extensions/services/dashboard/'; then if ! echo "$AFFECTED_DOCS" | grep -q "README.md"; then AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}README.md" fi fi # ods/config/ -> README.md, CLAUDE.md if echo "$CHANGED_FILES" | grep -qE '^ods/config/'; then for doc in "README.md" "CLAUDE.md"; do if ! echo "$AFFECTED_DOCS" | grep -q "$doc"; then AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}$doc" fi done fi # ods/ods-cli -> README.md, CLAUDE.md if echo "$CHANGED_FILES" | grep -qE '^ods/ods-cli$'; then for doc in "README.md" "CLAUDE.md"; do if ! echo "$AFFECTED_DOCS" | grep -q "$doc"; then AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}$doc" fi done fi # ods/docker-compose* -> README.md if echo "$CHANGED_FILES" | grep -qE '^ods/docker-compose'; then if ! echo "$AFFECTED_DOCS" | grep -q "README.md"; then AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}README.md" fi fi # ods/tests/ -> README.md if echo "$CHANGED_FILES" | grep -qE '^ods/tests/'; then if ! echo "$AFFECTED_DOCS" | grep -q "README.md"; then AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}README.md" fi fi # .env* -> README.md, CLAUDE.md if echo "$CHANGED_FILES" | grep -qE '^ods/\.env'; then for doc in "README.md" "CLAUDE.md"; do if ! echo "$AFFECTED_DOCS" | grep -q "$doc"; then AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}$doc" fi done fi # .github/workflows/ -> CLAUDE.md if echo "$CHANGED_FILES" | grep -qE '^\.github/workflows/'; then if ! echo "$AFFECTED_DOCS" | grep -q "CLAUDE.md"; then AFFECTED_DOCS="${AFFECTED_DOCS:+$AFFECTED_DOCS,}CLAUDE.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.0.2 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Validate API key env: HAS_API_KEY: ${{ secrets.ANTHROPIC_API_KEY != '' }} run: | if [ "$HAS_API_KEY" != "true" ]; then echo "::error::ANTHROPIC_API_KEY not configured" exit 1 fi echo "Required secrets present" - name: Setup Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 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 }} 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@2.1.89 \ --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 run: | ALLOWED_FILES=( "README.md" "CLAUDE.md" "ods/README.md" "ods/docs/INSTALLER-ARCHITECTURE.md" "ods/docs/EXTENSIONS.md" "ods/docs/TESTING.md" ) MODIFIED=$(git diff --name-only) if [ -z "$MODIFIED" ]; then echo "No files modified" exit 0 fi 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" fi done - name: Secret scanning 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 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." git checkout -- . exit 1 fi - name: Check for actual changes id: changes run: | if git diff --quiet; then echo "has_updates=false" >> $GITHUB_OUTPUT else echo "has_updates=true" >> $GITHUB_OUTPUT fi - name: Create Draft Pull Request if: steps.changes.outputs.has_updates == 'true' && inputs.dry_run != true uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 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 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. ### Review Checklist - [ ] Changes are factually accurate - [ ] No unintended content removed - [ ] Tables and formatting preserved --- Generated by Claude Code (claude-sonnet-4-6) | [Workflow Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) labels: | documentation ai-generated needs-human-review