sync-docs #42
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: sync-docs | |
| on: | |
| workflow_dispatch: | |
| schedule: | |
| # Runs every 12 hours | |
| - cron: '0 */12 * * *' | |
| env: | |
| DOCS_PATH: 'docs' | |
| # Space-separated list of subfolders the safety guard tracks. Must match what | |
| # the update scripts produce. | |
| DOCS_FOLDERS: 'api agents-and-tools build-with-claude manage-claude managed-agents test-and-evaluate release-notes general code' | |
| MAX_REMOVED_FILES_PERCENTAGE: "${{ vars.MAX_REMOVED_FILES_PERCENTAGE || '25' }}" | |
| jobs: | |
| update-and-publish: | |
| if: github.ref == 'refs/heads/main' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| issues: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ######################################################################## | |
| # GENERATE DOCS | |
| ######################################################################## | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| cache: 'pnpm' | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| - name: Install dependencies | |
| run: pnpm install | |
| - name: Run docs update script | |
| run: bun scripts/update-docs.js | |
| - name: Cleanup README files when docs are unchanged | |
| run: | | |
| for folder_name in ${DOCS_FOLDERS}; do | |
| README="${DOCS_PATH}/${folder_name}/${folder_name}-README.md" | |
| [ -f "$README" ] || continue | |
| REAL_CHANGES=$(git status --porcelain "${DOCS_PATH}/${folder_name}/" \ | |
| | grep -v "${folder_name}-README.md" || true) | |
| if [ -z "$REAL_CHANGES" ]; then | |
| echo "No real changes in ${folder_name}, reverting README" | |
| git checkout -- "$README" | |
| else | |
| echo "Real changes detected in ${folder_name}, keeping README" | |
| fi | |
| done | |
| - name: Check for file changes | |
| id: git_status | |
| run: | | |
| echo "Max allowed removed percentage: ${MAX_REMOVED_FILES_PERCENTAGE}%" | |
| echo "Docs path: ${DOCS_PATH}" | |
| DOCS_PATHS=() | |
| for folder_name in ${DOCS_FOLDERS}; do | |
| DOCS_PATHS+=("${DOCS_PATH}/${folder_name}/") | |
| done | |
| CHANGED_FILES="$(git status --porcelain "${DOCS_PATHS[@]}" || true)" | |
| if [ -z "$CHANGED_FILES" ]; then | |
| echo "No relevant changes detected." | |
| echo "has_changes=false" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| echo "Relevant changes detected:" | |
| echo "$CHANGED_FILES" | |
| echo "has_changes=true" >> $GITHUB_OUTPUT | |
| CRITICAL=false | |
| if git rev-parse --verify --quiet HEAD >/dev/null; then | |
| HAVE_HEAD=true | |
| else | |
| HAVE_HEAD=false | |
| fi | |
| for folder_name in ${DOCS_FOLDERS}; do | |
| FOLDER_PATH="${DOCS_PATH}/${folder_name}" | |
| if [ ! -d "$FOLDER_PATH" ]; then | |
| echo "❌ CRITICAL: Folder '${FOLDER_PATH}' is missing in working tree. Aborting." | |
| CRITICAL=true | |
| continue | |
| fi | |
| if [ "$HAVE_HEAD" = true ]; then | |
| TOTAL_FOLDER_FILES="$(git ls-tree -r --name-only HEAD -- "$FOLDER_PATH" | wc -l | tr -d '[:space:]')" | |
| REMOVED_FOLDER_FILES="$(git diff --name-only --diff-filter=D HEAD -- "$FOLDER_PATH" 2>/dev/null | wc -l | tr -d '[:space:]')" | |
| else | |
| CURRENT_FOLDER_FILES="$(find "$FOLDER_PATH" -type f 2>/dev/null | wc -l | tr -d '[:space:]')" | |
| REMOVED_FOLDER_FILES="$(echo "$CHANGED_FILES" | grep "^ D " | grep "$FOLDER_PATH" | wc -l | tr -d '[:space:]')" | |
| if [ "$CURRENT_FOLDER_FILES" -eq 0 ] && [ "$REMOVED_FOLDER_FILES" -eq 0 ]; then | |
| TOTAL_FOLDER_FILES=0 | |
| else | |
| TOTAL_FOLDER_FILES=$(( CURRENT_FOLDER_FILES + REMOVED_FOLDER_FILES )) | |
| fi | |
| fi | |
| TOTAL_FOLDER_FILES="${TOTAL_FOLDER_FILES:-0}" | |
| REMOVED_FOLDER_FILES="${REMOVED_FOLDER_FILES:-0}" | |
| if [ "$TOTAL_FOLDER_FILES" -eq 0 ]; then | |
| echo "❌ CRITICAL: Folder '$FOLDER_PATH' has 0 files in total (before deletion). Aborting." | |
| CRITICAL=true | |
| continue | |
| fi | |
| FOLDER_PERCENTAGE=$(( REMOVED_FOLDER_FILES * 100 / TOTAL_FOLDER_FILES )) | |
| echo "Folder check: $folder_name → removed ${REMOVED_FOLDER_FILES}/${TOTAL_FOLDER_FILES} (${FOLDER_PERCENTAGE}%)" | |
| if [ "$FOLDER_PERCENTAGE" -ge "$MAX_REMOVED_FILES_PERCENTAGE" ]; then | |
| echo "❌ CRITICAL: Folder '${folder_name}' exceeds safe deletion threshold (${FOLDER_PERCENTAGE}% ≥ ${MAX_REMOVED_FILES_PERCENTAGE}%)." | |
| CRITICAL=true | |
| fi | |
| done | |
| if [ "$CRITICAL" = true ]; then | |
| echo "❌ CRITICAL: deletion threshold exceeded or missing folders detected. Please check logs above. Aborting." | |
| exit 1 | |
| fi | |
| echo "All folder checks passed." | |
| exit 0 | |
| ######################################################################## | |
| # VERSION + RELEASE | |
| ######################################################################## | |
| - name: Configure Git user | |
| if: steps.git_status.outputs.has_changes == 'true' | |
| run: | | |
| git config --global user.name "GitHub Actions Bot" | |
| git config --global user.email "actions-bot@github.com" | |
| - name: Bump package version | |
| if: steps.git_status.outputs.has_changes == 'true' | |
| id: version | |
| run: | | |
| pnpm version patch --no-git-tag-version --commit-hooks false | |
| NEW_VERSION=$(node -p "require('./package.json').version") | |
| echo "version_number=${NEW_VERSION}" >> $GITHUB_OUTPUT | |
| - name: Generate Changelog | |
| id: changelog_generator | |
| if: steps.git_status.outputs.has_changes == 'true' | |
| run: | | |
| NEW_VERSION="${{ steps.version.outputs.version_number }}" | |
| HEADER="## 🤖 v${NEW_VERSION} - $(date +'%d/%m/%Y')" | |
| echo -e "$HEADER\n\nFile Changes:\n" > new_entry.tmp | |
| DOCS_PATHS=() | |
| for folder_name in ${DOCS_FOLDERS}; do | |
| DOCS_PATHS+=("${DOCS_PATH}/${folder_name}/") | |
| done | |
| git status --porcelain "${DOCS_PATHS[@]}" \ | |
| | while IFS= read -r line; do | |
| case "$line" in | |
| " M "*) echo "- Modified: \`${line:3}\`" >> new_entry.tmp ;; | |
| " D "*) echo "- Deleted: \`${line:3}\`" >> new_entry.tmp ;; | |
| "?? "*) echo "- Added: \`${line:3}\`" >> new_entry.tmp ;; | |
| esac | |
| done | |
| echo "body<<EOF" >> $GITHUB_OUTPUT | |
| cat new_entry.tmp >> $GITHUB_OUTPUT | |
| echo "EOF" >> $GITHUB_OUTPUT | |
| tail -n +2 CHANGELOG.md 2>/dev/null > old.tmp | |
| { echo "# Changelog"; echo ""; cat new_entry.tmp; echo ""; cat old.tmp; } > CHANGELOG.md | |
| rm new_entry.tmp old.tmp | |
| - name: Commit, Tag, and Push | |
| if: steps.git_status.outputs.has_changes == 'true' | |
| run: | | |
| DOCS_PATHS=() | |
| for folder_name in ${DOCS_FOLDERS}; do | |
| DOCS_PATHS+=("${DOCS_PATH}/${folder_name}/") | |
| done | |
| git add "${DOCS_PATHS[@]}" \ | |
| "${DOCS_PATH}/INDEX.md" \ | |
| CHANGELOG.md package.json pnpm-lock.yaml | |
| git commit -m "docs: release v${{ steps.version.outputs.version_number }}" | |
| git tag "${{ steps.version.outputs.version_number }}" | |
| git push | |
| git push --tags | |
| - name: Create GitHub Release | |
| if: steps.git_status.outputs.has_changes == 'true' | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: "${{ steps.version.outputs.version_number }}" | |
| name: "${{ steps.version.outputs.version_number }}" | |
| body: "${{ steps.changelog_generator.outputs.body }}" | |
| ######################################################################## | |
| # FAILURE NOTIFICATION | |
| ######################################################################## | |
| - name: Open issue on failure | |
| if: failure() | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const today = new Date().toISOString().slice(0, 10); | |
| const title = `sync-docs failed on ${today}`; | |
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | |
| const body = [ | |
| `Workflow run: ${runUrl}`, | |
| '', | |
| 'Likely causes:', | |
| '- Per-folder deletion threshold exceeded (upstream restructuring or sitemap change).', | |
| '- Network/HTTP failure when fetching the sitemap or a doc page.', | |
| '- A new top-level section appeared in the upstream sitemap that the script does not recognize yet.', | |
| '', | |
| 'Action: inspect the logs at the link above. If upstream restructured, update `scripts/update-platform-docs.js` (DOCS config) and the `DOCS_FOLDERS` env var in this workflow.', | |
| ].join('\n'); | |
| const existing = await github.rest.issues.listForRepo({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| labels: 'sync-docs-failure', | |
| }); | |
| if (existing.data.some((i) => i.title === title)) { | |
| core.info('Issue for today already open — skipping.'); | |
| return; | |
| } | |
| await github.rest.issues.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title, | |
| body, | |
| labels: ['sync-docs-failure'], | |
| }); |