@vitest-evals/github-reporter 0.16.0 → 0.17.0

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,19 @@ 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.
38
47
 
39
48
  ## Score and Pass-Rate Gates
40
49
 
@@ -49,9 +58,11 @@ workflow annotations and warns instead of failing.
49
58
 
50
59
  - `fail-on-failures: true` requires every eval case to pass
51
60
  - `min-pass-rate` and `min-score-average` set aggregate floors in the `0`-`1` range
52
- - `status`, Check Run conclusion/title, and step exit follow the gate
61
+ - `status` and Check Run conclusion/title follow the gate
53
62
  - quality misses become warnings when the gate still passes
54
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
55
66
  - use `evals-failed` / `pass-rate` for raw tallies (`pass-rate` is a 0-1 ratio)
56
67
 
57
68
  ## Sharded Reports
@@ -80,10 +91,12 @@ final reducer job:
80
91
  | `results` | `vitest-results.json` | Vitest JSON result files. Supports paths, `*` and `**` globs, and newline-separated entries. |
81
92
  | `publish-summary` | `true` | Write a GitHub Actions job summary. |
82
93
  | `publish-annotations` | `true` | Emit GitHub workflow annotations for failed evals. |
83
- | `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`. |
84
95
  | `check-name` | `vitest-evals` | Name of the GitHub Check Run. |
85
96
  | `github-token` | `${{ github.token }}` | Token used for Check Run publishing. |
97
+ | `sha` | PR head, else `GITHUB_SHA` | Commit SHA for the Check Run. |
86
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. |
87
100
  | `min-pass-rate` | unset | Minimum fraction of eval cases that must pass (`0`-`1`). |
88
101
  | `min-score-average` | unset | Minimum average eval score across scored cases (`0`-`1`). |
89
102
  | `max-annotations` | unset | Maximum number of failure annotations to publish. Check Run annotations are capped at 50 by GitHub. |
package/dist/cli.js CHANGED
@@ -41,6 +41,12 @@ 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;
44
50
  case "--min-pass-rate":
45
51
  options.minPassRate = readRatio(args, ++index, arg);
46
52
  break;
@@ -317,7 +323,8 @@ function collectEvalReport(input, options = {}) {
317
323
  const cases = workspace.cases.map(collectEvalCase);
318
324
  const failures = cases.filter((testCase) => testCase.status === "failed");
319
325
  const evalScores = cases.map((testCase) => testCase.eval?.avgScore).filter((score) => isFiniteNumber(score));
320
- const usage2 = sumUsage(cases);
326
+ const usage2 = sumAppUsage(cases);
327
+ const judgeUsage = sumJudgeUsage(cases);
321
328
  const durationMs = workspace.runs[0]?.durationMs;
322
329
  return {
323
330
  status: input.success && failures.length === 0 ? "passed" : "failed",
@@ -337,6 +344,7 @@ function collectEvalReport(input, options = {}) {
337
344
  minimum: Math.min(...evalScores)
338
345
  } : void 0,
339
346
  usage: usage2,
347
+ judgeUsage,
340
348
  cases,
341
349
  failures
342
350
  };
@@ -461,24 +469,67 @@ function stringifyReason(value) {
461
469
  }
462
470
  return typeof value === "string" ? value : stringifyValue(value, 4e3);
463
471
  }
