@vitest-evals/github-reporter 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,10 +31,39 @@ steps:
31
31
  with:
32
32
  results: vitest-results.json
33
33
  publish-check: true
34
+ min-pass-rate: 0.8
34
35
  ```
35
36
 
37
+ On `pull_request`, the Check Run attaches to the PR head SHA (not the temporary
38
+ merge `GITHUB_SHA`) so it shows on the PR checks list. Override with the `sha`
39
+ input when needed.
40
+
41
+ When a Check Run publishes successfully with a gate, the action step soft-fails
42
+ by default so the Check Run owns green/red. If publishing is skipped, the step
43
+ still fails on a rejected gate. Set `soft-fail: false` to also fail the job.
44
+
36
45
  If configuration or permission is missing, the action keeps the job summary and
37
- workflow annotations and warns instead of failing.
46
+ workflow annotations and warns instead of failing solely for the missing check.
47
+
48
+ ## Score and Pass-Rate Gates
49
+
50
+ ```yaml
51
+ - id: report
52
+ uses: getsentry/vitest-evals@v0
53
+ with:
54
+ results: eval-results/*.json
55
+ publish-check: true
56
+ min-pass-rate: 0.8
57
+ ```
58
+
59
+ - `fail-on-failures: true` requires every eval case to pass
60
+ - `min-pass-rate` and `min-score-average` set aggregate floors in the `0`-`1` range
61
+ - `status` and Check Run conclusion/title follow the gate
62
+ - quality misses become warnings when the gate still passes
63
+ - non-eval / infrastructure failures still fail hard
64
+ - published Check Runs soft-fail the step by default; set `soft-fail: false` to
65
+ also fail the workflow job
66
+ - use `evals-failed` / `pass-rate` for raw tallies (`pass-rate` is a 0-1 ratio)
38
67
 
39
68
  ## Sharded Reports
40
69
 
@@ -52,6 +81,7 @@ final reducer job:
52
81
  with:
53
82
  results: eval-results/*.json
54
83
  publish-check: true
84
+ min-pass-rate: 0.8
55
85
  ```
56
86
 
57
87
  ## Inputs
@@ -61,10 +91,14 @@ final reducer job:
61
91
  | `results` | `vitest-results.json` | Vitest JSON result files. Supports paths, `*` and `**` globs, and newline-separated entries. |
62
92
  | `publish-summary` | `true` | Write a GitHub Actions job summary. |
63
93
  | `publish-annotations` | `true` | Emit GitHub workflow annotations for failed evals. |
64
- | `publish-check` | `false` | Publish one GitHub Check Run for the combined report. |
94
+ | `publish-check` | `false` | Publish one GitHub Check Run for the combined report. Attaches to PR head on `pull_request`. |
65
95
  | `check-name` | `vitest-evals` | Name of the GitHub Check Run. |
66
96
  | `github-token` | `${{ github.token }}` | Token used for Check Run publishing. |
67
- | `fail-on-failures` | `false` | Fail the action when the combined report failed. |
97
+ | `sha` | PR head, else `GITHUB_SHA` | Commit SHA for the Check Run. |
98
+ | `fail-on-failures` | `false` | Fail the action when any eval case failed. Equivalent to `min-pass-rate: 1`. |
99
+ | `soft-fail` | auto | Keep the step green when a published Check Run owns a failed gate. |
100
+ | `min-pass-rate` | unset | Minimum fraction of eval cases that must pass (`0`-`1`). |
101
+ | `min-score-average` | unset | Minimum average eval score across scored cases (`0`-`1`). |
68
102
  | `max-annotations` | unset | Maximum number of failure annotations to publish. Check Run annotations are capped at 50 by GitHub. |
69
103
  | `max-failures` | unset | Maximum number of detailed failures to include in summaries and checks. |
70
104
 
package/dist/cli.js CHANGED
@@ -41,6 +41,18 @@ function parseCliArgs(args, env = process.env) {
41
41
  case "--fail-on-check-error":
42
42
  options.failOnCheckError = true;
43
43
  break;
44
+ case "--soft-fail":
45
+ options.softFail = true;
46
+ break;
47
+ case "--no-soft-fail":
48
+ options.softFail = false;
49
+ break;
50
+ case "--min-pass-rate":
51
+ options.minPassRate = readRatio(args, ++index, arg);
52
+ break;
53
+ case "--min-score-average":
54
+ options.minScoreAverage = readRatio(args, ++index, arg);
55
+ break;
44
56
  case "--max-annotations":
45
57
  options.maxAnnotations = readInteger(args, ++index, arg);
46
58
  break;
@@ -100,6 +112,14 @@ function readInteger(args, index, flag) {
100
112
  }
101
113
  return Number(rawValue);
102
114
  }
115
+ function readRatio(args, index, flag) {
116
+ const rawValue = readValue(args, index, flag);
117
+ const parsed = Number(rawValue);
118
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
119
+ throw new Error(`Invalid ratio for ${flag}`);
120
+ }
121
+ return parsed;
122
+ }
103
123
 
104
124
  // src/report.ts
105
125
  var import_promises = require("fs/promises");
@@ -188,9 +208,10 @@ var DEFAULT_MAX_CHECK_ANNOTATIONS = 50;
188
208
  var MAX_CHECK_FIELD_LENGTH = 64e3;
189
209
  function renderWorkflowCommands(report, options = {}) {
190
210
  const maxAnnotations = options.maxAnnotations ?? DEFAULT_MAX_WORKFLOW_ANNOTATIONS;
211
+ const command = caseAnnotationCommand(options.gate);
191
212
  return report.failures.filter(hasAnnotationLocation).slice(0, maxAnnotations).map(
192
213
  (testCase) => formatWorkflowCommand({
193
- command: "error",
214
+ command,
194
215
  properties: {
195
216
  file: testCase.displayFile,
196
217
  line: String(testCase.location.line),
@@ -206,11 +227,12 @@ function buildCheckAnnotations(report, options = {}) {
206
227
  options.maxAnnotations ?? DEFAULT_MAX_CHECK_ANNOTATIONS,
207
228
  DEFAULT_MAX_CHECK_ANNOTATIONS
208
229
  );
230
+ const annotationLevel = caseAnnotationLevel(options.gate);
209
231
  return report.failures.filter(hasAnnotationLocation).slice(0, maxAnnotations).map((testCase) => ({
210
232
  path: testCase.displayFile,
211
233
  start_line: testCase.location.line,
212
234
  end_line: testCase.location.line,
213
- annotation_level: "failure",
235
+ annotation_level: annotationLevel,
214
236
  title: truncate(
215
237
  `${testCase.primaryFailure?.judgeName ?? "vitest-evals"} - ${testCase.displayName}`,
216
238
  255
@@ -222,6 +244,12 @@ function buildCheckAnnotations(report, options = {}) {
222
244
  raw_details: truncate(formatRawDetails(testCase), MAX_CHECK_FIELD_LENGTH)
223
245
  }));
224
246
  }
247
+ function caseAnnotationCommand(gate) {
248
+ return gate?.ok ? "warning" : "error";
249
+ }
250
+ function caseAnnotationLevel(gate) {
251
+ return gate?.ok ? "warning" : "failure";
252
+ }
225
253
  function hasAnnotationLocation(testCase) {
226
254
  return Boolean(testCase.location);
227
255
  }
@@ -465,6 +493,167 @@ function toolCallCount(testCase) {
465
493
  return testCase.toolCalls.length;
466
494
  }
467
495
 
496
+ // src/gate.ts
497
+ function evaluateEvalGate(report, policy = {}) {
498
+ const minPassRate = resolveMinPassRate(policy);
499
+ const minScoreAverage = policy.minScoreAverage;
500
+ const enforced = minPassRate !== void 0 || minScoreAverage !== void 0 || policy.failOnFailures === true;
501
+ const passRate = computePassRate(report);
502
+ const counts = formatEvalCounts(report, passRate);
503
+ if (!enforced) {
504
+ const ok = report.status === "passed";
505
+ return {
506
+ ok,
507
+ status: ok ? "passed" : "failed",
508
+ enforced: false,
509
+ passRate,
510
+ title: defaultCheckTitle(report),
511
+ message: ok ? `eval report passed: ${counts}` : `eval report failed: ${counts}`
512
+ };
513
+ }
514
+ const nonEvalFailures = Math.max(
515
+ 0,
516
+ report.totals.failed - report.totals.evalFailed
517
+ );
518
+ if (nonEvalFailures > 0) {
519
+ return {
520
+ ok: false,
521
+ status: "failed",
522
+ enforced: true,
523
+ passRate,
524
+ title: "Eval report hard failure",
525
+ message: `${formatNumber(nonEvalFailures)} non-eval test failure${nonEvalFailures === 1 ? "" : "s"}; ${counts}`
526
+ };
527
+ }
528
+ if (report.totals.evalTotal === 0) {
529
+ return {
530
+ ok: false,
531
+ status: "failed",
532
+ enforced: true,
533
+ passRate: null,
534
+ title: "Eval report hard failure",
535
+ message: "no eval cases were reported"
536
+ };
537
+ }
538
+ if (report.status === "failed" && report.totals.failed === 0 && report.failures.length === 0) {
539
+ return {
540
+ ok: false,
541
+ status: "failed",
542
+ enforced: true,
543
+ passRate,
544
+ title: "Eval report hard failure",
545
+ message: `vitest run failed without counted test failures; ${counts}`
546
+ };
547
+ }
548
+ if (minPassRate !== void 0 && (passRate === null || passRate + Number.EPSILON < minPassRate)) {
549
+ return {
550
+ ok: false,
551
+ status: "failed",
552
+ enforced: true,
553
+ passRate,
554
+ title: `Eval pass rate ${formatPercent(passRate)} \u2014 required ${formatPercent(minPassRate)}`,
555
+ message: `eval pass rate below floor: ${counts}; required >= ${formatPercent(minPassRate)}`
556
+ };
557
+ }
558
+ if (minScoreAverage !== void 0) {
559
+ const average = report.score?.average;
560
+ if (average === void 0 || !Number.isFinite(average)) {
561
+ return {
562
+ ok: false,
563
+ status: "failed",
564
+ enforced: true,
565
+ passRate,
566
+ title: "Eval score gate failed",
567
+ message: `no score average available; required avg score >= ${formatScore(minScoreAverage)}`
568
+ };
569
+ }
570
+ if (average + Number.EPSILON < minScoreAverage) {
571
+ return {
572
+ ok: false,
573
+ status: "failed",
574
+ enforced: true,
575
+ passRate,
576
+ title: `Avg score ${formatScore(average)} \u2014 required ${formatScore(minScoreAverage)}`,
577
+ message: `avg score below floor: ${counts}; required avg score >= ${formatScore(minScoreAverage)}`
578
+ };
579
+ }
580
+ }
581
+ return {
582
+ ok: true,
583
+ status: "passed",
584
+ enforced: true,
585
+ passRate,
586
+ title: enforcedPassTitle(report, passRate, minPassRate, minScoreAverage),
587
+ message: `eval gate passed: ${counts}${formatFloorSuffix(minPassRate, minScoreAverage)}`
588
+ };
589
+ }
590
+ function computePassRate(report) {
591
+ if (report.totals.evalTotal <= 0) {
592
+ return null;
593
+ }
594
+ return report.totals.evalPassed / report.totals.evalTotal;
595
+ }
596
+ function formatPercent(value) {
597
+ if (value == null || !Number.isFinite(value)) {
598
+ return "n/a";
599
+ }
600
+ return `${(value * 100).toFixed(1)}%`;
601
+ }
602
+ function renderGateWorkflowCommand(gate) {
603
+ if (gate.ok || !gate.enforced) {
604
+ return void 0;
605
+ }
606
+ return `::error title=${escapeCommandProperty(gate.title)}::${escapeCommandData(gate.message)}`;
607
+ }
608
+ function resolveMinPassRate(policy) {
609
+ const configured = policy.minPassRate;
610
+ if (policy.failOnFailures) {
611
+ if (configured === void 0) {
612
+ return 1;
613
+ }
614
+ return Math.max(configured, 1);
615
+ }
616
+ return configured;
617
+ }
618
+ function defaultCheckTitle(report) {
619
+ if (report.failures.length === 0 && report.status === "passed") {
620
+ return "No eval failures";
621
+ }
622
+ if (report.failures.length === 0) {
623
+ return "Vitest run failed";
624
+ }
625
+ return `${report.failures.length} eval failure${report.failures.length === 1 ? "" : "s"}`;
626
+ }
627
+ function enforcedPassTitle(report, passRate, minPassRate, minScoreAverage) {
628
+ if (minPassRate !== void 0) {
629
+ return `Eval pass rate ${formatPercent(passRate)} \u2014 floor ${formatPercent(minPassRate)}`;
630
+ }
631
+ if (minScoreAverage !== void 0) {
632
+ return `Avg score ${formatScore(report.score?.average)} \u2014 floor ${formatScore(minScoreAverage)}`;
633
+ }
634
+ return defaultCheckTitle(report);
635
+ }
636
+ function formatEvalCounts(report, passRate) {
637
+ const scoreText = report.score?.average === void 0 ? "n/a" : formatScore(report.score.average);
638
+ const passRateText = passRate === null ? "n/a" : formatPercent(passRate);
639
+ return `${formatNumber(report.totals.evalPassed)}/${formatNumber(
640
+ report.totals.evalTotal
641
+ )} passed (${passRateText}), avg score ${scoreText}`;
642
+ }
643
+ function formatFloorSuffix(minPassRate, minScoreAverage) {
644
+ const parts = [];
645
+ if (minPassRate !== void 0) {
646
+ parts.push(`pass rate floor ${formatPercent(minPassRate)}`);
647
+ }
648
+ if (minScoreAverage !== void 0) {
649
+ parts.push(`avg score floor ${formatScore(minScoreAverage)}`);
650
+ }
651
+ return parts.length === 0 ? "" : `; ${parts.join(", ")}`;
652
+ }
653
+
654
+ // src/github.ts
655
+ var import_node_fs = require("fs");
656
+
468
657
  // src/summary.ts
469
658
  var DEFAULT_MAX_FAILURES = 20;
470
659
  var DEFAULT_MAX_REASON_CHARS = 8e3;
@@ -485,20 +674,22 @@ function renderJobSummary(report, options = {}) {
485
674
  const lines = [
486
675
  "# vitest-evals",
487
676
  "",
488
- ...renderSummaryTable(report, nonEvalFailures),
677
+ ...renderSummaryTable(report, nonEvalFailures, options.gate),
489
678
  "",
490
679
  ...renderScoreDistribution(report),
491
680
  "## Results",
492
681
  ""
493
682
  ];
494
683
  if (report.failures.length > 0) {
495
- lines.push("### Failures", "");
684
+ const failureHeading = options.gate?.ok === true ? "### Quality Misses" : "### Failures";
685
+ lines.push(failureHeading, "");
496
686
  failures.forEach((testCase, index) => {
497
687
  lines.push(...renderFailureDetails(testCase, index + 1, options), "");
498
688
  });
499
689
  if (report.failures.length > failures.length) {
690
+ const omittedLabel = options.gate?.ok === true ? "quality misses" : "failures";
500
691
  lines.push(
501
- `${report.failures.length - failures.length} more failures omitted from this summary.`,
692
+ `${report.failures.length - failures.length} more ${omittedLabel} omitted from this summary.`,
502
693
  ""
503
694
  );
504
695
  }
@@ -514,9 +705,9 @@ function renderJobSummary(report, options = {}) {
514
705
  function formatCountLine(passed, failed, total) {
515
706
  return `${formatNumber(passed)} passed, ${formatNumber(failed)} failed, ${formatNumber(total)} total`;
516
707
  }
517
- function renderSummaryTable(report, nonEvalFailures) {
708
+ function renderSummaryTable(report, nonEvalFailures, gate) {
518
709
  const rows = [
519
- ["Status", report.status],
710
+ ["Status", gate?.status ?? report.status],
520
711
  [
521
712
  "Evals",
522
713
  formatCountLine(
@@ -526,9 +717,20 @@ function renderSummaryTable(report, nonEvalFailures) {
526
717
  )
527
718
  ]
528
719
  ];
720
+ if (gate?.passRate !== void 0 && gate.passRate !== null) {
721
+ rows.push(["Pass Rate", formatPercent(gate.passRate)]);
722
+ } else if (report.totals.evalTotal > 0) {
723
+ rows.push([
724
+ "Pass Rate",
725
+ formatPercent(report.totals.evalPassed / report.totals.evalTotal)
726
+ ]);
727
+ }
529
728
  if (report.score) {
530
729
  rows.push(["Score", formatScoreSummary(report.score)]);
531
730
  }
731
+ if (gate?.enforced) {
732
+ rows.push(["Gate", gate.message]);
733
+ }
532
734
  if (nonEvalFailures > 0) {
533
735
  rows.push([
534
736
  "Other Failures",
@@ -752,10 +954,44 @@ function formatCaseUsage(testCase) {
752
954
  var DEFAULT_CHECK_NAME = "vitest-evals";
753
955
  var MAX_CHECK_SUMMARY_LENGTH = 64e3;
754
956
  var CHECK_SUMMARY_TRUNCATION_SUFFIX = "\n\n[truncated for GitHub Check Run]\n";
957
+ function resolveCheckSha(env = process.env, options = {}) {
958
+ const explicit = options.sha?.trim() || env.GITHUB_PR_HEAD_SHA?.trim();
959
+ if (explicit) {
960
+ return explicit;
961
+ }
962
+ const eventPath = options.eventPath?.trim() || env.GITHUB_EVENT_PATH?.trim();
963
+ if (eventPath) {
964
+ try {
965
+ const event = JSON.parse((0, import_node_fs.readFileSync)(eventPath, "utf8"));
966
+ const headSha = event.pull_request?.head?.sha;
967
+ if (typeof headSha === "string" && headSha.trim()) {
968
+ return headSha.trim();
969
+ }
970
+ } catch {
971
+ }
972
+ }
973
+ return env.GITHUB_SHA?.trim() || void 0;
974
+ }
975
+ function resolveCheckDetailsUrl(env = process.env, options = {}) {
976
+ const explicit = options.detailsUrl?.trim();
977
+ if (explicit) {
978
+ return explicit;
979
+ }
980
+ const server = env.GITHUB_SERVER_URL?.replace(/\/$/, "");
981
+ const repository = env.GITHUB_REPOSITORY?.trim();
982
+ const runId = env.GITHUB_RUN_ID?.trim();
983
+ if (!server || !repository || !runId) {
984
+ return void 0;
985
+ }
986
+ return `${server}/${repository}/actions/runs/${runId}`;
987
+ }
755
988
  async function publishCheckRun(report, options = {}) {
756
989
  const token = options.token ?? process.env.GITHUB_TOKEN;
757
990
  const repository = options.repository ?? process.env.GITHUB_REPOSITORY;
758
- const sha = options.sha ?? process.env.GITHUB_SHA;
991
+ const sha = resolveCheckSha(process.env, { sha: options.sha });
992
+ const detailsUrl = resolveCheckDetailsUrl(process.env, {
993
+ detailsUrl: options.detailsUrl
994
+ });
759
995
  if (!token) {
760
996
  return { status: "skipped", reason: "missing GITHUB_TOKEN" };
761
997
  }
@@ -763,7 +999,10 @@ async function publishCheckRun(report, options = {}) {
763
999
  return { status: "skipped", reason: "missing GITHUB_REPOSITORY" };
764
1000
  }
765
1001
  if (!sha && options.checkRunId === void 0) {
766
- return { status: "skipped", reason: "missing GITHUB_SHA" };
1002
+ return {
1003
+ status: "skipped",
1004
+ reason: "missing commit SHA (set --sha / options.sha, GITHUB_PR_HEAD_SHA, pull_request.head.sha, or GITHUB_SHA)"
1005
+ };
767
1006
  }
768
1007
  const [owner, repo] = repository.split("/");
769
1008
  if (!owner || !repo) {
@@ -772,7 +1011,7 @@ async function publishCheckRun(report, options = {}) {
772
1011
  reason: `invalid GitHub repository: ${repository}`
773
1012
  };
774
1013
  }
775
- const payload = buildCheckRunPayload(report, options);
1014
+ const payload = buildCheckRunPayload(report, options, detailsUrl);
776
1015
  const apiUrl = options.apiUrl ?? process.env.GITHUB_API_URL ?? "https://api.github.com";
777
1016
  const requestUrl = options.checkRunId === void 0 ? `${apiUrl}/repos/${owner}/${repo}/check-runs` : `${apiUrl}/repos/${owner}/${repo}/check-runs/${options.checkRunId}`;
778
1017
  const response = await fetch(requestUrl, {
@@ -787,6 +1026,7 @@ async function publishCheckRun(report, options = {}) {
787
1026
  options.checkRunId === void 0 ? {
788
1027
  name: options.name ?? DEFAULT_CHECK_NAME,
789
1028
  head_sha: sha,
1029
+ ...options.externalId ? { external_id: options.externalId } : {},
790
1030
  ...payload
791
1031
  } : payload
792
1032
  )
@@ -801,27 +1041,31 @@ async function publishCheckRun(report, options = {}) {
801
1041
  return {
802
1042
  status: options.checkRunId === void 0 ? "created" : "updated",
803
1043
  id: data.id,
804
- htmlUrl: data.html_url
1044
+ htmlUrl: data.html_url,
1045
+ sha
805
1046
  };
806
1047
  }
807
- function buildCheckRunPayload(report, options) {
1048
+ function buildCheckRunPayload(report, options, detailsUrl) {
1049
+ const gate = options.gate ?? evaluateEvalGate(report);
808
1050
  const annotations = buildCheckAnnotations(report, {
809
- maxAnnotations: options.maxAnnotations
1051
+ maxAnnotations: options.maxAnnotations,
1052
+ gate
810
1053
  });
811
- const title = report.failures.length === 0 && report.status === "passed" ? "No eval failures" : report.failures.length === 0 ? "Vitest run failed" : `${report.failures.length} eval failure${report.failures.length === 1 ? "" : "s"}`;
812
1054
  return {
813
1055
  status: "completed",
814
- conclusion: report.status === "passed" ? "success" : "failure",
1056
+ conclusion: gate.ok ? "success" : "failure",
815
1057
  completed_at: (/* @__PURE__ */ new Date()).toISOString(),
