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

## 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
This commit is contained in:
John D. Swanson 2026-03-18 19:09:44 -04:00 committed by GitHub
parent 2cc7d17f34
commit 10ddd9085d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 456 additions and 14 deletions

View file

@ -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 ...

View file

@ -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

View file

@ -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}

172
.github/workflows/pr-size-check.yml vendored Normal file
View file

@ -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}

115
.github/workflows/stale-pr-reminder.yml vendored Normal file
View file

@ -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}