Files
computer-playbook/tests/unit/cli/administration/deploy/ci/test_trigger.py
T
kevinveenbirkenbachandClaude Opus 5 4e168286cc fix(ci-trigger): read the priority input from the job log
GitHub cuts display_title at 512 UTF-8 bytes and closes it with a
literal '...'. The run-name template renders priority second-to-last
and whitelist last, and on the --failed path the whitelist is empty, so
the cut always lands inside the priority list: 21 of 69 roles survived
the source run's title, the 21st a half-token.

untriggered_priority parsed that title and passed undecodable names
through verbatim, so the fragment reached the dispatched priority
input, became the orchestrator's whitelist, and hit the complexity
filter's lexer, whose word class has no rule for CJK. Sequencing died
with argparse exit 2 and took the run with it. The retrigger that is
supposed to repair a red run was breaking its successor.

Read the priority from the ##[group] Inputs block a called job's log
records instead: it carries all 69 roles. Depth-1 jobs only -- a deeper
one echoes its own workflow's inputs, which have no priority.

Drop the decode passthrough now that the source is lossless. The loud
failure is only affordable once a whole value exists; before, it would
have aborted every large retrigger.

dispatched_priority cross-checks against the title: it still proves a
priority line existed even when truncation mangled it, so an empty log
read is a broken reader -- expired logs, a moved block -- and not a run
without priority. Without that check the carry-over would vanish
silently, which is what the rest of this change exists to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:33:20 +02:00

251 lines
9.6 KiB
Python

