Repository navigation
838 lines (812 loc) · 47.1 KB
/
Copy pathpr-size.yml
File metadata and controls
838 lines (812 loc) · 47.1 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
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
name: PR Size Cap (reusable)
# Reusable PR-size guardrail: counts a pull request's changed lines (added +
# deleted) across its net diff, excluding generated files and dependency
# lockfiles, and fails the check when the remainder exceeds `max_lines` — so
# diffs stay reviewable for humans and AI agents alike.
#
# What's excluded from the count:
# - dependency lockfiles (go.sum, package-lock.json, ... plus `extra_lockfiles`)
# - files marked `linguist-generated` in .gitattributes — read from the BASE
# ref only, so a PR cannot mark its own files generated to shrink its count
# - Go files carrying the canonical `// Code generated ... DO NOT EDIT.`
# marker before the package clause (this rule simply never matches in
# non-Go repos, so it is unconditional)
# - files matching `extra_generated_globs`
# - test files, ONLY when `exclude_tests: true` (off by default) — see below
#
# `exclude_tests` (opt-in, per repo): keeps test-file lines out of the counted
# total so a PR is capped on the production code a reviewer must actually reason
# about, not on its test coverage. Test files are recognized by naming
# convention only (`*_test.go`, `test_*.py`/`*_test.py`/`conftest.py`,
# `*.test.ts`/`*.spec.ts` & friends, plus test DIRECTORIES matched in three
# cases: `__tests__/`/`__mocks__/`/`__snapshots__/`/`testdata/` at any depth;
# `test/`/`tests/`/`testing/`/`e2e/` at the repo root only; and those four plus
# `it/` directly under `src/` for Maven/Gradle layouts. The root restriction is
# load-bearing — a consumer keeps production deployment manifests under
# `deploy/envs/testing/`, and an any-depth rule excluded cluster
# RBAC from the cap). Unlike the generated-file rules this is a convention, not a
# proof — nothing stops production code being parked in `tests/` — which is why
# it is opt-in rather than the default. The excluded total is ALWAYS reported on
# its own line, and when the exclusion is the ONLY reason a PR is under the cap
# the bot comment is posted even though the check is green — otherwise the
# number would be confined to the step summary in exactly the case it exists
# for, and a large test-only PR would pass unremarked. That green-check comment
# needs `comment: true` plus bot credentials; a repo that sets `exclude_tests`
# without the App keeps the loosening and loses the visibility, so configure
# both together.
#
# Bypass: add the `bypass_label` (default `oversized-ok`) to the PR for a
# legitimately large change. The job still runs (so it always posts a status)
# and reports green, but it SHORT-CIRCUITS: with the label present the verdict
# is a foregone conclusion, so both checkouts, the Go setup and the tool build
# are all skipped and a bypassed report is written directly. That report still
# carries the sticky comment and its flag files — a PR that went red and was
# THEN labelled must have its comment flipped to ✅, and an empty report
# artifact would leave the stale red one in place. The tradeoff is deliberate:
# the bypassed report shows no line count, because nothing counted the lines.
#
# Rollout: `mode: warn` reports (and comments) on overage but never fails the
# check — use it to trial a cap on a repo before flipping to `enforce`.
#
# When a PR is over the cap and bot credentials are configured, the bot posts a
# single sticky comment explaining the overage and the bypass label, then flips
# it to ✅ once the PR is trimmed or labeled — the failing status alone does not
# surface the bypass label. It also posts on a GREEN check when `exclude_tests`
# is the only thing holding the PR under the cap (see `tests_decisive`).
#
# Without `bot_app_id` + BOT_APP_PRIVATE_KEY, or with `comment: false`, the
# workflow degrades gracefully: the check status and the size job's step summary
# still carry the full report; no comment is posted. This also covers fork and
# Dependabot PRs, which never receive the secret at all.
#
# So that the credential-free path is not a silent hole, a decisive test
# exclusion ALSO emits a `::warning` annotation from the size job itself. That
# needs no secret, so it reaches fork and Dependabot PRs — the ones that would
# otherwise get the loosened cap with none of the visibility justifying it.
#
# Be precise about what that buys, because it is less than a comment: an
# annotation carrying no file/line renders on the run-summary page and behind
# the check's Details link, and in the Checks tab — NOT in the PR conversation
# (a passing check collapses to "All checks have passed") and NOT inline on
# Files changed. So on a fork PR the number is recorded and reachable, but it
# still takes a deliberate click; only the sticky comment puts it in front of a
# reviewer unprompted. Where the App is configured, the comment remains the
# real surface and the annotation is the backstop.
#
# Two-job security split: the `pr-size` job runs against PR code with a
# read-only token; the `comment` job holds the bot write token, checks out NO
# PR code, and receives the report as an artifact — so a write-scoped token is
# never present in a job that touched PR-authored content.
#
# What the size job fetches: the head commit, the base commit, and just enough
# history to resolve their merge base — never every branch and every tag. That
# is all the tool asks git for (merge-base, the three-dot diff, `.gitattributes`
# at base, and blob content for changed files), and on a large monorepo consumer
# the all-refs fetch that used to sit here consumed the whole `timeout-minutes`
# budget before any size logic ran. The PR checkout stays credential-free; the
# one fetch that needs a credential injects the job's own read-only
# `GITHUB_TOKEN` for the lifetime of that single git process, so nothing is left
# in `.git/config` while PR-authored content is on disk.
#
# The counting logic + its unit tests live in scripts/check-pr-size/ in THIS
# repo; the size job builds it from the `workflows_ref` checkout, so consumer
# repos carry only a thin caller and there is no logic to drift.
#
# Caller pattern (place in consumer repo at .github/workflows/ci-pr-size.yml):
#
# name: CI - PR Size Cap
# on:
# pull_request:
# branches: [main]
# # labeled/unlabeled are included (beyond the default opened/synchronize/
# # reopened) so toggling the bypass label re-runs the check immediately —
# # no dummy push needed to clear a failed run.
# types: [opened, synchronize, reopened, labeled, unlabeled]
# permissions:
# contents: read
# jobs:
# pr-size:
# uses: Comfy-Org/github-workflows/.github/workflows/pr-size.yml@<sha> # v1
# with:
# max_lines: 1000
# # REQUIRED. Pin the tool ref to the SAME commit SHA you pin `uses:`
# # to — otherwise the workflow is pinned but its tool is not.
# workflows_ref: <same-sha-as-uses>
# # Optional: post the sticky comment under your GitHub App.
# bot_app_id: ${{ vars.APP_ID }}
# secrets:
# BOT_APP_PRIVATE_KEY: ${{ secrets.CLOUD_CODE_BOT_PRIVATE_KEY }}
on:
workflow_call:
inputs:
max_lines:
description: Max non-generated changed lines (added + deleted) allowed.
type: number
required: false
default: 1000
mode:
description: >-
`enforce` fails the check when a PR is over the cap; `warn` still
reports (and comments) on overage but never fails the check — for
gradual per-repo rollout.
type: string
required: false
default: enforce
bypass_label:
description: >-
PR label that bypasses the cap for a legitimately large change.
Create it in the consumer repo before enforcing.
type: string
required: false
default: oversized-ok
extra_lockfiles:
description: >-
Extra dependency-lockfile base names to exclude from the count, on
top of the built-ins (go.sum, go.work.sum, package-lock.json,
pnpm-lock.yaml, yarn.lock, Cargo.lock, poetry.lock, uv.lock).
Whitespace- or comma-separated; matched at any directory depth.
type: string
required: false
default: ''
extra_generated_globs:
description: >-
Extra glob patterns treated as generated (excluded from the count).
Whitespace- or comma-separated. `*` matches within a path segment,
`**` across segments, `?` one character. A pattern without `/` is
matched against the file's base name at any depth; one with `/`
against the full repo-relative path.
type: string
required: false
default: ''
exclude_tests:
description: >-
Keep test-file lines out of the counted total, so the cap measures
production code rather than test coverage. Test files are matched by
naming convention: file names (`*_test.go`, `test_*.py`,
`conftest.py`, `*.test.ts`, `*.spec.ts`); the directories
`__tests__/`, `__mocks__/`, `__snapshots__/`, `testdata/` at ANY
depth; and `test/`, `tests/`, `testing/`, `e2e/` only at the REPO
ROOT or under a `src/` directory (Maven/Gradle; `it/` there also needs a
child segment, so `src/it/java/...` is excluded but `src/it/x.properties`
counts). The depth limit is
deliberate — those four names also label deployment environments and
product areas, and matching them anywhere excluded production
infrastructure from a real consumer's cap. A nested
`packages/foo/tests/` therefore COUNTS. Off by default: it
loosens the cap, so each repo opts in deliberately. Excluded test
lines are always reported separately, never silently dropped. For a
layout these conventions miss, use `extra_generated_globs` (those
land in the generated bucket instead).
type: boolean
required: false
default: false
comment:
description: >-
Post the sticky over-cap PR comment (needs bot_app_id +
BOT_APP_PRIVATE_KEY). Set false for status + step summary only.
type: boolean
required: false
default: true
bot_app_id:
description: >-
GitHub App ID used to post the sticky comment (paired with the
BOT_APP_PRIVATE_KEY secret). App IDs aren't secret, so this is an
input. Optional — when absent, the comment job degrades to status +
step summary only.
type: string
required: false
default: ''
workflows_ref:
description: >-
REQUIRED. Ref of Comfy-Org/github-workflows to load the check-pr-size
tool from. Must be the SAME commit SHA you pin `uses:` to — otherwise
the workflow is pinned but the code it runs is not. There is
deliberately no default: a floating `main` default would silently load
a mutable tool.
type: string
required: true
secrets:
BOT_APP_PRIVATE_KEY:
description: >-
PEM private key matching bot_app_id. Required only when bot_app_id
is set and comment is true.
required: false
# Mapped from `inputs` here so the run steps below read them without per-step
# plumbing; the check-pr-size tool reads the PR_SIZE_* variables directly.
env:
PR_SIZE_MAX_LINES: ${{ inputs.max_lines }}
PR_SIZE_MODE: ${{ inputs.mode }}
PR_SIZE_BYPASS: ${{ contains(github.event.pull_request.labels.*.name, inputs.bypass_label) }}
PR_SIZE_BYPASS_LABEL: ${{ inputs.bypass_label }}
PR_SIZE_EXTRA_LOCKFILES: ${{ inputs.extra_lockfiles }}
PR_SIZE_EXTRA_GENERATED_GLOBS: ${{ inputs.extra_generated_globs }}
PR_SIZE_EXCLUDE_TESTS: ${{ inputs.exclude_tests }}
concurrency:
# One size run per PR; a newer commit or label event cancels the in-flight
# one. Load-bearing for the bypass short-circuit below: `Report the bypass`
# finishes in seconds while a counted run takes minutes, so labelling a PR
# whose `synchronize` run is still in flight would otherwise let the slow run
# finish LAST and overwrite the ✅ bypass report with the stale red verdict
# (and, in enforce mode, a red check). Before the short-circuit both paths
# cost about the same and losing that race was incidental; now it is the
# likely outcome. A cancelled run posts nothing — that is the `comment` job's
# `!cancelled()` gate, NOT its `needs.pr-size.result` test, which only sees
# the narrower case where cancellation lands while `pr-size` is still running.
#
# WORKFLOW level, not `jobs.pr-size.concurrency`, on purpose. A job-scoped
# group only ever cancels a `pr-size` job that is STILL RUNNING; the losing
# case is the other one — a counted run whose `pr-size` job already finished
# red, superseded a moment later by a bypass run. Nothing is left to cancel,
# so its `comment` job goes on to PATCH the stale red report over the newer
# ✅. Cancelling at run scope reaches that queued `comment` job, which is the
# exact overwrite this block exists to prevent.
#
# CALLERS MUST STAY OUT OF THIS GROUP. A caller that declares the same group
# name deadlocks its own run — it holds the group while its reusable `uses:`
# job waits to acquire the same one (see the note in `ci-groom.yml`) — so the
# `-reusable-` infix keeps the name outside what a caller would naturally
# pick. And because cancellation is RUN-scoped, call this from a dedicated
# workflow file rather than as one job of a larger `ci.yml`, or a label event
# on the PR will cancel that run's unrelated jobs too. Both constraints are
# written up for consumers in docs/callers/pr-size.md. Same shape as the
# reusable `groom.yml` and `pr-area-label.yml` here.
group: pr-size-reusable-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
pr-size:
name: Cap PR size (LoC)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Checkout PR head
# Skipped entirely on a bypassed run — see "Report the bypass" below.
if: env.PR_SIZE_BYPASS != 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The head commit and its tree, nothing else. This used to be
# `fetch-depth: 0` for "full history for all refs", and on a large
# monorepo consumer that refspec — `+refs/heads/*:… +refs/tags/*:…`,
# every branch and every tag despite `--no-tags` — routinely ate the
# job's entire timeout budget before any size logic ran, and dominated
# the runs that did finish. The tool needs two commits, their merge
# base, and blob content either side of the diff; the next step
# fetches exactly that. (BE-8940)
fetch-depth: 1
# Deliberate, and load-bearing for the two-job security split in the
# header: this job holds a PR checkout, so no credential is left in
# `.git/config` for PR-authored content to sit next to. It is also why
# the fetch below carries its own single-process credential rather
# than relying on one surviving this step.
persist-credentials: false
ref: ${{ github.event.pull_request.head.sha }}
- name: Fetch the base commit and the merge-base history
# The other half of the fetch narrowing. `persist-credentials: false`
# above means there is nothing left to fetch with, and the consumer
# repos are private — which is also why a partial clone (`filter:
# blob:none`) is NOT the fix here: it would defer blob fetches to a
# point where no credential exists to make them, working on a public
# repo and failing on every private one.
if: env.PR_SIZE_BYPASS != 'true'
env:
# The job's own read-only token (`permissions: contents: read`).
FETCH_TOKEN: ${{ github.token }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# One-shot credential. GIT_CONFIG_COUNT/KEY/VALUE applies
# http.extraheader to the git processes this step starts and nothing
# else: it is never written to `.git/config` (so the checkout's
# hardening still holds while the tool runs) and, unlike `git -c`, it
# never reaches argv where another process could read it. Masked
# because the base64 form is not the literal GITHUB_TOKEN the runner
# already masks, and git echoes request headers on some transport
# errors.
B64="$(printf 'x-access-token:%s' "$FETCH_TOKEN" | base64 | tr -d '\n')"
echo "::add-mask::${B64}"
# SCOPED to the server, exactly as `actions/checkout` writes
# `http.https://github.com/.extraheader`. An unscoped
# `http.extraheader` rides along on every request this step's git
# processes make, including a redirect to another host — git does not
# strip a header injected through config the way curl strips its own
# auth. Bounded (read-only token, origin is the only remote), but
# there is no reason to leave it unscoped.
export GIT_CONFIG_COUNT=1
SERVER_URL="${GITHUB_SERVER_URL:-https://github.com}"
export GIT_CONFIG_KEY_0="http.${SERVER_URL%/}/.extraheader"
export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${B64}"
# Naming the two SHAs explicitly is the whole fix: the server walks
# these two commits and nothing else. GitHub allows fetching an
# arbitrary commit by SHA, and a fork PR's head is reachable in the
# base repo (refs/pull/N/head), so this covers forks too.
#
# Every rung's failure is recorded rather than discarded. Without this
# the ladder degrades so thoroughly that a PERMANENT problem — a
# force-pushed and GC'd base SHA, a pruned fork PR ref, a token that
# cannot read the repo — is silently relabelled as a merge-base
# problem by the error at the bottom of this step, sending the reader
# to the wrong place. A transient 5xx still degrades exactly as
# before; only the final message changes.
LAST_FETCH_ERR=''
fetch() {
local out
if out="$(git fetch --no-tags --no-recurse-submodules --quiet "$@" origin "$BASE_SHA" "$HEAD_SHA" 2>&1)"; then
# Cleared on success so the variable means "the LAST fetch failed",
# not "some fetch failed once" — a rung that failed and then
# recovered must not relabel a genuine merge-base failure below.
LAST_FETCH_ERR=''
return 0
fi
# Last line only: git can echo the request (headers included) on a
# transport error, and the whole point is a one-line attribution.
# The base64 credential is ::add-mask::ed above, so a leak through
# this path is masked either way.
LAST_FETCH_ERR="$(printf '%s' "$out" | tail -n 1)"
# Capturing stderr must not cost the log the failure itself. Plain
# stderr, not ::warning::, so a transient rung does not annotate a
# check that goes on to succeed.
printf 'git fetch %s failed: %s\n' "$*" "$LAST_FETCH_ERR" >&2
return 1
}
is_shallow() { [ "$(git rev-parse --is-shallow-repository)" = "true" ]; }
# A merge base read off a SHALLOW graph can be WRONG, not merely
# missing: grafted boundary commits look parentless, so while the true
# fork point is still hidden `git merge-base` will happily return an
# older common ancestor that some other path does reach — a long-lived
# branch rooted before the fork point and merged into the base branch
# recently is enough. The tool would then diff from there and count
# every base-branch change in between, reddening a small PR with no
# error to explain it. `fetch-depth: 0` could not produce that false
# positive, so the narrowing must not either.
#
# An answer is trustworthy once every shallow boundary this walk can
# reach is itself an ancestor of that answer: everything the graft
# hides is then OLDER than the merge base, and a better (newer) common
# ancestor cannot be hiding in it. Where that is not provable this
# returns false and the ladder deepens — worst case all the way to the
# complete-history fallback, which is the correct answer and still
# only these two commits' worth of history.
#
# Asked in ONE pass rather than three `merge-base --is-ancestor` calls
# per boundary line: `rev-list BASE HEAD --not mb` IS the set of
# commits this walk reaches that are not ancestors of `mb`, so a
# shallow boundary appearing in it is exactly a boundary that is
# inside the walk and not covered by `mb` — the same predicate the
# per-boundary loop computed, with the boundary count out of the
# subprocess budget.
have_merge_base() {
local mb shallow_file reachable_beyond_mb
mb="$(git merge-base "$BASE_SHA" "$HEAD_SHA" 2>/dev/null)" || return 1
is_shallow || return 0
shallow_file="$(git rev-parse --git-path shallow)"
# -s, not -f: an empty shallow file would make `grep -f` match
# nothing anyway, but the early return keeps the intent explicit.
[ -s "$shallow_file" ] || return 0
reachable_beyond_mb="$(git rev-list "$BASE_SHA" "$HEAD_SHA" --not "$mb" 2>/dev/null)" || return 1
if printf '%s\n' "$reachable_beyond_mb" | grep -qxF -f "$shallow_file"; then
return 1
fi
return 0
}
# The first rung is mandatory, but no more fatal than the deepen
# rungs below it: under `set -e` a transient network or 5xx failure
# here would exit the step and redden a consumer's check, when a retry
# — or the fallback further down, which fetches the same two SHAs —
# recovers. Retry once, then let the ladder carry it.
fetch --depth=100 || { sleep 3; fetch --depth=100; } || true
# Deepen until the merge base is reachable. The distance to the fork
# point is unknown — a monorepo's base branch can be far ahead of it —
# and a shallow graft hides the ancestry `merge-base` walks, so guessing
# one depth is not an option. Each rung is incremental, so the cost is
# the depth actually needed rather than the sum of the rungs, and the
# loop stops the moment the repo stops being shallow (deepening past
# the root cannot help).
for extra in 400 1600 6400; do
have_merge_base && break
is_shallow || break
# A rung that errors is NOT fatal — and must not skip the rest of
# the ladder either. `continue` retries at the next depth; only a
# genuinely exhausted ladder should reach the fallback below, which
# is the most expensive git operation in this step and runs inside
# a 5-minute budget on the very monorepos this change speeds up.
fetch "--deepen=${extra}" || continue
done
# Last resort: complete history — but still only for these two
# commits, which is the part `fetch-depth: 0` got wrong. `--unshallow`
# refuses to run on an already-complete repo, hence the guard.
if ! have_merge_base && is_shallow; then
fetch --unshallow || fetch --depth=2147483647 || true
fi
# Hard-fail rather than let the tool report `could not resolve merge
# base` from three steps away — or, worse, count a diff from a merge
# base the shallow graph cannot vouch for.
if ! have_merge_base; then
if [ -n "$LAST_FETCH_ERR" ]; then
echo "::error::could not fetch the history of ${BASE_SHA} and ${HEAD_SHA} — the last git fetch failed: ${LAST_FETCH_ERR}. A base SHA that was force-pushed and garbage-collected, a pruned fork PR ref, or a token without read access all land here; this is a fetch failure, not a merge-base one."
else
echo "::error::could not resolve a trustworthy merge base of ${BASE_SHA} and ${HEAD_SHA} after fetching both commits' history"
fi
exit 1
fi
- name: Require a pinned workflows_ref
# workflows_ref has no default on purpose. GitHub does NOT enforce
# `required: true` for workflow_call inputs, so an omitted input arrives
# as '' and actions/checkout would silently fall back to this repo's
# default branch — running MUTABLE scripts under a pin that claims
# otherwise. Fail fast instead. (BE-5546)
env:
WORKFLOWS_REF: ${{ inputs.workflows_ref }}
run: |
# actions/checkout reads `ref` through core.getInput, which TRIMS, so a
# whitespace-only value is an empty ref to IT while sailing past a bare
# -z test here. Compare the stripped form, and echo only that: dropping
# newlines also stops a multi-line value from smuggling a ::workflow
# command:: into the log, and from satisfying the line-oriented grep
# below on one 40-hex line among many.
REF="$(printf '%s' "$WORKFLOWS_REF" | tr -d '[:space:]')"
if [ -z "$REF" ]; then
echo "::error::workflows_ref is required; pin it to the same commit SHA as the uses: line (see header example)"
exit 1
fi
if ! printf '%s' "$REF" | grep -Eq '^[0-9a-f]{40}$'; then
echo "::warning::workflows_ref '$REF' is not a full 40-hex commit SHA — branch and tag refs are mutable and can skew between jobs mid-run"
fi
- name: Load check-pr-size tool
# The tool comes from THIS workflow's repo (public, pinned via
# workflows_ref) — never from the PR checkout, so no PR-authored code
# runs in this job.
#
# The `Require a pinned workflows_ref` guard above stays UNCONDITIONAL
# on purpose: a caller that forgot the pin is misconfigured whether or
# not this particular PR happens to carry the bypass label, and the
# guard is a shell test that costs nothing.
if: env.PR_SIZE_BYPASS != 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: Comfy-Org/github-workflows
ref: ${{ inputs.workflows_ref }}
path: _pr_size_tool
persist-credentials: false
- name: Set up Go
if: env.PR_SIZE_BYPASS != 'true'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: _pr_size_tool/scripts/check-pr-size/go.mod
# The module is dependency-free (no go.sum) — nothing to cache.
cache: false
- name: Build check-pr-size
if: env.PR_SIZE_BYPASS != 'true'
env:
# The tool is checked out into `_pr_size_tool/` UNDER the consumer
# repo's checkout, so `go build` discovers that repo's root `go.work`
# (if any) and refuses to build a module the workspace doesn't list:
# "current directory is contained in a module that is not one of the
# workspace modules listed in go.work". The tool is dependency-free
# (no go.sum), so disable workspace mode and build it standalone.
# Quoted: unquoted `off` is coerced to boolean false by the Actions
# YAML parser (YAML 1.1) and exported as GOWORK=false, which Go then
# treats as a workspace-file path and rejects ("must be an absolute
# path"). The literal string "off" is what disables workspace mode.
GOWORK: "off"
run: |
# Drop anything the PR checkout could have pre-seeded under the tool
# path (such files are untracked in the tool repo, which checkout
# does not remove), so the build compiles only the pinned sources.
git -C _pr_size_tool clean -ffdx
(cd _pr_size_tool/scripts/check-pr-size && go build -o "${RUNNER_TEMP}/check-pr-size" .)
- name: Check PR size
id: check
if: env.PR_SIZE_BYPASS != 'true'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -o pipefail
"${RUNNER_TEMP}/check-pr-size" \
--base "${BASE_SHA}" \
--head "${HEAD_SHA}" | tee "${RUNNER_TEMP}/pr-size-report.md"
- name: Report the bypass
# The whole of the bypassed path. With the label present the verdict is
# a foregone conclusion, so every step above is skipped and this writes
# the artifacts they would have produced.
#
# It MUST write pr-size-report.md AND both flag files, not just exit
# green: the `comment` job bails out on an empty report artifact and
# leaves any existing sticky comment untouched, so a PR that was red and
# then got bypass-labelled would keep its stale red comment forever —
# strictly worse than paying for the full run. Writing the report is
# what flips that comment to ✅.
#
# Deliberate tradeoff: this report carries no line count. Recovering one
# means the checkout, the build and the diff this step exists to skip.
if: env.PR_SIZE_BYPASS == 'true'
env:
BYPASS_LABEL: ${{ inputs.bypass_label }}
run: |
set -euo pipefail
# Same heading and bullet as the tool's own bypassed report, so the
# sticky comment reads identically whichever path produced it.
{
printf '## ✅ Passed — PR size check\n\n'
printf -- '- Bypassed via `%s` label ✅\n\n' "${BYPASS_LABEL}"
printf 'The size check was skipped before any checkout, so no line count was computed. Remove the `%s` label to get the counted report back.\n' "${BYPASS_LABEL}"
} > "${RUNNER_TEMP}/pr-size-report.md"
# The step summary is the surface that survives with no bot
# credentials (fork and Dependabot PRs), so the bypassed report has to
# reach it too.
cat "${RUNNER_TEMP}/pr-size-report.md" >> "${GITHUB_STEP_SUMMARY}"
# Written explicitly rather than left absent: the comment job reads
# these to decide whether to POST a new comment, and `false` is the
# answer that flips an existing one without spawning one on a PR that
# never had it.
printf 'false' > "${RUNNER_TEMP}/pr-size-over-cap"
printf 'false' > "${RUNNER_TEMP}/pr-size-tests-decisive"
- name: Annotate a decisive test exclusion
# An annotation is the ONE carrier that needs no bot credentials, so it
# is the only one that also reaches fork and Dependabot PRs — which
# never receive BOT_APP_PRIVATE_KEY and would otherwise get the loosened
# cap with none of the visibility that justifies it. That inversion is
# the worst shape this feature can take: the least-trusted contributions
# getting the least-scrutinized guardrail, silently, because a green
# check with no comment is indistinguishable from one that passed on its
# own merits. This fires on every PR regardless of secret availability,
# so the invariant "the exclusion is never invisible" holds everywhere.
if: always() && steps.check.outputs.tests_decisive == 'true'
env:
COUNTED: ${{ steps.check.outputs.counted }}
TESTS: ${{ steps.check.outputs.tests_excluded }}
# The APPLIED cap, emitted by the tool — not the raw max_lines input.
# envInt falls back to the 1000 default for anything Atoi rejects (a
# value arriving as `1250.5` from a GitHub expression, or with stray
# whitespace from a forwarded var), so the input can name a cap that
# was never enforced. On fork PRs this annotation is the ONLY surface,
# with no comment to cross-check it against.
CAP: ${{ steps.check.outputs.max_lines }}
run: |
echo "::warning title=Under the cap only via the test exclusion::This PR changes $((COUNTED + TESTS)) non-generated lines (${COUNTED} counted + ${TESTS} test) against a ${CAP}-line cap. exclude_tests is what brings it under — expected for a test-heavy change, surfaced so the real size is visible."
- name: Record over-cap flag
# The comment job keys off these flags rather than the pr-size job's
# result, so `warn` mode (job green) still comments on overage. They
# ride the artifact, which — unlike job outputs — carries identically
# whether this job passed or failed.
#
# tests_decisive covers the mirror-image case: the check is GREEN, but
# only because test lines were excluded. Without it no comment posts
# there, and the excluded total would be visible only in this job's step
# summary — hiding the number in exactly the case it exists for.
#
# Not on the bypassed path: `Report the bypass` already wrote both flags
# and `steps.check` never ran, so letting this overwrite them with the
# empty strings of a skipped step's outputs would only make the files
# less legible.
if: always() && env.PR_SIZE_BYPASS != 'true'
env:
OVER_CAP: ${{ steps.check.outputs.over_cap }}
TESTS_DECISIVE: ${{ steps.check.outputs.tests_decisive }}
run: |
printf '%s' "${OVER_CAP:-}" > "${RUNNER_TEMP}/pr-size-over-cap"
printf '%s' "${TESTS_DECISIVE:-}" > "${RUNNER_TEMP}/pr-size-tests-decisive"
# Hand the rendered report to the isolated `comment` job below. Uploaded
# as an artifact (not a job output) so the job that holds the bot
# write-token never runs in the same context as the PR checkout.
- name: Upload size report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: pr-size-report
path: |
${{ runner.temp }}/pr-size-report.md
${{ runner.temp }}/pr-size-over-cap
${{ runner.temp }}/pr-size-tests-decisive
if-no-files-found: warn
retention-days: 7
# Posts the size report as a single sticky PR comment when a PR is over the
# cap (and flips it to ✅ once the PR is trimmed or bypass-labeled), so
# contributors see WHY the check is red and how to clear it. Posting is keyed
# off the over-cap flag carried in the artifact, not the size job's result,
# so `warn` mode (job green) still comments on overage.
#
# Isolation: this job checks out NO PR code and only downloads the report
# artifact, so the bot's write-scoped token is never present in a job that
# held a PR checkout.
comment:
name: Comment when oversize
needs: pr-size
# `!cancelled()`, not `always()`: the interesting case is exactly when
# pr-size FAILED, so `success()` is wrong — but `always()` also runs the job
# when the RUN was cancelled, and `needs.pr-size.result != 'cancelled'` only
# catches cancellation that lands while `pr-size` is still going. A run
# superseded after its `pr-size` job already finished would otherwise still
# publish its stale report over the newer one, which is the whole point of
# the concurrency group above. Same spelling `cursor-review.yml` uses for
# this hazard.
if: ${{ !cancelled() && inputs.comment && needs.pr-size.result != 'cancelled' && needs.pr-size.result != 'skipped' }}
runs-on: ubuntu-latest
permissions:
contents: read
env:
# The `secrets` context is unavailable in `if` conditions, so the
# credentials-present test is evaluated here (job env allows it) and the
# steps below gate on the result. With no bot credentials they no-op and
# the check status + step summary stay the only outputs.
BOT_CONFIGURED: ${{ inputs.bot_app_id != '' && secrets.BOT_APP_PRIVATE_KEY != '' }}
steps:
- name: Download size report
id: dl
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: pr-size-report
path: report
- name: Mint bot token
if: steps.dl.outcome == 'success' && env.BOT_CONFIGURED == 'true'
id: bot
# Best-effort: the size verdict lives in the pr-size job, and the report
# is always in that job's step summary. A consumer whose bot app lacks
# the requested scope (or has creds misconfigured) must degrade to
# status + summary, never get a red check from the comment path — so a
# failed mint is tolerated here rather than failing the job.
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ inputs.bot_app_id }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
# Scope the token to exactly what the upsert uses: the sticky comment
# rides the issues-comments API on a PULL REQUEST, and for GitHub Apps
# that endpoint checks the app's Pull requests permission (not Issues) —
# so pull-requests:write is the one scope required (read is implied for
# the list-comments GET). Requesting an ungranted extra permission (e.g.
# issues:write) makes the mint 422 on an installation not granted it,
# for no gain — this workflow only ever comments on pull requests.
permission-pull-requests: write
- name: Upsert sticky size comment
if: steps.bot.outcome == 'success'
id: upsert
# Best-effort, same contract as the mint step: the size verdict lives in
# the pr-size job and the report is always in that job's step summary, so
# the comment path must degrade to status + summary, never redden the
# check. A runtime permission failure here (e.g. a 403 because the bot
# app lacks Pull requests on this installation — proven live on Jul-24)
# would otherwise escape the mint-only guard and fail the job, so tolerate
# it and let "Note degraded mode" report it.
continue-on-error: true
env:
GH_TOKEN: ${{ steps.bot.outputs.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# The minted token's OWN bot login. app-slug comes from the token
# mint, so it identifies this installation whether `bot_app_id` was
# given as a numeric App ID or a Client ID (`Iv1.…`) — which is what
# made matching on the app id unreliable.
BOT_LOGIN: ${{ steps.bot.outputs.app-slug }}[bot]
run: |
set -euo pipefail
if [ ! -s report/pr-size-report.md ]; then
echo "Report artifact empty — the size tool crashed before rendering; leaving any existing comment untouched."
exit 0
fi
OVER="$(cat report/pr-size-over-cap 2>/dev/null || true)"
# Green, but only because test lines were excluded — comment anyway,
# so the excluded total is not confined to the step summary.
DECISIVE="$(cat report/pr-size-tests-decisive 2>/dev/null || true)"
MARKER='<!-- ci-pr-size -->'
{ printf '%s\n\n' "$MARKER"; cat report/pr-size-report.md; } > body.md
# Find our existing sticky comment(s) by the hidden marker, so we
# update across pushes instead of stacking new ones. Both filters are
# load-bearing, and this mirrors `find_sticky` in
# scripts/pr-risk/publish-risk-surfaces.sh deliberately — same hazards,
# so the same shape rather than a second dialect of it.
#
# .user.login == our own bot — the marker is published in this
# PUBLIC workflow file, so without an author filter a PR author can
# pre-seed a comment carrying it and we would PATCH THEIRS instead
# of posting ours, after which they rewrite it freely. Since
# `exclude_tests` makes this comment the ONLY signal on a green
# test-heavy PR, that is a hole straight through the guarantee.
# Matching the LOGIN (not merely `.user.type == "Bot"`) is what
# keeps us to OUR comments: any other App or github-actions[bot]
# workflow in a consumer repo that echoes author-controlled text as
# its first line would otherwise be claimed as ours and PATCHed
# away — and while such a comment exists the POST branch below
# never runs, suppressing the decisive-green comment entirely.
# `.user.type` is kept as a cheap second condition, mirroring
# find_sticky.
#
# startswith, not contains — the body renders the marker as its
# FIRST line, while another bot QUOTING this comment carries the
# marker nested in its own prose. `contains` would PATCH that
# bot's comment away, destroying unrelated review output; the org
# bot posts cursor-review and groom comments too, so an author can
# induce this by quoting the marker in the PR body.
#
# Both the marker and the login are passed as jq DATA (--arg), never
# spliced into the program text, so no value can turn the filter into a
# syntax error — which `set -euo pipefail` plus this step's
# continue-on-error would convert into a silent "no comment posted".
existing="$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate \
| jq -r --arg m "$MARKER" --arg login "$BOT_LOGIN" '
.[] | select((.user.login // "") == $login)
| select((.user.type // "") == "Bot")
| select(((.body // "") | startswith($m)))
| .id')"
if [ -n "$existing" ]; then
# Update EVERY match, not just the first. The find-then-post is
# check-then-act with no lock, and the documented caller subscribes
# to labeled/unlabeled alongside synchronize — so a push racing a
# label toggle can leave two comments. Taking `head -n1` would then
# pin one forever while the other sat displaying a stale verdict,
# which on a decisive-green PR is a wrong number with equal
# standing. Rewriting all of them keeps every copy truthful, and
# costs one extra API call in the rare case there is a second.
# Unquoted on purpose: the ids are numeric and newline-separated, so
# word splitting is exactly the right parse (and a heredoc's
# terminator cannot be indented inside this YAML block scalar).
#
# Per-id failure is TOLERATED rather than fatal. Under
# `set -euo pipefail` a bare call would abort the loop on the first
# error and leave the remaining copies showing a stale verdict —
# precisely the split-brain this loop exists to prevent. A 404 is an
# expected case: the loop only has more than one id because
# duplicates happen, and a maintainer (or another run) deleting one
# between the list and the write is a real race.
rc=0
patched=0
failed=0
for id in $existing; do
if gh api -X PATCH "repos/${REPO}/issues/comments/${id}" -F body=@body.md >/dev/null; then
echo "Updated sticky size comment ${id}."
patched=$((patched + 1))
else
failed=$((failed + 1))
# gh's stderr is deliberately NOT discarded: a 403 from
# permission drift, a 422 on an over-long body and a concurrent
# delete all land here, and swallowing the message would
# misattribute every one of them to "deleted concurrently".
echo "Failed to update sticky size comment ${id} (see gh error above); continuing." >&2
fi
done
# Fail the step only when NOTHING was patched. A partial failure is
# the expected case this loop exists for — a duplicate 404s on a
# concurrent delete while the real sticky updates fine — and
# reporting that as a step failure makes the degraded-mode note
# below claim no comment was posted and blame a permission 403,
# which is affirmatively false and dilutes the signal for a genuine
# one. Nothing patched, on the other hand, means the verdict on this
# PR is stale and the note is exactly right.
if [ "$patched" -eq 0 ] && [ "$failed" -gt 0 ]; then
echo "No sticky comment could be updated (${failed} failed); the PR may show a stale verdict." >&2
rc=1
elif [ "$failed" -gt 0 ]; then
echo "Updated ${patched} sticky comment(s); ${failed} could not be updated (see errors above)."
fi
[ "$rc" -eq 0 ] || exit 1
elif [ "$OVER" = "true" ] || [ "$DECISIVE" = "true" ]; then
gh api -X POST "repos/${REPO}/issues/${PR_NUMBER}/comments" -F body=@body.md >/dev/null
if [ "$OVER" = "true" ]; then
echo "Posted oversize comment."
else
echo "Under cap only via the test exclusion — posted the size breakdown."
fi
else
echo "Under cap and no existing comment — nothing to post."
fi
- name: Note degraded mode
# Covers every path where no comment is posted so the job never goes
# green silently: "download failed" (dl errored under continue-on-error,
# e.g. the size tool crashed before uploading the report), "creds absent"
# (bot step skipped), "mint failed" (bot step errored) and "post failed"
# (upsert ran but failed at runtime — any of network/5xx, rate limit, a
# malformed body, or a residual permission 403; the upsert step log has
# the real error). Whenever the report artifact exists it still lives
# in the pr-size job's step summary.
if: steps.dl.outcome != 'success' || steps.bot.outcome != 'success' || steps.upsert.outcome == 'failure'
run: |
if [ "${{ steps.dl.outcome }}" != "success" ]; then
echo "No bot comment posted: the size report artifact could not be downloaded (the pr-size job likely failed before uploading it). Check the pr-size job logs."
elif [ "${{ steps.upsert.outcome }}" = "failure" ]; then
echo "No bot comment posted: the sticky comment upsert failed at runtime — see the 'Upsert sticky size comment' step log above for the actual error (e.g. a network/5xx failure, GitHub rate limit, or — now that the mint requests pull-requests:write — a residual permission 403 on an installation that has not granted it). The full report is in the pr-size job's step summary."
else
echo "No bot comment posted (credentials absent, or the app token could not be minted for this repo). The full report is in the pr-size job's step summary."
fi