From 2d42414667f9b2b79bdd69062c355e0b3d173e74 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 4 Oct 2026 07:19:54 -0400 Subject: [PATCH] [CI] (Test): annotate failed ctest entries with their failure lines, since the run logs need a signed-in reader --- .github/workflows/test.yml | 34 +++++++++++++-- scripts/ci/junit_annotate.py | 82 ++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 scripts/ci/junit_annotate.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 642153db..4e897cd1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -413,6 +413,12 @@ jobs: --discovery "${RUNNER_TEMP}/primary-inproc-lane.json" \ --junit "${RUNNER_TEMP}/primary-inproc.xml" --output "${RUNNER_TEMP}/primary-inproc-proof.json" + # The run log needs a signed-in reader; annotations do not. Name every failed ctest entry, + # with the lines of its output that say why, where anyone reading the run can see them. + - name: Annotate failed entries + if: failure() + run: python3 scripts/ci/junit_annotate.py "${RUNNER_TEMP}" + - name: Upload integration lane evidence if: always() uses: actions/upload-artifact@v7 @@ -1169,6 +1175,12 @@ jobs: # arm proof read; verify-split-logs/ is whatever the control steps left behind, which for # twelve entries is a deliberately red re-run under the same name. Uploading only the # second - which is what this step did before the copy above existed - loses the first. + # The run log needs a signed-in reader; annotations do not. Name every failed ctest entry, + # with the lines of its output that say why, where anyone reading the run can see them. + - name: Annotate failed entries + if: failure() + run: python3 scripts/ci/junit_annotate.py "${RUNNER_TEMP}" + - name: Upload verify-split lane logs if: always() uses: actions/upload-artifact@v7 @@ -1481,7 +1493,7 @@ jobs: working-directory: build-split env: MOBILEGL_ITEST_REQUIRE_GPU: "1" - run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)" + run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)" --output-junit "${RUNNER_TEMP}/unit-split.xml" # P7 wave 2-F, PH-7 (1). THE TCP SUPERVISOR LANE NEEDS flatc, AND CI HAD NONE. # @@ -1648,6 +1660,12 @@ jobs: CONTROL_TMPDIR: ${{ runner.temp }} run: bash "${GITHUB_WORKSPACE}/scripts/ci/split_negative_controls.sh" + # The run log needs a signed-in reader; annotations do not. Name every failed ctest entry, + # with the lines of its output that say why, where anyone reading the run can see them. + - name: Annotate failed entries + if: failure() + run: python3 scripts/ci/junit_annotate.py "${RUNNER_TEMP}" + - name: Upload split lane logs if: always() uses: actions/upload-artifact@v7 @@ -1737,7 +1755,7 @@ jobs: env: MOBILEGL_ITEST_REQUIRE_GPU: "1" MOBILEGL_IPC_STRICT_ERRORS: "1" - run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)" + run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)" --output-junit "${RUNNER_TEMP}/unit-strict.xml" # ---- P5e (ra), MG_Remote/CONTRACT-P5E.md ยง7: ONE STEP, TWO ARMS, AND THE TREE PICKS ---- # @@ -2246,6 +2264,12 @@ jobs: slp.require_green(lane, sys.argv[3], names) PY + # The run log needs a signed-in reader; annotations do not. Name every failed ctest entry, + # with the lines of its output that say why, where anyone reading the run can see them. + - name: Annotate failed entries + if: failure() + run: python3 scripts/ci/junit_annotate.py "${RUNNER_TEMP}" + - name: Upload strict lane logs if: always() uses: actions/upload-artifact@v7 @@ -2751,7 +2775,11 @@ jobs: && [ '${{ matrix.case }}' = 'improved-transparency-minecraft-26.3' ]; then export MOBILEGL_MAGMA_DISABLE_BLENDED_DEPTH_WRITE=1 fi - ctest -V --no-tests=error -R '^MobileGLTraceReplay\.${{ matrix.case }}\.${{ matrix.backend }}$' + ctest -V --no-tests=error -R '^MobileGLTraceReplay\.${{ matrix.case }}\.${{ matrix.backend }}$' --output-junit "${RUNNER_TEMP}/retrace.xml" + + - name: Annotate failed entries + if: failure() + run: python3 scripts/ci/junit_annotate.py --excerpts 1 "${RUNNER_TEMP}" - name: Upload core dumps if: failure() diff --git a/scripts/ci/junit_annotate.py b/scripts/ci/junit_annotate.py new file mode 100644 index 00000000..296f3376 --- /dev/null +++ b/scripts/ci/junit_annotate.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Turn the failed entries of ctest --output-junit files into GitHub error annotations. + +A red lane's log needs a signed-in reader, but its annotations do not, so the names of the +failed entries - and the lines of their output that say why - are what a reader of the run +page (or of the check-runs API) can see without downloading anything. GitHub keeps at most +ten error annotations per step, so this prints one summary naming every failed entry and then +one excerpt for each of the first few. + +Usage: junit_annotate.py [--excerpts N] FILE_OR_DIR... (a directory means every *.xml in it) +Exit status is always 0: this reports a failure, it is never one. +""" +import argparse +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +INTERESTING = re.compile( + r"Failure|FAILED|Failed|error|Error|Fatal|Refuse|Expected|Which is|Value of|Actual|" + r"Abort|abort|Segmentation|SIGSEGV|signal|timed out|Timeout|assert", re.ASCII) + + +def escape(text): + return text.replace("%", "%25").replace("\r", "").replace("\n", "%0A") + + +def failed_cases(path): + try: + root = ET.parse(path).getroot() + except (ET.ParseError, OSError) as error: + print(f"junit_annotate: {path}: {error}", file=sys.stderr) + return [] + out = [] + for case in root.iter("testcase"): + status = case.get("status", "") + failure = case.find("failure") + error = case.find("error") + if status not in ("fail", "failed") and failure is None and error is None: + continue + text = case.findtext("system-out") or "" + if failure is not None: + text = (failure.get("message") or "") + "\n" + (failure.text or "") + "\n" + text + out.append((case.get("name", "?"), text)) + return out + + +def excerpt(text, limit=40): + lines = [line.rstrip() for line in text.splitlines() if line.strip()] + keep = set() + for index, line in enumerate(lines): + if INTERESTING.search(line): + keep.update(range(index, min(index + 4, len(lines)))) + picked = [lines[index] for index in sorted(keep)] or lines[-limit:] + return "\n".join(line[:400] for line in picked[:limit]) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--excerpts", type=int, default=8) + parser.add_argument("paths", nargs="+") + args = parser.parse_args() + files = [] + for name in args.paths: + path = Path(name) + files.extend(sorted(path.glob("*.xml")) if path.is_dir() else [path]) + failed = [] + for path in files: + if path.is_file(): + failed.extend((path.name, name, text) for name, text in failed_cases(path)) + if not failed: + print("junit_annotate: no failed entries in " + ", ".join(str(f) for f in files)) + return 0 + names = "\n".join(f"{name} ({source})" for source, name, _ in failed) + print(f"::error title={len(failed)} failed ctest entr(y/ies)::{escape(names[:60000])}") + for source, name, text in failed[:max(args.excerpts, 0)]: + print(f"::error title={name[:200]}::{escape(excerpt(text))}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())