464
- function sumUsage(cases) {
465
- const usage2 = {
472
+ function emptyUsage() {
473
+ return {
466
474
  inputTokens: 0,
467
475
  outputTokens: 0,
468
476
  reasoningTokens: 0,
469
477
  totalTokens: 0,
470
478
  toolCalls: 0
471
479
  };
480
+ }
481
+ function addRunUsage(total, usage2) {
482
+ total.inputTokens += usage2?.inputTokens ?? 0;
483
+ total.outputTokens += usage2?.outputTokens ?? 0;
484
+ total.reasoningTokens += usage2?.reasoningTokens ?? 0;
485
+ total.totalTokens += usage2?.totalTokens ?? (usage2?.inputTokens ?? 0) + (usage2?.outputTokens ?? 0) + (usage2?.reasoningTokens ?? 0);
486
+ if (usage2?.costUsd !== void 0) {
487
+ total.costUsd = (total.costUsd ?? 0) + usage2.costUsd;
488
+ }
489
+ total.toolCalls += usage2?.toolCalls ?? 0;
490
+ }
491
+ function sumAppUsage(cases) {
492
+ const usage2 = emptyUsage();
493
+ const runUsages = cases.map(appUsageForCase).filter((item) => item !== void 0);
494
+ for (const runUsage of runUsages) {
495
+ addRunUsage(usage2, runUsage);
496
+ }
497
+ omitPartialCost(usage2, runUsages);
498
+ return usage2;
499
+ }
500
+ function appUsageForCase(testCase) {
501
+ const usage2 = testCase.harness?.usage;
502
+ const effectiveToolCalls = toolCallCount(testCase);
503
+ if (!usage2 && effectiveToolCalls === 0) {
504
+ return void 0;
505
+ }
506
+ return {
507
+ ...usage2,
508
+ ...effectiveToolCalls > 0 ? { toolCalls: effectiveToolCalls } : {}
509
+ };
510
+ }
511
+ function sumJudgeUsage(cases) {
512
+ const usage2 = emptyUsage();
513
+ const runUsages = [];
472
514
  for (const testCase of cases) {
473
- const caseUsage = testCase.harness?.usage;
474
- usage2.inputTokens += caseUsage?.inputTokens ?? 0;
475
- usage2.outputTokens += caseUsage?.outputTokens ?? 0;
476
- usage2.reasoningTokens += caseUsage?.reasoningTokens ?? 0;
477
- usage2.totalTokens += caseUsage?.totalTokens ?? (caseUsage?.inputTokens ?? 0) + (caseUsage?.outputTokens ?? 0) + (caseUsage?.reasoningTokens ?? 0);
478
- usage2.toolCalls += toolCallCount(testCase);
515
+ for (const score of testCase.eval?.scores ?? []) {
516
+ for (const run of score.judgeRuns ?? []) {
517
+ runUsages.push(run.usage);
518
+ addRunUsage(usage2, run.usage);
519
+ }
520
+ }
479
521
  }
522
+ omitPartialCost(usage2, runUsages);
480
523
  return usage2;
481
524
  }
525
+ function omitPartialCost(total, usages) {
526
+ if (usages.some(hasUsageWithoutCost)) {
527
+ total.costUsd = void 0;
528
+ }
529
+ }
530
+ function hasUsageWithoutCost(usage2) {
531
+ return usage2?.costUsd === void 0 && ((usage2?.totalTokens ?? (usage2?.inputTokens ?? 0) + (usage2?.outputTokens ?? 0) + (usage2?.reasoningTokens ?? 0)) > 0 || (usage2?.toolCalls ?? 0) > 0);
532
+ }
482
533
  function toolCallCount(testCase) {
483
534
  const usageToolCalls = testCase.harness?.usage?.toolCalls;
484
535
  if (usageToolCalls !== void 0) {
@@ -515,8 +566,8 @@ function evaluateEvalGate(report, policy = {}) {
515
566
  status: "failed",
516
567
  enforced: true,
517
568
  passRate,
518
- title: "Eval report hard failure",
519
- message: `${formatNumber(nonEvalFailures)} non-eval test failure${nonEvalFailures === 1 ? "" : "s"}; ${counts}`
569
+ title: "Eval run failed",
570
+ message: `${formatNumber(nonEvalFailures)} test failure${nonEvalFailures === 1 ? "" : "s"} outside eval cases; ${counts}`
520
571
  };
521
572
  }
522
573
  if (report.totals.evalTotal === 0) {
@@ -525,7 +576,7 @@ function evaluateEvalGate(report, policy = {}) {
525
576
  status: "failed",
526
577
  enforced: true,
527
578
  passRate: null,
528
- title: "Eval report hard failure",
579
+ title: "Eval run failed",
529
580
  message: "no eval cases were reported"
530
581
  };
531
582
  }
@@ -535,8 +586,8 @@ function evaluateEvalGate(report, policy = {}) {
535
586
  status: "failed",
536
587
  enforced: true,
537
588
  passRate,
538
- title: "Eval report hard failure",
539
- message: `vitest run failed without counted test failures; ${counts}`
589
+ title: "Eval run failed",
590
+ message: `Vitest failed without reporting a failed test; ${counts}`
540
591
  };
541
592
  }
542
593
  if (minPassRate !== void 0 && (passRate === null || passRate + Number.EPSILON < minPassRate)) {
@@ -546,7 +597,7 @@ function evaluateEvalGate(report, policy = {}) {
546
597
  enforced: true,
547
598
  passRate,
548
599
  title: `Eval pass rate ${formatPercent(passRate)} \u2014 required ${formatPercent(minPassRate)}`,
549
- message: `eval pass rate below floor: ${counts}; required >= ${formatPercent(minPassRate)}`
600
+ message: `pass rate is below the minimum: ${counts}; minimum ${formatPercent(minPassRate)}`
550
601
  };
551
602
  }
552
603
  if (minScoreAverage !== void 0) {
@@ -557,8 +608,8 @@ function evaluateEvalGate(report, policy = {}) {
557
608
  status: "failed",
558
609
  enforced: true,
559
610
  passRate,
560
- title: "Eval score gate failed",
561
- message: `no score average available; required avg score >= ${formatScore(minScoreAverage)}`
611
+ title: "Average score unavailable",
612
+ message: `no average score was reported; minimum ${formatScore(minScoreAverage)}`
562
613
  };
563
614
  }
564
615
  if (average + Number.EPSILON < minScoreAverage) {
@@ -567,8 +618,8 @@ function evaluateEvalGate(report, policy = {}) {
567
618
  status: "failed",
568
619
  enforced: true,
569
620
  passRate,
570
- title: `Avg score ${formatScore(average)} \u2014 required ${formatScore(minScoreAverage)}`,
571
- message: `avg score below floor: ${counts}; required avg score >= ${formatScore(minScoreAverage)}`
621
+ title: `Average score ${formatScore(average)} \u2014 required ${formatScore(minScoreAverage)}`,
622
+ message: `average score is below the minimum: ${counts}; minimum ${formatScore(minScoreAverage)}`
572
623
  };
573
624
  }
574
625
  }
@@ -578,7 +629,7 @@ function evaluateEvalGate(report, policy = {}) {
578
629
  enforced: true,
579
630
  passRate,
580
631
  title: enforcedPassTitle(report, passRate, minPassRate, minScoreAverage),
581
- message: `eval gate passed: ${counts}${formatFloorSuffix(minPassRate, minScoreAverage)}`
632
+ message: `requirements met: ${counts}${formatMinimumSuffix(minPassRate, minScoreAverage)}`
582
633
  };
583
634
  }
