Align docs_suggestions.yml with repo CI conventions (#49999)

John D. Swanson created

Cleans up a new GitHub Actions workflow.

Before you mark this PR as ready for review, make sure that you have:
- ~~[ ] Added a solid test coverage and/or screenshots from doing manual
testing~~
- [x] Done a self-review taking into account security and performance
aspects
- ~~[ ] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)~~

Release Notes:

- N/A

Change summary

.github/workflows/docs_suggestions.yml | 209 ++++++++++++++++++---------
1 file changed, 138 insertions(+), 71 deletions(-)

Detailed changes

.github/workflows/docs_suggestions.yml šŸ”—

@@ -17,7 +17,7 @@ on:
       - 'crates/**/*.rs'
       - '!crates/**/*_test.rs'
       - '!crates/**/tests/**'
-  
+
   # Run on cherry-picks to release branches
   pull_request_target:
     types: [opened, synchronize]
@@ -25,7 +25,7 @@ on:
       - 'v0.*'
     paths:
       - 'crates/**/*.rs'
-  
+
   # Manual trigger for testing
   workflow_dispatch:
     inputs:
@@ -42,10 +42,6 @@ on:
           - immediate
         default: batch
 
-permissions:
-  contents: write
-  pull-requests: write
-
 env:
   DROID_MODEL: claude-sonnet-4-5-20250929
   SUGGESTIONS_BRANCH: docs/suggestions-pending
@@ -56,16 +52,19 @@ jobs:
   batch-suggestions:
     runs-on: ubuntu-latest
     timeout-minutes: 10
+    permissions:
+      contents: write
+      pull-requests: read
     if: |
-      (github.event_name == 'pull_request' && 
+      (github.event_name == 'pull_request' &&
        github.event.pull_request.merged == true &&
        github.event.pull_request.base.ref == 'main' &&
        github.event.pull_request.head.repo.full_name == github.repository) ||
       (github.event_name == 'workflow_dispatch' && inputs.mode == 'batch')
-    
+
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
           token: ${{ secrets.GITHUB_TOKEN }}
@@ -92,35 +91,48 @@ jobs:
 
       - name: Get PR info
         id: pr
+        env:
+          INPUT_PR_NUMBER: ${{ inputs.pr_number }}
+          EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
+          GH_TOKEN: ${{ github.token }}
         run: |
-          if [ -n "${{ inputs.pr_number }}" ]; then
-            PR_NUM="${{ inputs.pr_number }}"
+          if [ -n "$INPUT_PR_NUMBER" ]; then
+            PR_NUM="$INPUT_PR_NUMBER"
           else
-            PR_NUM="${{ github.event.pull_request.number }}"
+            PR_NUM="$EVENT_PR_NUMBER"
+          fi
+          if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
+            echo "::error::Invalid PR number: $PR_NUM"
+            exit 1
           fi
           echo "number=$PR_NUM" >> "$GITHUB_OUTPUT"
-          
-          # Get PR title
-          PR_TITLE=$(gh pr view "$PR_NUM" --json title --jq '.title')
-          echo "title=$PR_TITLE" >> "$GITHUB_OUTPUT"
-        env:
-          GH_TOKEN: ${{ github.token }}
+          PR_TITLE=$(gh pr view "$PR_NUM" --json title --jq '.title' | tr -d '\n\r' | head -c 200)
+          EOF_MARKER="EOF_$(openssl rand -hex 8)"
+          {
+            echo "title<<$EOF_MARKER"
+            echo "$PR_TITLE"
+            echo "$EOF_MARKER"
+          } >> "$GITHUB_OUTPUT"
 
       - name: Analyze PR for documentation needs
         id: analyze
+        env:
+          GH_TOKEN: ${{ github.token }}
+          FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
+          PR_NUMBER: ${{ steps.pr.outputs.number }}
         run: |
           # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected)
           # Unset GH_TOKEN first to allow gh auth login to store credentials
           echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token)
-          
+
           OUTPUT_FILE=$(mktemp)
-          
+
           # Retry with exponential backoff for transient Factory API failures
           MAX_RETRIES=3
           for i in $(seq 1 "$MAX_RETRIES"); do
             echo "Attempt $i of $MAX_RETRIES to analyze PR..."
             if ./script/docs-suggest \