1058
+ ...detailsUrl ? { details_url: detailsUrl } : {},
816
1059
  output: {
817
- title,
1060
+ title: gate.title,
818
1061
  summary: truncateCheckSummary(
819
1062
  renderJobSummary(report, {
820
1063
  ...options,
821
1064
  maxFailures: options.maxFailures ?? 5,
822
1065
  maxReasonChars: options.maxReasonChars ?? 4e3,
823
1066
  maxOutputChars: options.maxOutputChars ?? 2e3,
824
- maxToolCalls: options.maxToolCalls ?? 10
1067
+ maxToolCalls: options.maxToolCalls ?? 10,
1068
+ gate
825
1069
  })
826
1070
  ),
827
1071
  annotations
@@ -920,11 +1164,17 @@ async function publishEvalReport(options) {
920
1164
  })
921
1165
  );
922
1166
  const report = mergeEvalReports(reports);
1167
+ const gate = evaluateEvalGate(report, {
1168
+ failOnFailures: options.failOnFailures,
1169
+ minPassRate: options.minPassRate,
1170
+ minScoreAverage: options.minScoreAverage
1171
+ });
923
1172
  const summary = renderJobSummary(report, {
924
1173
  maxFailures: options.maxFailures,
925
1174
  maxOutputChars: options.maxOutputChars,
926
1175
  maxReasonChars: options.maxReasonChars,
927
- maxToolCalls: options.maxToolCalls
1176
+ maxToolCalls: options.maxToolCalls,
1177
+ gate
928
1178
  });
