@codyswann/lisa 4.26.0 → 4.26.2

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.
Files changed (71) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +64 -1
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +18 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts +9 -4
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts.map +1 -1
  7. package/dist/core/nightly-e2e-guard-behavior-certificate.js +14 -7
  8. package/dist/core/nightly-e2e-guard-behavior-certificate.js.map +1 -1
  9. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  10. package/dist/core/upstream-evidence-manifest.js +8 -5
  11. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  12. package/dist/opencode/plugin-templates/parity-safety-net.sh +64 -1
  13. package/expo/create-only/.github/workflows/nightly-e2e-report.yml +21 -6
  14. package/package.json +3 -3
  15. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa/hooks/parity-safety-net.sh +64 -1
  18. package/plugins/lisa-agy/hooks/parity-safety-net.sh +64 -1
  19. package/plugins/lisa-agy/plugin.json +1 -1
  20. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  23. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +64 -1
  27. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +64 -1
  29. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-expo-agy/plugin.json +1 -1
  32. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  52. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails-agy/plugin.json +1 -1
  57. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  62. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  67. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/src/base/hooks/parity-safety-net.sh +64 -1
  70. package/scripts/generate-nightly-e2e-guard-certificate.mjs +22 -1
  71. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +575 -82
@@ -28,9 +28,24 @@
28
28
  * it stays red, closed when a complete green run lands.
29
29
  *
30
30
  * They are separate because filing is reporting, not verdict. Issue writes live
31
- * behind `apiWrite`, reachable only from `runReport`; the gate path cannot reach
32
- * them, and the gate's reusable workflow requests no `issues:` scope. An Issues
33
- * API that is down must never be able to redden a required check.
31
+ * behind `apiWrite`, reachable only from `applyIssuePlan`, which only
32
+ * `--report-issues` reaches; the gate path cannot reach them, and the gate's
33
+ * reusable workflow requests no `issues:` scope. An Issues API that is down must
34
+ * never be able to redden a required check.
35
+ *
36
+ * ## `--report-issues` publishes BEST EFFORT, and the summary is the report
37
+ *
38
+ * The tracking issue is a convenience; the verdict is the product. So the
39
+ * reporting half resolves the verdict, writes it to `$GITHUB_STEP_SUMMARY`
40
+ * FIRST, and only then attempts to publish — with the publish's outcome
41
+ * absorbed into a `::warning::` that cannot change the job's result. A report
42
+ * channel must not die because its optional publishing destination is
43
+ * unavailable, which is precisely what a repository that switched GitHub Issues
44
+ * off measured: three attempts at `POST /repos/{o}/{r}/issues`, HTTP 410, exit 1
45
+ * — a dead publisher reported as a dead reporter (§10.4).
46
+ *
47
+ * Its exit code therefore answers "is the suite green", and nothing else. See
48
+ * §10.4 for the full three-outcome contract.
34
49
  *
35
50
  * Zero dependencies and no install step, on purpose: a gate that sits on every
36
51
  * pull request has to be cheap enough to stay uncontroversial, and a gate that
@@ -96,6 +111,42 @@
96
111
  * label, because manufacturing a bypass surface in a repository that never
97
112
  * adopted §6 is a new hole rather than a closed one.
98
113
  *
114
+ * ## The newest run is not the newest CONCLUSIVE run (contract 1.8.0, rows 41-42)
115
+ *
116
+ * Measured on 2026-08-29 in a consuming repository: two runs of the same native
117
+ * suite, seven seconds apart. The first concluded `success` with all eight jobs
118
+ * green — the whole suite executed. The second concluded `cancelled` with one
119
+ * cancelled preflight job and nothing else, because the suite's `concurrency`
120
+ * group keeps only ONE pending run and displaces the rest. The second run tested
121
+ * nothing, but it was the newest completed run, so the gate scored it and
122
+ * reported `⚪ UNKNOWN [cancelled]` over a suite that had genuinely passed. The
123
+ * gate is a required check, so that blocked every merge for three hours.
124
+ *
125
+ * Refusing to score a cancellation green stays exactly as it was (rows 6-8).
126
+ * The defect is one layer upstream: SELECTION. `observe` now walks candidates
127
+ * newest-first and stops at the first that produced evidence.
128
+ *
129
+ * "Produced evidence" is decided from the run's JOBS, not from a status
130
+ * allowlist, because `cancelled` is overloaded across at least three causes —
131
+ * a duplicate displaced by the concurrency group (tested nothing), a job killed
132
+ * at its own `timeout-minutes` ceiling (tested most of the suite), and an
133
+ * operator cancel (anywhere in between) — and only the first may ever be walked
134
+ * past. See `runProducedEvidence`.
135
+ *
136
+ * Bounded twice over: the walk stops at the first run with evidence, and it
137
+ * never leaves the arm's own freshness window. Without the second bound a stale
138
+ * green could hold the gate open indefinitely, which is the opposite failure and
139
+ * a worse one. When nothing conclusive is found inside the window it falls back
140
+ * to the newest run, so `stale_run` / `indecisive_conclusion` / `no_run` still
141
+ * fire and the gate still blocks. That fallback is what makes this change unable
142
+ * to invent a pass: it may only ever promote an older FRESH, CONCLUSIVE run over
143
+ * a run that tested nothing.
144
+ *
145
+ * And it SAYS SO. The original failure was silent — a real verdict was replaced
146
+ * with no trace — so every finding carries a `selection` record naming the run
147
+ * that was scored and every run walked past with the reason. A gate that quietly
148
+ * changes which run it scores is the same class of defect one layer down.
149
+ *
99
150
  * ## Inherited from three implementations, with one path closed
100
151
  *
101
152
  * `DECISIVE_CONCLUSIONS` comes from acmeorgb's `check-nightly-e2e.mjs` and is
@@ -118,7 +169,7 @@ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
118
169
  * rather than running a contract neither half agrees on. See §8 of
119
170
  * `docs/nightly-e2e-gate.md` for what counts as major / minor / patch.
120
171
  */
121
- export const NIGHTLY_E2E_CONTRACT_VERSION = "1.7.0";
172
+ export const NIGHTLY_E2E_CONTRACT_VERSION = "1.9.0";
122
173
 
123
174
  /**
124
175
  * The conclusions that constitute a verdict about the code.
@@ -152,6 +203,19 @@ export const GREEN_CONCLUSION = "success";
152
203
  */
153
204
  export const COUNTED_EVENTS = Object.freeze(["schedule", "workflow_dispatch"]);
154
205
 
206
+ /**
207
+ * How many completed runs per event the selection walk (rows 41-42) may read.
208
+ *
209
+ * Bounded on purpose. The walk exists to step over runs that tested nothing, and
210
+ * every candidate it examines costs a job read on the merge-gate path; an
211
+ * unbounded walk would turn a long chain of displaced duplicates into an
212
+ * unbounded number of requests. Ten is ample — the measured displacement chain
213
+ * that motivated this was two runs, seven seconds apart — and in the ordinary
214
+ * case where the newest run is conclusive the walk still reads exactly one run's
215
+ * jobs, the same as before. The freshness window bounds it a second time.
216
+ */
217
+ export const RUN_CANDIDATE_PAGE_SIZE = 10;
218
+
155
219
  /** Suite states. `unknown` is "no readable verdict", which is not a pass. */
