Skip to content

Commit 304ff5d

Browse files
Effi-SAkramBitar
authored andcommitted
Updated Benchmark validation comment and error strategy
Signed-off-by: Effi-S <effi.szt@gmail.com>
1 parent 1e74ae8 commit 304ff5d

2 files changed

Lines changed: 98 additions & 35 deletions

File tree

.github/workflows/token-validation-benchmark.yml

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,24 @@ name: Token Validation Benchmark
2828
# If a run is interrupted, clear leftover containers before retrying:
2929
# docker rm -f $(docker ps -aq --filter ancestor=catthehacker/ubuntu:act-latest)
3030
on:
31-
# pull_request_target runs in the BASE repo context, so GITHUB_TOKEN keeps
32-
# write access even for fork PRs — which is what lets the compare job post its
33-
# comment. The tradeoff is that this event is inherently more dangerous: the
34-
# benchmark job below checks out and runs the PR HEAD (untrusted) code. To
35-
# keep that safe, the default token here is read-only (see permissions), and
36-
# pull-requests: write is granted ONLY to the trusted compare job that never
37-
# runs PR code. Do not add secrets or write scopes to the benchmark job.
38-
pull_request_target:
31+
# The benchmark job below checks out and runs the PR HEAD (untrusted fork)
32+
# code, so this workflow runs on `pull_request`, NOT `pull_request_target`:
33+
# fork PRs run in the fork's untrusted context with a read-only GITHUB_TOKEN
34+
# and no access to secrets, and `actions/checkout` pulls the fork's PR code
35+
# directly (no `allow-unsafe-pr-checkout` needed). This workflow only produces
36+
# artifacts; posting the PR comment happens in the companion
37+
# `token-validation-benchmark-comment.yml`, which runs on `workflow_run` in the
38+
# trusted base-repo context and therefore has `pull-requests: write` even for
39+
# fork PRs.
40+
pull_request:
3941
workflow_dispatch:
4042

41-
# Read-only by default. The benchmark job runs untrusted PR head code, so it
42-
# must not have a write-capable token. Write access is scoped per-job on the
43-
# compare job (below), which only runs trusted base-repo code.
43+
# Read-only by default: the benchmark job runs untrusted fork code and must not
44+
# have a write-capable token. pull-requests: write is scoped per-job on the
45+
# compare job (below). Under `pull_request` that write scope is effective only
46+
# for same-repo PRs; for fork PRs the token stays read-only, so the compare
47+
# job's comment step fails harmlessly (it is continue-on-error) and the
48+
# companion workflow_run workflow posts the comment from the trusted context.
4449
permissions:
4550
contents: read
4651

@@ -191,17 +196,18 @@ jobs:
191196
needs: benchmark
192197
runs-on: ubuntu-latest
193198
# Write scope lives HERE, not at the top level, because this job runs only
194-
# trusted base-repo code (the checkout below takes the default base ref, and
195-
# it never checks out or executes PR head code). The benchmark job keeps the
196-
# read-only default token.
199+
# trusted base-repo code (it never checks out or executes PR head code). The
200+
# benchmark job keeps the read-only default token. On fork PRs the token is
201+
# read-only regardless, so the comment step below cannot post — that is why
202+
# the comment step itself is continue-on-error and the companion workflow_run
203+
# workflow handles commenting for forks. The job as a whole is NOT
204+
# continue-on-error: a detected regression fails it (see the final step).
197205
permissions:
198206
contents: read
199207
pull-requests: write
200-
continue-on-error: true
201208
steps:
202-
# No ref specified: under pull_request_target this checks out the BASE
203-
# repo/branch (trusted), which is exactly what we want for the code that
204-
# holds the write token.
209+
# No ref specified: checks out this workflow's ref (the base repo), NOT the
210+
# PR head, so the compare script we run is trusted.
205211
- name: Checkout code
206212
uses: actions/checkout@v4
207213

@@ -221,15 +227,44 @@ jobs:
221227
run: pip install pandas
222228