584
635
  function computePassRate(report) {
@@ -611,7 +662,7 @@ function resolveMinPassRate(policy) {
611
662
  }
612
663
  function defaultCheckTitle(report) {
613
664
  if (report.failures.length === 0 && report.status === "passed") {
614
- return "No eval failures";
665
+ return "No eval cases failed";
615
666
  }
616
667
  if (report.failures.length === 0) {
617
668
  return "Vitest run failed";
@@ -620,10 +671,10 @@ function defaultCheckTitle(report) {
620
671
  }
621
672
  function enforcedPassTitle(report, passRate, minPassRate, minScoreAverage) {
622
673
  if (minPassRate !== void 0) {
623
- return `Eval pass rate ${formatPercent(passRate)} \u2014 floor ${formatPercent(minPassRate)}`;
674
+ return `Eval pass rate ${formatPercent(passRate)} \u2014 minimum ${formatPercent(minPassRate)}`;
624
675
  }
625
676
  if (minScoreAverage !== void 0) {
626
- return `Avg score ${formatScore(report.score?.average)} \u2014 floor ${formatScore(minScoreAverage)}`;
677
+ return `Average score ${formatScore(report.score?.average)} \u2014 minimum ${formatScore(minScoreAverage)}`;
627
678
  }
628
679
  return defaultCheckTitle(report);
629
680
  }
@@ -632,19 +683,22 @@ function formatEvalCounts(report, passRate) {
632
683
  const passRateText = passRate === null ? "n/a" : formatPercent(passRate);
633
684
  return `${formatNumber(report.totals.evalPassed)}/${formatNumber(
634
685
  report.totals.evalTotal
635
- )} passed (${passRateText}), avg score ${scoreText}`;
686
+ )} passed (${passRateText}), average score ${scoreText}`;
636
687
  }
637
- function formatFloorSuffix(minPassRate, minScoreAverage) {
688
+ function formatMinimumSuffix(minPassRate, minScoreAverage) {
638
689
  const parts = [];
639
690
  if (minPassRate !== void 0) {
640
- parts.push(`pass rate floor ${formatPercent(minPassRate)}`);
691
+ parts.push(`minimum pass rate ${formatPercent(minPassRate)}`);
641
692
  }
642
693
  if (minScoreAverage !== void 0) {
643
- parts.push(`avg score floor ${formatScore(minScoreAverage)}`);
694
+ parts.push(`minimum average score ${formatScore(minScoreAverage)}`);
644
695
  }
645
696
  return parts.length === 0 ? "" : `; ${parts.join(", ")}`;
646
697
  }
647
698
 
699
+ // src/github.ts
700
+ var import_node_fs = require("fs");
701
+
648
702
  // src/summary.ts
649
703
  var DEFAULT_MAX_FAILURES = 20;
650
704
  var DEFAULT_MAX_REASON_CHARS = 8e3;
@@ -672,23 +726,23 @@ function renderJobSummary(report, options = {}) {
672
726
  ""
673
727
  ];
674
728
  if (report.failures.length > 0) {
675
- const failureHeading = options.gate?.ok === true ? "### Quality Misses" : "### Failures";
729
+ const failureHeading = options.gate?.ok === true ? "### Cases Below Target" : "### Failures";
676
730
  lines.push(failureHeading, "");
677
731
  failures.forEach((testCase, index) => {
678
732
  lines.push(...renderFailureDetails(testCase, index + 1, options), "");
679
733
  });
680
734
  if (report.failures.length > failures.length) {
681
- const omittedLabel = options.gate?.ok === true ? "quality misses" : "failures";
735
+ const omittedLabel = options.gate?.ok === true ? "cases below target" : "failures";
682
736
  lines.push(
683
737
  `${report.failures.length - failures.length} more ${omittedLabel} omitted from this summary.`,
684
738
  ""
685
739
  );
686
740
  }
687
741
  } else if (report.totals.evalTotal > 0) {
688
- lines.push("### Failures", "", "No eval failures.", "");
742
+ lines.push("### Failures", "", "No eval cases failed.", "");
689
743
  }
690
744
  if (report.totals.evalTotal === 0) {
691
- lines.push("No eval metadata was found in the Vitest JSON report.", "");
745
+ lines.push("No eval results were found in the Vitest JSON report.", "");
692
746
  }
693
747
  return `${lines.join("\n")}
694
748
  `;
@@ -698,9 +752,9 @@ function formatCountLine(passed, failed, total) {
698
752
  }
699
753
  function renderSummaryTable(report, nonEvalFailures, gate) {
700
754
  const rows = [
701
- ["Status", gate?.status ?? report.status],
755
+ ["Status", capitalize(gate?.status ?? report.status)],
702
756
  [
703
- "Evals",
757
+ "Eval cases",
704
758
  formatCountLine(
705
759
  report.totals.evalPassed,
706
760
  report.totals.evalFailed,
@@ -709,23 +763,31 @@ function renderSummaryTable(report, nonEvalFailures, gate) {
709
763
  ]
710
764
  ];
711
765
  if (gate?.passRate !== void 0 && gate.passRate !== null) {
712
- rows.push(["Pass Rate", formatPercent(gate.passRate)]);
766
+ rows.push(["Pass rate", formatPercent(gate.passRate)]);
713
767
  } else if (report.totals.evalTotal > 0) {
714
768
  rows.push([
715
- "Pass Rate",
769
+ "Pass rate",
716
770
  formatPercent(report.totals.evalPassed / report.totals.evalTotal)
717
771
  ]);
718
772
  }
719
773
  if (report.score) {
720
774
  rows.push(["Score", formatScoreSummary(report.score)]);
721
775
  }
776
+ if (hasUsage(report.usage) || hasUsage(report.judgeUsage)) {
777
+ rows.push(["App usage", formatUsage(report.usage)]);
778
+ rows.push(["Judge usage", formatUsage(report.judgeUsage)]);
779
+ rows.push([
780
+ "Total usage",
781
+ formatUsage(sumUsage(report.usage, report.judgeUsage))
782
+ ]);
783
+ }
722
784
  if (gate?.enforced) {
723
- rows.push(["Gate", gate.message]);
785
+ rows.push(["Requirements", gate.message]);
724
786
  }
725
787
  if (nonEvalFailures > 0) {
726
788
  rows.push([
727
- "Other Failures",
728
- `${formatNumber(nonEvalFailures)} non-eval test failure${nonEvalFailures === 1 ? "" : "s"}`
789
+ "Other test failures",
790
+ `${formatNumber(nonEvalFailures)} test failure${nonEvalFailures === 1 ? "" : "s"} outside eval cases`
729
791
  ]);
730
792
  }
731
793
  rows.push(["Duration", formatDuration(report.durationMs)]);
@@ -738,7 +800,53 @@ function renderSummaryTable(report, nonEvalFailures, gate) {
738
800
  ];
739
801
  }
740
802
  function formatScoreSummary(score) {
741
- return `avg ${formatScore(score.average)}${score.minimum === void 0 ? "" : `, min ${formatScore(score.minimum)}`}`;
803
+ return `average ${formatScore(score.average)}${score.minimum === void 0 ? "" : `, lowest ${formatScore(score.minimum)}`}`;
804
+ }
805
+ function capitalize(value) {
806
+ return value.charAt(0).toUpperCase() + value.slice(1);
807
+ }
808
+ function hasUsage(usage2) {
809
+ return usage2.totalTokens > 0 || usage2.costUsd !== void 0 || usage2.toolCalls > 0;
810
+ }
811
+ function sumUsage(app, judge) {
812
+ const appCostKnown = !hasUsageWithoutCost2(app);
813
+ const judgeCostKnown = !hasUsageWithoutCost2(judge);
814
+ return {
815
+ inputTokens: app.inputTokens + judge.inputTokens,
816
+ outputTokens: app.outputTokens + judge.outputTokens,
817
+ reasoningTokens: app.reasoningTokens + judge.reasoningTokens,
818
+ totalTokens: app.totalTokens + judge.totalTokens,
819
+ ...appCostKnown && judgeCostKnown ? { costUsd: (app.costUsd ?? 0) + (judge.costUsd ?? 0) } : {},
820
+ toolCalls: app.toolCalls + judge.toolCalls
821
+ };
822
+ }
823
+ function hasUsageWithoutCost2(usage2) {
824
+ return usage2.costUsd === void 0 && (usage2.totalTokens > 0 || usage2.toolCalls > 0);
825
+ }
826
+ function formatUsage(usage2) {
827
+ const parts = [];
828
+ if (usage2.totalTokens > 0) {
829
+ parts.push(`${formatNumber(usage2.totalTokens)} tokens`);
830
+ }
831
+ if (usage2.costUsd !== void 0) {
832
+ parts.push(
833
+ usage2.costUsd.toLocaleString("en-US", {
834
+ style: "currency",
835
+ currency: "USD",
836
+ minimumFractionDigits: 2,
837
+ maximumFractionDigits: 6
838
+ })
839
+ );
840
+ }
841
+ if (hasUsageWithoutCost2(usage2)) {
842
+ parts.push("cost unavailable");
843
+ }
844
+ if (usage2.toolCalls > 0) {
845
+ parts.push(
846
+ `${formatNumber(usage2.toolCalls)} tool call${usage2.toolCalls === 1 ? "" : "s"}`
847
+ );
848
+ }
849
+ return parts.join(", ") || "none";
742
850
  }
743
851
  function escapeTableCell(value) {
744
852
  return value.replace(/\r?\n/g, " ").replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
@@ -818,7 +926,7 @@ function renderFailureBlock(testCase, {
818
926
  ["Case", `${number}. ${testCase.displayName}`],
819
927
  ["Status", testCase.status],
820
928
  ["Location", formatLocation(testCase.displayFile, testCase.location)],
821
- ["Harness", testCase.harness?.name ?? "n/a"],
929
+ ["App runner", testCase.harness?.name ?? "n/a"],
822
930
  ["Score", formatScore(failure?.score ?? testCase.eval?.avgScore)],
823
931
  ["Judge", failure?.judgeName ?? "n/a"]
824
932
  ];
@@ -856,7 +964,7 @@ function renderFailureBlock(testCase, {
856
964
  if (finalOutput !== void 0) {
857
965
  lines.push(
858
966
  ...renderAsciiSection(
859
- "Final Output",
967
+ "Output",
860
968
  stringifyValue(finalOutput, maxOutputChars).split(/\r?\n/)
861
969
  ),
862
970
  ""
@@ -884,7 +992,7 @@ function renderFailureBlock(testCase, {
884
992
  if (testCase.harness?.errors.length) {
885
993
  lines.push(
886
994
  ...renderAsciiSection(
887
- "Harness Errors",
995
+ "App errors",
888
996
  stringifyValue(testCase.harness.errors, maxReasonChars).split(/\r?\n/)
889
997
  ),
890
998
  ""
@@ -933,7 +1041,9 @@ function formatCaseUsage(testCase) {
933
1041
  parts.push(`${formatNumber(totalTokens)} tokens`);
934
1042
  }
935
1043
  if (toolCalls > 0) {
936
- parts.push(`${formatNumber(toolCalls)} tool${toolCalls === 1 ? "" : "s"}`);
1044
+ parts.push(
1045
+ `${formatNumber(toolCalls)} tool call${toolCalls === 1 ? "" : "s"}`
1046
+ );
937
1047
  }
938
1048
  if (testCase.harness?.timingMs !== void 0) {
939
1049
  parts.push(formatDuration(testCase.harness.timingMs));
@@ -945,10 +1055,44 @@ function formatCaseUsage(testCase) {
945
1055
  var DEFAULT_CHECK_NAME = "vitest-evals";
946
1056
  var MAX_CHECK_SUMMARY_LENGTH = 64e3;
947
1057
  var CHECK_SUMMARY_TRUNCATION_SUFFIX = "\n\n[truncated for GitHub Check Run]\n";
1058
+ function resolveCheckSha(env = process.env, options = {}) {
1059
+ const explicit = options.sha?.trim() || env.GITHUB_PR_HEAD_SHA?.trim();
1060
+ if (explicit) {
1061
+ return explicit;
1062
+ }
1063
+ const eventPath = options.eventPath?.trim() || env.GITHUB_EVENT_PATH?.trim();
1064
+ if (eventPath) {
1065
+ try {
1066
+ const event = JSON.parse((0, import_node_fs.readFileSync)(eventPath, "utf8"));
1067
+ const headSha = event.pull_request?.head?.sha;
1068
+ if (typeof headSha === "string" && headSha.trim()) {
1069
+ return headSha.trim();
1070
+ }
1071
+ } catch {
1072
+ }
1073
+ }
1074
+ return env.GITHUB_SHA?.trim() || void 0;
1075
+ }
1076
+ function resolveCheckDetailsUrl(env = process.env, options = {}) {
1077
+ const explicit = options.detailsUrl?.trim();
1078
+ if (explicit) {
1079
+ return explicit;
1080
+ }
1081
+ const server = env.GITHUB_SERVER_URL?.replace(/\/$/, "");
1082
+ const repository = env.GITHUB_REPOSITORY?.trim();
1083
+ const runId = env.GITHUB_RUN_ID?.trim();
1084
+ if (!server || !repository || !runId) {
1085
+ return void 0;
1086
+ }
1087
+ return `${server}/${repository}/actions/runs/${runId}`;
1088
+ }
948
1089
  async function publishCheckRun(report, options = {}) {
949
1090
  const token = options.token ?? process.env.GITHUB_TOKEN;
950
1091
  const repository = options.repository ?? process.env.GITHUB_REPOSITORY;
951
- const sha = options.sha ?? process.env.GITHUB_SHA;
1092
+ const sha = resolveCheckSha(process.env, { sha: options.sha });
1093
+ const detailsUrl = resolveCheckDetailsUrl(process.env, {
1094
+ detailsUrl: options.detailsUrl
1095
+ });
952
1096
  if (!token) {
953
1097
  return { status: "skipped", reason: "missing GITHUB_TOKEN" };
954
1098
  }
@@ -956,7 +1100,10 @@ async function publishCheckRun(report, options = {}) {
956
1100
  return { status: "skipped", reason: "missing GITHUB_REPOSITORY" };
957
1101
  }
958
1102
  if (!sha && options.checkRunId === void 0) {
959
- return { status: "skipped", reason: "missing GITHUB_SHA" };
1103
+ return {
1104
+ status: "skipped",
1105
+ reason: "missing commit SHA (set --sha / options.sha, GITHUB_PR_HEAD_SHA, pull_request.head.sha, or GITHUB_SHA)"
1106
+ };
960
1107
  }
961
1108
  const [owner, repo] = repository.split("/");
962
1109
  if (!owner || !repo) {
@@ -965,7 +1112,7 @@ async function publishCheckRun(report, options = {}) {
965
1112
  reason: `invalid GitHub repository: ${repository}`
966
1113
  };
967
1114
  }
968
- const payload = buildCheckRunPayload(report, options);
1115
+ const payload = buildCheckRunPayload(report, options, detailsUrl);
969
1116
  const apiUrl = options.apiUrl ?? process.env.GITHUB_API_URL ?? "https://api.github.com";
970
1117
  const requestUrl = options.checkRunId === void 0 ? `${apiUrl}/repos/${owner}/${repo}/check-runs` : `${apiUrl}/repos/${owner}/${repo}/check-runs/${options.checkRunId}`;
971
1118
  const response = await fetch(requestUrl, {
@@ -980,6 +1127,7 @@ async function publishCheckRun(report, options = {}) {
980
1127
  options.checkRunId === void 0 ? {
981
1128
  name: options.name ?? DEFAULT_CHECK_NAME,
982
1129
  head_sha: sha,
1130
+ ...options.externalId ? { external_id: options.externalId } : {},
983
1131
  ...payload
984
1132
  } : payload
985
1133
  )
@@ -994,10 +1142,11 @@ async function publishCheckRun(report, options = {}) {
994
1142
  return {
995
1143
  status: options.checkRunId === void 0 ? "created" : "updated",
996
1144
  id: data.id,
997
- htmlUrl: data.html_url
1145
+ htmlUrl: data.html_url,
1146
+ sha
998
1147
  };
999
1148
  }
1000
- function buildCheckRunPayload(report, options) {
1149
+ function buildCheckRunPayload(report, options, detailsUrl) {
1001
1150
  const gate = options.gate ?? evaluateEvalGate(report);
1002
1151
  const annotations = buildCheckAnnotations(report, {
1003
1152
  maxAnnotations: options.maxAnnotations,
@@ -1007,6 +1156,7 @@ function buildCheckRunPayload(report, options) {
1007
1156
  status: "completed",
1008
1157
  conclusion: gate.ok ? "success" : "failure",
1009
1158
  completed_at: (/* @__PURE__ */ new Date()).toISOString(),
1159
+ ...detailsUrl ? { details_url: detailsUrl } : {},
1010
1160
  output: {
1011
1161
  title: gate.title,
1012
1162
  summary: truncateCheckSummary(
@@ -1059,16 +1209,22 @@ function mergeEvalReports(reports) {
1059
1209
  minimum: Math.min(...scoredCases)
1060
1210
  } : void 0,
1061
1211
  usage: mergeUsage(reports.map((report) => report.usage)),
1212
+ judgeUsage: mergeUsage(reports.map((report) => report.judgeUsage)),
1062
1213
  cases,
1063
1214
  failures
1064
1215
  };
1065
1216
  }
1066
1217
  function mergeUsage(usages) {
1218
+ const costs = usages.map((usage2) => usage2.costUsd).filter((cost) => cost !== void 0);
1219
+ const costComplete = !usages.some(
1220
+ (usage2) => usage2.costUsd === void 0 && (usage2.totalTokens > 0 || usage2.toolCalls > 0)
1221
+ );
1067
1222
  return {
1068
1223
  inputTokens: sum(usages, (usage2) => usage2.inputTokens),
1069
1224
  outputTokens: sum(usages, (usage2) => usage2.outputTokens),
1070
1225
  reasoningTokens: sum(usages, (usage2) => usage2.reasoningTokens),
1071
1226
  totalTokens: sum(usages, (usage2) => usage2.totalTokens),
1227
+ ...costComplete && costs.length > 0 ? { costUsd: costs.reduce((total, cost) => total + cost, 0) } : {},
1072
1228
  toolCalls: sum(usages, (usage2) => usage2.toolCalls)
1073
1229
  };
1074
1230
  }
@@ -1160,11 +1316,17 @@ async function publishEvalReport(options) {
1160
1316
  name: options.checkName,
1161
1317
  repository: options.repository,
1162
1318
  sha: options.sha,
1319
+ detailsUrl: options.detailsUrl,
1320
+ externalId: options.externalId,
1163
1321
  token: options.token,
1164
1322
  gate
1165
1323
  });
1166
1324
  if (checkRun.status === "skipped") {
1167
1325
  options.warn?.(`GitHub Check Run skipped: ${checkRun.reason}`);
1326
+ } else if (checkRun.htmlUrl) {
1327
+ console.log(`published check run: ${checkRun.htmlUrl}`);
1328
+ } else if (checkRun.id !== void 0) {
1329
+ console.log(`published check run id: ${checkRun.id}`);
1168
1330
  }
1169
1331
  } catch (error) {
1170
1332
  const message = error instanceof Error ? error.message : String(error);
@@ -1174,14 +1336,21 @@ async function publishEvalReport(options) {
1174
1336
  options.warn?.(message);
1175
1337
  }
1176
1338
  }
1339
+ const gateFailed = gate.enforced && !gate.ok;
1340
+ const wantsSoftFail = options.softFail ?? options.checkRun === true;
1341
+ const softFail = wantsSoftFail && checkRunPublished(checkRun);
1342
+ const shouldFail = gateFailed && !softFail;
1177
1343
  return {
1178
1344
  report,
1179
1345
  resultFiles,
1180
1346
  gate,
1181
- shouldFail: gate.enforced && !gate.ok,
1347
+ shouldFail,
1182
1348
  checkRun
1183
1349
  };
1184
1350
  }
1351
+ function checkRunPublished(checkRun) {
1352
+ return checkRun?.status === "created" || checkRun?.status === "updated";
1353
+ }
1185
1354
 
1186
1355
  // src/cli.ts
1187
1356
  main().catch((error) => {
@@ -1206,6 +1375,7 @@ async function main() {
1206
1375
  checkName: options.checkName,
1207
1376
  failOnCheckError: options.failOnCheckError,
1208
1377
  failOnFailures: options.failOnFailures,
1378
+ softFail: options.softFail,
1209
1379
  minPassRate: options.minPassRate,
1210
1380
  minScoreAverage: options.minScoreAverage,
1211
1381
  maxAnnotations: options.maxAnnotations,
@@ -1241,12 +1411,14 @@ function usage() {
1241
1411
  " --fail-on-failures Exit non-zero when any eval case failed",
1242
1412
  " --min-pass-rate <0-1> Exit non-zero when eval pass rate is below this floor",
1243
1413
  " --min-score-average <0-1> Exit non-zero when average score is below this floor",
1414
+ " --soft-fail Keep exit 0 when a published Check Run owns gate status",
1415
+ " --no-soft-fail Always exit non-zero when an enforced gate fails",
1244
1416
  " --fail-on-check-error Fail when Check Run publishing fails",
1245
1417
  " --check-run-id <id> Update an existing Check Run",
1246
1418
  " --check-name <name> Check Run name (default: vitest-evals)",
1247
1419
  " --token <token> GitHub token (default: GITHUB_TOKEN)",
1248
1420
  " --repo <owner/repo> GitHub repository (default: GITHUB_REPOSITORY)",
1249
- " --sha <sha> Git commit SHA (default: GITHUB_SHA)",
1421
+ " --sha <sha> Git commit SHA (default: PR head, then GITHUB_SHA)",
1250
1422
  " --workspace <path> Workspace path for relative annotation files",
1251
1423
  " --max-annotations <n> Maximum annotations to emit",
1252
1424
  " --max-failures <n> Maximum failures to include in details"