Revert Zendesk portal → support@pulumi.com link swap (#20868) #2110
Workflow file for this run
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: Pre-merge Review (triage) | |
| # Triage runs on non-draft PR open and on the draft → ready transition. | |
| # Drafts are the author's workbench — we don't apply labels until they | |
| # ask for feedback. `ready_for_review` only fires on draft → ready, so | |
| # `opened` is still needed for PRs that skip the draft phase. | |
| # It does NOT run on every push (synchronize) — that fires the | |
| # review-state mark-stale step in claude-code-review.yml instead, which | |
| # transitions terminal review-state labels (outstanding-issues / no-blockers) | |
| # to review:stale. It does NOT run on reopen — reopening is usually | |
| # administrative; the explicit `@claude #new-review` path is the way to | |
| # request a fresh review on an existing PR. | |
| on: | |
| pull_request: | |
| types: [opened, ready_for_review] | |
| jobs: | |
| triage: | |
| # Skip drafts (the `opened` event fires for both draft and non-draft) | |
| # and skip automated PRs from pulumi-bot and dependabot — they have | |
| # their own labeling pipelines (label-dependabot.yml) and don't | |
| # carry secrets. `ready_for_review` always has `draft: false`, so | |
| # the draft guard is a no-op for that event. | |
| # | |
| # Exception: content-review/* PRs are bot-authored but are first-class | |
| # automated docs fixes we DO want triaged and reviewed like any human PR | |
| # (the worker opens them ready when the re-lint passes — falling back to | |
| # draft only on lint failure — so triage fires on the non-draft `opened` | |
| # event). They flow through the normal triage → review chain instead of | |
| # being force-dispatched. | |
| if: >- | |
| !github.event.pull_request.draft | |
| && github.event.pull_request.user.login != 'dependabot[bot]' | |
| && (github.event.pull_request.user.login != 'pulumi-bot' | |
| || startsWith(github.event.pull_request.head.ref, 'content-review/')) | |
| concurrency: | |
| group: claude-triage-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| id-token: write | |
| steps: | |
| # Surface "triage is running" as visible label state from workflow | |
| # start — fires ~3s after PR open, before checkout + mise install | |
| # (~15-50s combined). Uses `actions/github-script` so the swap runs | |
| # pre-checkout (set-review-label.sh isn't on disk yet) without | |
| # shell-escape concerns; the cleanup step at end-of-job uses | |
| # set-review-label.sh --clear (checkout has completed by then). | |
| # STATE_LABELS list mirrors the script's; extend both if the set | |
| # ever grows. | |
| - name: Set review:triaging label | |
| uses: actions/github-script@v9 | |
| continue-on-error: true | |
| with: | |
| script: | | |
| const pr = context.payload.pull_request.number; | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const stateLabels = [ | |
| 'review:in-progress', | |
| 'review:outstanding-issues', | |
| 'review:no-blockers', | |
| 'review:stale', | |
| 'review:error', | |
| ]; | |
| const { data: existing } = await github.rest.issues.listLabelsOnIssue({ | |
| owner, repo, issue_number: pr, | |
| }); | |
| const names = new Set(existing.map(l => l.name)); | |
| await Promise.all( | |
| stateLabels | |
| .filter(s => names.has(s)) | |
| .map(name => | |
| github.rest.issues.removeLabel({ | |
| owner, repo, issue_number: pr, name, | |
| }).catch(() => {}) | |
| ) | |
| ); | |
| if (!names.has('review:triaging')) { | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number: pr, labels: ['review:triaging'], | |
| }); | |
| } | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 1 | |
| # Install mise-managed tools (Vale, etc.) so the prose-check pass | |
| # below can run vale alongside the Haiku spelling/grammar call. | |
| - name: Install mise-managed tools | |
| uses: jdx/mise-action@v4 | |
| with: | |
| cache: true | |
| - name: Check repository write access | |
| id: check-access | |
| run: | | |
| # Use the actual repository the workflow is running in, not a hardcoded | |
| # upstream name. The GITHUB_TOKEN is only scoped to this repo, so a | |
| # hardcoded owner/repo would always return "none" in fork-based testing | |
| # and in repo transfers. | |
| REPO_FULL="${{ github.repository }}" | |
| AUTHOR="${{ github.event.pull_request.user.login }}" | |
| # GitHub App bots are not collaborators, so the permission API | |
| # below returns "none" for them. Trusted bots that open PRs on this | |
| # repo are whitelisted by name instead. `workprentice` is Joe Duffy's | |
| # docs-automation identity (the Docs Groundskeeper agent) and is | |
| # trusted like an internal author. The author string differs by source: | |
| # this workflow reads the REST webhook `user.login` (`workprentice[bot]`) | |
| # while claude-code-review.yml reads `gh pr view` (`app/workprentice`), | |
| # so both forms are listed in every file. Keep this list in sync with | |
| # the matching checks in claude-code-review.yml and claude-update.yml. | |
| if [[ "$AUTHOR" == "github-copilot[bot]" || "$AUTHOR" == "eon-pulumi-agent[bot]" \ | |
| || "$AUTHOR" == "workprentice[bot]" || "$AUTHOR" == "app/workprentice" ]]; then | |
| echo "has_write_access=true" >> $GITHUB_OUTPUT | |
| echo "✓ Bot $AUTHOR is whitelisted for triage" | |
| exit 0 | |
| fi | |
| PERMISSION=$(curl -s \ | |
| -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "https://api.github.com/repos/$REPO_FULL/collaborators/$AUTHOR/permission" \ | |
| | jq -r '.permission // "none"') | |
| if [[ "$PERMISSION" == "admin" || "$PERMISSION" == "write" ]]; then | |
| echo "has_write_access=true" >> $GITHUB_OUTPUT | |
| echo "✓ User $AUTHOR has $PERMISSION access to $REPO_FULL" | |
| else | |
| echo "has_write_access=false" >> $GITHUB_OUTPUT | |
| echo "✗ User $AUTHOR has $PERMISSION access to $REPO_FULL (insufficient permissions)" | |
| fi | |
| # Triage is a narrow classification task: read the PR, decide which | |
| # domains it touches, and emit a label delta. Almost all of that is | |
| # deterministic path matching and grep-on-diff, so it runs in shell | |
| # via triage-classify.py — no API call needed. | |
| # | |
| # The model is invoked ONLY when the shell classifies the PR as | |
| # trivial or frontmatter-only — the two cases that short-circuit the | |
| # full review and therefore need a sanity-check prose pass to guard | |
| # against rubber-stamping. Most PRs skip the model entirely. | |
| # | |
| # When invoked, the model gets a focused prose-check prompt and a | |
| # diff slice (capped at 50KB — trivial/frontmatter-only PRs are | |
| # small by definition). Direct curl to the Anthropic API keeps | |
| # cold-start latency near zero. | |
| # | |
| # continue-on-error keeps the workflow green on transient API or | |
| # gh failures. A missed triage is self-healing at the next | |
| # ready-transition, and claude-code-review.yml has a missing-label | |
| # fallback so initial review still runs correctly. | |
| - name: Run triage classification | |
| if: steps.check-access.outputs.has_write_access == 'true' | |
| continue-on-error: true | |
| env: | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| PR: ${{ github.event.pull_request.number }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| # 1. Gather PR state. | |
| PR_DATA=$(gh pr view "$PR" --repo "$REPO" \ | |
| --json title,body,author,files,labels,additions,deletions,commits,isDraft) | |
| # 100KB diff cap. The | |
| # classifier doesn't need every byte to detect frontmatter / | |
| # link / code-block / version-claim signals. | |
| DIFF=$(gh pr diff "$PR" --repo "$REPO" | head -c 100000 || true) | |
| # 2. Deterministic classification. No API call. | |
| PR_DATA_FILE=$(mktemp) | |
| trap 'rm -f "$PR_DATA_FILE"' EXIT | |
| printf '%s' "$PR_DATA" > "$PR_DATA_FILE" | |
| CLASS=$(printf '%s' "$DIFF" \ | |
| | python3 .claude/commands/docs-review/scripts/triage-classify.py "$PR_DATA_FILE" 2>&1) \ | |
| || CLASS="" | |
| if [[ -z "$CLASS" ]] || ! echo "$CLASS" | jq -e . >/dev/null 2>&1; then | |
| echo "triage: pr=$PR error=classifier_failed" | |
| echo "$CLASS" | head -c 2000 >&2 | |
| exit 0 | |
| fi | |
| DOMAINS_JSON=$(echo "$CLASS" | jq -r '.target_domains // [] | .[]') | |
| MIXED=$(echo "$CLASS" | jq -r '.mixed // false') | |
| TRIVIAL=$(echo "$CLASS" | jq -r '.trivial // false') | |
| FRONTMATTER_ONLY=$(echo "$CLASS" | jq -r '.frontmatter_only // false') | |
| OVERSIZED=$(echo "$CLASS" | jq -r '.oversized // false') | |
| PROSE_CHECK_NEEDED=$(echo "$CLASS" | jq -r '.prose_check_needed // false') | |
| # 3. Conditional prose check (model call only for trivial / | |
| # frontmatter-only PRs). | |
| PROSE_CONCERNS="" | |
| if [[ "$PROSE_CHECK_NEEDED" == "true" ]]; then | |
| # 50KB diff cap — trivial/frontmatter-only PRs are tiny. | |
| PROSE_DIFF=$(printf '%s' "$DIFF" | head -c 50000) | |
| PROSE_RULES=$(cat .claude/commands/docs-review/triage-prose.md \ | |
| .claude/commands/docs-review/references/spelling-grammar.md) | |
| REQUEST=$(jq -n \ | |
| --arg rules "$PROSE_RULES" \ | |
| --arg diff "$PROSE_DIFF" \ | |
| '{ | |
| model: "claude-haiku-4-5-20251001", | |
| max_tokens: 512, | |
| messages: [{ | |
| role: "user", | |
| content: ("Apply the rules below to the diff that follows.\n\n=== RULES ===\n\n" + $rules + "\n\n=== DIFF (truncated to 50000 bytes) ===\n\n" + $diff) | |
| }] | |
| }') | |
| RESPONSE=$(curl -sS https://api.anthropic.com/v1/messages \ | |
| -H "x-api-key: $ANTHROPIC_API_KEY" \ | |
| -H "anthropic-version: 2023-06-01" \ | |
| -H "content-type: application/json" \ | |
| -d "$REQUEST" || echo '{"error":"curl_failed"}') | |
| TEXT=$(echo "$RESPONSE" | jq -r '.content[0].text // empty') | |
| if [[ -n "$TEXT" ]]; then | |
| PROSE_JSON=$(echo "$TEXT" \ | |
| | sed -E 's/^[[:space:]]*```(json)?[[:space:]]*//' \ | |
| | sed -E 's/[[:space:]]*```[[:space:]]*$//' \ | |
| | tr -d '\r') | |
| if echo "$PROSE_JSON" | jq -e . >/dev/null 2>&1; then | |
| PROSE_CONCERNS=$(echo "$PROSE_JSON" | jq -r '.prose_concerns // [] | .[]') | |
| fi | |
| fi | |
| fi | |
| # 3b. Vale style check — runs alongside the Haiku call (different | |
| # coverage). Same gate (PROSE_CHECK_NEEDED) because trivial / | |
| # frontmatter-only PRs skip the full review and therefore skip | |
| # the Vale step in claude-code-review.yml. Findings are filtered | |
| # to PR-added lines so we don't surface pre-existing prose. | |
| VALE_CONCERNS="" | |
| VALE_BLOCKERS="" | |
| if [[ "$PROSE_CHECK_NEEDED" == "true" ]]; then | |
| VALE_FILES=$(gh pr diff "$PR" --repo "$REPO" --name-only \ | |
| | grep -E '^content/(docs|blog|what-is|tutorials)/.*\.md$' || true) | |
| if [[ -n "$VALE_FILES" ]]; then | |
| vale --no-exit --output=JSON $VALE_FILES > .vale-raw.json 2>/dev/null \ | |
| || echo '{}' > .vale-raw.json | |
| python3 .claude/commands/docs-review/scripts/vale-findings-filter.py \ | |
| --pr "$PR" --in .vale-raw.json --out .vale-findings.json 2>/dev/null \ | |
| || echo '[]' > .vale-findings.json | |
| # Blocker-tier findings (the blocker: allowlist in | |
| # vale-deterministic-fixes.yaml) surface with a distinct | |
| # [style-blocker] prefix so trivial-PR authors see the correctness | |
| # errors first; the comment itself stays advisory on this lane. | |
| VALE_BLOCKERS=$(jq -r '.[] | select(.blocker) | "\(.file):\(.line) — \(.category): \(.message)"' .vale-findings.json) | |
| VALE_CONCERNS=$(jq -r '.[] | select(.blocker | not) | "\(.file):\(.line) — \(.category): \(.message)"' .vale-findings.json) | |
| fi | |
| fi | |
| # 4. Build TARGET label set. | |
| declare -A TARGET | |
| for d in $DOMAINS_JSON; do | |
| TARGET[$d]=1 | |
| done | |
| [[ "$MIXED" == "true" ]] && TARGET["domain:mixed"]=1 | |
| if [[ "$TRIVIAL" == "true" ]]; then | |
| TARGET["review:trivial"]=1 | |
| elif [[ "$FRONTMATTER_ONLY" == "true" ]]; then | |
| TARGET["review:frontmatter-only"]=1 | |
| fi | |
| [[ "$OVERSIZED" == "true" ]] && TARGET["review:oversized"]=1 | |
| # Prose concerns flag — applies to either trivial or | |
| # frontmatter-only when EITHER the Haiku spelling/grammar check | |
| # OR the Vale style check turned up issues. | |
| if [[ "$PROSE_CHECK_NEEDED" == "true" && ( -n "$PROSE_CONCERNS" || -n "$VALE_CONCERNS" || -n "$VALE_BLOCKERS" ) ]]; then | |
| TARGET["review:prose-flagged"]=1 | |
| fi | |
| # 5. Current triage-managed labels (exclude state labels — | |
| # review:triaging is owned by the wrapping set/clear steps in | |
| # this workflow; the others are owned by claude-code-review.yml / | |
| # claude-update.yml). | |
| # | |
| # review:oversized is add-only here, never reconciled away: the | |
| # review workflow's timeout message tells maintainers to apply it | |
| # by hand to stop re-attempts on a PR that times out *under* the | |
| # size thresholds, so a hand-applied label must survive triage | |
| # re-runs (excluded from EXISTING → never lands in REMOVE_LIST; | |
| # re-adding an already-present label is a no-op). The cost is | |
| # that a PR split down below the thresholds keeps the label until | |
| # a human removes it — the right default, since only a human | |
| # knows whether the label was theirs. | |
| declare -A EXISTING | |
| while IFS= read -r lbl; do | |
| case "$lbl" in | |
| review:triaging|review:in-progress|review:outstanding-issues|review:no-blockers|review:stale|review:error|review:oversized|needs-author-response) | |
| continue ;; | |
| domain:*|review:trivial|review:frontmatter-only|review:prose-flagged) | |
| EXISTING["$lbl"]=1 ;; | |
| esac | |
| done < <(echo "$PR_DATA" | jq -r '.labels[].name') | |
| # 6. Compute ADD / REMOVE. | |
| ADD_LIST=() | |
| for t in "${!TARGET[@]}"; do | |
| [[ -z "${EXISTING[$t]:-}" ]] && ADD_LIST+=("$t") | |
| done | |
| REMOVE_LIST=() | |
| for e in "${!EXISTING[@]}"; do | |
| [[ -z "${TARGET[$e]:-}" ]] && REMOVE_LIST+=("$e") | |
| done | |
| # 7. Apply the delta. Single gh pr edit call when non-empty. | |
| ARGS=() | |
| if (( ${#ADD_LIST[@]} > 0 )); then | |
| ARGS+=(--add-label "$(IFS=,; echo "${ADD_LIST[*]}")") | |
| fi | |
| if (( ${#REMOVE_LIST[@]} > 0 )); then | |
| ARGS+=(--remove-label "$(IFS=,; echo "${REMOVE_LIST[*]}")") | |
| fi | |
| if (( ${#ARGS[@]} > 0 )); then | |
| gh pr edit "$PR" --repo "$REPO" "${ARGS[@]}" || true | |
| fi | |
| # 8. Prose-check advisory comment. | |
| # Always delete any prior TRIAGE_PROSE comment first so re-triage | |
| # cleans up (e.g., a re-classification that demotes the PR from | |
| # trivial to non-trivial must drop the stale prose comment). | |
| # Then post fresh when prose_check_needed AND concerns are non-empty. | |
| gh api --paginate "repos/$REPO/issues/$PR/comments" \ | |
| --jq '.[] | select(.body | startswith("<!-- TRIAGE_PROSE -->")) | .id' \ | |
| | while read -r cid; do | |
| [[ -n "$cid" ]] && gh api -X DELETE "repos/$REPO/issues/comments/$cid" >/dev/null 2>&1 || true | |
| done | |
| if [[ "$PROSE_CHECK_NEEDED" == "true" && ( -n "$PROSE_CONCERNS" || -n "$VALE_CONCERNS" || -n "$VALE_BLOCKERS" ) ]]; then | |
| if [[ "$TRIVIAL" == "true" ]]; then | |
| SHORTCIRCUIT_LABEL="review:trivial" | |
| else | |
| SHORTCIRCUIT_LABEL="review:frontmatter-only" | |
| fi | |
| BULLETS="" | |
| if [[ -n "$VALE_BLOCKERS" ]]; then | |
| BULLETS+=$(echo "$VALE_BLOCKERS" | sed 's/^/- [style-blocker] /') | |
| fi | |
| if [[ -n "$PROSE_CONCERNS" ]]; then | |
| [[ -n "$BULLETS" ]] && BULLETS+=$'\n' | |
| BULLETS+=$(echo "$PROSE_CONCERNS" | sed 's/^/- [spelling] /') | |
| fi | |
| if [[ -n "$VALE_CONCERNS" ]]; then | |
| [[ -n "$BULLETS" ]] && BULLETS+=$'\n' | |
| BULLETS+=$(echo "$VALE_CONCERNS" | sed 's/^/- [style] /') | |
| fi | |
| BODY=$(cat <<EOF | |
| <!-- TRIAGE_PROSE --> | |
| 🔍 **Triage prose check** — possible issues in the diff. Full review is skipped (\`$SHORTCIRCUIT_LABEL\`); please double-check before merging. | |
| $BULLETS | |
| _This is a simplified spelling/grammar/style check in lieu of a full review. \`[style-blocker]\` entries come from the correctness tier (wrong or deprecated product names, banned terms, misspellings, agreement) and would block on a full review — please fix those. Reject \`[spelling]\` / \`[style]\` false positives at your discretion._ | |
| EOF | |
| ) | |
| gh pr comment "$PR" --repo "$REPO" --body "$BODY" || true | |
| fi | |
| # 8b. Oversized advisory comment. Same delete-and-repost semantics | |
| # as TRIAGE_PROSE: always clear any prior copy first (so a PR that | |
| # shrinks below the threshold on re-push loses the stale notice), | |
| # then post fresh while the PR classifies as oversized. | |
| gh api --paginate "repos/$REPO/issues/$PR/comments" \ | |
| --jq '.[] | select(.body | startswith("<!-- TRIAGE_OVERSIZED -->")) | .id' \ | |
| | while read -r cid; do | |
| [[ -n "$cid" ]] && gh api -X DELETE "repos/$REPO/issues/comments/$cid" >/dev/null 2>&1 || true | |
| done | |
| if [[ "$OVERSIZED" == "true" ]]; then | |
| PR_ADDITIONS=$(echo "$PR_DATA" | jq -r '.additions') | |
| PR_DELETIONS=$(echo "$PR_DATA" | jq -r '.deletions') | |
| PR_FILES=$(echo "$PR_DATA" | jq -r '.files | length') | |
| BODY=$(cat <<EOF | |
| <!-- TRIAGE_OVERSIZED --> | |
| 📦 **Oversized PR** — this diff (+$PR_ADDITIONS/−$PR_DELETIONS across $PR_FILES files) exceeds the automated review budget, so the Claude review is skipped (\`review:oversized\`). | |
| A diff this size is usually mostly generated output, which an automated line-review can't finish (and wouldn't add value to). What works better: | |
| - Split the hand-written source (scripts, workflows, layouts, templates) into its own PR — that PR gets a normal full review. | |
| - Have a human spot-check a sample of the generated output here. | |
| \`@claude #new-review\` still force-runs a full review, but on a diff this size it will likely hit the job timeout. | |
| EOF | |
| ) | |
| gh pr comment "$PR" --repo "$REPO" --body "$BODY" || true | |
| fi | |
| # 9. Summary line for the workflow log. | |
| DOMAINS_CSV=$(echo "$DOMAINS_JSON" | paste -sd, -) | |
| ADDED_CSV="${ADD_LIST[*]:-}"; ADDED_CSV="${ADDED_CSV// /,}" | |
| REMOVED_CSV="${REMOVE_LIST[*]:-}"; REMOVED_CSV="${REMOVED_CSV// /,}" | |
| PROSE_COUNT=$(echo "$PROSE_CONCERNS" | grep -c . || true) | |
| VALE_COUNT=$(echo "$VALE_CONCERNS" | grep -c . || true) | |
| echo "triage: pr=$PR domains=${DOMAINS_CSV:-none} trivial=$TRIVIAL frontmatter-only=$FRONTMATTER_ONLY oversized=$OVERSIZED prose-checked=$PROSE_CHECK_NEEDED prose-concerns=$PROSE_COUNT vale-concerns=$VALE_COUNT added=${ADDED_CSV:-none} removed=${REMOVED_CSV:-none}" | |
| # Clear the triaging label whether classification succeeded or not | |
| # — the early-set step ran unconditionally (no access gate), so the | |
| # cleanup must also run unconditionally to leave no orphan label. | |
| # The next state (review:in-progress / nothing) is owned by | |
| # claude-code-review.yml or by the trivial/frontmatter-only | |
| # short-circuit. set-review-label.sh --clear strips any state label | |
| # currently present without adding a new one. | |
| - name: Clear review:triaging label | |
| if: always() | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| # Read current label state and only --clear if triaging is still | |
| # the active state label — avoids stomping on a downstream | |
| # in-progress (theoretically possible if claude-code-review's | |
| # workflow_run fires between our final label-edit and this step, | |
| # but unlikely given triage is the same job). | |
| LABELS=$(gh pr view "${{ github.event.pull_request.number }}" \ | |
| --repo "${{ github.repository }}" --json labels \ | |
| --jq '[.labels[].name] | join(",")' || echo "") | |
| if [[ ",$LABELS," == *",review:triaging,"* ]]; then | |
| .claude/commands/docs-review/scripts/set-review-label.sh \ | |
| --pr "${{ github.event.pull_request.number }}" \ | |
| --repo "${{ github.repository }}" \ | |
| --clear | |
| fi |