@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.
- package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +64 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +18 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts +9 -4
- package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +14 -7
- package/dist/core/nightly-e2e-guard-behavior-certificate.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +8 -5
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/parity-safety-net.sh +64 -1
- package/expo/create-only/.github/workflows/nightly-e2e-report.yml +21 -6
- package/package.json +3 -3
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/parity-safety-net.sh +64 -1
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +64 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/parity-safety-net.sh +64 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +64 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/parity-safety-net.sh +64 -1
- package/scripts/generate-nightly-e2e-guard-certificate.mjs +22 -1
- 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 `
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
|
1548
|
-
stale_sha:
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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<{
|
|
2719
|
+
* @returns {Promise<{runs: ReadonlyArray<object>, missing: boolean}>} The candidates, or a 404 marker
|
|
2460
2720
|
*/
|
|
2461
|
-
export async function
|
|
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:
|
|
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 {
|
|
2474
|
-
return {
|
|
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
|
|
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
|
|
2500
|
-
// pagination limit that had nothing to do with it AND
|
|
2501
|
-
// already collected.
|
|
2502
|
-
if (result === null)
|
|
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)
|
|
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
|
-
|
|
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
|
|
2783
|
-
// schedule. ISO-8601 UTC compares correctly as strings.
|
|
2784
|
-
const
|
|
2785
|
-
.
|
|
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
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
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 (
|
|
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
|
-
|
|
2807
|
-
|
|
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
|
-
*
|
|
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,
|
|
3873
|
+
* @returns {Promise<object>} Findings, requiredness, label state, plan, settings
|
|
3515
3874
|
*/
|
|
3516
|
-
export async function
|
|
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
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
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
|
-
*
|
|
3590
|
-
*
|
|
3591
|
-
*
|
|
3592
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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, ...
|
|
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(
|
|
4071
|
+
const report = formatIssueReport(results, {
|
|
3615
4072
|
branch: settings.branch,
|
|
3616
|
-
requiredness:
|
|
4073
|
+
requiredness: verdict.requiredness,
|
|
3617
4074
|
gateContext: settings.gateContext,
|
|
3618
4075
|
bypassLabel: settings.bypassLabel,
|
|
3619
|
-
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 (
|
|
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. ${
|
|
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
|
|
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 (
|
|
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 (
|
|
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. ${
|
|
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
|
|
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
|
-
|
|
3653
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
3749
|
-
|
|
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
|
/**
|