929
1179
  if (options.summaryEnabled !== false) {
930
1180
  if (options.summaryPath) {
@@ -935,8 +1185,13 @@ async function publishEvalReport(options) {
935
1185
  }
936
1186
  }
937
1187
  if (options.annotations) {
1188
+ const gateCommand = renderGateWorkflowCommand(gate);
1189
+ if (gateCommand) {
1190
+ console.log(gateCommand);
1191
+ }
938
1192
  for (const command of renderWorkflowCommands(report, {
939
- maxAnnotations: options.maxAnnotations
1193
+ maxAnnotations: options.maxAnnotations,
1194
+ gate
940
1195
  })) {
941
1196
  console.log(command);
942
1197
  }
@@ -954,10 +1209,17 @@ async function publishEvalReport(options) {
954
1209
  name: options.checkName,
955
1210
  repository: options.repository,
956
1211
  sha: options.sha,
957
- token: options.token
1212
+ detailsUrl: options.detailsUrl,
1213
+ externalId: options.externalId,
1214
+ token: options.token,
1215
+ gate
958
1216
  });
959
1217
  if (checkRun.status === "skipped") {
960
1218
  options.warn?.(`GitHub Check Run skipped: ${checkRun.reason}`);
1219
+ } else if (checkRun.htmlUrl) {
1220
+ console.log(`published check run: ${checkRun.htmlUrl}`);
1221
+ } else if (checkRun.id !== void 0) {
1222
+ console.log(`published check run id: ${checkRun.id}`);
961
1223
  }
962
1224
  } catch (error) {
963
1225
  const message = error instanceof Error ? error.message : String(error);
@@ -967,12 +1229,21 @@ async function publishEvalReport(options) {
967
1229
  options.warn?.(message);
968
1230
  }
969
1231
  }
