@codyswann/lisa 4.26.1 → 4.26.3

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 (83) 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 +6 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts +5 -0
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts.map +1 -1
  7. package/dist/core/nightly-e2e-guard-behavior-certificate.js +8 -1
  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 -7
  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/.codex-plugin/skills/lisa-jira-journey/SKILL.md +24 -2
  18. package/plugins/lisa/.codex-plugin/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
  19. package/plugins/lisa/hooks/parity-safety-net.sh +64 -1
  20. package/plugins/lisa/skills/lisa-jira-journey/SKILL.md +24 -2
  21. package/plugins/lisa/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
  22. package/plugins/lisa-agy/hooks/parity-safety-net.sh +64 -1
  23. package/plugins/lisa-agy/plugin.json +1 -1
  24. package/plugins/lisa-agy/skills/lisa-jira-journey/SKILL.md +24 -2
  25. package/plugins/lisa-agy/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
  26. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  29. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +64 -1
  33. package/plugins/lisa-copilot/skills/lisa-jira-journey/SKILL.md +24 -2
  34. package/plugins/lisa-copilot/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
  35. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +64 -1
  37. package/plugins/lisa-cursor/skills/lisa-jira-journey/SKILL.md +24 -2
  38. package/plugins/lisa-cursor/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
  39. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-expo-agy/plugin.json +1 -1
  42. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  52. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  57. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  62. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails-agy/plugin.json +1 -1
  67. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  72. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  77. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  79. package/plugins/src/base/hooks/parity-safety-net.sh +64 -1
  80. package/plugins/src/base/skills/lisa-jira-journey/SKILL.md +24 -2
  81. package/plugins/src/base/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
  82. package/scripts/generate-nightly-e2e-guard-certificate.mjs +17 -1
  83. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +238 -44
@@ -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
@@ -154,7 +169,7 @@ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
154
169
  * rather than running a contract neither half agrees on. See §8 of
155
170
  * `docs/nightly-e2e-gate.md` for what counts as major / minor / patch.
156
171
  */
157
- export const NIGHTLY_E2E_CONTRACT_VERSION = "1.8.0";
172
+ export const NIGHTLY_E2E_CONTRACT_VERSION = "1.9.0";
158
173
 
159
174
  /**
160
175
  * The conclusions that constitute a verdict about the code.
@@ -2476,11 +2491,42 @@ function bypassLabelSummary(context) {
2476
2491
  }
2477
2492
 
2478
2493
  /**
2479
- * 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.
2480
2527
  *
2481
2528
  * @param {ReadonlyArray<object>} results - Output of `applyIssuePlan`
2482
- * @param {{branch: string, requiredness?: object, gateContext?: string,
2483
- * bypassLabel?: string, bypassLabelState?: object}} context - Reporting context
2529
+ * @param {object} context - `{branch, requiredness, gateContext, bypassLabel, bypassLabelState, publishFailure}`
2484
2530
  * @returns {string} Markdown
2485
2531
  */
