ci(pr-size): narrow the size job's fetch and short-circuit a bypassed run (BE-8940) - #218
Conversation
… run (BE-8940) The PR-head checkout used fetch-depth: 0, which pulls every branch and every tag of the consumer repo. On a large monorepo consumer that fetch alone consumed the job's whole timeout-minutes budget before any size logic ran, leaving a red pr-size check on an otherwise-green PR, and dominated the runs that did finish. Fetch only what the counting tool asks git for instead: the head commit, the base commit, and enough history to resolve their merge base. persist-credentials stays false on the PR checkout — the one fetch that needs a credential injects the job's own read-only GITHUB_TOKEN via GIT_CONFIG_* for the lifetime of that git process, so nothing is written to .git/config and nothing reaches argv. A partial clone is deliberately not used: with no persisted credential it cannot lazily fetch its missing blobs, which works on a public repo and fails on every private one. Also short-circuit a bypass-labelled run: both checkouts, the Go setup and the build are skipped, and a bypassed report is written directly. It still writes pr-size-report.md and both flag files, because the comment job bails out on an empty report artifact — without them a PR that was red and then got bypass-labelled would keep its stale red sticky comment forever.
|
Warning Review limit reachedNext included review available in 33 minutes. View limit detailsLimit details: You’ve used the included review currently available. Your 116 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 5 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 1 |
| 🟡 Medium | 2 |
| 🟢 Low | 2 |
Panel: 8/8 reviewers contributed findings.
Review follow-ups on the fetch narrowing, all in the new fetch step: - A merge base read off a SHALLOW graph can be WRONG, not just missing: grafted boundaries look parentless, so while the true fork point is hidden `git merge-base` returns an older common ancestor that some other path reaches (a long-lived branch rooted before the fork and merged into the base recently). The tool then counted every base-branch change in between and reddened a small PR. `have_merge_base` now accepts an answer only once every shallow boundary the walk can reach is itself an ancestor of that answer — everything the graft hides is then older than the merge base, so no better common ancestor can be in it. - Scope the injected auth header to `http.<server>/.extraheader`, the key `actions/checkout` writes, so it cannot ride along to a redirect target on another host. - The mandatory first rung retries once instead of exiting the step: under `set -e` a transient 5xx there reddened a consumer's check. - A failed deepen rung `continue`s to the next depth instead of `break`ing straight into the complete-history fallback. - Serialize runs per PR (`concurrency` + `cancel-in-progress`): the bypassed path finishes in seconds while a counted run takes minutes, so labelling a PR mid-run let the slow run finish last and overwrite the bypass report with a stale red verdict. Verified on purpose-built repos: the false-positive case (fork point 100 commits back, base 200 ahead, a 10-commit branch rooted 40 commits earlier merged into the base) resolves to the WRONG ancestor under the old ladder and to the true merge base under the new one, at the cost of one extra `--deepen=400` rung; a normal PR (fork 3 back) still resolves at `--depth=100` with 100 commits fetched and the repo still shallow; a distant fork (250 back of 600) resolves in two fetches without unshallowing. actionlint clean (two pre-existing SC2016 infos unchanged); workflow-pins suite 216 passed and its checker OK.
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Round 2 — ledger: 5 prior finding(s) across 1 round(s) (0 never answered).
Found 6 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 2 |
| 🟡 Medium | 2 |
| 🟢 Low | 2 |
Panel: 8/8 reviewers contributed findings.
…e publish race (BE-8940) Round-2 review follow-ups on the merge-base narrowing. - `concurrency` stays at workflow level — job scope only cancels a `pr-size` job that is still running, and the losing case is a counted run whose job already finished red before a bypass run superseded it — but the group is renamed `pr-size-reusable-<n>` so a caller cannot collide with it and deadlock its own run, and both constraints (stay out of the group; call from a dedicated workflow file, since cancellation is run-scoped) are now written up for consumers. - `comment` gates on `!cancelled()` rather than `always()`. The old `needs.pr-size.result != 'cancelled'` test only caught cancellation that landed while `pr-size` was still going, so a superseded run whose job had already finished still PATCHed its stale report over the newer verdict — the exact overwrite the concurrency group exists to prevent. - `have_merge_base` asks the same question in one `rev-list BASE HEAD --not mb` pass instead of three `merge-base --is-ancestor` calls per boundary line. Semantics are unchanged (verified identical across 60 shallow states spanning six repo shapes); the boundary count is simply out of the subprocess budget. - A fetch failure no longer masquerades as a merge-base failure. Each rung records its error (cleared on success, so it means "the LAST fetch failed"), and a permanently unfetchable base SHA — force-pushed and GC'd, a pruned fork ref, a token without read access — now says so instead of blaming merge-base resolution. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ELI-5
The PR-size check was downloading the entire consumer repo — every branch, every tag — just to compare two commits. On a big monorepo that download alone used up the job's whole 5-minute allowance, so the check got killed before it ever counted anything and showed up as a red X on an otherwise-green PR. Now it downloads just the two commits it actually needs plus enough history to line them up. Separately, if a PR already carries the "this is allowed to be big" label, the job now skips all the expensive work instead of doing it and then throwing the answer away.
What changed
1. The fetch is narrowed (this is the half that produces red checks). The PR-head checkout drops from
fetch-depth: 0tofetch-depth: 1, and a new step fetches exactly what the counting tool asks git for — the head commit, the base commit, and enough history forgit merge-baseto resolve. That is the full set:merge-base, thebase...headthree-dot diff,check-attr --source <base>against.gitattributes, andshow <ref>:<path>for the generated-marker scan. None of it needs every branch and every tag.The fetch starts at
--depth=100, then walks a bounded ladder of--deepenrungs (400 / 1600 / 6400) only while the merge base is still unreachable and the repo is still shallow, then falls back to complete history for those two commits only. A rung that errors is not fatal — it breaks to the fallback, because a complete answer is one fetch away and a rung failing must never redden a consumer's check. If the merge base is still unresolvable after complete history, the step fails loudly rather than letting the tool reportcould not resolve merge basethree steps later.2.
persist-credentials: falseis preserved on the PR-head checkout. No change, and no request to change it. That is why the new fetch carries its own credential: the job's own read-onlyGITHUB_TOKEN, injected viaGIT_CONFIG_COUNT/GIT_CONFIG_KEY_0/GIT_CONFIG_VALUE_0so it applies to that step's git processes and nothing else — never written to.git/config, and (unlikegit -c) never in argv. The base64 form is::add-mask::-ed, since it is not the literal token the runner already masks.A partial clone (
filter: blob:none/tree:0) is deliberately NOT used. With no persisted credential there is nothing left to lazily fetch the missing blobs with, and the consumer repos are private — it would appear to work on a public repo and fail on every private one.timeout-minutesis also left at 5 on purpose: raising it would convert 5-minute failures into 6-minute successes and leave the steady-state cost in place.3. A bypass-labelled run short-circuits. Both checkouts,
Set up Go,Build check-pr-sizeandCheck PR sizeare gated onenv.PR_SIZE_BYPASS != 'true', and one cheap step writes a "Bypassed via<label>✅" report instead. It writespr-size-report.mdand both flag files (pr-size-over-cap,pr-size-tests-decisive), because thecommentjob bails out on an empty report artifact and leaves any existing sticky comment untouched — skip that and a PR that was red and then got bypass-labelled keeps a stale red comment forever, strictly worse than paying for the full run.Require a pinned workflows_refstays unconditional: a caller that forgot the pin is misconfigured whether or not this particular PR happens to carry the label, and the guard is a shell test that costs nothing.Record over-cap flagis the one existing step gated off the bypassed path, so a skippedsteps.check's empty outputs cannot overwrite the flags the bypass step just wrote.Known tradeoff, stated rather than engineered around: the bypassed report loses the real line count (no more "1500 counted / 1000 cap"), because nothing counted the lines. Recovering it would mean paying for the checkout and build the short-circuit exists to skip. The report says so and points at removing the label;
docs/callers/pr-size.mdcarries the same note.Verification
Counts are unchanged — that was measured, not assumed. I built the tool from this branch and ran it twice per case: once in a full clone (the
fetch-depth: 0object set) and once in a repo built by replayingactions/checkout @ fetch-depth: 1followed by this PR's new fetch step verbatim. Reports were compared byte-for-byte.The ladder resolves a genuinely distant fork point. Against a real GitHub remote on a large public repo, an old PR head (≈1500 PRs back) versus today's default-branch tip:
--depth=100then one--deepen=400rung, 2s, merge base matching the full clone exactly.Cost. Same large public repo, same two commits, from my workstation (not a runner, so treat as a ratio not an absolute):
actions/checkout'sfetch-depth: 0refspec —+refs/heads/*:… +refs/tags/*:…— took 18s and produced a 100M.git; the narrowed sequence took 7s and 18M. On this repo the narrowed fetch leaves a 2.9M.gitversus a 5.7M full clone.The bypass transition — the one the short-circuit can silently break — was exercised by replaying the
commentjob's upsert script against a stubbedgh, with the exact artifact the new step writes: (a) PR with an existing red sticky comment →PATCHed to the bypassed ✅ report, no new comment; (b) PR with no comment → "nothing to post" (no spam); (c) regression guard, empty report artifact → existing comment left untouched.Suites.
cd scripts/check-pr-size && gofmt -l . && go vet ./... && go test ./...— clean.python3 -m unittest discover -s .github/workflow-pins/tests -p 'test_*.py'— 216 passed;python3 .github/workflow-pins/check_workflow_pins.py— OK, 11 workflows, none with a default, every ref checkout guarded.Judgment calls, and what I could not verify
go test ./...fails on a workstation whoseinit.defaultBranchismain—TestResolveMergeBaseWithAdvancedBaseBranchdoesgit initthencheckout -b main. Pre-existing and environmental (CI'sgit initdefaults tomaster); the suite is green withGIT_CONFIG_GLOBALpinninginit.defaultBranch=master. No Go file is touched by this PR.check_agents_md.py --root .fails onmaintoo —AGENTS.mdis 305 lines against a 200-line ceiling. Untouched here and out of scope.fetch-depth: 0—cursor-review.yml(3 uses),groom.yml(3),refresh-reviewers.yml(1); 7 occurrences total. Not all are defects —groom.ymlandrefresh-reviewers.ymldo whole-repo and git-history analysis where full history is the point — but nobody has checked which. That audit is explicitly out of scope here and is not fixed by this PR.bump-pr-size-callers.yml's path filter and the fleet rolls on its own; consumers carry only a thin caller.Refs BE-8940 — kept as a reference rather than a closing keyword: the ticket's first acceptance criterion is about wall-clock on a monorepo-sized consumer, and that consumer is a private repo this run had no access to, so the criterion is argued from a public-repo reproduction rather than measured where it was reported.
Review round 1 — what the panel changed
Five review threads, all in the new fetch step; every one is addressed in code rather than argued away.
A shallow merge base can be WRONG, not just missing (the high finding). Grafted boundary commits look parentless, so while the true fork point is still hidden
git merge-basereturns 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 count every base-branch change in between against a small PR. Reproduced before fixing: on a purpose-built repo (fork point 100 commits in, base 200 commits past it, a 10-commit branch rooted 40 commits before the fork and merged near the tip), the first ladder resolved to that older ancestor.have_merge_basenow accepts an answer only once every shallow boundary the walk can reach is itself an ancestor of that answer — all the graft hides is then older than the merge base, so no better common ancestor can be in it. Unprovable ⇒ deepen, worst case to the complete-history fallback.Runs are serialized per PR. (Both details in this paragraph were revised in round 2 below — the group name and the
commentgate.) A workflow-levelconcurrencygroup keyed on the PR number withcancel-in-progress: true. The bypassed path finishes in seconds while a counted run takes minutes, so labelling a PR whose run is still in flight let the slow run finish LAST and overwrite the ✅ bypass report with the stale red verdict. Thecommentjob already bails on a cancelledpr-size, so a superseded run publishes nothing. Same shape aspr-area-label.yml;docs/callers/pr-size.mdstates the consumer-visible behaviour.Three smaller ones. The injected auth header is scoped to
http.${GITHUB_SERVER_URL}/.extraheader— the keyactions/checkoutwrites — so it cannot ride along to a redirect on another host. The mandatory first rung retries once instead of exiting the step underset -eon a transient 5xx. A failed deepen rungcontinues to the next depth instead ofbreaking straight into the most expensive fetch in the step.Cost of the merge-base fix, measured on three built repos: the false-positive case resolves to the true merge base after one extra
--deepen=400rung (no unshallow); a normal PR (fork 3 back, 300-commit base) still resolves at--depth=100, 100 commits, repo still shallow — fast path unchanged; a distant fork (250 back of 600) resolves in two fetches, no unshallow.Review round 2 — what the panel changed
Six threads. Four taken in code, two answered with evidence.
The concurrency group is namespaced, and the caller constraints are documented (high).
pr-size-<n>is exactly the name a caller reaches for, andci-groom.ymlalready records what happens next: the caller holds the group while its reusableuses:job waits to acquire the same one, and the run hangs to timeout. It is nowpr-size-reusable-<n>. It stays at workflow level rather than moving tojobs.pr-size.concurrency, because job scope only ever cancels apr-sizejob that is still running — and the losing case is the other one, a counted run whose job already finished red before a bypass run superseded it, leaving nothing to cancel and itscommentjob free to overwrite the newer ✅.docs/callers/pr-size.mdnow carries both consumer-facing constraints in theci-groom.ymlregister: do not declare this group yourself, and call this from a dedicated workflow file rather than one job of a largerci.yml, since cancellation is run-scoped.The publish race was only half closed (high). Round 1 relied on
always() && needs.pr-size.result != 'cancelled', butalways()runs on a cancelled run by design and theneedstest only sees cancellation that lands whilepr-sizeis still going. A run superseded after itspr-sizejob had already finished still PATCHed its stale report over the newer verdict — the likelier ordering, given the short-circuit is the thing that finishes first. Now!cancelled(), the spellingcursor-review.ymlalready uses for this hazard.The trustworthiness check is one pass.
rev-list BASE HEAD --not mbintersected with the shallow set replaces threemerge-base --is-ancestorcalls per boundary line. Fuzzed against the old form over 60 shallow states (six repo shapes × ten fetch depths): zero disagreements. The stated motivation did not hold up and is reported as such below.A fetch failure no longer masquerades as a merge-base failure. Each rung records its error — cleared on success, so it means "the last fetch failed" rather than "some fetch failed once" — and a permanently unfetchable base SHA (force-pushed and GC'd, a pruned fork ref, a token without read access) now says so. A failed rung still prints to plain stderr, not
::warning::, so a transient rung the ladder recovers from does not annotate a passing check.Not taken: relaxing the merge-base check to one side. The proposal was to accept when all HEAD-reachable boundaries are ancestors of
mbor all BASE-reachable ones are. Run against round 1's own repro, the one-side OR re-admits the original false positive — it accepts the wrong ancestorc60where the both-sides check deepens once and resolves the true fork point. It accepts because there are no HEAD-reachable boundaries there at all, so that half is vacuously true. The soundness argument has a gap:merge-basealso misses a better ancestor that is present in the graph but unreachable from one side, which is exactly this case, and only the BASE-side half catches it. Both halves are load-bearing.Not taken: keying the group on head SHA + bypass-label state. The underlying waste is real — an unrelated auto-labeler preempts an in-flight counted run — but putting bypass state in the key lands the bypass run and the counted run in different groups, so the bypass run stops cancelling the counted one and the round-1 race reopens. Fixed at the caller layer instead:
docs/callers/pr-size.mdshows an optional job-levelif:that skips label events other than the bypass label, since GitHub cannot filterlabeledtriggers by name.Measurements behind round 2.
--deepenrungsThe objection that the both-sides check makes
--unshallowthe normal outcome on merge-heavy history did not reproduce on any of them, including the fixture built specifically to strand a boundary inside every merged branch. The per-boundary cost premise did not either: 5 calls took 0.055s (loop) vs 0.053s (rev-list) at depth 100, and 0.218s vs 0.221s after--deepen=400, with the boundary count going 2 → 1 rather than accumulating. The rewrite is kept for the bounded worst case and the smaller code, not for a measured speedup.Provenance
python3 -c yaml.safe_loadon the edited workflow OK;check_workflow_pins.pyOK (11 workflows, 0 exempt) and its 216 unit tests pass;unittestsuites for cursor-review / agents-md-integrity / groom / workflow-pins all OK;shellcheck -xclean on the bump-callers scripts and on the merge-base step extracted verbatim from the workflow; the extracted step driven end-to-end against 6 built fixtures — correct merge base in all 6, plus a negative case (unfetchable base SHA) asserting the new error blames the fetch; old vs newhave_merge_basefuzzed over 60 shallow states with 0 disagreements;go test ./...ok withinit.defaultBranch=master. Round 1 — as recorded above; unchanged.actionlintreports two SC2016 info notes on the bypass-report step's markdown backticks — introduced earlier in this PR, not a gate (nothing in this repo's CI runs actionlint) and left as-is.check_agents_md.py --root .still fails onAGENTS.mdbeing 305 lines against a 200-line ceiling — pre-existing onmain, untouched, out of scope.