223229
- name: Build comparison report
230+
id: report
224231
run: |
232+
# Always build and print the report so it is visible in the job log,
233+
# regardless of whether a regression is detected. The script exits 3
234+
# when it detects a regression, 0 otherwise; any other non-zero code is
235+
# a real failure and must propagate. Capture the code without letting
236+
# `set -e` abort the step on the expected exit 3.
237+
set +e
225238
python cmd/benchmarking/compare_benchmarks.py \
226239
--input-dir "$OUTPUT_DIR" \
227240
--base-tag '_base_' \
228241
--pr-tag '_pr_' \
229242
--delta "$DELTA" \
230243
--output comment.md
244+
rc=$?
245+
set -e
231246
247+
case "$rc" in
248+
0) echo "degraded=false" >> "$GITHUB_OUTPUT" ;;
249+
3) echo "degraded=true" >> "$GITHUB_OUTPUT" ;;
250+
*) echo "compare_benchmarks.py failed with exit code $rc" >&2
251+
exit "$rc" ;;
252+
esac
253+
254+
# Only comment when there is a regression to report. On fork PRs the token
255+
# is read-only and this step cannot post, so it is continue-on-error; the
256+
# companion workflow_run workflow handles commenting for forks.
232257
- name: Post comparison comment
258+
if: steps.report.outputs.degraded == 'true'
259+
continue-on-error: true
233260
env:
234261
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
235262
run: gh pr comment "${{ github.event.pull_request.number }}" --body-file comment.md
263+
264+
# Fail the job on regression, after the comment has been posted, so the
265+
# PR check turns red and the degradation is not silently accepted.
266+
- name: Fail on regression
267+
if: steps.report.outputs.degraded == 'true'
268+
run: |
269+
echo "::error::Token validation benchmark detected a performance regression (see report above)."
270+
exit 1

cmd/benchmarking/compare_benchmarks.py

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,18 @@
2020
from __future__ import annotations
2121

2222
import argparse
23+
import sys
2324
from pathlib import Path
2425

2526
import pandas as pd
2627

2728
from bench_parse import simple_parser
2829

30+
# Exit code used by ``main`` when the report contains at least one regression.
31+
# Distinct from 1 (used by argparse/uncaught errors) so callers can tell a
32+
# detected regression apart from the script failing to run at all.
33+
EXIT_REGRESSION = 3
34+
2935
# Measurement/derived columns produced by the parser. These vary run-to-run
3036
# and must never be part of a row's identity key — only the benchmark's input
3137
# parameters (the ``key=value`` sub-tests) identify a row.
@@ -110,26 +116,38 @@ def _ranges_overlap(base_samples: list, pr_samples: list) -> bool:
110116
return min(base_samples) <= max(pr_samples) and min(pr_samples) <= max(base_samples)
111117

112118