-              --pr "${{ steps.pr.outputs.number }}" \
+              --pr "$PR_NUMBER" \
               --immediate \
               --preview \
               --output "$OUTPUT_FILE" \
@@ -135,7 +147,7 @@ jobs:
             echo "Retrying in $((i * 5)) seconds..."
             sleep $((i * 5))
           done
-          
+
           # Check if we got actionable suggestions (not "no updates needed")
           if grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \
              ! grep -q "No Documentation Updates Needed" "$OUTPUT_FILE"; then
@@ -146,9 +158,6 @@ jobs:
             echo "No actionable documentation suggestions for this PR"
             cat "$OUTPUT_FILE"
           fi
-        env:
-          GH_TOKEN: ${{ github.token }}
-          FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
 
       - name: Commit suggestions to queue branch
         if: steps.analyze.outputs.has_suggestions == 'true'
@@ -156,18 +165,19 @@ jobs:
           PR_NUM: ${{ steps.pr.outputs.number }}
           PR_TITLE: ${{ steps.pr.outputs.title }}
           OUTPUT_FILE: ${{ steps.analyze.outputs.output_file }}
+          REPO: ${{ github.repository }}
         run: |
           set -euo pipefail
-          
+
           # Configure git
           git config user.name "github-actions[bot]"
           git config user.email "github-actions[bot]@users.noreply.github.com"
-          
+
           # Retry loop for handling concurrent pushes
           MAX_RETRIES=3
           for i in $(seq 1 "$MAX_RETRIES"); do
             echo "Attempt $i of $MAX_RETRIES"
-            
+
             # Fetch and checkout suggestions branch (create if doesn't exist)
             if git ls-remote --exit-code --heads origin "$SUGGESTIONS_BRANCH" > /dev/null 2>&1; then
               git fetch origin "$SUGGESTIONS_BRANCH"
@@ -176,7 +186,7 @@ jobs:
               # Create orphan branch for clean history
               git checkout --orphan "$SUGGESTIONS_BRANCH"
               git rm -rf . > /dev/null 2>&1 || true
-              
+
               # Initialize with README
               cat > README.md << 'EOF'
           # Documentation Suggestions Queue
@@ -198,34 +208,34 @@ jobs:
           3. At preview release, suggestions are collected into a docs PR
           4. After docs PR is created, this branch is reset
           EOF
-              
+
               mkdir -p suggestions
               echo '{"suggestions":[]}' > manifest.json
               git add README.md suggestions manifest.json
               git commit -m "Initialize documentation suggestions queue"
             fi
-            
+
             # Create suggestion file
             SUGGESTION_FILE="suggestions/PR-${PR_NUM}.md"
-            
+
             {
               echo "# PR #${PR_NUM}: ${PR_TITLE}"
               echo ""
               echo "_Merged: $(date -u +%Y-%m-%dT%H:%M:%SZ)_"
-              echo "_PR: https://github.com/${{ github.repository }}/pull/${PR_NUM}_"
+              echo "_PR: https://github.com/${REPO}/pull/${PR_NUM}_"
               echo ""
               cat "$OUTPUT_FILE"
             } > "$SUGGESTION_FILE"
-            
+
             # Update manifest
             MANIFEST=$(cat manifest.json)
             NEW_ENTRY="{\"pr\":${PR_NUM},\"title\":$(echo "$PR_TITLE" | jq -R .),\"file\":\"$SUGGESTION_FILE\",\"date\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
-            
+
             # Add to manifest if not already present
             if ! echo "$MANIFEST" | jq -e ".suggestions[] | select(.pr == $PR_NUM)" > /dev/null 2>&1; then
               echo "$MANIFEST" | jq ".suggestions += [$NEW_ENTRY]" > manifest.json
             fi
-            
+
             # Commit
             git add "$SUGGESTION_FILE" manifest.json
             git commit -m "docs: Add suggestions for PR #${PR_NUM}
@@ -233,7 +243,7 @@ jobs:
           ${PR_TITLE}
 
           Auto-generated documentation suggestions for review at next preview release."
-            
+
             # Try to push
             if git push origin "$SUGGESTIONS_BRANCH"; then
               echo "Successfully pushed suggestions"
@@ -250,33 +260,47 @@ jobs:
 
       - name: Summary
         if: always()
