1name: Documentation Suggestions
2
3# Stable release callout stripping plan (not wired yet):
4# 1. Add a separate stable-only workflow trigger on `release.published`
5# with `github.event.release.prerelease == false`.
6# 2. In that workflow, run `script/docs-strip-preview-callouts` on `main`.
7# 3. Open a PR with stripped preview callouts for human review.
8# 4. Fail loudly on script errors or when no callout changes are produced.
9# 5. Keep this workflow focused on suggestions only until that stable workflow is added.
10
11on:
12 # Run when PRs are merged to main
13 pull_request:
14 types: [closed]
15 branches: [main]
16 paths:
17 - 'crates/**/*.rs'
18 - '!crates/**/*_test.rs'
19 - '!crates/**/tests/**'
20
21 # Run on cherry-picks to release branches
22 pull_request_target:
23 types: [opened, synchronize]
24 branches:
25 - 'v0.*'
26 paths:
27 - 'crates/**/*.rs'
28
29 # Manual trigger for testing
30 workflow_dispatch:
31 inputs:
32 pr_number:
33 description: 'PR number to analyze'
34 required: true
35 type: string
36 mode:
37 description: 'Output mode'
38 required: true
39 type: choice
40 options:
41 - batch
42 - immediate
43 default: batch
44
45permissions:
46 contents: write
47 pull-requests: write
48
49env:
50 DROID_MODEL: claude-sonnet-4-5-20250929
51 SUGGESTIONS_BRANCH: docs/suggestions-pending
52
53jobs:
54 # Job for PRs merged to main - batch suggestions to branch
55 batch-suggestions:
56 runs-on: ubuntu-latest
57 timeout-minutes: 10
58 if: |
59 (github.event_name == 'pull_request' &&
60 github.event.pull_request.merged == true &&
61 github.event.pull_request.base.ref == 'main') ||
62 (github.event_name == 'workflow_dispatch' && inputs.mode == 'batch')
63
64 steps:
65 - name: Checkout repository
66 uses: actions/checkout@v4
67 with:
68 fetch-depth: 0
69 token: ${{ secrets.GITHUB_TOKEN }}
70
71 - name: Install Droid CLI
72 run: |
73 # Retry with exponential backoff for transient network/auth issues
74 MAX_RETRIES=3
75 for i in $(seq 1 "$MAX_RETRIES"); do
76 echo "Attempt $i of $MAX_RETRIES to install Droid CLI..."
77 if curl -fsSL https://app.factory.ai/cli | sh; then
78 echo "Droid CLI installed successfully"
79 break
80 fi
81 if [ "$i" -eq "$MAX_RETRIES" ]; then
82 echo "Failed to install Droid CLI after $MAX_RETRIES attempts"
83 exit 1
84 fi
85 sleep $((i * 5))
86 done
87 echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
88 env:
89 FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
90
91 - name: Get PR info
92 id: pr
93 run: |
94 if [ -n "${{ inputs.pr_number }}" ]; then
95 PR_NUM="${{ inputs.pr_number }}"
96 else
97 PR_NUM="${{ github.event.pull_request.number }}"
98 fi
99 echo "number=$PR_NUM" >> "$GITHUB_OUTPUT"
100
101 # Get PR title
102 PR_TITLE=$(gh pr view "$PR_NUM" --json title --jq '.title')
103 echo "title=$PR_TITLE" >> "$GITHUB_OUTPUT"
104 env:
105 GH_TOKEN: ${{ github.token }}
106
107 - name: Analyze PR for documentation needs
108 id: analyze
109 run: |
110 # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected)
111 # Unset GH_TOKEN first to allow gh auth login to store credentials
112 echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token)
113
114 OUTPUT_FILE=$(mktemp)
115
116 # Retry with exponential backoff for transient Factory API failures
117 MAX_RETRIES=3
118 for i in $(seq 1 "$MAX_RETRIES"); do
119 echo "Attempt $i of $MAX_RETRIES to analyze PR..."
120 if ./script/docs-suggest \
121 --pr "${{ steps.pr.outputs.number }}" \
122 --immediate \
123 --preview \
124 --output "$OUTPUT_FILE" \
125 --verbose; then
126 echo "Analysis completed successfully"
127 break
128 fi
129 if [ "$i" -eq "$MAX_RETRIES" ]; then
130 echo "Analysis failed after $MAX_RETRIES attempts"
131 exit 1
132 fi
133 echo "Retrying in $((i * 5)) seconds..."
134 sleep $((i * 5))
135 done
136
137 # Check if we got actionable suggestions (not "no updates needed")
138 if grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \
139 ! grep -q "No Documentation Updates Needed" "$OUTPUT_FILE"; then
140 echo "has_suggestions=true" >> "$GITHUB_OUTPUT"
141 echo "output_file=$OUTPUT_FILE" >> "$GITHUB_OUTPUT"
142 else
143 echo "has_suggestions=false" >> "$GITHUB_OUTPUT"
144 echo "No actionable documentation suggestions for this PR"
145 cat "$OUTPUT_FILE"
146 fi
147 env:
148 GH_TOKEN: ${{ github.token }}
149 FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
150
151 - name: Commit suggestions to queue branch
152 if: steps.analyze.outputs.has_suggestions == 'true'
153 env:
154 PR_NUM: ${{ steps.pr.outputs.number }}
155 PR_TITLE: ${{ steps.pr.outputs.title }}
156 OUTPUT_FILE: ${{ steps.analyze.outputs.output_file }}
157 run: |
158 set -euo pipefail
159
160 # Configure git
161 git config user.name "github-actions[bot]"
162 git config user.email "github-actions[bot]@users.noreply.github.com"
163
164 # Retry loop for handling concurrent pushes
165 MAX_RETRIES=3
166 for i in $(seq 1 "$MAX_RETRIES"); do
167 echo "Attempt $i of $MAX_RETRIES"
168
169 # Fetch and checkout suggestions branch (create if doesn't exist)
170 if git ls-remote --exit-code --heads origin "$SUGGESTIONS_BRANCH" > /dev/null 2>&1; then
171 git fetch origin "$SUGGESTIONS_BRANCH"
172 git checkout -B "$SUGGESTIONS_BRANCH" "origin/$SUGGESTIONS_BRANCH"
173 else
174 # Create orphan branch for clean history
175 git checkout --orphan "$SUGGESTIONS_BRANCH"
176 git rm -rf . > /dev/null 2>&1 || true
177
178 # Initialize with README
179 cat > README.md << 'EOF'
180 # Documentation Suggestions Queue
181
182 This branch contains batched documentation suggestions for the next Preview release.
183
184 Each file represents suggestions from a merged PR. At preview branch cut time,
185 run `script/docs-suggest-publish` to create a documentation PR from these suggestions.
186
187 ## Structure
188
189 - `suggestions/PR-XXXXX.md` - Suggestions for PR #XXXXX
190 - `manifest.json` - Index of all pending suggestions
191
192 ## Workflow
193
194 1. PRs merged to main trigger documentation analysis
195 2. Suggestions are committed here as individual files
196 3. At preview release, suggestions are collected into a docs PR
197 4. After docs PR is created, this branch is reset
198 EOF
199
200 mkdir -p suggestions
201 echo '{"suggestions":[]}' > manifest.json
202 git add README.md suggestions manifest.json
203 git commit -m "Initialize documentation suggestions queue"
204 fi
205
206 # Create suggestion file
207 SUGGESTION_FILE="suggestions/PR-${PR_NUM}.md"
208
209 {
210 echo "# PR #${PR_NUM}: ${PR_TITLE}"
211 echo ""
212 echo "_Merged: $(date -u +%Y-%m-%dT%H:%M:%SZ)_"
213 echo "_PR: https://github.com/${{ github.repository }}/pull/${PR_NUM}_"
214 echo ""
215 cat "$OUTPUT_FILE"
216 } > "$SUGGESTION_FILE"
217
218 # Update manifest
219 MANIFEST=$(cat manifest.json)
220 NEW_ENTRY="{\"pr\":${PR_NUM},\"title\":$(echo "$PR_TITLE" | jq -R .),\"file\":\"$SUGGESTION_FILE\",\"date\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
221
222 # Add to manifest if not already present
223 if ! echo "$MANIFEST" | jq -e ".suggestions[] | select(.pr == $PR_NUM)" > /dev/null 2>&1; then
224 echo "$MANIFEST" | jq ".suggestions += [$NEW_ENTRY]" > manifest.json
225 fi
226
227 # Commit
228 git add "$SUGGESTION_FILE" manifest.json
229 git commit -m "docs: Add suggestions for PR #${PR_NUM}
230
231 ${PR_TITLE}
232
233 Auto-generated documentation suggestions for review at next preview release."
234
235 # Try to push
236 if git push origin "$SUGGESTIONS_BRANCH"; then
237 echo "Successfully pushed suggestions"
238 break
239 else
240 echo "Push failed, retrying..."
241 if [ "$i" -eq "$MAX_RETRIES" ]; then
242 echo "Failed after $MAX_RETRIES attempts"
243 exit 1
244 fi
245 sleep $((i * 2))
246 fi
247 done
248
249 - name: Summary
250 if: always()
251 run: |
252 {
253 echo "## Documentation Suggestions"
254 echo ""
255 if [ "${{ steps.analyze.outputs.has_suggestions }}" == "true" ]; then
256 echo "✅ Suggestions queued for PR #${{ steps.pr.outputs.number }}"
257 echo ""
258 echo "View pending suggestions: [docs/suggestions-pending branch](https://github.com/${{ github.repository }}/tree/${{ env.SUGGESTIONS_BRANCH }})"
259 else
260 echo "No documentation updates needed for this PR."
261 fi
262 } >> "$GITHUB_STEP_SUMMARY"
263
264 # Job for cherry-picks to release branches - immediate output to step summary
265 cherry-pick-suggestions:
266 runs-on: ubuntu-latest
267 timeout-minutes: 10
268 if: |
269 (github.event_name == 'pull_request_target' &&
270 startsWith(github.event.pull_request.base.ref, 'v0.')) ||
271 (github.event_name == 'workflow_dispatch' && inputs.mode == 'immediate')
272
273 steps:
274 - name: Checkout repository
275 uses: actions/checkout@v4
276 with:
277 fetch-depth: 0
278
279 - name: Install Droid CLI
280 run: |
281 # Retry with exponential backoff for transient network/auth issues
282 MAX_RETRIES=3
283 for i in $(seq 1 "$MAX_RETRIES"); do
284 echo "Attempt $i of $MAX_RETRIES to install Droid CLI..."
285 if curl -fsSL https://app.factory.ai/cli | sh; then
286 echo "Droid CLI installed successfully"
287 break
288 fi
289 if [ "$i" -eq "$MAX_RETRIES" ]; then
290 echo "Failed to install Droid CLI after $MAX_RETRIES attempts"
291 exit 1
292 fi
293 sleep $((i * 5))
294 done
295 echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
296 env:
297 FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
298
299 - name: Get PR number
300 id: pr
301 run: |
302 if [ -n "${{ inputs.pr_number }}" ]; then
303 echo "number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
304 else
305 echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
306 fi
307
308 - name: Analyze PR for documentation needs
309 id: analyze
310 run: |
311 # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected)
312 # Unset GH_TOKEN first to allow gh auth login to store credentials
313 echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token)
314
315 OUTPUT_FILE="${RUNNER_TEMP}/suggestions.md"
316
317 # Cherry-picks don't get preview callout
318 # Retry with exponential backoff for transient Factory API failures
319 MAX_RETRIES=3
320 for i in $(seq 1 "$MAX_RETRIES"); do
321 echo "Attempt $i of $MAX_RETRIES to analyze PR..."
322 if ./script/docs-suggest \
323 --pr "${{ steps.pr.outputs.number }}" \
324 --immediate \
325 --no-preview \
326 --output "$OUTPUT_FILE" \
327 --verbose; then
328 echo "Analysis completed successfully"
329 break
330 fi
331 if [ "$i" -eq "$MAX_RETRIES" ]; then
332 echo "Analysis failed after $MAX_RETRIES attempts"
333 exit 1
334 fi
335 echo "Retrying in $((i * 5)) seconds..."
336 sleep $((i * 5))
337 done
338
339 # Check if we got actionable suggestions
340 if [ -s "$OUTPUT_FILE" ] && \
341 grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \
342 ! grep -q "No Documentation Updates Needed" "$OUTPUT_FILE"; then
343 echo "has_suggestions=true" >> "$GITHUB_OUTPUT"
344 echo "suggestions_file=$OUTPUT_FILE" >> "$GITHUB_OUTPUT"
345 else
346 echo "has_suggestions=false" >> "$GITHUB_OUTPUT"
347 fi
348 env:
349 GH_TOKEN: ${{ github.token }}
350 FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
351
352 - name: Post suggestions as PR comment
353 if: steps.analyze.outputs.has_suggestions == 'true'
354 uses: actions/github-script@v7
355 env:
356 SUGGESTIONS_FILE: ${{ steps.analyze.outputs.suggestions_file }}
357 with:
358 script: |
359 const fs = require('fs');
360 const suggestions = fs.readFileSync(process.env.SUGGESTIONS_FILE, 'utf8');
361
362 const body = `## 📚 Documentation Suggestions
363
364 This cherry-pick contains changes that may need documentation updates.
365
366 ${suggestions}
367
368 ---
369 <details>
370 <summary>About this comment</summary>
371
372 This comment was generated automatically by analyzing code changes in this cherry-pick.
373 Cherry-picks typically don't need new documentation since the feature was already
374 documented when merged to main, but please verify.
375
376 </details>`;
377
378 // Find existing comment to update (avoid spam)
379 const { data: comments } = await github.rest.issues.listComments({
380 owner: context.repo.owner,
381 repo: context.repo.repo,
382 issue_number: ${{ steps.pr.outputs.number }}
383 });
384
385 const botComment = comments.find(c =>
386 c.user.type === 'Bot' &&
387 c.body.includes('Documentation Suggestions')
388 );
389
390 if (botComment) {
391 await github.rest.issues.updateComment({
392 owner: context.repo.owner,
393 repo: context.repo.repo,
394 comment_id: botComment.id,
395 body: body
396 });
397 } else {
398 await github.rest.issues.createComment({
399 owner: context.repo.owner,
400 repo: context.repo.repo,
401 issue_number: ${{ steps.pr.outputs.number }},
402 body: body
403 });
404 }
405
406 - name: Summary
407 if: always()
408 run: |
409 {
410 echo "## 📚 Documentation Suggestions (Cherry-pick)"
411 echo ""
412 if [ "${{ steps.analyze.outputs.has_suggestions }}" == "true" ]; then
413 echo "Suggestions posted as PR comment."
414 echo ""
415 cat "${{ steps.analyze.outputs.suggestions_file }}"
416 else
417 echo "No documentation suggestions for this cherry-pick."
418 fi
419 } >> "$GITHUB_STEP_SUMMARY"