Update assign-reviewers workflow for fork PR support (#51876)

John D. Swanson created

## Summary

Deploy updated `assign-reviewers.yml` from
[codeowner-coordinator#83](https://github.com/zed-industries/codeowner-coordinator/pull/83):

- Switch trigger from `pull_request` to `pull_request_target` to support
fork PRs and fix `author_association` misclassification bug (org members
reported as `COLLABORATOR`)
- Remove `author_association` filter and fork gate β€” reviewer
assignments are inherently scoped to org team members by the GitHub
Teams API
- Remove `--min-association member` from script invocation
- Add `SECURITY INVARIANTS` comment block documenting
`pull_request_target` safety requirements
- Add concurrency guard to prevent duplicate runs per PR
- Add `--require-hashes` + SHA256 pin for pyyaml install

## Test plan

- [ ] Verify a fork PR triggers the workflow and receives team
assignment
- [ ] Verify a draft→ready PR triggers correctly
- [ ] Verify org member PRs continue to work

Release Notes:

- N/A

Change summary

.github/pull_request_template.md            |  30 +++
.github/workflows/assign-reviewers.yml      |  38 ++++-
.github/workflows/hotfix-review-monitor.yml | 115 +++++++++++++++
.github/workflows/pr-size-check.yml         | 172 +++++++++++++++++++++++
.github/workflows/stale-pr-reminder.yml     | 115 +++++++++++++++
5 files changed, 456 insertions(+), 14 deletions(-)

Detailed changes

.github/pull_request_template.md πŸ”—

@@ -1,10 +1,28 @@
-Closes #ISSUE
+## Context
 
-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
-- [ ] 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)
+<!-- What does this PR do, and why? How is it expected to impact users?
+     Not just what changed, but what motivated it and why this approach.
+
+     Link to Linear issue (e.g., ENG-123) or GitHub issue (e.g., Closes #456)
+     if one exists β€” helps with traceability. -->
+
+## How to Review
+
+<!-- Help reviewers focus their attention:
+     - For small PRs: note what to focus on (e.g., "error handling in foo.rs")
+     - For large PRs (>400 LOC): provide a guided tour β€” numbered list of
+       files/commits to read in order. (The `large-pr` label is applied automatically.)
+     - See the review process guidelines for comment conventions -->
+
+## Self-Review Checklist
+
+<!-- Check before requesting review: -->
+- [ ] I've reviewed my own diff for quality, security, and reliability
+- [ ] Unsafe blocks (if any) have justifying comments
+- [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
+- [ ] Tests cover the new/changed behavior
+- [ ] Performance impact has been considered and is acceptable
 
 Release Notes:
 
-- N/A *or* Added/Fixed/Improved ...
+- N/A or Added/Fixed/Improved ...

.github/workflows/assign-reviewers.yml πŸ”—

@@ -10,25 +10,43 @@
 # AUTH NOTE: Uses a GitHub App (COORDINATOR_APP_ID + COORDINATOR_APP_PRIVATE_KEY)
 # for all API operations: cloning the private coordinator repo, requesting team
 # reviewers, and setting PR assignees. GITHUB_TOKEN is not used.
+#
+# SECURITY INVARIANTS (pull_request_target):
+# This workflow runs with access to secrets for ALL PRs including forks.
+# It is safe ONLY because:
+#   1. The checkout is the coordinator repo at ref: main β€” NEVER the PR head/branch
+#   2. No ${{ }} interpolation of event fields in run: blocks β€” all routed via env:
+#   3. The script never executes, sources, or reads files from the PR branch
+# Violating any of these enables remote code execution with secret access.
 
 name: Assign Reviewers
 
 on:
-  pull_request:
+  # zizmor: ignore[dangerous-triggers] reviewed β€” no PR code checkout, only coordinator repo at ref: main
+  pull_request_target:
     types: [opened, ready_for_review]
 
 # GITHUB_TOKEN is not used β€” all operations use the GitHub App token.
 # Declare minimal permissions so the default token has no write access.
 permissions: {}
 
-# Only run for PRs from within the org (not forks) β€” fork PRs don't have
-# write access to request team reviewers.
+# Prevent duplicate runs for the same PR (e.g., rapid push + ready_for_review).
+concurrency:
+  group: assign-reviewers-${{ github.event.pull_request.number }}
+  cancel-in-progress: true
+
+# NOTE: For ready_for_review events, the webhook payload may still carry
+# draft: true due to a GitHub race condition (payload serialized before DB
+# update). We trust the event type instead β€” the script rechecks draft status
+# via a live API call as defense-in-depth.
+#
+# No author_association filter β€” external and fork PRs also get reviewer
+# assignments. Assigned reviewers are inherently scoped to org team members
+# by the GitHub Teams API.
 jobs:
   assign-reviewers:
     if: >-
-      github.event.pull_request.head.repo.full_name == github.repository &&
-      github.event.pull_request.draft == false &&
-      contains(fromJSON('["MEMBER", "OWNER"]'), github.event.pull_request.author_association)
+      github.event.action == 'ready_for_review' || github.event.pull_request.draft == false
     runs-on: ubuntu-latest
     steps:
       - name: Generate app token
@@ -39,6 +57,8 @@ jobs:
           private-key: ${{ secrets.COORDINATOR_APP_PRIVATE_KEY }}
           repositories: codeowner-coordinator,zed
 
+      # SECURITY: checks out the coordinator repo at ref: main, NOT the PR branch.
+      # persist-credentials: false prevents the token from leaking into .git/config.
       - name: Checkout coordinator repo
         uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5  # v4.3.1
         with:
@@ -54,7 +74,10 @@ jobs:
           python-version: "3.11"
 
       - name: Install dependencies
-        run: pip install pyyaml==6.0.3
+        run: |
+          pip install pyyaml==6.0.3 \
+            --require-hashes --no-deps -q --only-binary ':all:' \
+            -c /dev/stdin <<< "pyyaml==6.0.3 --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"
 
       - name: Assign reviewers
         env:
@@ -69,7 +92,6 @@ jobs:
             --rules-file team-membership-rules.yml \
             --repo "$TARGET_REPO" \
             --org zed-industries \
-            --min-association member \
             2>&1 | tee /tmp/assign-reviewers-output.txt
 
       - name: Upload output

.github/workflows/hotfix-review-monitor.yml πŸ”—

@@ -0,0 +1,115 @@
+# Hotfix Review Monitor
+#
+# Runs daily and checks for merged PRs with the 'hotfix' label that have not
+# received a post-merge review approval within one business day. Posts a summary to
+# Slack if any are found. This is a SOC2 compensating control for the
+# emergency hotfix fast path.
+#
+# Security note: No untrusted input (PR titles, bodies, etc.) is interpolated
+# into shell commands. All PR metadata is read via gh API + jq, not via
+# github.event context expressions.
+#
+# Required secrets:
+#   SLACK_WEBHOOK_PR_REVIEW_BOT - Incoming webhook URL for the #pr-review-ops channel
+
+name: Hotfix Review Monitor
+
+on:
+  schedule:
+    - cron: "30 13 * * 1-5" # 1:30 PM UTC weekdays
+  workflow_dispatch: {}
+
+permissions:
+  contents: read
+  pull-requests: read
+
+jobs:
+  check-hotfix-reviews:
+    if: github.repository_owner == 'zed-industries'
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    env:
+      REPO: ${{ github.repository }}
+    steps:
+      - name: Find unreviewed hotfixes
+        id: check
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          # 80h lookback covers the Friday-to-Monday gap (72h) with buffer.
+          # Overlap on weekdays is harmless β€” reviewed PRs are filtered out below.
+          SINCE=$(date -u -v-80H +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
+            || date -u -d '80 hours ago' +%Y-%m-%dT%H:%M:%SZ)
+
+          # Get merged PRs with hotfix label from the lookback window
+          gh api --paginate \
+            "repos/${REPO}/pulls?state=closed&sort=updated&direction=desc&per_page=50" \
+            --jq "[
+              .[] |
+              select(.merged_at != null) |
+              select(.merged_at > \"$SINCE\") |
+              select(.labels | map(.name) | index(\"hotfix\"))
+            ]" > /tmp/hotfix_prs.json
+
+          # Check each hotfix PR for a post-merge approving review
+          jq -r '.[].number' /tmp/hotfix_prs.json | while read -r PR_NUMBER; do
+            APPROVALS=$(gh api \
+              "repos/${REPO}/pulls/${PR_NUMBER}/reviews" \
+              --jq "[.[] | select(.state == \"APPROVED\")] | length")
+
+            if [ "$APPROVALS" -eq 0 ]; then
+              jq ".[] | select(.number == ${PR_NUMBER}) | {number, title, merged_at}" \
+                /tmp/hotfix_prs.json
+            fi
+          done | jq -s '.' > /tmp/unreviewed.json
+
+          COUNT=$(jq 'length' /tmp/unreviewed.json)
+          echo "count=$COUNT" >> "$GITHUB_OUTPUT"
+
+      - name: Notify Slack
+        if: steps.check.outputs.count != '0'
+        env:
+          SLACK_WEBHOOK_PR_REVIEW_BOT: ${{ secrets.SLACK_WEBHOOK_PR_REVIEW_BOT }}
+          COUNT: ${{ steps.check.outputs.count }}
+        run: |
+          # Build Block Kit payload from JSON β€” no shell interpolation of PR titles.
+          # Why jq? PR titles are attacker-controllable input. By reading them
+          # through jq -r from the JSON file and passing the result to jq --arg,
+          # the content stays safely JSON-encoded in the final payload. Block Kit
+          # doesn't change this β€” the same jq pipeline feeds into the blocks
+          # structure instead of plain text.
+          PRS=$(jq -r '.[] | "β€’ <https://github.com/'"${REPO}"'/pull/\(.number)|#\(.number)> β€” \(.title) (merged \(.merged_at | split("T")[0]))"' /tmp/unreviewed.json)
+
+          jq -n \
+            --arg count "$COUNT" \
+            --arg prs "$PRS" \
+            '{
+              text: ($count + " hotfix PR(s) still need post-merge review"),
+              blocks: [
+                {
+                  type: "section",
+                  text: {
+                    type: "mrkdwn",
+                    text: (":rotating_light: *" + $count + " Hotfix PR(s) Need Post-Merge Review*")
+                  }
+                },
+                {
+                  type: "section",
+                  text: { type: "mrkdwn", text: $prs }
+                },
+                { type: "divider" },
+                {
+                  type: "context",
+                  elements: [{
+                    type: "mrkdwn",
+                    text: "Hotfix PRs require review within one business day of merge."
+                  }]
+                }
+              ]
+            }' | \
+          curl -s -X POST "$SLACK_WEBHOOK_PR_REVIEW_BOT" \
+            -H 'Content-Type: application/json' \
+            -d @-
+defaults:
+  run:
+    shell: bash -euxo pipefail {0}

.github/workflows/pr-size-check.yml πŸ”—

@@ -0,0 +1,172 @@
+# PR Size Check
+#
+# Comments on PRs that exceed the 400 LOC soft limit with a friendly reminder
+# to consider splitting. Does NOT block the PR β€” advisory only.
+# Also adds size labels (size/S, size/M, size/L, size/XL) for tracking.
+#
+# Security note: Uses actions/github-script (JavaScript API) β€” no shell
+# interpolation of untrusted input. PR body is accessed via the JS API,
+# not via expression interpolation in run: blocks.
+
+name: PR Size Check
+
+on:
+  pull_request:
+    types: [opened, synchronize]
+
+permissions:
+  contents: read
+
+jobs:
+  check-size:
+    if: github.repository_owner == 'zed-industries'
+    permissions:
+      contents: read
+      pull-requests: write  # PR comments
+      issues: write         # label management (GitHub routes labels through Issues API)
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    steps:
+      - name: Calculate PR size and label
+        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+        with:
+          script: |
+            const { data: files } = await github.rest.pulls.listFiles({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              pull_number: context.issue.number,
+              per_page: 300,
+            });
+
+            // Sum additions + deletions, excluding generated/lock files
+            const IGNORED_PATTERNS = [
+              /\.lock$/,
+              /^Cargo\.lock$/,
+              /pnpm-lock\.yaml$/,
+              /\.generated\./,
+              /\/fixtures\//,
+              /\/snapshots\//,
+            ];
+
+            let totalChanges = 0;
+            for (const file of files) {
+              const ignored = IGNORED_PATTERNS.some(p => p.test(file.filename));
+              if (!ignored) {
+                totalChanges += file.additions + file.deletions;
+              }
+            }
+
+            // Assign size label
+            const SIZE_BRACKETS = [
+              ['size/S',  0,       100,  '0e8a16'],
+              ['size/M',  100,     400,  'fbca04'],
+              ['size/L',  400,     800,  'e99695'],
+              ['size/XL', 800, Infinity, 'b60205'],
+            ];
+
+            let sizeLabel = 'size/S';
+            let labelColor = '0e8a16';
+            for (const [label, min, max, color] of SIZE_BRACKETS) {
+              if (totalChanges >= min && totalChanges < max) {
+                sizeLabel = label;
+                labelColor = color;
+                break;
+              }
+            }
+
+            // Remove existing size labels, then apply the current one
+            const existingLabels = (await github.rest.issues.listLabelsOnIssue({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              issue_number: context.issue.number,
+            })).data.map(l => l.name);
+
+            for (const label of existingLabels) {
+              if (label.startsWith('size/')) {
+                await github.rest.issues.removeLabel({
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  issue_number: context.issue.number,
+                  name: label,
+                });
+              }
+            }
+
+            // Create the label if it doesn't exist (ignore 422 = already exists)
+            try {
+              await github.rest.issues.createLabel({
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                name: sizeLabel,
+                color: labelColor,
+              });
+            } catch (e) {
+              if (e.status !== 422) throw e;
+            }
+
+            await github.rest.issues.addLabels({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              issue_number: context.issue.number,
+              labels: [sizeLabel],
+            });
+
+            // For large PRs (400+ LOC): auto-apply large-pr label and comment once
+            if (totalChanges >= 400) {
+              // Auto-apply the large-pr label
+              if (!existingLabels.includes('large-pr')) {
+                try {
+                  await github.rest.issues.createLabel({
+                    owner: context.repo.owner,
+                    repo: context.repo.repo,
+                    name: 'large-pr',
+                    color: 'e99695',
+                  });
+                } catch (e) {
+                  if (e.status !== 422) throw e;
+                }
+
+                await github.rest.issues.addLabels({
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  issue_number: context.issue.number,
+                  labels: ['large-pr'],
+                });
+              }
+
+              // Comment once with guidance
+              const MARKER = '<!-- pr-size-check -->';
+              const { data: comments } = await github.rest.issues.listComments({
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                issue_number: context.issue.number,
+              });
+
+              const alreadyCommented = comments.some(c => c.body.includes(MARKER));
+              if (!alreadyCommented) {
+                const prBody = context.payload.pull_request.body || '';
+                const guidedTourPresent = /how to review|guided tour|read.*in.*order/i.test(prBody);
+
+                let body = `${MARKER}\n`;
+                body += `### :straight_ruler: PR Size: **${totalChanges} lines changed** (${sizeLabel})\n\n`;
+                body += `Please note: this PR exceeds the 400 LOC soft limit.\n`;
+                body += `- Consider **splitting** into separate PRs if the changes are separable\n`;
+                body += `- Ensure the PR description includes a **guided tour** in the "How to Review" section so reviewers know where to start\n`;
+
+                if (guidedTourPresent) {
+                  body += `\n:white_check_mark: Guided tour detected β€” thank you!\n`;
+                }
+
+                await github.rest.issues.createComment({
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  issue_number: context.issue.number,
+                  body: body,
+                });
+              }
+            }
+
+            console.log(`PR #${context.issue.number}: ${totalChanges} LOC changed, labeled ${sizeLabel}`);
+defaults:
+  run:
+    shell: bash -euxo pipefail {0}

.github/workflows/stale-pr-reminder.yml πŸ”—

@@ -0,0 +1,115 @@
+# Stale PR Review Reminder
+#
+# Runs daily on weekdays (second run at 8 PM UTC disabled during rollout) and posts a Slack summary of open PRs that
+# have been awaiting review for more than 72 hours. Team-level signal only β€”
+# no individual shaming.
+#
+# Security note: No untrusted input is interpolated into shell commands.
+# All PR metadata is read via gh API + jq.
+#
+# Required secrets:
+#   SLACK_WEBHOOK_PR_REVIEW_BOT - Incoming webhook URL for the #pr-review-ops channel
+
+name: Stale PR Review Reminder
+
+on:
+  schedule:
+    - cron: "0 14 * * 1-5" # 2 PM UTC weekdays
+    # - cron: "0 20 * * 1-5" # 8 PM UTC weekdays β€” enable after initial rollout
+  workflow_dispatch: {}
+
+permissions:
+  contents: read
+  pull-requests: read
+
+jobs:
+  check-stale-prs:
+    if: github.repository_owner == 'zed-industries'
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    env:
+      REPO: ${{ github.repository }}
+      # Only surface PRs created on or after this date. Update this if the
+      # review process enforcement date changes.
+      PROCESS_START_DATE: "2026-03-19T00:00:00Z"
+    steps:
+      - name: Find PRs awaiting review longer than 72h
+        id: stale
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          CUTOFF=$(date -u -v-72H +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
+            || date -u -d '72 hours ago' +%Y-%m-%dT%H:%M:%SZ)
+
+          # Get open, non-draft PRs with pending review requests, created before cutoff
+          # but after the review process start date (to exclude pre-existing backlog)
+          gh api --paginate \
+            "repos/${REPO}/pulls?state=open&sort=updated&direction=asc&per_page=100" \
+            --jq "[
+              .[] |
+              select(.draft == false) |
+              select(.created_at > \"$PROCESS_START_DATE\") |
+              select(.created_at < \"$CUTOFF\") |
+              select((.requested_reviewers | length > 0) or (.requested_teams | length > 0))
+            ]" > /tmp/candidates.json
+
+          # Filter to PRs with zero approving reviews
+          jq -r '.[].number' /tmp/candidates.json | while read -r PR_NUMBER; do
+            APPROVALS=$(gh api \
+              "repos/${REPO}/pulls/${PR_NUMBER}/reviews" \
+              --jq "[.[] | select(.state == \"APPROVED\")] | length" 2>/dev/null || echo "0")
+
+            if [ "$APPROVALS" -eq 0 ]; then
+              jq ".[] | select(.number == ${PR_NUMBER}) | {number, title, author: .user.login, created_at}" \
+                /tmp/candidates.json
+            fi
+          done | jq -s '.' > /tmp/awaiting.json
+
+          COUNT=$(jq 'length' /tmp/awaiting.json)
+          echo "count=$COUNT" >> "$GITHUB_OUTPUT"
+
+      - name: Notify Slack
+        if: steps.stale.outputs.count != '0'
+        env:
+          SLACK_WEBHOOK_PR_REVIEW_BOT: ${{ secrets.SLACK_WEBHOOK_PR_REVIEW_BOT }}
+          COUNT: ${{ steps.stale.outputs.count }}
+        run: |
+          # Build Block Kit payload from JSON β€” no shell interpolation of PR titles.
+          # Why jq? PR titles are attacker-controllable input. By reading them
+          # through jq -r from the JSON file and passing the result to jq --arg,
+          # the content stays safely JSON-encoded in the final payload.
+          PRS=$(jq -r '.[] | "β€’ <https://github.com/'"${REPO}"'/pull/\(.number)|#\(.number)> β€” \(.title) (by \(.author), opened \(.created_at | split("T")[0]))"' /tmp/awaiting.json)
+
+          jq -n \
+            --arg count "$COUNT" \
+            --arg prs "$PRS" \
+            '{
+              text: ($count + " PR(s) awaiting review for >72 hours"),
+              blocks: [
+                {
+                  type: "section",
+                  text: {
+                    type: "mrkdwn",
+                    text: (":hourglass_flowing_sand: *" + $count + " PR(s) Awaiting Review >72 Hours*")
+                  }
+                },
+                {
+                  type: "section",
+                  text: { type: "mrkdwn", text: $prs }
+                },
+                { type: "divider" },
+                {
+                  type: "context",
+                  elements: [{
+                    type: "mrkdwn",
+                    text: "PRs awaiting review are surfaced daily. Reviewers: pick one up or reassign."
+                  }]
+                }
+              ]
+            }' | \
+          curl -s -X POST "$SLACK_WEBHOOK_PR_REVIEW_BOT" \
+            -H 'Content-Type: application/json' \
+            -d @-
+defaults:
+  run:
+    shell: bash -euxo pipefail {0}