2486
2532
  export function formatIssueReport(results, context) {
@@ -2508,6 +2554,17 @@ export function formatIssueReport(results, context) {
2508
2554
  "",
2509
2555
  ...bypassLabelSummary(context),
2510
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
+ }
2511
2568
  for (const result of results) {
2512
2569
  const where = result.issues.length
2513
2570
  ? ` (#${result.issues.join(", #")})`
@@ -2526,7 +2583,7 @@ export function formatIssueReport(results, context) {
2526
2583
  }
2527
2584
  lines.push(
2528
2585
  "",
2529
- "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.",
2530
2587
  ""
2531
2588
  );
2532
2589
  return `${lines.join("\n")}\n`;
@@ -3794,7 +3851,7 @@ export async function runGate(env, wait) {
3794
3851
  }
3795
3852
 
3796
3853
  /**
3797
- * 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.
3798
3855
  *
3799
3856
  * Reads exactly what the gate reads and assesses it with exactly the same
3800
3857
  * classifier, so the issue and the merge gate can never disagree about whether a
@@ -3807,11 +3864,15 @@ export async function runGate(env, wait) {
3807
3864
  * ONE pull request, it does not make the nightly green, and the tracking issue
3808
3865
  * stays open until a green run lands.
3809
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
+ *
3810
3871
  * @param {NodeJS.ProcessEnv} env - The environment
3811
3872
  * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
3812
- * @returns {Promise<object>} Findings, plan and per-suite results
3873
+ * @returns {Promise<object>} Findings, requiredness, label state, plan, settings
3813
3874
  */
3814
- export async function runReport(env, wait) {
3875
+ export async function planReport(env, wait) {
3815
3876
  const settings = resolveSettings(env);
3816
3877
  const now = new Date();
3817
3878
  const observations = await observe(
@@ -3867,36 +3928,99 @@ export async function runReport(env, wait) {
3867
3928
  bypassLabelState,
3868
3929
  pinIssues: settings.pinIssues,
3869
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;
3870
3950
  const plan = planIssueActions(
3871
3951
  findings,
3872
3952
  await fetchTrackingIssues(settings.api, settings.issueLabel, wait),
3873
3953
  context
3874
3954
  );
3875
- return {
3876
- findings,
3877
- requiredness,
3878
- bypassLabelState,
3879
- plan,
3880
- results: await applyIssuePlan(settings.api, plan, wait),
3881
- settings,
3882
- };
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);
3883
3991
  }
3884
3992
 
3885
3993
  /**
3886
3994
  * The reporting entry point, as the scheduled workflow invokes it.
3887
3995
  *
3888
- * Its exit code answers "did REPORTING work", never "is the suite green". A red
3889
- * nightly reported correctly is a SUCCESSFUL report — conflating the two would
3890
- * hand operators a second red check that means something different from the
3891
- * 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.
3892
4012
  *
3893
4013
  * @param {boolean} asJson - Emit the machine record instead of prose
3894
4014
  * @returns {Promise<void>} Resolves once the report is written
3895
4015
  */
3896
- async function reportIssues(asJson) {
3897
- 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;
3898
4022
  try {
3899
- outcome = await runReport(process.env);
4023
+ planned = await planReport(process.env);
3900
4024
  } catch (error) {
3901
4025
  const message = error instanceof Error ? error.message : String(error);
3902
4026
  process.stderr.write(
@@ -3906,16 +4030,51 @@ async function reportIssues(asJson) {
3906
4030
  process.exitCode = 1;
3907
4031
  return;
3908
4032
  }
3909
- 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 };
3910
4068
  if (asJson) {
3911
4069
  process.stdout.write(`${JSON.stringify(machine, null, 2)}\n`);
3912
4070
  } else {
3913
- const report = formatIssueReport(machine.results, {
4071
+ const report = formatIssueReport(results, {
3914
4072
  branch: settings.branch,
3915
- requiredness: machine.requiredness,
4073
+ requiredness: verdict.requiredness,
3916
4074
  gateContext: settings.gateContext,
3917
4075
  bypassLabel: settings.bypassLabel,
3918
- bypassLabelState: machine.bypassLabelState,
4076
+ bypassLabelState: verdict.bypassLabelState,
4077
+ publishFailure,
3919
4078
  });
3920
4079
  process.stdout.write(report);
3921
4080
  await appendSummary(report);
@@ -3923,38 +4082,56 @@ async function reportIssues(asJson) {
3923
4082
  // An `unknown` requiredness is annotated on the run, because it means every
3924
4083
  // issue this report touched is deliberately silent about merge consequences —
3925
4084
  // and the reason (a scope, a rename, an outage) is fixable.
3926
- if (machine.requiredness?.state === REQUIREDNESS.unknown) {
4085
+ if (verdict.requiredness?.state === REQUIREDNESS.unknown) {
3927
4086
  process.stderr.write(
3928
- `::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`
3929
4088
  );
3930
4089
  }
3931
4090
  // §10.9. An annotation rather than a failure, and that is not timidity: this
3932
- // 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
3933
4092
  // repository-configuration defect would teach operators to ignore the one job
3934
4093
  // that tells them a suite is down. It is loud where loudness is free.
3935
- if (machine.bypassLabelState?.state === BYPASS_LABEL_STATE.absent) {
4094
+ if (verdict.bypassLabelState?.state === BYPASS_LABEL_STATE.absent) {
3936
4095
  process.stderr.write(
3937
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`
3938
4097
  );
3939
- } else if (machine.bypassLabelState?.state === BYPASS_LABEL_STATE.unknown) {
4098
+ } else if (verdict.bypassLabelState?.state === BYPASS_LABEL_STATE.unknown) {
3940
4099
  process.stderr.write(
3941
- `::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`
3942
4101
  );
3943
4102
  }
3944
- for (const result of machine.results) {
4103
+ for (const result of results) {
3945
4104
  for (const warning of result.warnings ?? []) {
3946
4105
  process.stderr.write(
3947
4106
  `::warning title=Nightly E2E tracking issue not pinned::${result.label} — ${warning}\n`
3948
4107
  );
3949
4108
  }
3950
4109
  }
3951
- const failed = machine.results.filter(result => !result.ok);
3952
- 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) {
3953
4130
  process.stderr.write(
3954
- `::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`
3955
4132
  );
3956
4133
  }
3957
- if (failed.length > 0) process.exitCode = 1;
4134
+ if (red.length > 0) process.exitCode = 1;
3958
4135
  }
3959
4136
 
3960
4137
  /**
@@ -4039,13 +4216,30 @@ async function main(argv) {
4039
4216
  /**
4040
4217
  * Appends a report to the job summary when one exists.
4041
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
+ *
4042
4229
  * @param {string} report - Markdown
4043
- * @returns {Promise<void>} Resolves when written
4230
+ * @returns {Promise<void>} Resolves when written, or when it could not be
4044
4231
  */
4045
4232
  async function appendSummary(report) {
4046
4233
  if (!process.env.GITHUB_STEP_SUMMARY) return;
4047
- const { appendFileSync } = await import("node:fs");
4048
- 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
+ }
4049
4243
  }
4050
4244
 
4051
4245
  /**