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