-
Notifications
You must be signed in to change notification settings - Fork 484
Expand file tree
/
Copy pathaction.yml
More file actions
670 lines (629 loc) · 35 KB
/
Copy pathaction.yml
File metadata and controls
670 lines (629 loc) · 35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
name: "React Doctor"
description: "Scan React codebases for security, performance, and correctness issues"
branding:
icon: "activity"
color: "blue"
inputs:
directory:
description: "Project directory to scan"
default: "."
project:
description: 'Workspace project(s) to scan (comma-separated). Use "*" to scan every discovered project.'
default: "*"
scope:
description: "On pull requests, what to scan/report (same meanings as the CLI --scope): 'changed' (only issues the PR introduced vs base — the default), 'files' (all issues in changed files), 'lines' (only issues on the changed lines), or 'full' (the entire project, every finding). Non-PR events always scan the full project."
default: "changed"
blocking:
description: "Severity that fails the workflow: none (default; advisory — report findings but always exit 0), warning, or error."
default: "none"
comment:
description: "Create or update a sticky pull request summary comment. Automatically skipped when the pull request changes no React-eligible files (`.tsx`/`.jsx` or framework entry files)."
default: "true"
review-comments:
description: "Post inline review comments on the changed lines that triggered diagnostics (pull requests only)."
default: "true"
silence-missing-baseline-warning:
description: "By default the PR comment shows a warning when a compare scope (`changed`) can't reach the base branch (e.g. a shallow checkout) and falls back to reporting every issue in the changed files, not only the ones the PR introduced. Set to true to hide that warning (fixing the checkout with `fetch-depth: 0` is the real remedy)."
default: "false"
commit-status:
description: 'Publish a commit status with the score and error / warning counts, linking to the run. Surfaces the result on pushes to the default branch (where the PR comment is skipped). Reads "Skipped" when the pull request changes no React-eligible files. Requires `statuses: write` (skipped with a warning otherwise).'
default: "true"
node-version:
description: "Node.js version to use"
default: "24"
version:
description: "react-doctor npm version or package spec to run"
default: "latest"
outputs:
score:
description: "Health score (0-100). Empty when scoring was unavailable."
value: ${{ steps.render.outputs.score }}
total-issues:
description: "Total diagnostic count in the scan report. In baseline mode (pull requests), this is the count of issues introduced by the PR."
value: ${{ steps.render.outputs.total-issues }}
fixed-issues:
description: "Baseline mode (pull requests) only: count of issues the PR resolved. 0 otherwise."
value: ${{ steps.render.outputs.fixed-issues }}
error-count:
description: "Error diagnostic count in the scan report."
value: ${{ steps.render.outputs.error-count }}
warning-count:
description: "Warning diagnostic count in the scan report."
value: ${{ steps.render.outputs.warning-count }}
affected-files:
description: "Number of files with diagnostics."
value: ${{ steps.render.outputs.affected-files }}
runs:
using: "composite"
steps:
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: ${{ inputs.node-version }}
package-manager-cache: false
# Resolve the version to a concrete published one so the install cache key is
# stable even for `version: latest`. A local-path spec (this repo's self-test)
# is reported non-cacheable and falls through to the npx path below.
- id: resolve-version
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
run: node "$GITHUB_ACTION_PATH/scripts/resolve-package-spec.mjs" "$INPUT_VERSION"
# Cache the react-doctor install (the dominant CI cost — ~15s of an ~18s PR
# run is the uncached npm download). Keyed on the exact version + toolchain
# identity (the install is a pure function of those); no `restore-keys`
# fallback, since a partial/older restore could mismatch the native binding
# ABI. On a hit, the ~15s install collapses to a ~1-2s restore.
- id: toolchain-cache
if: ${{ steps.resolve-version.outputs.cacheable == 'true' }}
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/react-doctor-toolchain
key: react-doctor-toolchain-${{ steps.resolve-version.outputs.resolved }}-node${{ inputs.node-version }}-${{ runner.os }}-${{ runner.arch }}
# Restore react-doctor's scan caches (the whole-repo scan-result cache,
# the per-file content-addressed lint cache, the cross-file sidecar cache,
# the dead-code result + incremental summary caches, and the supply-chain
# cache) from the most recent previous run. In CI every commit is a fresh,
# SHA-scoped checkout, so the project-local `node_modules/.cache` never
# survives between commits — pointing REACT_DOCTOR_CACHE_DIR (set on the
# scan step) at a stable `${runner.temp}` path lets actions/cache carry it
# across runs. Restore + explicit save (after the scan) instead of the
# combined action: the combined post-job save is skipped when the job
# fails, and a blocking PR scan with findings fails the job BY DESIGN —
# exactly the runs whose fix-and-push retry needs a warm cache. The key is
# unique per run (an exact hit would suppress the save), so every run
# persists its refreshed state instead of freezing the first snapshot
# under an immutable key; restore falls back by prefix to the newest
# same-version save, then to ANY version. Cross-version restore is sound
# because every cached artifact re-validates internally (content hash +
# ruleset hash for lint entries — the bucket is LRU-pruned — schema
# version + per-file content hashes for scan results and the dead-code
# caches, TTL for supply-chain scores): a stale entry just misses and
# re-computes, while the version-independent supply-chain cache keeps a
# react-doctor release from cold-starting every repo's network checks at
# once. The stat-fingerprinted caches carry content-hash repair witnesses,
# so the fresh checkout's bumped mtimes are repaired in place instead of
# missing every run.
- id: scan-cache
if: ${{ steps.resolve-version.outputs.cacheable == 'true' }}
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/react-doctor-cache
key: react-doctor-scan-cache-${{ runner.os }}-${{ runner.arch }}-${{ steps.resolve-version.outputs.resolved }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
react-doctor-scan-cache-${{ runner.os }}-${{ runner.arch }}-${{ steps.resolve-version.outputs.resolved }}-
react-doctor-scan-cache-${{ runner.os }}-${{ runner.arch }}-
# Resolve the PR's changed files LOCALLY first: fetch the base commit (also
# needed for baseline mode), then derive the changed set with `git diff`.
# Faster than the GitHub API, no API rate limit, and works on forks where
# `pulls.listFiles` can be denied — which otherwise loses the diff fast path
# to a full scan. The `pr-files` step below is the API fallback, used only
# when the base isn't reachable for a local diff (shallow history).
- id: base
if: ${{ github.event_name == 'pull_request' && inputs.scope != 'full' }}
shell: bash
env:
INPUT_DIRECTORY: ${{ inputs.directory }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
CHANGED_FILES_FILE: ${{ runner.temp }}/react-doctor-changed-files.txt
run: |
# Best-effort fetch of the PR base commit so react-doctor can read base
# file content and resolve the merge-base for baseline (PR-introduced-
# issues-only) mode. Depth 1 covers the standard merge-ref checkout (base
# is a parent of HEAD); for deeper history use `fetch-depth: 0` on
# actions/checkout. The base may ALREADY be present (e.g. `fetch-depth: 0`
# or a prior fetch), so don't gate the diff on this succeeding — `|| true`
# keeps a redundant/failed fetch from blocking the local diff below.
if [ -n "$BASE_SHA" ] && git -C "$INPUT_DIRECTORY" rev-parse --git-dir >/dev/null 2>&1; then
git -C "$INPUT_DIRECTORY" fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null || true
fi
# The prefix that maps repo-root-relative diff paths to scan-relative
# ones. Derived from git (not the raw `directory` input) so it's correct
# for an absolute `directory`, an `actions/checkout` `path:` layout, and
# a nested-git-repo scan alike — the raw input only matches repo paths
# in the default layout, and a wrong prefix silently drops EVERY changed
# file (the scan then reads "Skipped — no React files changed"). A
# resolved-to-root prefix is emitted as the `.` sentinel: `--show-prefix`
# legitimately returns "" at the repo root, and GHA's `||` fallback in
# the pr-files step would treat that empty output as "unresolved" and
# substitute the raw input again.
if SCAN_PREFIX="$(git -C "$INPUT_DIRECTORY" rev-parse --show-prefix 2>/dev/null)"; then
SCAN_PREFIX="${SCAN_PREFIX:-.}"
else
SCAN_PREFIX="$INPUT_DIRECTORY"
fi
# A newline in the prefix (a hostile directory name reached through the
# checkout) would smuggle extra entries into $GITHUB_OUTPUT's
# line-oriented protocol. No legitimate prefix contains one.
case "$SCAN_PREFIX" in
*$'\n'*) SCAN_PREFIX="." ;;
esac
echo "prefix=$SCAN_PREFIX" >> "$GITHUB_OUTPUT"
# Derive the changed set locally. Three-dot (merge-base) + AMR
# (added/modified/renamed) match the `pulls.listFiles` API contract and
# the CLI's baseline semantics. Attempt it whenever a base SHA is known —
# the base may be reachable in history even when the fetch above was a
# no-op or failed. On any failure (shallow history with no reachable
# merge-base), leave the file unwritten so the API fallback runs — no
# regression vs the previous API-only behavior.
RAW_CHANGED="${RUNNER_TEMP:-/tmp}/react-doctor-raw-changed.txt"
if [ -n "$BASE_SHA" ] && git -C "$INPUT_DIRECTORY" diff --name-only --diff-filter=AMR "$BASE_SHA...HEAD" > "$RAW_CHANGED" 2>/dev/null; then
node "$GITHUB_ACTION_PATH/scripts/normalize-changed-files.mjs" "$SCAN_PREFIX" "$CHANGED_FILES_FILE" < "$RAW_CHANGED"
echo "path=$CHANGED_FILES_FILE" >> "$GITHUB_OUTPUT"
else
echo "::warning::React Doctor could not derive the PR's changed files from git (the base commit isn't reachable — set \`fetch-depth: 0\` on actions/checkout for deep histories). Falling back to the GitHub API."
fi
# API fallback: only when the local `git diff` above couldn't run. Reuses the
# shared changed-file normalization so both paths emit an identical
# scan-relative set (the CLI resolves `--changed-files-from` relative to the
# scanned `directory`; see normalize-changed-files.mjs).
- id: pr-files
if: ${{ github.event_name == 'pull_request' && inputs.scope != 'full' && steps.base.outputs.path == '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
REACT_DOCTOR_CHANGED_FILES: ${{ runner.temp }}/react-doctor-changed-files.txt
# Prefer the git-derived scan prefix from the base step (correct for
# absolute `directory` values and `actions/checkout` `path:` layouts);
# fall back to the raw input when git couldn't resolve one.
INPUT_DIRECTORY: ${{ steps.base.outputs.prefix || inputs.directory }}
# `github.action_path` is THIS composite action's directory. Inside a
# nested `actions/github-script` step, the ambient GITHUB_ACTION_PATH env
# points at github-script's own dir, not ours — so the shared script
# would be unresolvable. Pass the composite path explicitly.
COMPOSITE_ACTION_PATH: ${{ github.action_path }}
with:
script: |
const fs = require("fs");
const { pathToFileURL } = require("url");
const { normalizeChangedFiles } = await import(
pathToFileURL(`${process.env.COMPOSITE_ACTION_PATH}/scripts/normalize-changed-files.mjs`).href,
);
const outputPath = process.env.REACT_DOCTOR_CHANGED_FILES;
const pullRequest = context.payload.pull_request;
if (!pullRequest || !outputPath) return;
let files;
try {
files = await github.paginate(github.rest.pulls.listFiles, {
...context.repo,
pull_number: pullRequest.number,
per_page: 100,
});
} catch (error) {
core.warning(
`React Doctor could not list pull request files (${error.message}). ` +
"Scanning the full project instead. Grant the workflow `pull-requests: read` " +
"permission to scan only the files changed in the pull request.",
);
return;
}
const includedStatuses = new Set(["added", "modified", "renamed"]);
const changedFiles = normalizeChangedFiles(
files.filter((file) => includedStatuses.has(file.status)).map((file) => file.filename),
process.env.INPUT_DIRECTORY,
);
fs.writeFileSync(outputPath, changedFiles.join("\n") + (changedFiles.length ? "\n" : ""));
core.setOutput("path", outputPath);
- id: scan
shell: bash
env:
NO_COLOR: "1"
INPUT_DIRECTORY: ${{ inputs.directory }}
INPUT_PROJECT: ${{ inputs.project }}
INPUT_SCOPE: ${{ inputs.scope }}
INPUT_BLOCKING: ${{ inputs.blocking }}
# Install spec + cacheability resolved by the resolve-version step. A
# cacheable spec installs into (and reruns from) the cached toolchain
# prefix; a non-cacheable local-path spec falls through to npx.
PACKAGE_SPEC: ${{ steps.resolve-version.outputs.spec }}
CACHEABLE: ${{ steps.resolve-version.outputs.cacheable }}
CHANGED_FILES_FROM: ${{ steps.base.outputs.path || steps.pr-files.outputs.path }}
# Forwarded so baseline mode reads base content against a fetched SHA
# (branch names rarely resolve in a shallow PR checkout). Empty off PRs.
REACT_DOCTOR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
RUNNER_TEMP: ${{ runner.temp }}
# Write the on-disk scan caches (per-file lint cache, supply-chain cache)
# to a stable path the scan-cache step persists across runs — not the
# SHA-scoped node_modules/.cache that a fresh CI checkout discards.
REACT_DOCTOR_CACHE_DIR: ${{ runner.temp }}/react-doctor-cache
GITHUB_RUN_ID: ${{ github.run_id }}
# Telemetry: mark runs as launched by the official action and forward the
# inputs the CLI can't otherwise see (comment / review-comments are
# handled in later steps, not as flags). These ride along on the scan's
# Sentry wide event. Anonymous: no repo, owner, actor, or ref.
REACT_DOCTOR_GITHUB_ACTION: ${{ github.action_ref || '1' }}
REACT_DOCTOR_ACTION_BLOCKING: ${{ inputs.blocking }}
REACT_DOCTOR_ACTION_COMMENT: ${{ inputs.comment }}
REACT_DOCTOR_ACTION_REVIEW_COMMENTS: ${{ inputs.review-comments }}
REACT_DOCTOR_ACTION_VERSION: ${{ inputs.version }}
run: |
REPORT_FILE="${RUNNER_TEMP:-/tmp}/react-doctor-report-${GITHUB_RUN_ID:-$$}.json"
echo "report-file=$REPORT_FILE" >> "$GITHUB_OUTPUT"
# actions/cache/save errors on a missing path; guarantee the cache dir
# exists even when the scan dies before its first cache write.
mkdir -p "${RUNNER_TEMP:-/tmp}/react-doctor-cache"
FLAGS=("--json" "--json-compact")
if [ -n "$INPUT_BLOCKING" ]; then FLAGS+=("--blocking" "$INPUT_BLOCKING"); fi
if [ -n "$INPUT_PROJECT" ]; then FLAGS+=("--project" "$INPUT_PROJECT"); fi
# Lowercase the scope so this bash test matches the case-insensitive
# comparison the pr-files/base steps' `inputs.scope != 'full'`
# expressions use. Without this, `scope: Full` would skip those steps
# (GHA matches) yet fall through to changed-files here (bash wouldn't),
# running a diff scan despite the user asking for a full one.
SCOPE="$(printf '%s' "$INPUT_SCOPE" | tr '[:upper:]' '[:lower:]')"
if [ "$SCOPE" = "full" ]; then
# `--scope full` scans the whole project, overriding any `scope` /
# `diff` set in the project's config (flags win over config).
FLAGS+=("--scope" "full")
elif [ -n "$CHANGED_FILES_FROM" ]; then
# changed (default) | files | lines — forward the PR's changed-file
# list plus the matching scope. For `lines` the CLI derives the
# changed line ranges from the fetched base SHA.
case "$SCOPE" in
files | lines | changed) FLAGS+=("--scope" "$SCOPE") ;;
*) FLAGS+=("--scope" "changed") ;;
esac
FLAGS+=("--changed-files-from" "$CHANGED_FILES_FROM")
fi
# Install/run from a cached prefix for a published version (the common
# path): actions/cache restored $TOOLCHAIN_DIR before this step, so a
# cache hit skips the ~15s npm download. `--prefix` gives a deterministic,
# cacheable location (unlike npx's opaque _npx/<hash> dir). The version
# probe re-installs if the restore was empty or broken. A non-cacheable
# local-path spec (self-test) keeps the npx path unchanged.
TOOLCHAIN_DIR="${RUNNER_TEMP:-/tmp}/react-doctor-toolchain"
RD_BIN=""
if [ "$CACHEABLE" = "true" ]; then
# Run the install under `set +e` so a transient npm failure on a cache
# miss doesn't abort the whole step under the composite shell's errexit.
set +e
if ! "$TOOLCHAIN_DIR/node_modules/.bin/react-doctor" --version >/dev/null 2>&1; then
mkdir -p "$TOOLCHAIN_DIR"
npm install --prefix "$TOOLCHAIN_DIR" --no-save --no-audit --no-fund "$PACKAGE_SPEC"
fi
# Adopt the toolchain only if the bin actually RUNS, not merely
# exists: npm treats optionalDependency failures (e.g. the oxlint
# platform binding) as non-fatal, so `npm install` can exit 0 with a
# linked-but-broken bin. Persisting that via the post-job cache save
# under the immutable version key would poison every later run; a
# failed probe wipes the dir so nothing broken is ever cached, and
# the scan falls through to the npx path below.
if "$TOOLCHAIN_DIR/node_modules/.bin/react-doctor" --version >/dev/null 2>&1; then
RD_BIN="$TOOLCHAIN_DIR/node_modules/.bin/react-doctor"
else
rm -rf "$TOOLCHAIN_DIR"
fi
set -e
fi
set +e
if [ -n "$RD_BIN" ]; then
"$RD_BIN" "$INPUT_DIRECTORY" "${FLAGS[@]}" > "$REPORT_FILE"
else
npm exec --yes --package "$PACKAGE_SPEC" -- react-doctor "$INPUT_DIRECTORY" "${FLAGS[@]}" > "$REPORT_FILE"
fi
SCAN_STATUS=$?
set -e
if ! node "$GITHUB_ACTION_PATH/scripts/ensure-json-report.mjs" "$REPORT_FILE" "$SCAN_STATUS"; then
SCAN_STATUS=1
fi
echo "exit-code=$SCAN_STATUS" >> "$GITHUB_OUTPUT"
# Persist the refreshed scan caches for the next run. Explicit save rather
# than actions/cache's post-job hook — the post hook is skipped when the
# job fails, and the "Fail if React Doctor found blocking issues" step
# fails PR runs with findings by design. Guarded on the scan step's own
# outcome (it defers the CLI exit code, so it only "fails" on a genuine
# crash) rather than the job's.
- name: Save scan cache
if: ${{ always() && steps.scan.outcome == 'success' && steps.resolve-version.outputs.cacheable == 'true' }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/react-doctor-cache
key: react-doctor-scan-cache-${{ runner.os }}-${{ runner.arch }}-${{ steps.resolve-version.outputs.resolved }}-${{ github.run_id }}-${{ github.run_attempt }}
- id: render
if: always()
shell: bash
env:
REPORT_FILE: ${{ steps.scan.outputs.report-file }}
COMMENT_FILE: ${{ runner.temp }}/react-doctor-comment.md
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# The reviewed commit, shown in the sticky comment's footer. On a PR the
# head SHA is the merge ref's parent; `github.sha` covers push runs.
REACT_DOCTOR_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
# Opt out of the missing-baseline warning (see the `silence-missing-baseline-warning` input).
REACT_DOCTOR_SILENCE_MISSING_BASELINE_WARNING: ${{ inputs.silence-missing-baseline-warning }}
run: |
node "$GITHUB_ACTION_PATH/scripts/render-github-action-comment.mjs" "$REPORT_FILE" "$COMMENT_FILE"
echo "comment-file=$COMMENT_FILE" >> "$GITHUB_OUTPUT"
# Mirror the rendered report into the run's job summary so every event —
# including pushes to the default branch, where the sticky PR comment is
# skipped — has a visible result on the run page. The commit status's
# "Details" link lands here.
if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f "$COMMENT_FILE" ]; then
cat "$COMMENT_FILE" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Update sticky PR comment
if: ${{ always() && github.event_name == 'pull_request' && inputs.comment == 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
REACT_DOCTOR_COMMENT_FILE: ${{ steps.render.outputs.comment-file }}
REACT_DOCTOR_SKIPPED: ${{ steps.render.outputs.skipped }}
with:
script: |
const fs = require("fs");
const commentPath = process.env.REACT_DOCTOR_COMMENT_FILE;
if (!commentPath || !fs.existsSync(commentPath)) return;
const body = fs.readFileSync(commentPath, "utf8").trim();
if (!body) return;
// A skipped scan (no React-eligible files changed) gets no fresh
// comment — but an existing one is refreshed to the "skipped" note so a
// prior run's findings don't linger as stale feedback after the React
// changes were reverted out of the PR.
const skipped = process.env.REACT_DOCTOR_SKIPPED === "true";
const marker = "<!-- react-doctor:summary -->";
try {
const { data: comments } = await github.rest.issues.listComments({
...context.repo,
issue_number: context.issue.number,
per_page: 100,
});
const previous = comments.find((comment) => comment.body?.startsWith(marker));
if (skipped && !previous) return;
if (previous) {
await github.rest.issues.updateComment({
...context.repo,
comment_id: previous.id,
body,
});
} else {
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body,
});
}
} catch (error) {
core.warning(`React Doctor could not update the sticky PR comment: ${error.message}`);
}
- name: Post inline review comments
if: ${{ always() && github.event_name == 'pull_request' && inputs.review-comments == 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
REACT_DOCTOR_REPORT_FILE: ${{ steps.scan.outputs.report-file }}
# The same git-derived scan prefix the changed-files steps use (correct
# for absolute `directory` values and checkout `path:` layouts). Here
# the raw-input fallback IS load-bearing: this step also runs when the
# base step was skipped (`scope: full`), where the output is empty.
INPUT_DIRECTORY: ${{ steps.base.outputs.prefix || inputs.directory }}
with:
script: |
const fs = require("fs");
const reportPath = process.env.REACT_DOCTOR_REPORT_FILE;
const pullRequest = context.payload.pull_request;
if (!pullRequest || !reportPath || !fs.existsSync(reportPath)) return;
let report;
try {
report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
} catch {
return;
}
// A failed/aborted scan emits `ok: false` with an empty `diagnostics`
// array (CLI error reports + the ensure-json-report fallback). Don't
// treat that as a clean scan — leave any existing inline feedback in
// place rather than wiping it on a transient failure.
if (report.ok === false) return;
const diagnostics = Array.isArray(report.diagnostics) ? report.diagnostics : [];
const marker = "<!-- react-doctor:review -->";
const directoryPrefix = (process.env.INPUT_DIRECTORY || ".")
.replace(/^\.\/?/, "")
.replace(/\/$/, "");
let files;
try {
files = await github.paginate(github.rest.pulls.listFiles, {
...context.repo,
pull_number: pullRequest.number,
per_page: 100,
});
} catch (error) {
core.warning(
`React Doctor could not list pull request files for review comments (${error.message}).`,
);
return;
}
// The Reviews API accepts RIGHT-side comments on any line present in a
// diff hunk — added (`+`) AND unchanged context (` `) lines (a
// PR-introduced diagnostic can land on a context line when the change
// that triggered it is elsewhere in the hunk). Removed (`-`) lines and
// the `\ No newline at end of file` marker aren't RIGHT-side lines.
const commentableLinesByFile = new Map();
for (const file of files) {
if (!file.patch) continue;
const commentableLines = new Set();
let newLine = 0;
for (const patchLine of file.patch.split("\n")) {
const hunkHeader = patchLine.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (hunkHeader) {
newLine = Number(hunkHeader[1]);
continue;
}
if (patchLine.startsWith("+") || patchLine.startsWith(" ")) {
commentableLines.add(newLine);
newLine += 1;
}
}
commentableLinesByFile.set(file.filename, commentableLines);
}
const toRepoPath = (filePath) => {
const normalized = String(filePath || "")
.replace(/\\/g, "/")
.replace(/^\.\//, "");
if (commentableLinesByFile.has(normalized)) return normalized;
const prefixed = directoryPrefix ? `${directoryPrefix}/${normalized}` : normalized;
if (commentableLinesByFile.has(prefixed)) return prefixed;
const suffixMatches = [...commentableLinesByFile.keys()].filter((name) =>
name.endsWith(`/${normalized}`),
);
return suffixMatches.length === 1 ? suffixMatches[0] : null;
};
const seen = new Set();
const comments = [];
for (const diagnostic of diagnostics) {
if (!diagnostic || typeof diagnostic.line !== "number" || diagnostic.line <= 0) continue;
const repoPath = toRepoPath(diagnostic.filePath);
if (!repoPath) continue;
const commentableLines = commentableLinesByFile.get(repoPath);
if (!commentableLines || !commentableLines.has(diagnostic.line)) continue;
const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
const dedupeKey = `${repoPath}:${diagnostic.line}:${ruleKey}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
const severity = diagnostic.severity === "error" ? "error" : "warning";
const fixLine = diagnostic.help ? `\n\n**Fix** → ${diagnostic.help}` : "";
const docsLink = `\n\n[Docs](https://react.doctor/docs/rules/${ruleKey})`;
comments.push({
path: repoPath,
line: diagnostic.line,
side: "RIGHT",
body: `${marker}\n**React Doctor** · \`${ruleKey}\` _(${severity})_\n\n${diagnostic.message}${fixLine}${docsLink}`,
});
}
// A scan that found issues but mapped none onto changed lines (path
// mismatch, missing patch, line outside the diff hunks) must not wipe
// existing inline feedback — leave it untouched and bail.
if (comments.length === 0 && diagnostics.length > 0) return;
// Capture the previous run's review comments up front so they can be
// cleared AFTER a successful post — never before, so a failed post
// can't wipe prior feedback (it just leaves the last run's comments).
let priorCommentIds = [];
try {
const existing = await github.paginate(github.rest.pulls.listReviewComments, {
...context.repo,
pull_number: pullRequest.number,
per_page: 100,
});
priorCommentIds = existing
.filter((comment) => comment.body && comment.body.startsWith(marker))
.map((comment) => comment.id);
} catch (error) {
core.warning(`React Doctor could not list prior review comments: ${error.message}`);
}
// Inline comments are intentionally capped to avoid flooding a PR and
// to stay within GitHub's per-review size limits. When more than this
// anchor on the diff, the sticky summary comment remains the complete
// record of every finding; the inline subset is just the first slice.
const maxComments = 50;
if (comments.length > 0) {
try {
await github.rest.pulls.createReview({
...context.repo,
pull_number: pullRequest.number,
event: "COMMENT",
comments: comments.slice(0, maxComments),
});
} catch (error) {
// Keep the prior comments rather than clearing them, so a failed
// post doesn't leave the PR with no inline feedback.
core.warning(`React Doctor could not post inline review comments: ${error.message}`);
return;
}
}
// Clear the previous run's comments now that the new review posted (or
// the scan came back genuinely clean) so re-runs don't stack.
for (const commentId of priorCommentIds) {
try {
await github.rest.pulls.deleteReviewComment({
...context.repo,
comment_id: commentId,
});
} catch (error) {
core.warning(`React Doctor could not clear a prior review comment: ${error.message}`);
}
}
- name: Publish commit status
if: ${{ always() && inputs.commit-status == 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
REACT_DOCTOR_SCORE: ${{ steps.render.outputs.score }}
REACT_DOCTOR_ERROR_COUNT: ${{ steps.render.outputs.error-count }}
REACT_DOCTOR_WARNING_COUNT: ${{ steps.render.outputs.warning-count }}
REACT_DOCTOR_SKIPPED: ${{ steps.render.outputs.skipped }}
REACT_DOCTOR_SCAN_EXIT: ${{ steps.scan.outputs.exit-code }}
# On a PR the merge ref's parent is the reviewed commit; `github.sha`
# covers push runs.
REACT_DOCTOR_STATUS_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
REACT_DOCTOR_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const sha = process.env.REACT_DOCTOR_STATUS_SHA;
if (!sha) return;
const score = process.env.REACT_DOCTOR_SCORE;
const errorCount = Number(process.env.REACT_DOCTOR_ERROR_COUNT || "0");
const warningCount = Number(process.env.REACT_DOCTOR_WARNING_COUNT || "0");
const scanExit = process.env.REACT_DOCTOR_SCAN_EXIT;
const skipped = process.env.REACT_DOCTOR_SKIPPED === "true";
const isPullRequest = context.eventName === "pull_request";
const pluralize = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
const counts = `${pluralize(errorCount, "error")} · ${pluralize(warningCount, "warning")}`;
// An empty / missing exit code means the scan step never finished; treat
// it as a failure so this status can't show green while the PR gate (which
// uses `${SCAN_STATUS:-1}`) fails the run.
const scanFailed = scanExit !== "0";
// A skipped scan (no React-eligible files changed) reads "Skipped" rather
// than a zero-filled score line. `score` is empty when the scan couldn't
// produce one (crash / aborted).
const description = skipped
? "Skipped — no React files changed"
: score ? `Score: ${score}/100 · ${counts}` : scanFailed ? "Scan could not complete" : counts;
// Advisory on pushes (a default-branch health-trend signal that must
// never turn the branch red); on a PR the status mirrors the gate so it
// agrees with the job's own pass/fail.
const state = isPullRequest && scanFailed ? "failure" : "success";
try {
await github.rest.repos.createCommitStatus({
...context.repo,
sha,
state,
context: "React Doctor",
description: description.slice(0, 140),
target_url: process.env.REACT_DOCTOR_RUN_URL,
});
} catch (error) {
core.warning(
`React Doctor could not publish the commit status (${error.message}). ` +
"Grant the workflow `statuses: write` to enable it.",
);
}
- name: Fail if React Doctor found blocking issues
if: always()
shell: bash
env:
SCAN_STATUS: ${{ steps.scan.outputs.exit-code }}
EVENT_NAME: ${{ github.event_name }}
run: |
# Non-PR events (e.g. a push to the default branch) run a full-project
# health snapshot, not a PR gate: the result is surfaced through the job
# summary + commit status, but the run never fails on findings so `main`
# doesn't go red on pre-existing issues. PRs still propagate the CLI exit
# code, which already reflects the `blocking` threshold (`blocking: none`
# exits 0 even with findings).
if [ "$EVENT_NAME" != "pull_request" ]; then
exit 0
fi
exit "${SCAN_STATUS:-1}"