From 90b0a2ee88403082c80a8e1881d30c7b8d1ff36a Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 30 Oct 2026 19:26:27 +0100 Subject: [PATCH] fix(ci-artifacts): sign lint failures by their finding and ignore steps after the failure Co-Authored-By: Claude Opus 5.5 (1M context) --- skills/ci-artifacts/scripts/ci_failures.py | 31 ++++++++++++++++++---- tests/test_ci_tools.py | 14 ++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/skills/ci-artifacts/scripts/ci_failures.py b/skills/ci-artifacts/scripts/ci_failures.py index 1d89948..03f654f 100755 --- a/skills/ci-artifacts/scripts/ci_failures.py +++ b/skills/ci-artifacts/scripts/ci_failures.py @@ -23,9 +23,13 @@ from pathlib import Path FAILED = {"failure", "cancelled", "timed_out"} ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") STAMP = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z\s?") -GENERIC = re.compile(r"Process completed with exit code|failed=0\b|FAILED - RETRYING") +GENERIC = re.compile(r"failed=0\b|FAILED - RETRYING|^make(\[\d+\])?: \*\*\* \[.*\] Error \d+") +PROCESS_END = re.compile(r"##\[error\]Process completed with exit code") ANNOTATION = re.compile(r"##\[error\]") FATAL = re.compile(r"fatal: \[|FAILED!|Traceback|AssertionError|panic:") +LINT_AT = re.compile(r"\S+:\d+:\d+:? ((error|warning) )?[A-Z]{1,4}\d{3,4}\b") +LINT_CODE = re.compile(r"^[A-Z]{1,4}\d{3,4}\b") +LINT_ARROW = re.compile(r"^-->\s+\S+:\d+:\d+") SIGNAL = re.compile(r"\b(ERROR|Error|FAILED|FAIL|fatal|Exception|failed)\b") TASK = re.compile(r"^TASK \[") REFUSAL = "terminal escape sequences" @@ -49,6 +53,8 @@ def output_lines(text: str) -> list[tuple[str, str]]: kept, echo, task = [], False, "" for raw in text.splitlines(): line = clean(raw) + if PROCESS_END.search(line): + break if line.startswith("##[group]Run "): echo = True elif line.startswith("##[endgroup]"): @@ -60,13 +66,28 @@ def output_lines(text: str) -> list[tuple[str, str]]: return kept +def is_lint(lines: list[tuple[str, str]], index: int) -> bool: + line = lines[index][1] + if LINT_AT.search(line): + return True + following = lines[index + 1][1] if index + 1 < len(lines) else "" + return bool(LINT_CODE.match(line) and LINT_ARROW.match(following)) + + def signature(text: str) -> str: lines = output_lines(text) - for pattern in (ANNOTATION, FATAL, SIGNAL): - for task, line in reversed(lines): - if pattern.search(line): + tiers = ( + (lambda i: ANNOTATION.search(lines[i][1]), False), + (lambda i: FATAL.search(lines[i][1]), True), + (lambda i: is_lint(lines, i), False), + (lambda i: SIGNAL.search(lines[i][1]), False), + ) + for matches, with_task in tiers: + for index in reversed(range(len(lines))): + if matches(index): + task, line = lines[index] found = ANNOTATION.sub("", line).strip() - return normalise(f"{task} {found}" if pattern is FATAL and task else found) + return normalise(f"{task} {found}" if with_task and task else found) return normalise(lines[-1][1]) if lines else "" diff --git a/tests/test_ci_tools.py b/tests/test_ci_tools.py index a29a74b..35890e7 100644 --- a/tests/test_ci_tools.py +++ b/tests/test_ci_tools.py @@ -50,6 +50,20 @@ class TestCiFailures(unittest.TestCase): 'TASK [web-app-x : Wait for health] **** fatal: [localhost]: FAILED! => {"msg": "timeout "}', ) + def test_lint_finding_beats_make_error_and_later_steps(self): + text = ( + "Ran 41 tests in 1.0s\n" + "OK\n" + "UP017 [*] Use `datetime.UTC` alias\n" + " --> skills/democracy/scripts/council.py:92:40\n" + "Found 1 error.\n" + "make: *** [Makefile:15: test] Error 1\n" + "##[error]Process completed with exit code 2.\n" + "Post job cleanup.\n" + "SHA256 digest of uploaded artifact is 0123456789abcdef\n" + ) + self.assertEqual(self.ci.signature(text), "UP [*] Use `datetime.UTC` alias") + def test_generic_exit_code_line_is_not_the_signature(self): text = "Error: disk full\n##[error]Process completed with exit code 1.\n" self.assertEqual(self.ci.signature(text), "Error: disk full")