+        env:
+          HAS_SUGGESTIONS: ${{ steps.analyze.outputs.has_suggestions }}
+          PR_NUM: ${{ steps.pr.outputs.number }}
+          REPO: ${{ github.repository }}
         run: |
           {
             echo "## Documentation Suggestions"
             echo ""
-            if [ "${{ steps.analyze.outputs.has_suggestions }}" == "true" ]; then
-              echo "āœ… Suggestions queued for PR #${{ steps.pr.outputs.number }}"
+            if [ "$HAS_SUGGESTIONS" == "true" ]; then
+              echo "āœ… Suggestions queued for PR #${PR_NUM}"
               echo ""
-              echo "View pending suggestions: [docs/suggestions-pending branch](https://github.com/${{ github.repository }}/tree/${{ env.SUGGESTIONS_BRANCH }})"
+              echo "View pending suggestions: [docs/suggestions-pending branch](https://github.com/${REPO}/tree/${SUGGESTIONS_BRANCH})"
             else
               echo "No documentation updates needed for this PR."
             fi
           } >> "$GITHUB_STEP_SUMMARY"
 
-  # Job for cherry-picks to release branches - immediate output to step summary
+  # Job for cherry-picks to release branches - immediate output as PR comment
   cherry-pick-suggestions:
     runs-on: ubuntu-latest
     timeout-minutes: 10
+    permissions:
+      contents: read
+      pull-requests: write
+    concurrency:
+      group: docs-suggestions-${{ github.event.pull_request.number || inputs.pr_number || 'manual' }}
+      cancel-in-progress: true
     if: |
-      (github.event_name == 'pull_request_target' && 
-       startsWith(github.event.pull_request.base.ref, 'v0.')) ||
+      (github.event_name == 'pull_request_target' &&
+       startsWith(github.event.pull_request.base.ref, 'v0.') &&
+       contains(fromJSON('["MEMBER","OWNER"]'),
+                github.event.pull_request.author_association)) ||
       (github.event_name == 'workflow_dispatch' && inputs.mode == 'immediate')
-    
+
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
+          ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.ref || '' }}
+          persist-credentials: false
 
       - name: Install Droid CLI
         run: |
@@ -300,29 +324,41 @@ jobs:
 
       - name: Get PR number
         id: pr
+        env:
+          INPUT_PR_NUMBER: ${{ inputs.pr_number }}
+          EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
         run: |
-          if [ -n "${{ inputs.pr_number }}" ]; then
-            echo "number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
+          if [ -n "$INPUT_PR_NUMBER" ]; then
+            PR_NUM="$INPUT_PR_NUMBER"
           else
-            echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
+            PR_NUM="$EVENT_PR_NUMBER"
           fi
+          if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
+            echo "::error::Invalid PR number: $PR_NUM"
+            exit 1
+          fi
+          echo "number=$PR_NUM" >> "$GITHUB_OUTPUT"
 
       - name: Analyze PR for documentation needs
         id: analyze
+        env:
+          GH_TOKEN: ${{ github.token }}
+          FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
+          PR_NUMBER: ${{ steps.pr.outputs.number }}
         run: |
           # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected)
           # Unset GH_TOKEN first to allow gh auth login to store credentials
           echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token)
-          
+
           OUTPUT_FILE="${RUNNER_TEMP}/suggestions.md"
-          
+
           # Cherry-picks don't get preview callout
           # Retry with exponential backoff for transient Factory API failures
           MAX_RETRIES=3
           for i in $(seq 1 "$MAX_RETRIES"); do
             echo "Attempt $i of $MAX_RETRIES to analyze PR..."
             if ./script/docs-suggest \
-              --pr "${{ steps.pr.outputs.number }}" \
+              --pr "$PR_NUMBER" \
               --immediate \
               --no-preview \
               --output "$OUTPUT_FILE" \
@@ -337,7 +373,7 @@ jobs:
             echo "Retrying in $((i * 5)) seconds..."
             sleep $((i * 5))
           done
-          
+
           # Check if we got actionable suggestions
           if [ -s "$OUTPUT_FILE" ] && \
              grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \
@@ -347,48 +383,78 @@ jobs:
           else
             echo "has_suggestions=false" >> "$GITHUB_OUTPUT"
           fi
-        env:
-          GH_TOKEN: ${{ github.token }}
-          FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
 
       - name: Post suggestions as PR comment
         if: steps.analyze.outputs.has_suggestions == 'true'