from __future__ import annotations
import io
import unittest
from contextlib import redirect_stdout
from unittest import mock
from cli.administration.deploy.ci import runs
from cli.administration.deploy.ci.trigger import __main__ as trigger
from tests.utils.ci.job_names import deploy_job_name
from tests.utils.ci.run_name import render
def _job(mode: str, app: str, conclusion: str) -> dict:
return {
"name": deploy_job_name(mode, app, "0,1"),
"status": "completed",
"conclusion": conclusion,
}
_JOBS = [
_job("docker", "web-app-x", "success"),
_job("swarm", "web-app-x", "failure"),
_job("docker", "web-app-y", "failure"),
_job("swarm", "web-app-y", "success"),
]
_RUN_URL = "https://github.com/o/r/actions/runs/55" # nocheck: url
_SOURCE_CONFIG = {
"distros": "arch centos",
"modes": "swarm compose",
"lifecycles": "stable",
"filesystem": "btrfs",
"sequencing": "serial",
"mode_fail_fast": "false",
"workspace": "false",
}
_SOURCE_RUN = {"jobs": _JOBS, "displayTitle": render(_SOURCE_CONFIG)}
class TestTriggerMain(unittest.TestCase):
def _run(
self,
argv: list[str],
run: dict | None = None,
inputs: dict[str, str] | None = None,
) -> tuple[int, list]:
calls: list[tuple] = []
buf = io.StringIO()
with (
mock.patch.object(runs, "current_branch", return_value="feature/x"),
mock.patch.object(runs, "resolve_repo", return_value="o/r"),
mock.patch.object(runs, "find_last_deploy_run", return_value=run),
mock.patch.object(runs, "inputs_from_jobs", return_value=inputs or {}),
mock.patch.object(
runs,
"dispatch_workflow",
side_effect=lambda wf, ref, wl="", priority="", config=None, repo=None: (
calls.append((wf, ref, wl, priority, config, repo))
),
),
redirect_stdout(buf),
):
rc = trigger.main(argv)
return rc, calls
def test_default_triggers_all(self) -> None:
rc, calls = self._run([])
self.assertEqual(rc, 0)
self.assertEqual(
calls, [("entry-manual-steer.yml", "feature/x", "__ALL__", "", {}, "o/r")]
)
def test_apps_explicit_list(self) -> None:
rc, calls = self._run(["--apps", "web-app-a web-app-b"])
self.assertEqual(rc, 0)
self.assertEqual(calls[0][2], "web-app-a web-app-b")
self.assertEqual(calls[0][3], "")
def test_failed_total_sends_priority_without_whitelist(self) -> None:
rc, calls = self._run(["--failed"], run={"_jobs": _JOBS})
self.assertEqual(rc, 0)
self.assertEqual(calls[0][2], "")
self.assertEqual(calls[0][3], "web-app-x web-app-y")
def test_failed_swarm_scope(self) -> None:
_rc, calls = self._run(["--failed", "swarm"], run={"_jobs": _JOBS})
self.assertEqual(calls[0][3], "web-app-x")
def test_failed_compose_scope(self) -> None:
_rc, calls = self._run(["--failed", "compose"], run={"_jobs": _JOBS})
self.assertEqual(calls[0][3], "web-app-y")
def test_never_deployed_priority_roles_join_the_retrigger(self) -> None:
source = {"jobs": _JOBS, "displayTitle": render(_SOURCE_CONFIG)}
calls: list = []
with (
mock.patch.object(runs, "current_branch", return_value="feature/x"),
mock.patch.object(runs, "resolve_repo", return_value="o/r"),
mock.patch.object(runs, "fetch_run", return_value=source),
mock.patch.object(
runs,
"inputs_from_jobs",
return_value={"priority": "web-app-x web-app-never"},
),
mock.patch.object(
runs,
"dispatch_workflow",
side_effect=lambda wf, ref, wl="", priority="", config=None, repo=None: (
calls.append(priority)
),
),
redirect_stdout(io.StringIO()),
):
rc = trigger.main(["--failed", "--run", _RUN_URL])
self.assertEqual(rc, 0)
self.assertEqual(calls[0], "web-app-never web-app-x web-app-y")
def test_an_unreadable_job_log_aborts_instead_of_dropping_the_priority(
self,
) -> None:
source = {
"jobs": _JOBS,
"displayTitle": render({**_SOURCE_CONFIG, "priority": "web-app-x"}),
}
with (
mock.patch.object(runs, "current_branch", return_value="feature/x"),
mock.patch.object(runs, "resolve_repo", return_value="o/r"),
mock.patch.object(runs, "fetch_run", return_value=source),
mock.patch.object(runs, "inputs_from_jobs", return_value={}),
redirect_stdout(io.StringIO()),
self.assertRaises(SystemExit),
):
trigger.main(["--failed", "--run", _RUN_URL])
def test_a_never_deployed_priority_role_alone_still_dispatches(self) -> None:
green = [
_job("docker", "web-app-x", "success"),
_job("swarm", "web-app-x", "success"),
]
source = {"jobs": green, "displayTitle": render({})}
calls: list = []
with (
mock.patch.object(runs, "current_branch", return_value="feature/x"),
mock.patch.object(runs, "resolve_repo", return_value="o/r"),
mock.patch.object(runs, "fetch_run", return_value=source),
mock.patch.object(
runs,
"inputs_from_jobs",
return_value={"priority": "web-app-x web-app-never"},
),
mock.patch.object(
runs,
"dispatch_workflow",
side_effect=lambda wf, ref, wl="", priority="", config=None, repo=None: (
calls.append(priority)
),
),
redirect_stdout(io.StringIO()),
):
rc = trigger.main(["--failed", "--run", _RUN_URL])
self.assertEqual(rc, 0)
self.assertEqual(calls[0], "web-app-never")
def test_failed_nothing_does_not_dispatch(self) -> None:
green = [
_job("docker", "web-app-x", "success"),
_job("swarm", "web-app-x", "success"),
]
rc, calls = self._run(["--failed"], run={"_jobs": green})
self.assertEqual(rc, 0)
self.assertEqual(calls, [])
def test_failed_with_run_url_uses_that_run(self) -> None:
calls: list = []
with (
mock.patch.object(runs, "current_branch", return_value="feature/x"),
mock.patch.object(runs, "resolve_repo", return_value="o/r"),
mock.patch.object(runs, "fetch_run", return_value=_SOURCE_RUN) as fetch,
mock.patch.object(runs, "find_last_deploy_run") as find_last,
mock.patch.object(
runs,
"dispatch_workflow",
side_effect=lambda wf, ref, wl="", priority="", config=None, repo=None: (
calls.append((priority, config))
),
),
redirect_stdout(io.StringIO()),
):
rc = trigger.main(["--failed", "--run", _RUN_URL])
self.assertEqual(rc, 0)
self.assertEqual(calls[0], ("web-app-x web-app-y", _SOURCE_CONFIG))
fetch.assert_called_once()
find_last.assert_not_called()
def test_failed_with_bare_run_id_resolves_against_branch_repo(self) -> None:
calls: list = []
with (
mock.patch.object(runs, "current_branch", return_value="feature/x"),
mock.patch.object(runs, "resolve_repo", return_value="o/r"),
mock.patch.object(runs, "fetch_run", return_value=_SOURCE_RUN) as fetch,
mock.patch.object(runs, "find_last_deploy_run") as find_last,
mock.patch.object(
runs,
"dispatch_workflow",
side_effect=lambda wf, ref, wl="", priority="", config=None, repo=None: (
calls.append((priority, config))
),
),
redirect_stdout(io.StringIO()),
):
rc = trigger.main(["--failed", "--run", "55"])
self.assertEqual(rc, 0)
self.assertEqual(calls[0], ("web-app-x web-app-y", _SOURCE_CONFIG))
fetch.assert_called_once_with("55", repo="o/r")
find_last.assert_not_called()
def test_apps_with_a_run_reproduces_its_configuration(self) -> None:
calls: list = []
with (
mock.patch.object(runs, "current_branch", return_value="feature/x"),
mock.patch.object(runs, "resolve_repo", return_value="o/r"),
mock.patch.object(runs, "fetch_run", return_value=_SOURCE_RUN),
mock.patch.object(
runs,
"dispatch_workflow",
side_effect=lambda wf, ref, wl="", priority="", config=None, repo=None: (
calls.append((wl, priority, config))
),
),
redirect_stdout(io.StringIO()),
):
rc = trigger.main(["--apps", "web-app-a", "--run", _RUN_URL])
self.assertEqual(rc, 0)
self.assertEqual(calls[0], ("web-app-a", "", _SOURCE_CONFIG))
def test_failed_no_run_found(self) -> None:
rc, calls = self._run(["--failed"], run=None)
self.assertEqual(rc, 1)
self.assertEqual(calls, [])
def test_apps_and_failed_mutually_exclusive(self) -> None:
with self.assertRaises(SystemExit) as ctx, redirect_stdout(io.StringIO()):
trigger.main(["--failed", "--apps", "x"])
self.assertEqual(ctx.exception.code, 2)
if __name__ == "__main__":
unittest.main()