113-
def _emoji(metric: str, pct: float, base_samples: list, pr_samples: list, delta: float = _DEFAULT_DELTA) -> str:
114-
"""Pick an indicator for a change, given whether lower is better.
119+
# Classification of a single metric change, independent of how it is rendered.
120+
_NEUTRAL, _IMPROVED, _REGRESSED = "neutral", "improved", "regressed"
121+
122+
_STATUS_EMOJI = {_NEUTRAL: "➖", _IMPROVED: "🟢", _REGRESSED: "🔴"}
123+
124+
125+
def _classify(metric: str, pct: float, base_samples: list, pr_samples: list, delta: float = _DEFAULT_DELTA) -> str:
126+
"""Classify a metric change as neutral, improved, or regressed.
115127
116-
Returns the neutral marker when the change is within the ±1% noise band or
117-
when the base/PR sample ranges overlap (i.e. the delta is not statistically
118-
distinguishable from run-to-run jitter).
128+
Returns ``_NEUTRAL`` when the change is within the ±delta% noise band or when
129+
the base/PR sample ranges overlap (i.e. the delta is not statistically
130+
distinguishable from run-to-run jitter). This is the single source of truth
131+
for whether a row is a regression — the emoji is derived from it, never the
132+
other way around.
119133
"""
120-
if abs(pct) < delta: # treat sub-1% as noise
121-
return "➖"
134+
if abs(pct) < delta: # treat sub-delta as noise
135+
return _NEUTRAL
122136
if _ranges_overlap(base_samples, pr_samples):
123-
return "➖"
137+
return _NEUTRAL
124138
improved = (pct < 0) == _LOWER_IS_BETTER[metric]
125-
return "🟢" if improved else "🔴"
139+
return _IMPROVED if improved else _REGRESSED
126140

127141

128-
def build_report(base: pd.DataFrame, pr: pd.DataFrame, delta: float = _DEFAULT_DELTA) -> str:
129-
"""Render the Markdown comparison table for two parsed benchmark groups."""
142+
def build_report(base: pd.DataFrame, pr: pd.DataFrame, delta: float = _DEFAULT_DELTA) -> tuple[str, bool]:
143+
"""Render the Markdown comparison table for two parsed benchmark groups.
144+
145+
Returns ``(report, regressed)`` where ``regressed`` is ``True`` if any row was
146+
classified as a regression. Missing results are not treated as a regression.
147+
"""
130148
if base.empty or pr.empty:
131149
missing = "base" if base.empty else "PR"
132-
return f"⚠️ No benchmark results found for the **{missing}** branch."
150+
return f"⚠️ No benchmark results found for the **{missing}** branch.", False
133151

134152
# Parameter columns identify a row: everything except variant/bench/workers,
135153
# the measurements, and any derived latency column (``... (ms)``).
@@ -154,7 +172,7 @@ def by_key(df: pd.DataFrame) -> dict:
154172
for r in rows if not pd.isna(r.get(metric))]
155173
agg[metric] = sum(vals) / len(vals) if vals else float("nan")
156174
# Retain the raw per-count samples so the significance guard in
157-
# ``_emoji`` can compare the base/PR ranges, not just the means.
175+
# ``_classify`` can compare the base/PR ranges, not just the means.
158176
# A plain string key avoids pandas treating a tuple as a
159177
# multi-index label on the Series.
160178
agg[f"_samples_{metric}"] = vals
@@ -164,6 +182,8 @@ def by_key(df: pd.DataFrame) -> dict:
164182
base_by_key = by_key(base)
165183
pr_by_key = by_key(pr)
166184

185+
regressed = False
186+
167187
lines = [
168188
"## 📊 Token Validation Benchmark",
169189
"",
@@ -192,9 +212,11 @@ def by_key(df: pd.DataFrame) -> dict:
192212
pct = _pct(bv, pv)
193213
base_samples = b.get(f"_samples_{metric}", [])
194214
pr_samples = p.get(f"_samples_{metric}", [])
215+
status = _classify(metric, pct, base_samples, pr_samples, delta)
216+
if status == _REGRESSED:
217+
regressed = True
195218
cells.append(f"{bv:,.0f}{pv:,.0f}")
196-
cells.append(
197-
f"{_emoji(metric, pct, base_samples, pr_samples)} {pct:+.1f}%")
219+
cells.append(f"{_STATUS_EMOJI[status]} {pct:+.1f}%")
198220
lines.append("| " + " | ".join(cells) + " |")
199221

200222
only_pr = pr_by_key.keys() - base_by_key.keys()
@@ -206,7 +228,7 @@ def by_key(df: pd.DataFrame) -> dict:
206228
lines += ["",
207229
f"> ℹ️ {len(only_base)} benchmark(s) present only on the base branch (removed/renamed)."]
208230

209-
return "\n".join(lines) + "\n"
231+
return "\n".join(lines) + "\n", regressed
210232

211233

212234
def main() -> None:
@@ -230,11 +252,17 @@ def main() -> None:
230252

231253
base = _load_group(args.input_dir, args.base_tag)
232254
pr = _load_group(args.input_dir, args.pr_tag)
233-
report = build_report(base, pr, delta=args.delta)
255+
report, regressed = build_report(base, pr, delta=args.delta)
234256

257+
# Always write and print the report so it is visible regardless of outcome.
235258
args.output.write_text(report)
236259
print(report)
237260

261+
# Signal a detected regression via a distinct exit code so callers (e.g. CI)
262+
# can gate on it without parsing the rendered Markdown/emoji.
263+
if regressed:
264+
sys.exit(EXIT_REGRESSION)
265+
238266

239267
if __name__ == "__main__":
240268
main()

0 commit comments

Comments
 (0)