commit 099201ce8813f17b8cce839a82202ef3a8d0daf5
parent 1e21f30825baca3cfca4a33325be7b25e9f1b91c
Author: Florian Dold <dold@taler.net>
Date: Tue, 8 Sep 2026 21:15:37 +0200
buildbot: report advisory job failures as warnings
Treat a job as advisory only when warnings are requested without
halting on failure. Preserve fatal deployment and container build
failures, and propagate advisory warnings to the build result.
Diffstat:
2 files changed, 116 insertions(+), 3 deletions(-)
diff --git a/buildbot/master.cfg b/buildbot/master.cfg
@@ -250,9 +250,13 @@ def container_add_step(HALT_ON_FAILURE,
runCommand += [CONTAINER_NAME, jobCmd]
+ # Halting jobs (including deployment) remain fatal even if they also
+ # request warnings. Only non-halting jobs may be advisory.
+ advisory = WARN_ON_FAILURE and not HALT_ON_FAILURE
runArg = util.ShellArg(command=runCommand,
logname='run inside container',
- warnOnFailure=WARN_ON_FAILURE,
+ flunkOnFailure=not advisory,
+ warnOnFailure=advisory,
haltOnFailure=HALT_ON_FAILURE)
buildArg = util.ShellArg(command=["podman", "build", "-t", CONTAINER_NAME,
@@ -265,7 +269,8 @@ def container_add_step(HALT_ON_FAILURE,
name=stepName,
commands=[runArg],
haltOnFailure=HALT_ON_FAILURE,
- warnOnFailure=WARN_ON_FAILURE,
+ flunkOnFailure=True,
+ warnOnWarnings=True,
workdir=WORK_DIR
)
else:
@@ -273,7 +278,8 @@ def container_add_step(HALT_ON_FAILURE,
name=stepName,
commands=[buildArg, runArg],
haltOnFailure=HALT_ON_FAILURE,
- warnOnFailure=WARN_ON_FAILURE,
+ flunkOnFailure=True,
+ warnOnWarnings=True,
workdir=WORK_DIR
)
diff --git a/buildbot/test_container_steps.py b/buildbot/test_container_steps.py
@@ -0,0 +1,107 @@
+# This file is part of TALER
+# (C) 2026 Taler Systems SA
+#
+# TALER is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Affero General Public
+# License as published by the Free Software Foundation; either
+# version 3, or (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty
+# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+# See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public
+# License along with TALER; see the file COPYING. If not,
+# see <http://www.gnu.org/licenses/>
+#
+
+"""Run with python3 -m unittest discover -s buildbot -p 'test_*.py'."""
+
+import ast
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+
+from buildbot.plugins import steps, util
+from buildbot.process import results
+from twisted.internet import defer
+from twisted.trial import unittest
+
+
+# Load the helper without executing the master's workers, services and jobs.
+config_path = Path(__file__).with_name("master.cfg")
+config_ast = ast.parse(config_path.read_text(), filename=str(config_path))
+helper_ast = next(node for node in config_ast.body
+ if isinstance(node, ast.FunctionDef)
+ and node.name == "container_add_step")
+namespace = {
+ "steps": steps,
+ "util": util,
+ "os": SimpleNamespace(path=SimpleNamespace(isdir=lambda _: False)),
+ "pwd": SimpleNamespace(getpwnam=lambda _: SimpleNamespace(pw_uid=1000)),
+}
+exec(compile(ast.Module(body=[helper_ast], type_ignores=[]),
+ str(config_path), "exec"), namespace)
+container_add_step = namespace["container_add_step"]
+
+
+class ContainerStepTests(unittest.TestCase):
+ def make_step(self, halt, warn, build=False):
+ with patch("builtins.print"):
+ template = container_add_step(
+ halt, warn, build, "localhost/test", None,
+ "/workdir", "test", "test-job")
+ step = template.get_step_factory().buildStep()
+ # Rendering the environment values is unrelated to result propagation.
+ for arg in step.commands:
+ arg.command = [str(value) for value in arg.command]
+ return step
+
+ def run_sequence(self, step, command_results):
+ step.makeRemoteShellCommand = Mock(side_effect=[
+ SimpleNamespace(results=lambda result=result: result)
+ for result in command_results
+ ])
+ step.runCommand = Mock(return_value=defer.succeed(None))
+ result = self.successResultOf(step.runShellSequence(step.commands))
+ build_result, halt = results.computeResultAndTermination(
+ step, result, results.SUCCESS)
+ return result, build_result, halt
+
+ def test_advisory_failure_warns_build_and_continues(self):
+ for build in (False, True):
+ with self.subTest(build=build):
+ step = self.make_step(False, True, build)
+ outcomes = ([results.SUCCESS] if build else []) + [results.FAILURE]
+ self.assertEqual(self.run_sequence(step, outcomes),
+ (results.WARNINGS, results.WARNINGS, False))
+
+ def test_halting_failure_is_fatal_even_when_warnings_requested(self):
+ for warn in (False, True):
+ with self.subTest(warn=warn):
+ step = self.make_step(True, warn)
+ self.assertEqual(self.run_sequence(step, [results.FAILURE]),
+ (results.FAILURE, results.FAILURE, True))
+
+ def test_non_halting_failure_without_warnings_still_fails_build(self):
+ step = self.make_step(False, False)
+ self.assertEqual(self.run_sequence(step, [results.FAILURE]),
+ (results.FAILURE, results.FAILURE, False))
+
+ def test_container_failure_is_fatal_and_skips_run_command(self):
+ for halt in (False, True):
+ with self.subTest(halt=halt):
+ step = self.make_step(halt, True, build=True)
+ self.assertEqual(self.run_sequence(step, [results.FAILURE]),
+ (results.FAILURE, results.FAILURE, halt))
+ self.assertEqual(step.runCommand.call_count, 1)
+
+ def test_success_stays_success(self):
+ for halt in (False, True):
+ for warn in (False, True):
+ with self.subTest(halt=halt, warn=warn):
+ step = self.make_step(halt, warn, build=True)
+ self.assertEqual(self.run_sequence(
+ step, [results.SUCCESS, results.SUCCESS]),
+ (results.SUCCESS, results.SUCCESS, False))