-        uses: actions/github-script@v7
+        uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
         env:
           SUGGESTIONS_FILE: ${{ steps.analyze.outputs.suggestions_file }}
+          PR_NUMBER: ${{ steps.pr.outputs.number }}
         with:
           script: |
             const fs = require('fs');
-            const suggestions = fs.readFileSync(process.env.SUGGESTIONS_FILE, 'utf8');
-            
+
+            // Read suggestions from file
+            const suggestionsRaw = fs.readFileSync(process.env.SUGGESTIONS_FILE, 'utf8');
+
+            // Sanitize AI-generated content
+            let sanitized = suggestionsRaw
+              // Strip HTML tags
+              .replace(/<[^>]*>/g, '')
+              // Strip markdown links but keep display text
+              .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+              // Strip raw URLs
+              .replace(/https?:\/\/[^\s)>\]]+/g, '[link removed]')
+              // Strip protocol-relative URLs
+              .replace(/\/\/[^\s)>\]]+\.[^\s)>\]]+/g, '[link removed]')
+              // Neutralize @-mentions (preserve JSDoc-style annotations)
+              .replace(/@(?!param\b|returns?\b|throws?\b|typedef\b|type\b|see\b|example\b|since\b|deprecated\b|default\b)(\w+)/g, '`@$1`')
+              // Strip cross-repo references that could be confused with real links
+              .replace(/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+#\d+/g, '[ref removed]');
+
+            // Truncate to 20,000 characters
+            if (sanitized.length > 20000) {
+              sanitized = sanitized.substring(0, 20000) + '\n\n…(truncated)';
+            }
+
+            // Parse and validate PR number
+            const prNumber = parseInt(process.env.PR_NUMBER, 10);
+            if (isNaN(prNumber) || prNumber <= 0) {
+              core.setFailed(`Invalid PR number: ${process.env.PR_NUMBER}`);
+              return;
+            }
+
             const body = `## šŸ“š Documentation Suggestions
 
             This cherry-pick contains changes that may need documentation updates.
 
-            ${suggestions}
+            ${sanitized}
 
             ---
+            > **Note:** This comment was generated automatically by an AI model analyzing
+            > code changes. Suggestions may contain inaccuracies — please verify before acting.
+
             <details>
             <summary>About this comment</summary>
 
             This comment was generated automatically by analyzing code changes in this cherry-pick.
-            Cherry-picks typically don't need new documentation since the feature was already 
+            Cherry-picks typically don't need new documentation since the feature was already
             documented when merged to main, but please verify.
 
             </details>`;
-            
+
             // Find existing comment to update (avoid spam)
             const { data: comments } = await github.rest.issues.listComments({
               owner: context.repo.owner,
               repo: context.repo.repo,
-              issue_number: ${{ steps.pr.outputs.number }}
+              issue_number: prNumber
             });
-            
-            const botComment = comments.find(c => 
-              c.user.type === 'Bot' && 
+
+            const botComment = comments.find(c =>
+              c.user.type === 'Bot' &&
               c.body.includes('Documentation Suggestions')
             );
-            
+
             if (botComment) {
               await github.rest.issues.updateComment({
                 owner: context.repo.owner,
@@ -400,21 +466,22 @@ jobs:
               await github.rest.issues.createComment({
                 owner: context.repo.owner,
                 repo: context.repo.repo,
-                issue_number: ${{ steps.pr.outputs.number }},
+                issue_number: prNumber,
                 body: body
               });
             }
 
       - name: Summary
         if: always()
+        env:
+          HAS_SUGGESTIONS: ${{ steps.analyze.outputs.has_suggestions }}
+          PR_NUM: ${{ steps.pr.outputs.number }}
         run: |
           {
             echo "## šŸ“š Documentation Suggestions (Cherry-pick)"
             echo ""
-            if [ "${{ steps.analyze.outputs.has_suggestions }}" == "true" ]; then
-              echo "Suggestions posted as PR comment."
-              echo ""
-              cat "${{ steps.analyze.outputs.suggestions_file }}"
+            if [ "$HAS_SUGGESTIONS" == "true" ]; then
+              echo "Suggestions posted as PR comment on #${PR_NUM}."
             else
               echo "No documentation suggestions for this cherry-pick."
             fi