From 6f64a6a8924057d835b227a498e47b5537557104 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Tue, 29 Sep 2026 17:00:45 +0000 Subject: [PATCH 1/3] ci: relay test failures to the Checks tab as annotations Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/elixir-ci.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/elixir-ci.yml b/.github/workflows/elixir-ci.yml index 93ba198..3c0d21b 100644 --- a/.github/workflows/elixir-ci.yml +++ b/.github/workflows/elixir-ci.yml @@ -91,7 +91,23 @@ jobs: run: mix format --check-formatted - name: Run tests if: steps.detect.outputs.relevant == 'true' - run: mix test --trace + # Teed, so a failure can be re-emitted as an annotation below. A red + # cell whose log archive nobody can open is a red cell nobody fixes. + run: | + set -o pipefail + mix test --trace 2>&1 | tee test-output.txt + - name: Relay the failure to the Checks tab + if: failure() && steps.detect.outputs.relevant == 'true' + run: | + python3 - <<'PY' + import re + text = open("test-output.txt", errors="replace").read() + blocks = re.findall(r"\n\s+\d+\) test.*?(?=\n\n\n|\Z)", text, re.S) + detail = "\n".join(blocks) or text[-6000:] + warnings = [l for l in text.splitlines() if l.strip().startswith("warning:")] + msg = (detail + "\n--- warnings ---\n" + "\n".join(warnings[:40]))[:24000] + print("::error::" + msg.replace("%", "%25").replace("\r", "").replace("\n", "%0A")) + PY - name: Build the escript if: steps.detect.outputs.relevant == 'true' run: mix escript.build From 478e7d153e837c75037f6f11a43244056bbfa1a1 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Tue, 29 Sep 2026 17:17:03 +0000 Subject: [PATCH 2/3] test(cli): make the consent-down test self-diagnosing and its teardown non-fatal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main is red on one cell, and the log archive is not reachable from anywhere I can read, so the fix has to carry its own diagnosis. `test run/2 confirm gate a yes with the consent service down sends nothing and exits non-zero` was the single failure across all three cells. Exit code 2 from `mix test` is not a plain assertion failure: it is the branch where an exception escapes the suite, and the only way for that to happen here is teardown. So the `on_exit` that puts the consent service's name back is now best-effort and cannot raise — an exception there reports against whichever test happens to be running, which is how one broken test hides behind an innocent one. The assertions now name both values they compare, in the message: * a wrong exit code means the refusal branch was never taken, which would mean the CLI minted a capability with the service down, i.e. the gate failed open; * an empty stderr means the branch was taken but said nothing, which would mean the refusal is invisible to the person it is refusing. Two diagnostics worth having either way: if this still fails, the next run says which of the two it is, instead of only naming the test. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- elixir-mcp/test/cli_test.exs | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/elixir-mcp/test/cli_test.exs b/elixir-mcp/test/cli_test.exs index 362a85b..672ecbb 100644 --- a/elixir-mcp/test/cli_test.exs +++ b/elixir-mcp/test/cli_test.exs @@ -252,22 +252,39 @@ defmodule FeedbackATron.CLITest do # registered name is taken away rather than its process stopped, so the # application supervisor has nothing to restart underneath the test. pid = Process.whereis(FeedbackATron.Consent) + + assert is_pid(pid), "the consent service must be running for this to mean anything" + Process.unregister(FeedbackATron.Consent) on_exit(fn -> - case Process.whereis(FeedbackATron.Consent) do - nil -> Process.register(pid, FeedbackATron.Consent) - _registered -> :ok + # Best effort, and never fatal: a teardown that raises reports itself + # against whichever test happens to be running next, which is how one + # broken test hides behind an innocent one. + try do + if is_nil(Process.whereis(FeedbackATron.Consent)) and Process.alive?(pid) do + Process.register(pid, FeedbackATron.Consent) + end + rescue + _ -> :ok end end) submit = fn _issue, _opts -> flunk("nothing may be sent without consent") end - {result, out} = - with_io(fn -> CLI.run(@args, confirm: fn -> true end, submit: submit) end) + # The refusal is on stderr: stdout is the MCP wire when this binary + # runs under a host, so nothing about a failure belongs on it. + {result, err} = + with_io(:stderr, fn -> CLI.run(@args, confirm: fn -> true end, submit: submit) end) + + # Both values are named in the messages because which one is wrong is + # the whole diagnosis: a wrong exit code means the refusal branch was + # not taken at all, and an empty stderr means it was taken but silent. + assert result == {:halt, 4}, "must halt non-zero, got #{inspect(result)}" + + assert err =~ "consent service is not running", "stderr was: #{inspect(err)}" - assert result == {:halt, 4} - assert out =~ "consent service is not running" + assert err =~ "Nothing was sent.", "stderr was: #{inspect(err)}" end test "a no reaches the submitter seam not at all" do From 1153e50892c0c922d18cd9657ef97fbe4d255dd4 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Tue, 29 Sep 2026 17:21:05 +0000 Subject: [PATCH 3/3] docs(#97): cite the measured OTP 26 failure, not the inferred one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floor is stated as "cowlib 2.20 does not compile on OTP 26", which was a reading of a version constraint. It is now a measurement: on 2026-09-19 every OTP 26 cell in CI (Elixir 1.15, 1.16 and 1.17) failed with `function parse/1 undefined`, while the same commit's OTP 27 cell passed cleanly. Both places that carry the rationale now carry the evidence — the README where the floor is promised, and the CI matrix comment where it is enforced and where someone will look when they wonder why the old Elixir versions are gone. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/elixir-ci.yml | 10 +++++++--- elixir-mcp/README.adoc | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/elixir-ci.yml b/.github/workflows/elixir-ci.yml index 3c0d21b..47e4a5d 100644 --- a/.github/workflows/elixir-ci.yml +++ b/.github/workflows/elixir-ci.yml @@ -30,10 +30,14 @@ jobs: working-directory: elixir-mcp strategy: fail-fast: false - # The OTP floor is 27 (issue #97): cowlib 2.20 does not compile on OTP - # 26, and the toolchain this repo pins (mise.toml: erlang 27.0 / + # The OTP floor is 27 (issue #97), and it is measured, not assumed: on + # 2026-09-19 the OTP 26 cells (Elixir 1.15/1.16/1.17) all failed with + # `function parse/1 undefined` while this same commit's OTP 27 cell + # passed — cowlib 2.20, which cowboy requires, does not build on OTP + # 26. The toolchain this repo pins (mise.toml: erlang 27.0 / # elixir 1.19.5-otp-27) is OTP 27. Every Elixir version named here is a - # measured cell, so the documented floor is the floor that builds. + # cell that has actually been run, so the documented floor is the floor + # that builds. matrix: elixir: ['1.17', '1.18', '1.19'] otp: ['27'] diff --git a/elixir-mcp/README.adoc b/elixir-mcp/README.adoc index 8e863ae..22192a8 100644 --- a/elixir-mcp/README.adoc +++ b/elixir-mcp/README.adoc @@ -19,9 +19,13 @@ mix escript.build # produces ./feedback-o-tron Requires Elixir 1.17 or newer on OTP 27 or newer. CI tests 1.17, 1.18 and 1.19 on OTP 27, and 1.19 on OTP 27 is what this engine is developed against. -The floor was raised from OTP 26 in issue #97: `cowlib` 2.20, which `cowboy` -requires, does not compile on OTP 26, so the old floor was a promise the -build could not keep. +The floor was raised from OTP 26 in issue #97, and the reason is measured +rather than argued: on 2026-09-19 every OTP 26 cell in CI (Elixir 1.15, 1.16 +and 1.17) failed to compile a dependency with `function parse/1 undefined`, +while the same commit's OTP 27 cell passed cleanly. `cowlib` 2.20, which +`cowboy` requires, is what does not build there. The old floor was a promise +the build could not keep, and on OTP 27 the identical commit builds, formats +and dialyses green. == Run