156
220
  export const SUITE_STATES = Object.freeze({
157
221
  pass: "pass",
@@ -814,6 +878,67 @@ export function stateForConclusion(conclusion) {
814
878
  return SUITE_STATES.unknown;
815
879
  }
816
880
 
881
+ /**
882
+ * Whether a run produced EVIDENCE about the code — rows 41-42's discriminator.
883
+ *
884
+ * This decides only whether a run is worth SCORING, never what its verdict is.
885
+ * A run with evidence goes to `assessSuite` and is judged there exactly as
886
+ * before; a run without evidence is stepped over so the next-older candidate can
887
+ * be judged instead.
888
+ *
889
+ * Evidence is read from the run's JOBS rather than from a status allowlist,
890
+ * because `cancelled` is overloaded across at least three distinct causes — a
891
+ * duplicate displaced by the suite's `concurrency` group (tested nothing), a job
892
+ * killed at its own `timeout-minutes` ceiling (tested most of the suite), and an
893
+ * operator cancel (anywhere in between). A status-only rule cannot tell them
894
+ * apart, and only the first may ever be walked past.
895
+ *
896
+ * Four cases, and three of them answer "scoreable":
897
+ *
898
+ * 1. A DECISIVE run conclusion is evidence whatever it says — including
899
+ * `failure`. Nothing here may skip over a red run to find an older green.
900
+ * 2. An indecisive conclusion whose jobs contain at least one decisive outcome
901
+ * is PARTIAL evidence. Scoreable, and it will block, because a suite killed
902
+ * part-way through reached no verdict. Deliberately not skipped: walking past
903
+ * it to an older green would RELAX the gate, which is the failure mode a fix
904
+ * here must not introduce.
905
+ * 3. An UNREAD job list — empty, or a list that would not load, whether it
906
+ * failed on the first page or part-way through — is scoreable. Fail-closed:
907
+ * a completed run always has at least one job, so an empty list is an unread
908
+ * one, and "we could not check" is never grounds to skip a run. A PARTIAL
909
+ * read belongs here rather than in case 4 for exactly the same reason: 100
910
+ * indecisive jobs and an unreadable page 2 do not show that the run tested
911
+ * nothing, and skipping it would hide a failure on the page that would not
912
+ * load behind an older green.
913
+ * 4. An indecisive conclusion whose job list was read IN FULL and holds no
914
+ * decisive outcome tested nothing. This is the only case that is skipped.
915
+ *
916
+ * @param {object} run - An Actions run
917
+ * @param {ReadonlyArray<object>|null|undefined} jobs - That run's jobs, as read
918
+ * @param {boolean} [complete] - Whether that job list was read to its end; a partial read is never grounds to skip
919
+ * @returns {boolean} True when the run is worth scoring
920
+ */
921
+ export function runProducedEvidence(run, jobs, complete = true) {
922
+ if (DECISIVE_CONCLUSIONS.has(run?.conclusion)) return true;
923
+ if (!Array.isArray(jobs) || jobs.length === 0) return true;
924
+ if (complete !== true) return true;
925
+ return jobs.some(job => DECISIVE_CONCLUSIONS.has(job?.conclusion));
926
+ }
927
+
928
+ /**
929
+ * Counts how many of a run's jobs reached a verdict.
930
+ *
931
+ * Carried onto the `selection` record so the report can say "0 of 1 job(s)
932
+ * reached a verdict" rather than the unfalsifiable "it tested nothing".
933
+ *
934
+ * @param {ReadonlyArray<object>|null|undefined} jobs - A run's jobs
935
+ * @returns {number} How many carry a decisive conclusion
936
+ */
937
+ export function countDecisiveJobs(jobs) {
938
+ if (!Array.isArray(jobs)) return 0;
939
+ return jobs.filter(job => DECISIVE_CONCLUSIONS.has(job?.conclusion)).length;
940
+ }
941
+
817
942
  /**
818
943
  * Reads the scope markers a run published, from its artifact NAMES.
819
944
  *
@@ -969,7 +1094,16 @@ export function assessSuiteScope(suite, scope) {
969
1094
  * @returns {{label: string, state: string, reason: string, conclusion: string|null, url: string|null, createdAt: string|null, event: string|null}} The finding
970
1095
  */
971
1096
  export function assessSuite(suite, observation, context) {
972
- const base = { label: suite.label, workflow: suite.workflow };
1097
+ // Rows 41-42. Attached to EVERY return path, because the defect being fixed
1098
+ // was a gate silently changing which run it scored — a selection nobody can
1099
+ // see is the same class of defect one layer down. Attached only when the
1100
+ // observation carried one, so a caller that never went through `observe`
1101
+ // produces byte-identical findings.
1102
+ const base = {
1103
+ label: suite.label,
1104
+ workflow: suite.workflow,
1105
+ ...(observation.selection ? { selection: observation.selection } : {}),
1106
+ };
973
1107
  const blank = { conclusion: null, url: null, createdAt: null, event: null };
974
1108
  // An observation with no `scope` at all is the same fact as an artifacts list
975
1109
  // that would not load — "nobody asked" and "the answer did not come back" are
@@ -993,6 +1127,27 @@ export function assessSuite(suite, observation, context) {
993
1127
  * @returns {object} The finding
994
1128
  */
995
1129
  const green = (seen, reason) => {
1130
+ // Row 26's rule, applied to the half of it a job list cannot show you.
1131
+ // Every green below is argued from the jobs that were READ, so a list the
1132
+ // walk could not finish reading cannot support one: a `success` run whose
1133
+ // page 2 would not load looks, from page 1, exactly like a `success` run
1134
+ // with nothing behind it — and the failing shard hides on the page that
1135
+ // 404'd. The empty-list case already landed on `incomplete_run` for this
1136
+ // reason; a PARTIAL read is the same "we could not check", and it must not
1137
+ // render as "it is fine" either.
1138
+ //
1139
+ // Placed inside `green` on purpose, so it can only ever turn a PASS into
1140
+ // `unknown`. A decisive run or job conclusion returns before reaching here,
1141
+ // which keeps `failure` conclusive and unswallowable — an unreadable page
1142
+ // is never allowed to soften a red into an inconclusive.
1143
+ if (observation.jobsComplete === false) {
1144
+ return {
1145
+ ...base,
1146
+ ...seen,
1147
+ state: SUITE_STATES.unknown,
1148
+ reason: INCOMPLETE_EVIDENCE_REASON,
1149
+ };
1150
+ }
996
1151
  const disqualifier = assessSuiteScope(suite, scope);
997
1152
  if (disqualifier) {
998
1153
  return {
@@ -1544,14 +1699,15 @@ const REASON_TEXT = Object.freeze({
1544
1699
  "the workflow file this gate watches does not exist any more. Someone renamed or deleted the suite out from under the gate — fix the `suites` table or restore the workflow.",
1545
1700
  no_run: "no completed run on this branch at all.",
1546
1701
  stale_run: "no completed run inside the freshness window.",
1547
- wrong_branch: "the newest run is on a different branch.",
1548
- stale_sha: "the newest run is for a different commit than the one required.",
1702
+ wrong_branch: "the run this gate scored is on a different branch.",
1703
+ stale_sha:
1704
+ "the run this gate scored is for a different commit than the one required.",
1549
1705
  indecisive_conclusion:
1550
- "the newest run reached no verdict about the code (cancelled / skipped / neutral). That is not a green — cancelling a run must never be a one-click way to clear a merge gate.",
1706
+ "the run this gate scored reached no verdict about the code (cancelled / skipped / neutral). That is not a green — cancelling a run must never be a one-click way to clear a merge gate. The `↳` line beneath names the run that was scored and every run walked past for having tested nothing, so a displaced duplicate is no longer mistaken for last night's verdict.",
1551
1707
  job_not_found:
1552
1708
  "the run completed without ever producing the job this gate reads. The job was renamed, which silently disarms the gate.",
1553
1709
  pattern_matched_nothing:
1554
- "the job pattern matched zero jobs in the newest run. Zero matches is the signature of a renamed job.",
1710
+ "the job pattern matched zero jobs in the run this gate scored. Zero matches is the signature of a renamed job.",
1555
1711
  [INCOMPLETE_EVIDENCE_REASON]:
1556
1712
  "the run reported `success`, but it did not run everything: at least one job was skipped, failed under `continue-on-error`, or could not be read. A run that skipped part of itself did not gather the evidence its green claims — a suite re-run for one platform only is not a verdict about the other one. Re-run the suite WITHOUT narrowing it.",
1557
1713
  [FILTERED_RUN_REASON]:
@@ -1610,6 +1766,53 @@ export function formatFinding(finding) {
1610
1766
  return `${marker} ${finding.label} — ${verdictWord}${conclusion}${when} — ${detail}${scopeDetail}${link}${grace}`;
1611
1767
  }
1612
1768
 
1769
+ /**
1770
+ * Renders which run a finding was scored on, and every run walked past.
1771
+ *
1772
+ * Rows 41-42, and it prints on EVERY finding that has a selection — greens
1773
+ * included. The defect this closes was silent: a run that tested nothing
1774
+ * replaced the verdict of a run that tested everything, with no trace, and three
1775
+ * hours went into debugging a suite that was fine. A gate that quietly changes
1776
+ * which run it scores is the same defect one layer down, so the selection is not
1777
+ * conditional on anything having gone wrong.
1778
+ *
1779
+ * A scored run that reached NO verdict also names its cause, from the job
1780
+ * counts the walk already read. `cancelled` is overloaded across a displaced
1781
+ * duplicate, a job killed at its own `timeout-minutes` ceiling, and an operator
1782
+ * cancel; "0 of 1 job(s) reached a verdict" and "7 of 8" are different enough
1783
+ * that a reader can tell "it never started" from "it ran out of time" without
1784
+ * opening the run. That costs nothing — the counts come from jobs already
1785
+ * fetched, and no workflow job configuration is read to produce them.
1786
+ *
1787
+ * A DECISIVE conclusion gets no such suffix. `success` and `failure` already say
1788
+ * what happened, and appending job arithmetic to every green is noise.
1789
+ *
1790
+ * @param {object|null|undefined} selection - The finding's selection record
1791
+ * @returns {string|null} One trailing line, or null when there is nothing to say
1792
+ */
1793
+ export function formatSelection(selection) {
1794
+ if (!selection) return null;
1795
+ const conclusion = selection.conclusion ?? null;
1796
+ const counted =
1797
+ !DECISIVE_CONCLUSIONS.has(conclusion) &&
1798
+ typeof selection.decisiveJobs === "number" &&
1799
+ typeof selection.totalJobs === "number";
1800
+ const cause = counted
1801
+ ? ` — ${selection.decisiveJobs} of ${selection.totalJobs} job(s) reached a verdict, so it ${selection.decisiveJobs === 0 ? "tested nothing" : "ran but did not finish"}`
1802
+ : "";
1803
+ const scored = `↳ scored run ${selection.runId ?? "?"} (${conclusion ?? "no conclusion"}${cause}, ${selection.createdAt ?? "unknown time"})`;
1804
+ const fallback = selection.fellBack
1805
+ ? " (no conclusive run in the freshness window — fell back to the newest)"
1806
+ : "";
1807
+ const skipped = (selection.skipped ?? [])
1808
+ .map(
1809
+ entry =>
1810
+ `${entry.runId ?? "?"} [${entry.conclusion ?? "no conclusion"} — ${entry.decisiveJobs} of ${entry.totalJobs} job(s) reached a verdict, so it tested nothing]`
1811
+ )
1812
+ .join(", ");
1813
+ return `${scored}${fallback}${skipped ? `; skipped ${skipped}` : ""}`;
1814
+ }
1815
+
1613
1816
  /**
1614
1817
  * Renders the full report.
1615
1818
  *
@@ -1619,7 +1822,13 @@ export function formatFinding(finding) {
1619
1822
  */
1620
1823
  export function formatReport(verdict, context) {
1621
1824
  const lines = ["## 🌙 Nightly E2E Health", ""];
1622
- lines.push(...verdict.findings.map(finding => `- ${formatFinding(finding)}`));
1825
+ lines.push(
1826
+ ...verdict.findings.flatMap(finding => {
1827
+ const line = `- ${formatFinding(finding)}`;
1828
+ const selection = formatSelection(finding.selection);
1829
+ return selection === null ? [line] : [line, ` - ${selection}`];
1830
+ })
1831
+ );
1623
1832
  lines.push("");
1624
1833
 
1625
1834
  // A limit the caller asked for and did not get must be visible, or the gate
@@ -2084,7 +2293,11 @@ function issueBody(finding, context) {
2084
2293
  `| Workflow | \`${finding.workflow ?? "—"}\` |`,
2085
2294
  `| Branch | \`${context.branch}\` |`,
2086
2295
  `| Blocks merges | ${blockingCell} |`,
2087
- `| Newest run | ${finding.conclusion ?? "—"}${finding.createdAt ? ` at ${finding.createdAt}` : ""}${finding.event ? ` via \`${finding.event}\`` : ""} |`,
2296
+ // "Scored", not "Newest": since the selection walk landed, the run this
2297
+ // row describes is the newest CONCLUSIVE one, which may be older than the
2298
+ // newest candidate. Labelling it "Newest" would reintroduce the exact
2299
+ // confusion the walk exists to remove.
2300
+ `| Scored run | ${finding.conclusion ?? "—"}${finding.createdAt ? ` at ${finding.createdAt}` : ""}${finding.event ? ` via \`${finding.event}\`` : ""} |`,
2088
2301
  `| Why it is not green | ${detail || finding.reason} |`,
2089
2302
  `| Last checked | ${context.now.toISOString()} |`,
2090
2303
  "",
@@ -2278,11 +2491,42 @@ function bypassLabelSummary(context) {
2278
2491
  }
2279
2492
 
2280
2493
  /**
2281
- * Renders the reporting outcome for the job log and summary.
2494
+ * Renders the VERDICT — which suites are green and which are not.
2495
+ *
2496
+ * Written to the job summary before any publishing is attempted (§10.4), which
2497
+ * is the whole reason it is a separate renderer from `formatIssueReport`: this
2498
+ * one describes the suites and needs nothing from the Issues API, so it survives
2499
+ * an Issues API that is switched off, throttled or forbidden.
2500
+ *
2501
+ * @param {ReadonlyArray<object>} findings - Output of `assessSuite`, per suite
2502
+ * @param {{branch: string}} context - Reporting context
2503
+ * @returns {string} Markdown
2504
+ */
2505
+ export function formatVerdictReport(findings, context) {
2506
+ const red = findings.filter(finding => finding.state === SUITE_STATES.fail);
2507
+ const lines = [
2508
+ red.length > 0
2509
+ ? `## 🔴 Nightly E2E verdict — ${red.length} suite(s) not green`
2510
+ : "## 🌙 Nightly E2E verdict",
2511
+ "",
2512
+ `Branch: \`${context.branch}\``,
2513
+ "",
2514
+ ...findings.map(finding => `- ${formatFinding(finding)}`),
2515
+ "",
2516
+ // Said here, on the surface the verdict lands on, rather than only in the
2517
+ // contract: the next reader of a run whose publish warned needs to know
2518
+ // that they are already looking at the report.
2519
+ "**This section is the report.** Publishing it to a GitHub tracking issue is a best-effort side-effect recorded below; a publish that fails leaves this verdict untouched and does not change this job's result.",
2520
+ "",
2521
+ ];
2522
+ return `${lines.join("\n")}\n`;
2523
+ }
2524
+
2525
+ /**
2526
+ * The publishing half of the job summary — what happened to the issues.
2282
2527
  *
2283
2528
  * @param {ReadonlyArray<object>} results - Output of `applyIssuePlan`
2284
- * @param {{branch: string, requiredness?: object, gateContext?: string,
2285
- * bypassLabel?: string, bypassLabelState?: object}} context - Reporting context
2529
+ * @param {object} context - `{branch, requiredness, gateContext, bypassLabel, bypassLabelState, publishFailure}`
2286
2530
  * @returns {string} Markdown
2287
2531
  */
2288
2532
  export function formatIssueReport(results, context) {
@@ -2310,6 +2554,17 @@ export function formatIssueReport(results, context) {
2310
2554
  "",
2311
2555
  ...bypassLabelSummary(context),
2312
2556
  ];
2557
+ // The publish died outright rather than per-suite. Said on the summary as
2558
+ // well as in the annotation, because the annotation scrolls off a busy run
2559
+ // page and this section is where a reader goes looking for it.
2560
+ if (context.publishFailure) {
2561
+ lines.push(
2562
+ `- ⚠️ **Nothing was published** — ${context.publishFailure}`,
2563
+ "",
2564
+ "The verdict above is unaffected: it was written before publishing was attempted, and this job's result reflects the SUITE, not the publish.",
2565
+ ""
2566
+ );
2567
+ }
2313
2568
  for (const result of results) {
2314
2569
  const where = result.issues.length
2315
2570
  ? ` (#${result.issues.join(", #")})`
@@ -2328,7 +2583,7 @@ export function formatIssueReport(results, context) {
2328
2583
  }
2329
2584
  lines.push(
2330
2585
  "",
2331
- "This job REPORTS; it does not gate. Nothing here can block a pull request — the merge gate is a separate workflow that never writes.",
2586
+ "This job REPORTS; it does not gate. Nothing here can block a pull request — the merge gate is a separate workflow that never writes. This job's own result answers whether the SUITE is green, never whether publishing worked: a tracking issue that could not be written is a ⚠️ above, not a failure.",
2332
2587
  ""
2333
2588
  );
2334
2589
  return `${lines.join("\n")}\n`;
@@ -2449,29 +2704,37 @@ export async function apiGet(api, path, wait = sleep) {
2449
2704
  }
2450
2705
 
2451
2706
  /**
2452
- * Newest completed run of one workflow on one branch for one event.
2707
+ * The newest completed runs of one workflow on one branch for one event.
2708
+ *
2709
+ * Reads a bounded PAGE rather than a single run, which is rows 41-42's whole
2710
+ * mechanism. `per_page: "1"` made "the newest run" and "the newest run that
2711
+ * tested anything" the same query; they are not, and a displaced duplicate that
2712
+ * executed nothing is routinely the newer of the two.
2453
2713
  *
2454
2714
  * @param {object} api - API coordinates
2455
2715
  * @param {string} file - Workflow file name
2456
2716
  * @param {string} branch - Branch to read
2457
2717
  * @param {string} event - Run event
2458
2718
  * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
2459
- * @returns {Promise<{run: object|null, missing: boolean}>} The newest run, or a 404 marker
2719
+ * @returns {Promise<{runs: ReadonlyArray<object>, missing: boolean}>} The candidates, or a 404 marker
2460
2720
  */
2461
- export async function fetchNewestRun(api, file, branch, event, wait) {
2721
+ export async function fetchRunCandidates(api, file, branch, event, wait) {
2462
2722
  const query = new URLSearchParams({
2463
2723
  branch,
2464
2724
  status: "completed",
2465
2725
  event,
2466
- per_page: "1",
2726
+ per_page: String(RUN_CANDIDATE_PAGE_SIZE),
2467
2727
  });
2468
2728
  const result = await apiGet(
2469
2729
  api,
2470
2730
  `/repos/${api.repo}/actions/workflows/${encodeURIComponent(file)}/runs?${query}`,
2471
2731
  wait
2472
2732
  );
2473
- if (result === null) return { run: null, missing: true };
2474
- return { run: result.body.workflow_runs?.[0] ?? null, missing: false };
2733
+ if (result === null) return { runs: Object.freeze([]), missing: true };
2734
+ return {
2735
+ runs: Object.freeze(result.body.workflow_runs ?? []),
2736
+ missing: false,
2737
+ };
2475
2738
  }
2476
2739
 
2477
2740
  /**
@@ -2482,10 +2745,18 @@ export async function fetchNewestRun(api, file, branch, event, wait) {
2482
2745
  * Hitting the page cap while still unread is raised rather than silently
2483
2746
  * truncated.
2484
2747
  *
2748
+ * `complete` says whether the walk reached the end of the list, and it is the
2749
+ * half a caller cannot reconstruct from the jobs alone. A list cut short by a
2750
+ * mid-walk 404 is indistinguishable, by inspection, from a short list that was
2751
+ * read in full — and the two must not be treated alike: 100 indecisive jobs
2752
+ * followed by an unreadable page 2 is *not* proof that the run tested nothing,
2753
+ * because the failing shard may be on the page that would not load. Callers
2754
+ * that decide anything from the ABSENCE of a job outcome must consult it.
2755
+ *
2485
2756
  * @param {object} api - API coordinates
2486
2757
  * @param {number|string} runId - The run
2487
2758
  * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
2488
- * @returns {Promise<ReadonlyArray<object>>} All jobs
2759
+ * @returns {Promise<{jobs: ReadonlyArray<object>, complete: boolean}>} All jobs read, and whether the list was read to its end
2489
2760
  */
2490
2761
  export async function fetchAllJobs(api, runId, wait) {
2491
2762
  const jobs = [];
@@ -2496,13 +2767,15 @@ export async function fetchAllJobs(api, runId, wait) {
2496
2767
  wait
2497
2768
  );
2498
2769
  // A 404 means the run's job list stopped being readable mid-walk. Return
2499
- // what was read; falling through to the page-cap throw below would blame a
2500
- // pagination limit that had nothing to do with it AND discard every job
2501
- // already collected.
2502
- if (result === null) return Object.freeze(jobs);
2770
+ // what was read, flagged INCOMPLETE; falling through to the page-cap throw
2771
+ // below would blame a pagination limit that had nothing to do with it AND
2772
+ // discard every job already collected.
2773
+ if (result === null)
2774
+ return Object.freeze({ jobs: Object.freeze(jobs), complete: false });
2503
2775
  const batch = result.body.jobs ?? [];
2504
2776
  jobs.push(...batch);
2505
- if (batch.length < 100) return Object.freeze(jobs);
2777
+ if (batch.length < 100)
2778
+ return Object.freeze({ jobs: Object.freeze(jobs), complete: true });
2506
2779
  }
2507
2780
  throw new GateApiError(
2508
2781
  `Run ${runId} reports more jobs than \`api_max_pages\` (${api.maxPages}) allows this gate to read. A truncated job list can hide the failing shard, so this is RED rather than a partial read.`
@@ -2759,58 +3032,139 @@ export async function fetchRunArtifacts(api, runId, wait) {
2759
3032
  return null;
2760
3033
  }
2761
3034
 
3035
+ /**
3036
+ * Selects the run one suite will be scored on, and records what it walked past.
3037
+ *
3038
+ * Rows 41-42. Candidates arrive newest-first; the walk stops at the first that
3039
+ * produced evidence (`runProducedEvidence`) and never leaves the arm's own
3040
+ * freshness window. Both bounds are load-bearing. Without the first the gate
3041
+ * would keep reading runs it has no reason to; without the second a stale green
3042
+ * could hold the gate open indefinitely, which is the opposite failure and a
3043
+ * worse one.
3044
+ *
3045
+ * When nothing conclusive is found inside the window it falls back to the NEWEST
3046
+ * candidate — the run the gate scored before this walk existed — so `stale_run`,
3047
+ * `indecisive_conclusion` and the rest still fire and the gate still blocks.
3048
+ * That fallback is what makes selection unable to invent a pass: it may only
3049
+ * ever promote an older fresh, conclusive run over a run that tested nothing.
3050
+ *
3051
+ * @param {object} api - API coordinates
3052
+ * @param {ReadonlyArray<object>} candidates - Completed runs, newest first
3053
+ * @param {number} freshnessHours - This arm's freshness window
3054
+ * @param {Date} now - Evaluation instant
3055
+ * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
3056
+ * @returns {Promise<{run: object, jobs: ReadonlyArray<object>, jobsComplete: boolean, selection: object}>} The scored run, its jobs, whether that job list was read to its end, and the audit
3057
+ */
3058
+ async function selectScoredRun(api, candidates, freshnessHours, now, wait) {
3059
+ const jobsById = new Map();
3060
+ const walkedPast = [];
3061
+ let chosen = null;
3062
+ for (const candidate of candidates) {
3063
+ if (!isFresh(candidate, freshnessHours, now)) break;
3064
+ const read = await fetchAllJobs(api, candidate.id, wait);
3065
+ const { jobs, complete } = read;
3066
+ jobsById.set(candidate.id, read);
3067
+ if (runProducedEvidence(candidate, jobs, complete)) {
3068
+ chosen = candidate;
3069
+ break;
3070
+ }
3071
+ walkedPast.push({
3072
+ runId: candidate.id ?? null,
3073
+ conclusion: candidate.conclusion ?? null,
3074
+ createdAt: candidate.created_at ?? null,
3075
+ decisiveJobs: countDecisiveJobs(jobs),
3076
+ totalJobs: jobs.length,
3077
+ });
3078
+ }
3079
+ const run = chosen ?? candidates[0];
3080
+ const read = jobsById.get(run.id) ?? (await fetchAllJobs(api, run.id, wait));
3081
+ const { jobs, complete: jobsComplete } = read;
3082
+ // A run cannot be both scored and skipped. When the walk falls back onto a
3083
+ // candidate it had already stepped over — the single-candidate case — the
3084
+ // honest report is "this was scored because nothing better existed", not a
3085
+ // line contradicting itself.
3086
+ const skipped = walkedPast.filter(entry => entry.runId !== run.id);
3087
+ return {
3088
+ run,
3089
+ jobs,
3090
+ jobsComplete,
3091
+ selection: Object.freeze({
3092
+ runId: run.id ?? null,
3093
+ conclusion: run.conclusion ?? null,
3094
+ createdAt: run.created_at ?? null,
3095
+ // Carried for the SCORED run too, not only the skipped ones, so an
3096
+ // inconclusive verdict can name its cause. See `formatSelection`.
3097
+ decisiveJobs: countDecisiveJobs(jobs),
3098
+ totalJobs: jobs.length,
3099
+ fellBack: chosen === null && walkedPast.length > 0,
3100
+ skipped: Object.freeze(skipped.map(entry => Object.freeze(entry))),
3101
+ }),
3102
+ };
3103
+ }
3104
+
2762
3105
  /**
2763
3106
  * Observes every suite.
2764
3107
  *
2765
3108
  * @param {object} api - API coordinates
2766
3109
  * @param {ReadonlyArray<object>} suites - Validated suites
2767
3110
  * @param {string} branch - Branch to read
3111
+ * @param {{freshnessHours: number, now: Date}} context - Evaluation context, bounding the selection walk
2768
3112
  * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
2769
3113
  * @returns {Promise<ReadonlyArray<object>>} One observation per suite
2770
3114
  */
2771
- export async function observe(api, suites, branch, wait) {
3115
+ export async function observe(api, suites, branch, context, wait) {
2772
3116
  return await Promise.all(
2773
3117
  suites.map(async suite => {
2774
3118
  const perEvent = await Promise.all(
2775
3119
  COUNTED_EVENTS.map(event =>
2776
- fetchNewestRun(api, suite.workflow, branch, event, wait)
3120
+ fetchRunCandidates(api, suite.workflow, branch, event, wait)
2777
3121
  )
2778
3122
  );
2779
3123
  if (perEvent.every(result => result.missing)) {
2780
3124
  return { workflowMissing: true, run: null, jobs: [] };
2781
3125
  }
2782
- // Newest by created_at, so a fresh dispatch supersedes an older failed
2783
- // schedule. ISO-8601 UTC compares correctly as strings.
2784
- const run = perEvent
2785
- .map(result => result.run)
3126
+ // Newest FIRST by created_at, so a fresh dispatch supersedes an older
3127
+ // failed schedule. ISO-8601 UTC compares correctly as strings.
3128
+ const candidates = perEvent
3129
+ .flatMap(result => result.runs)
2786
3130
  .filter(
2787
3131
  candidate => candidate && typeof candidate.created_at === "string"
2788
3132
  )
2789
- .reduce(
2790
- (newest, candidate) =>
2791
- newest === null || candidate.created_at > newest.created_at
2792
- ? candidate
2793
- : newest,
2794
- null
3133
+ // Equal timestamps compare 0 rather than falling through to -1, which
3134
+ // would make the sort unstable and let two runs created in the same
3135
+ // second swap places between reads of the same history.
3136
+ .sort((left, right) =>
3137
+ left.created_at === right.created_at
3138
+ ? 0
3139
+ : left.created_at < right.created_at
3140
+ ? 1
3141
+ : -1
2795
3142
  );
2796
3143
  // Jobs are read for EVERY match mode, `run` included. A run-scoped suite
2797
3144
  // needs them to prove the run was complete (row 26) — a `success` run
2798
3145
  // that skipped half its jobs is not evidence about the half it skipped.
2799
- if (!run) {
2800
- return { workflowMissing: false, run, jobs: [] };
3146
+ if (candidates.length === 0) {
3147
+ return { workflowMissing: false, run: null, jobs: [] };
2801
3148
  }
3149
+ const { run, jobs, jobsComplete, selection } = await selectScoredRun(
3150
+ api,
3151
+ candidates,
3152
+ suite.freshness_hours ?? context.freshnessHours,
3153
+ context.now,
3154
+ wait
3155
+ );
2802
3156
  // Artifacts are read for every suite, not only the ones declaring
2803
3157
  // `min_flows`: a run that recorded ITSELF as filtered disqualifies with no
2804
3158
  // declaration at all (row 36), and a gate that only looked when asked to
2805
- // would miss exactly the repos that have not adopted the field yet.
2806
- const [jobs, artifacts] = await Promise.all([
2807
- fetchAllJobs(api, run.id, wait),
2808
- fetchRunArtifacts(api, run.id, wait),
2809
- ]);
3159
+ // would miss exactly the repos that have not adopted the field yet. Read
3160
+ // for the SCORED run, which is the run every other row also judges.
3161
+ const artifacts = await fetchRunArtifacts(api, run.id, wait);
2810
3162
  return {
2811
3163
  workflowMissing: false,
2812
3164
  run,
2813
3165
  jobs,
3166
+ jobsComplete,
3167
+ selection,
2814
3168
  scope: readSuiteScope(artifacts),
2815
3169
  };
2816
3170
  })
@@ -3426,6 +3780,7 @@ export async function runGate(env, wait) {
3426
3780
  settings.api,
3427
3781
  settings.suites,
3428
3782
  settings.branch,
3783
+ { freshnessHours: settings.freshnessHours, now },
3429
3784
  wait
3430
3785
  );
3431
3786
  const findings = settings.suites.map((suite, index) => {
@@ -3496,7 +3851,7 @@ export async function runGate(env, wait) {
3496
3851
  }
3497
3852
 
3498
3853
  /**
3499
- * Runs the REPORTING half: files, refreshes and closes the tracking issues.
3854
+ * The READ half of reporting: everything up to, and not including, the writes.
3500
3855
  *
3501
3856
  * Reads exactly what the gate reads and assesses it with exactly the same
3502
3857
  * classifier, so the issue and the merge gate can never disagree about whether a
@@ -3509,17 +3864,22 @@ export async function runGate(env, wait) {
3509
3864
  * ONE pull request, it does not make the nightly green, and the tracking issue
3510
3865
  * stays open until a green run lands.
3511
3866
  *
3867
+ * Split from the writes so the CLI can surface the verdict BEFORE publishing it
3868
+ * (§10.4). Everything here is a `GET`; the first write in the reporting path is
3869
+ * `applyIssuePlan`.
3870
+ *
3512
3871
  * @param {NodeJS.ProcessEnv} env - The environment
3513
3872
  * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
3514
- * @returns {Promise<object>} Findings, plan and per-suite results
3873
+ * @returns {Promise<object>} Findings, requiredness, label state, plan, settings
3515
3874
  */
3516
- export async function runReport(env, wait) {
3875
+ export async function planReport(env, wait) {
3517
3876
  const settings = resolveSettings(env);
3518
3877
  const now = new Date();
3519
3878
  const observations = await observe(
3520
3879
  settings.api,
3521
3880
  settings.suites,
3522
3881
  settings.branch,
3882
+ { freshnessHours: settings.freshnessHours, now },
3523
3883
  wait
3524
3884
  );
3525
3885
  const findings = settings.suites.map((suite, index) => {
@@ -3568,36 +3928,99 @@ export async function runReport(env, wait) {
3568
3928
  bypassLabelState,
3569
3929
  pinIssues: settings.pinIssues,
3570
3930
  };
3931
+ return { findings, requiredness, bypassLabelState, context, settings };
3932
+ }
3933
+
3934
+ /**
3935
+ * The PUBLISH half: read the open tracking issues, plan, and write.
3936
+ *
3937
+ * The read of the existing issues lives on THIS side of the split, not with the
3938
+ * verdict, and that placement is the point. Listing open issues is the Issues
3939
+ * API — the same API that can be switched off, throttled or forbidden — so a
3940
+ * verdict that depended on it would still die with its publisher, which is the
3941
+ * whole defect (§10.4). Everything the verdict needs comes from Actions run
3942
+ * history instead.
3943
+ *
3944
+ * @param {object} planned - Output of `planReport`
3945
+ * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
3946
+ * @returns {Promise<{plan: ReadonlyArray<object>, results: ReadonlyArray<object>}>} Plan and outcomes
3947
+ */
3948
+ export async function publishReport(planned, wait) {
3949
+ const { settings, findings, context } = planned;
3571
3950
  const plan = planIssueActions(
3572
3951
  findings,
3573
3952
  await fetchTrackingIssues(settings.api, settings.issueLabel, wait),
3574
3953
  context
3575
3954
  );
3576
- return {
3577
- findings,
3578
- requiredness,
3579
- bypassLabelState,
3580
- plan,
3581
- results: await applyIssuePlan(settings.api, plan, wait),
3582
- settings,
3583
- };
3955
+ return { plan, results: await applyIssuePlan(settings.api, plan, wait) };
3956
+ }
3957
+
3958
+ /**
3959
+ * Plan, then publish. The whole report in one call.
3960
+ *
3961
+ * Kept as the single-call entry point it has always been — every existing
3962
+ * caller and every §10 test reads the same five fields back. `reportIssues`
3963
+ * deliberately does NOT use it: the CLI has to get the verdict onto the job
3964
+ * summary between the two halves, and has to survive the second half failing,
3965
+ * neither of which a combined call can express (§10.4).
3966
+ *
3967
+ * @param {Record<string, string|undefined>} env - Process environment
3968
+ * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
3969
+ * @returns {Promise<object>} `{findings, requiredness, bypassLabelState, plan, results, settings}`
3970
+ */
3971
+ export async function runReport(env, wait) {
3972
+ const planned = await planReport(env, wait);
3973
+ const { context: _context, ...verdict } = planned;
3974
+ return { ...verdict, ...(await publishReport(planned, wait)) };
3975
+ }
3976
+
3977
+ /**
3978
+ * Whether a suite is red in the sense §10.4's exit code needs.
3979
+ *
3980
+ * `fail` AND complete evidence — the same pair `planIssueActions` requires
3981
+ * before it will file anything, asked here of the FINDING so the exit code needs
3982
+ * nothing from the Issues API. A `fail` that row 30 routed to
3983
+ * `evidence_incomplete` decided nothing there and decides nothing here either,
3984
+ * or the job would go red about a run whose own report says it proved nothing.
3985
+ *
3986
+ * @param {object} finding - A finding
3987
+ * @returns {boolean} True when the suite genuinely failed
3988
+ */
3989
+ function isGenuinelyRed(finding) {
3990
+ return finding.state === SUITE_STATES.fail && isCompleteEvidence(finding);
3584
3991
  }
3585
3992
 
3586
3993
  /**
3587
3994
  * The reporting entry point, as the scheduled workflow invokes it.
3588
3995
  *
3589
- * Its exit code answers "did REPORTING work", never "is the suite green". A red
3590
- * nightly reported correctly is a SUCCESSFUL report — conflating the two would
3591
- * hand operators a second red check that means something different from the
3592
- * first one.
3996
+ * Three outcomes, three deliberately different exit codes (§10.4):
3997
+ *
3998
+ * 1. **No verdict could be resolved** — configuration, a schema refusal, an
3999
+ * unreadable run history → EXIT 1. Nothing was reported, and "we could not
4000
+ * check" must never render as "it is fine".
4001
+ * 2. **Verdict resolved, publishing failed** → EXIT 0 + `::warning::`. The
4002
+ * tracking issue is a convenience; the verdict is the product, and it is
4003
+ * already on the job summary by the time publishing is attempted. A report
4004
+ * channel must not die because its optional publishing destination is
4005
+ * unavailable — measured as HTTP 410 on `POST /repos/{o}/{r}/issues` for a
4006
+ * repository that switched Issues off.
4007
+ * 3. **The SUITE itself is red** → EXIT 1 + `::error::` per red suite.
4008
+ *
4009
+ * Case 3 is why case 2 is safe to absorb. Without it this entry point would have
4010
+ * no failing path left at all once publishing stopped reddening it, and a job
4011
+ * that cannot go red is a job whose green means nothing.
3593
4012
  *
3594
4013
  * @param {boolean} asJson - Emit the machine record instead of prose
3595
4014
  * @returns {Promise<void>} Resolves once the report is written
3596
4015
  */
3597
- async function reportIssues(asJson) {
3598
- let outcome;
4016
+ export async function reportIssues(asJson) {
4017
+ // ---------------------------------------------------------------------
4018
+ // 1. Resolve the verdict. Read-only, and the one path that still exits 1
4019
+ // for a reporting failure — because it produced no report at all.
4020
+ // ---------------------------------------------------------------------
4021
+ let planned;
3599
4022
  try {
3600
- outcome = await runReport(process.env);
4023
+ planned = await planReport(process.env);
3601
4024
  } catch (error) {
3602
4025
  const message = error instanceof Error ? error.message : String(error);
3603
4026
  process.stderr.write(
@@ -3607,16 +4030,51 @@ async function reportIssues(asJson) {
3607
4030
  process.exitCode = 1;
3608
4031
  return;
3609
4032
  }
3610
- const { settings, ...machine } = outcome;
4033
+ const { settings, context: _context, ...verdict } = planned;
4034
+
4035
+ // ---------------------------------------------------------------------
4036
+ // 2. Surface the verdict, BEFORE anything can go wrong publishing it.
4037
+ // Making publishing non-fatal on its own would trade a false red for a
4038
+ // silent gap — the same defect pointing the other way. The step summary
4039
+ // is a channel that exists whether or not GitHub Issues do.
4040
+ // ---------------------------------------------------------------------
4041
+ if (!asJson) {
4042
+ const verdictReport = formatVerdictReport(verdict.findings, {
4043
+ branch: settings.branch,
4044
+ });
4045
+ process.stdout.write(verdictReport);
4046
+ await appendSummary(verdictReport);
4047
+ }
4048
+
4049
+ // ---------------------------------------------------------------------
4050
+ // 3. Publish — best effort, and nothing it does reaches the exit code.
4051
+ // ---------------------------------------------------------------------
4052
+ // `applyIssuePlan` already records a per-suite write failure rather than
4053
+ // throwing (row 31). This catch is for everything around it — most concretely
4054
+ // the LIST of open issues, which is the same Issues API the writes use and can
4055
+ // therefore be off, throttled or forbidden in exactly the same way.
4056
+ let plan = [];
4057
+ let results = [];
4058
+ let publishFailure = null;
4059
+ try {
4060
+ ({ plan, results } = await publishReport(planned));
4061
+ } catch (error) {
4062
+ publishFailure = error instanceof Error ? error.message : String(error);
4063
+ process.stderr.write(
4064
+ `::warning title=Nightly E2E tracking issues not published::${publishFailure.split("\n")[0]} — the verdict for this run is in the job summary, and this job's result reflects the SUITE, not the publish.\n`
4065
+ );
4066
+ }
4067
+ const machine = { ...verdict, plan, results, publishFailure };
3611
4068
  if (asJson) {
3612
4069
  process.stdout.write(`${JSON.stringify(machine, null, 2)}\n`);
3613
4070
  } else {
3614
- const report = formatIssueReport(machine.results, {
4071
+ const report = formatIssueReport(results, {
3615
4072
  branch: settings.branch,
3616
- requiredness: machine.requiredness,
4073
+ requiredness: verdict.requiredness,
3617
4074
  gateContext: settings.gateContext,
3618
4075
  bypassLabel: settings.bypassLabel,
3619
- bypassLabelState: machine.bypassLabelState,
4076
+ bypassLabelState: verdict.bypassLabelState,
4077
+ publishFailure,
3620
4078
  });
3621
4079
  process.stdout.write(report);
3622
4080
  await appendSummary(report);
@@ -3624,38 +4082,56 @@ async function reportIssues(asJson) {
3624
4082
  // An `unknown` requiredness is annotated on the run, because it means every
3625
4083
  // issue this report touched is deliberately silent about merge consequences —
3626
4084
  // and the reason (a scope, a rename, an outage) is fixable.
3627
- if (machine.requiredness?.state === REQUIREDNESS.unknown) {
4085
+ if (verdict.requiredness?.state === REQUIREDNESS.unknown) {
3628
4086
  process.stderr.write(
3629
- `::warning title=Nightly E2E requiredness unknown::Could not read \`${settings.branch}\`'s branch rules, so the tracking issues claim neither that merges are blocked nor that they are not. ${machine.requiredness.detail ?? ""}\n`
4087
+ `::warning title=Nightly E2E requiredness unknown::Could not read \`${settings.branch}\`'s branch rules, so the tracking issues claim neither that merges are blocked nor that they are not. ${verdict.requiredness.detail ?? ""}\n`
3630
4088
  );
3631
4089
  }
3632
4090
  // §10.9. An annotation rather than a failure, and that is not timidity: this
3633
- // job's status answers "did REPORTING work" (§10.4), and reddening it for a
4091
+ // job's exit code answers "is the suite green" (§10.4), and reddening it for a
3634
4092
  // repository-configuration defect would teach operators to ignore the one job
3635
4093
  // that tells them a suite is down. It is loud where loudness is free.
3636
- if (machine.bypassLabelState?.state === BYPASS_LABEL_STATE.absent) {
4094
+ if (verdict.bypassLabelState?.state === BYPASS_LABEL_STATE.absent) {
3637
4095
  process.stderr.write(
3638
4096
  `::error title=Nightly E2E bypass label missing::The gate is required on \`${settings.branch}\` but the \`${settings.bypassLabel}\` label does not exist in this repository, so the audited waiver cannot be applied and an unaudited admin merge is the only exit. Create it once: gh label create ${settings.bypassLabel}\n`
3639
4097
  );
3640
- } else if (machine.bypassLabelState?.state === BYPASS_LABEL_STATE.unknown) {
4098
+ } else if (verdict.bypassLabelState?.state === BYPASS_LABEL_STATE.unknown) {
3641
4099
  process.stderr.write(
3642
- `::warning title=Nightly E2E bypass label unreadable::Could not read whether \`${settings.bypassLabel}\` exists in this repository. ${machine.bypassLabelState.detail ?? ""}\n`
4100
+ `::warning title=Nightly E2E bypass label unreadable::Could not read whether \`${settings.bypassLabel}\` exists in this repository. ${verdict.bypassLabelState.detail ?? ""}\n`
3643
4101
  );
3644
4102
  }
3645
- for (const result of machine.results) {
4103
+ for (const result of results) {
3646
4104
  for (const warning of result.warnings ?? []) {
3647
4105
  process.stderr.write(
3648
4106
  `::warning title=Nightly E2E tracking issue not pinned::${result.label} — ${warning}\n`
3649
4107
  );
3650
4108
  }
3651
4109
  }
3652
- const failed = machine.results.filter(result => !result.ok);
3653
- for (const result of failed) {
4110
+ // A tracking issue that did not land is a `::warning::`, NOT an `::error::`
4111
+ // and not a failure. It names the suite and the cause, which is what an
4112
+ // operator needs to fix the publisher; the verdict it could not publish is
4113
+ // three paragraphs up the same summary.
4114
+ for (const result of results.filter(entry => !entry.ok)) {
4115
+ process.stderr.write(
4116
+ `::warning title=Nightly E2E tracking issue not updated::${result.label} — ${result.error} (the verdict for this run is in the job summary; this job's result reflects the SUITE, not the publish)\n`
4117
+ );
4118
+ }
4119
+
4120
+ // ---------------------------------------------------------------------
4121
+ // 4. Exit on the SUITE, never on the publish.
4122
+ // ---------------------------------------------------------------------
4123
+ // Read off the FINDINGS, never off the plan or the results: the plan needs the
4124
+ // Issues API to build, and a verdict that could not be reached because the
4125
+ // publisher was unreachable is the defect this whole section deletes.
4126
+ // `::error::` annotates but does not fail a step, so the explicit non-zero
4127
+ // exit code is what actually carries the red.
4128
+ const red = verdict.findings.filter(isGenuinelyRed);
4129
+ for (const finding of red) {
3654
4130
  process.stderr.write(
3655
- `::error title=Nightly E2E tracking issue not updated::${result.label} — ${result.error}\n`
4131
+ `::error title=Nightly E2E is not green::${finding.label} — ${finding.state.toUpperCase()}${finding.conclusion ? ` (${finding.conclusion})` : ""} [${finding.reason}]${finding.url ? ` — ${finding.url}` : ""}\n`
3656
4132
  );
3657
4133
  }
3658
- if (failed.length > 0) process.exitCode = 1;
4134
+ if (red.length > 0) process.exitCode = 1;
3659
4135
  }
3660
4136
 
3661
4137
  /**
@@ -3740,13 +4216,30 @@ async function main(argv) {
3740
4216
  /**
3741
4217
  * Appends a report to the job summary when one exists.
3742
4218
  *
4219
+ * NEVER throws. The same report has already gone to stdout, and on the gate path
4220
+ * the verdict is additionally in the step outputs and the exit code, so an
4221
+ * unwritable `$GITHUB_STEP_SUMMARY` is a failure of one RENDERING SURFACE and
4222
+ * nothing more. Left to propagate it would reach top-level `main`, where on the
4223
+ * reporting path it would fail the job AND skip publishing — a summary file the
4224
+ * runner could not open taking down both channels at once, which is §10.4's
4225
+ * defect wearing different clothes. It is absorbed into a `::warning::` rather
4226
+ * than swallowed, because a summary that silently never appears is how an
4227
+ * operator learns to stop looking at it.
4228
+ *
3743
4229
  * @param {string} report - Markdown
3744
- * @returns {Promise<void>} Resolves when written
4230
+ * @returns {Promise<void>} Resolves when written, or when it could not be
3745
4231
  */
3746
4232
  async function appendSummary(report) {
3747
4233
  if (!process.env.GITHUB_STEP_SUMMARY) return;
3748
- const { appendFileSync } = await import("node:fs");
3749
- appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
4234
+ try {
4235
+ const { appendFileSync } = await import("node:fs");
4236
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
4237
+ } catch (error) {
4238
+ const message = error instanceof Error ? error.message : String(error);
4239
+ process.stderr.write(
4240
+ `::warning title=Nightly E2E job summary unwritable::Could not append to \`$GITHUB_STEP_SUMMARY\` (${message.split("\n")[0]}). The same report is in this job's log.\n`
4241
+ );
4242
+ }
3750
4243
  }
3751
4244
 
3752
4245
  /**