1232
+ const gateFailed = gate.enforced && !gate.ok;
1233
+ const wantsSoftFail = options.softFail ?? options.checkRun === true;
1234
+ const softFail = wantsSoftFail && checkRunPublished(checkRun);
1235
+ const shouldFail = gateFailed && !softFail;
970
1236
  return {
971
1237
  report,
972
1238
  resultFiles,
1239
+ gate,
1240
+ shouldFail,
973
1241
  checkRun
974
1242
  };
975
1243
  }
1244
+ function checkRunPublished(checkRun) {
1245
+ return checkRun?.status === "created" || checkRun?.status === "updated";
1246
+ }
976
1247
 
977
1248
  // src/cli.ts
978
1249
  main().catch((error) => {
@@ -996,6 +1267,10 @@ async function main() {
996
1267
  checkRunId: options.checkRunId,
997
1268
  checkName: options.checkName,
998
1269
  failOnCheckError: options.failOnCheckError,
1270
+ failOnFailures: options.failOnFailures,
1271
+ softFail: options.softFail,
1272
+ minPassRate: options.minPassRate,
1273
+ minScoreAverage: options.minScoreAverage,
999
1274
  maxAnnotations: options.maxAnnotations,
1000
1275
  maxFailures: options.maxFailures,
1001
1276
  repository: options.repository,
@@ -1003,7 +1278,8 @@ async function main() {
1003
1278
  token: options.token,
1004
1279
  warn
1005
1280
  });
1006
- if (options.failOnFailures && result.report.status === "failed") {
1281
+ if (result.shouldFail) {
1282
+ console.error(result.gate.message);
1007
1283
  process.exitCode = 1;
1008
1284
  }
1009
1285
  }
@@ -1025,13 +1301,17 @@ function usage() {
1025
1301
  " --annotations Emit GitHub workflow-command annotations",
1026
1302
  " --no-annotations Disable workflow-command annotations",
1027
1303
  " --check-run Publish a GitHub Check Run when configured",
1028
- " --fail-on-failures Exit non-zero when the combined report failed",
1304
+ " --fail-on-failures Exit non-zero when any eval case failed",
1305
+ " --min-pass-rate <0-1> Exit non-zero when eval pass rate is below this floor",
1306
+ " --min-score-average <0-1> Exit non-zero when average score is below this floor",
1307
+ " --soft-fail Keep exit 0 when a published Check Run owns gate status",
1308
+ " --no-soft-fail Always exit non-zero when an enforced gate fails",
1029
1309
  " --fail-on-check-error Fail when Check Run publishing fails",
1030
1310
  " --check-run-id <id> Update an existing Check Run",
1031
1311
  " --check-name <name> Check Run name (default: vitest-evals)",
1032
1312
  " --token <token> GitHub token (default: GITHUB_TOKEN)",
1033
1313
  " --repo <owner/repo> GitHub repository (default: GITHUB_REPOSITORY)",
1034
- " --sha <sha> Git commit SHA (default: GITHUB_SHA)",
1314
+ " --sha <sha> Git commit SHA (default: PR head, then GITHUB_SHA)",
1035
1315
  " --workspace <path> Workspace path for relative annotation files",
1036
1316
  " --max-annotations <n> Maximum annotations to emit",
1037
1317
  " --max-failures <n> Maximum failures to include in details"