Split size check into compute + label workflows for fork PR support (#51972)

John D. Swanson created

## Context

Fork PRs receive a read-only `GITHUB_TOKEN`, causing 403 errors on all
label and comment writes
([example](https://github.com/zed-industries/zed/pull/51878)). This
splits the single workflow into two:

- **pr-size-check.yml** (`pull_request`): computes size and guided tour
detection, uploads a JSON artifact — read-only, works for forks
- **pr-size-label.yml** (`workflow_run`): downloads the artifact,
applies labels and comments — runs on the base repo with full write
access

### Security

- Artifact treated as untrusted data: fields are cast and validated
(`Number()`, `String()` + prefix check, `Boolean()`) before use
- No artifact content is executed or interpolated into shell
- Missing artifact handled gracefully (steps skip via output flag)

### Also included

- Structural guided tour detection: extracts "How to Review" section,
strips HTML comment placeholders, checks for actual author content
(fixes false positive in #51957)
- Softer confirmation: "appears to include guidance"

Tested locally end-to-end against 4 real PRs
(XL/small/medium/false-positive).

## How to Review

1. `pr-size-check.yml` — the compute half. Compare against the previous
version: all write operations removed, artifact upload added at the end.
2. `pr-size-label.yml` — new file. Download artifact, validate, apply
labels/comments. Same label logic as before, just in a `workflow_run`
context.

## Self-Review Checklist

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

Change summary

.github/workflows/pr-size-check.yml | 165 +++++++------------------
.github/workflows/pr-size-label.yml | 195 +++++++++++++++++++++++++++++++
2 files changed, 243 insertions(+), 117 deletions(-)

Detailed changes

.github/workflows/pr-size-check.yml 🔗

@@ -1,12 +1,17 @@
-# PR Size Check
+# PR Size Check — Compute
 #
-# 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.
+# Calculates PR size and saves the result as an artifact. A companion
+# workflow (pr-size-label.yml) picks up the artifact via workflow_run
+# and applies labels + comments with write permissions.
 #
-# 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.
+# This two-workflow split is required because fork PRs receive a
+# read-only GITHUB_TOKEN. The compute step needs no write access;
+# the label/comment step runs via workflow_run on the base repo with
+# full write permissions.
+#
+# Security note: This workflow only reads PR file data via the JS API
+# and writes a JSON artifact. No untrusted input is interpolated into
+# shell commands.
 
 name: PR Size Check
 
@@ -14,20 +19,22 @@ on:
   pull_request:
     types: [opened, synchronize]
 
+permissions:
+  contents: read
+  pull-requests: read
+
 jobs:
-  check-size:
+  compute-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
+      - name: Calculate PR size
         uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
         with:
           script: |
+            const fs = require('fs');
+
             const { data: files } = await github.rest.pulls.listFiles({
               owner: context.repo.owner,
               repo: context.repo.repo,
@@ -53,15 +60,15 @@ jobs:
               }
             }
 
-            // Assign size label
+            // Assign size bracket
             const SIZE_BRACKETS = [
-              ['size/S',  0,       100,  '0e8a16'],
-              ['size/M',  100,     400,  'fbca04'],
-              ['size/L',  400,     800,  'e99695'],
-              ['size/XL', 800, Infinity, 'b60205'],
+              ['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 sizeLabel = 'Size S';
             let labelColor = '0e8a16';
             for (const [label, min, max, color] of SIZE_BRACKETS) {
               if (totalChanges >= min && totalChanges < max) {
@@ -71,108 +78,32 @@ jobs:
               }
             }
 
-            // Update size label only if the classification changed
-            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);
-
-            const existingSizeLabels = existingLabels.filter(l => l.startsWith('size/'));
-            const alreadyCorrect = existingSizeLabels.length === 1 && existingSizeLabels[0] === sizeLabel;
-
-            if (!alreadyCorrect) {
-              for (const label of existingSizeLabels) {
-                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,
-              });
+            // Check if the author wrote content in the "How to Review" section.
+            const rawBody = context.payload.pull_request.body || '';
+            const howToReview = rawBody.match(/## How to Review\s*\n([\s\S]*?)(?=\n## |$)/i);
+            const hasReviewGuidance = howToReview
+              ? howToReview[1].replace(/<!--[\s\S]*?-->/g, '').trim().length > 0
+              : false;
 
-              const alreadyCommented = comments.some(c => c.body.includes(MARKER));
-              if (!alreadyCommented) {
-                // Check if the author wrote content in the "How to Review" section.
-                // Extract the section, strip HTML comment placeholders from the
-                // template, and check for any remaining non-whitespace content.
-                const rawBody = context.payload.pull_request.body || '';
-                const howToReview = rawBody.match(/## How to Review\s*\n([\s\S]*?)(?=\n## |$)/i);
-                const guidedTourPresent = howToReview
-                  ? howToReview[1].replace(/<!--[\s\S]*?-->/g, '').trim().length > 0
-                  : false;
+            const result = {
+              pr_number: context.issue.number,
+              total_changes: totalChanges,
+              size_label: sizeLabel,
+              label_color: labelColor,
+              has_review_guidance: hasReviewGuidance,
+            };
 
-                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`;
+            console.log(`PR #${result.pr_number}: ${totalChanges} LOC, ${sizeLabel}`);
 
-                if (guidedTourPresent) {
-                  body += `\n:white_check_mark: "How to Review" section appears to include guidance — thank you!\n`;
-                }
+            fs.mkdirSync('pr-size', { recursive: true });
+            fs.writeFileSync('pr-size/result.json', JSON.stringify(result));
 
-                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}`);
+      - name: Upload size result
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+        with:
+          name: pr-size-result
+          path: pr-size/
+          retention-days: 1
 defaults:
   run:
     shell: bash -euxo pipefail {0}

.github/workflows/pr-size-label.yml 🔗

@@ -0,0 +1,195 @@
+# PR Size Check — Label & Comment
+#
+# Triggered by workflow_run after pr-size-check.yml completes.
+# Downloads the size result artifact and applies labels + comments.
+#
+# This runs on the base repo with full GITHUB_TOKEN write access,
+# so it works for both same-repo and fork PRs.
+#
+# Security note: The artifact is treated as untrusted data — only
+# structured JSON fields (PR number, size label, color, boolean) are
+# read. No artifact content is executed or interpolated into shell.
+
+name: PR Size Label
+
+on:
+  workflow_run:
+    workflows: ["PR Size Check"]
+    types: [completed]
+
+jobs:
+  apply-labels:
+    if: >
+      github.repository_owner == 'zed-industries' &&
+      github.event.workflow_run.conclusion == 'success'
+    permissions:
+      contents: read
+      pull-requests: write
+      issues: write
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    steps:
+      - name: Download size result artifact
+        id: download
+        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+        with:
+          script: |
+            const fs = require('fs');
+            const path = require('path');
+
+            const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              run_id: context.payload.workflow_run.id,
+            });
+
+            const match = allArtifacts.data.artifacts.find(a => a.name === 'pr-size-result');
+            if (!match) {
+              console.log('No pr-size-result artifact found, skipping');
+              core.setOutput('found', 'false');
+              return;
+            }
+
+            const download = await github.rest.actions.downloadArtifact({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              artifact_id: match.id,
+              archive_format: 'zip',
+            });
+
+            const temp = path.join(process.env.RUNNER_TEMP, 'pr-size');
+            fs.mkdirSync(temp, { recursive: true });
+            fs.writeFileSync(path.join(temp, 'result.zip'), Buffer.from(download.data));
+            core.setOutput('found', 'true');
+
+      - name: Unzip artifact
+        if: steps.download.outputs.found == 'true'
+        env:
+          ARTIFACT_DIR: ${{ runner.temp }}/pr-size
+        run: unzip "$ARTIFACT_DIR/result.zip" -d "$ARTIFACT_DIR"
+
+      - name: Apply labels and comment
+        if: steps.download.outputs.found == 'true'
+        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+        with:
+          script: |
+            const fs = require('fs');
+            const path = require('path');
+
+            const temp = path.join(process.env.RUNNER_TEMP, 'pr-size');
+            const resultPath = path.join(temp, 'result.json');
+            if (!fs.existsSync(resultPath)) {
+              console.log('No result.json found, skipping');
+              return;
+            }
+
+            const result = JSON.parse(fs.readFileSync(resultPath, 'utf8'));
+
+            // Validate artifact data (treat as untrusted)
+            const prNumber = Number(result.pr_number);
+            const totalChanges = Number(result.total_changes);
+            const sizeLabel = String(result.size_label);
+            const labelColor = String(result.label_color);
+            const hasReviewGuidance = Boolean(result.has_review_guidance);
+
+            if (!prNumber || !sizeLabel.startsWith('Size ')) {
+              core.setFailed(`Invalid artifact data: pr=${prNumber}, label=${sizeLabel}`);
+              return;
+            }
+
+            console.log(`PR #${prNumber}: ${totalChanges} LOC, ${sizeLabel}`);
+
+            // --- Size label (idempotent) ---
+            const existingLabels = (await github.rest.issues.listLabelsOnIssue({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              issue_number: prNumber,
+            })).data.map(l => l.name);
+
+            const existingSizeLabels = existingLabels.filter(l => l.startsWith('Size '));
+            const alreadyCorrect = existingSizeLabels.length === 1 && existingSizeLabels[0] === sizeLabel;
+
+            if (!alreadyCorrect) {
+              for (const label of existingSizeLabels) {
+                await github.rest.issues.removeLabel({
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  issue_number: prNumber,
+                  name: label,
+                });
+              }
+
+              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: prNumber,
+                labels: [sizeLabel],
+              });
+            }
+
+            // --- Large PR handling (400+ LOC) ---
+            if (totalChanges >= 400) {
+              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: prNumber,
+                  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: prNumber,
+              });
+
+              const alreadyCommented = comments.some(c => c.body.includes(MARKER));
+              if (!alreadyCommented) {
+                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 (hasReviewGuidance) {
+                  body += `\n:white_check_mark: "How to Review" section appears to include guidance — thank you!\n`;
+                }
+
+                await github.rest.issues.createComment({
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  issue_number: prNumber,
+                  body: body,
+                });
+              }
+            }
+
+            console.log(`PR #${prNumber}: labeled ${sizeLabel}, done`);
+defaults:
+  run:
+    shell: bash -euxo pipefail {0}