From cf5516dc83427ae4bdd74d31922f377c1885b51f Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Fri, 2 Oct 2026 11:23:46 +0200 Subject: [PATCH 1/3] fix: use locked CI dependencies and separate save warnings --- .github/workflows/lint-format.yaml | 9 ++- .github/workflows/test-netbox-main.yaml | 6 +- .github/workflows/test.yaml | 24 +++--- .../tests/test_ci_workflow.py | 73 +++++++++++++++++++ .../tests/test_views.py | 11 ++- netbox_interface_name_rules/views.py | 12 +-- pyproject.toml | 3 + uv.lock | 13 ++++ 8 files changed, 129 insertions(+), 22 deletions(-) diff --git a/.github/workflows/lint-format.yaml b/.github/workflows/lint-format.yaml index e2ff6766..31538b05 100644 --- a/.github/workflows/lint-format.yaml +++ b/.github/workflows/lint-format.yaml @@ -26,7 +26,10 @@ jobs: python-version: '3.12' - name: Install dependencies - run: 'uv pip install --system --only-binary=:all: --group lint' + shell: bash + run: | + uv export --system-certs --locked --only-group lint --no-emit-project --format requirements-txt | \ + uv pip install --system-certs --system --only-binary=:all: -r /dev/stdin - name: Check the lockfile is current run: uv lock --check @@ -42,8 +45,10 @@ jobs: run: pre-commit run --all-files zizmor - name: Check the release configuration + shell: bash run: | - uv pip install --system --only-binary=:all: --group release + uv export --system-certs --locked --only-group release --no-emit-project --format requirements-txt | \ + uv pip install --system-certs --system --only-binary=:all: -r /dev/stdin python .github/scripts/check_release_config.py fixtures="$(mktemp -d)" trap 'rm -rf "$fixtures"' EXIT diff --git a/.github/workflows/test-netbox-main.yaml b/.github/workflows/test-netbox-main.yaml index 6fa95605..e4156906 100644 --- a/.github/workflows/test-netbox-main.yaml +++ b/.github/workflows/test-netbox-main.yaml @@ -71,10 +71,12 @@ jobs: - name: Install NetBox and plugin working-directory: netbox-InterfaceNameRules-plugin + shell: bash run: | uv pip install --system -r ../netbox/requirements.txt - uv pip install --system --only-binary=:all: --group ci-tests - uv pip install --system -e . + uv export --system-certs --locked --group ci-tests --no-default-groups --no-emit-project --format requirements-txt | \ + uv pip install --system-certs --system --only-binary=:all: -r /dev/stdin + uv pip install --system-certs --system --no-deps -e . - name: Set up NetBox configuration working-directory: netbox diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3e6bcba0..962d5e24 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -41,7 +41,7 @@ jobs: # The same release with netbox-branching, so the branch tests run against a real provisioned branch. - python-version: "3.14" netbox-version: "v4.7.0" - netbox-branching: "1.2.1" + netbox-branching: true # The second coverage leg: only this cell runs the netbox-branching code paths. coverage: "netbox-v4.7.0-branching" # Early warning for upcoming NetBox changes. Non-blocking: unreleased NetBox breaks @@ -100,10 +100,12 @@ jobs: - name: Install NetBox and plugin working-directory: netbox-InterfaceNameRules-plugin + shell: bash run: | uv pip install --system -r ../netbox/requirements.txt - uv pip install --system --only-binary=:all: --group ci-tests - uv pip install --system -e . + uv export --system-certs --locked --group ci-tests --no-default-groups --no-emit-project --format requirements-txt | \ + uv pip install --system-certs --system --only-binary=:all: -r /dev/stdin + uv pip install --system-certs --system --no-deps -e . - name: Set up NetBox configuration working-directory: netbox @@ -131,12 +133,12 @@ jobs: # netbox-branching refuses to start without DynamicSchemaDict and its router, and it must be the last plugin. - name: Install and configure netbox-branching if: matrix.netbox-branching - working-directory: netbox - env: - NETBOX_BRANCHING_VERSION: ${{ matrix.netbox-branching }} + working-directory: netbox-InterfaceNameRules-plugin + shell: bash run: | - uv pip install --system --only-binary=:all: "netboxlabs-netbox-branching==${NETBOX_BRANCHING_VERSION}" - cat >> netbox/netbox/configuration.py << 'EOF' + uv export --system-certs --locked --only-group ci-branching --no-emit-project --format requirements-txt | \ + uv pip install --system-certs --system --only-binary=:all: -r /dev/stdin + cat >> ../netbox/netbox/netbox/configuration.py << 'EOF' from netbox_branching.utilities import DynamicSchemaDict DATABASES = DynamicSchemaDict({'default': DATABASE}) del DATABASE @@ -196,7 +198,7 @@ jobs: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -218,8 +220,10 @@ jobs: - name: Install coverage working-directory: netbox-InterfaceNameRules-plugin + shell: bash run: | - uv pip install --system --only-binary=:all: --group ci-tests + uv export --system-certs --locked --only-group ci-tests --no-emit-project --format requirements-txt | \ + uv pip install --system-certs --system --only-binary=:all: -r /dev/stdin # `coverage report` enforces `fail_under` from pyproject.toml on the combined data. - name: Combine and check coverage diff --git a/netbox_interface_name_rules/tests/test_ci_workflow.py b/netbox_interface_name_rules/tests/test_ci_workflow.py index 246a221e..25af3285 100644 --- a/netbox_interface_name_rules/tests/test_ci_workflow.py +++ b/netbox_interface_name_rules/tests/test_ci_workflow.py @@ -2,6 +2,7 @@ # Copyright (C) 2025 Marcin Zieba """Tests for the coverage wiring of the CI test workflow.""" +import shlex import tomllib import unittest from pathlib import Path @@ -20,6 +21,78 @@ def _steps_using(job, action): return [step for step in job["steps"] if step.get("uses", "").startswith(f"{action}@")] +class WorkflowActionPinsTest(unittest.TestCase): + """Workflow jobs must use the same setup-uv action revision.""" + + def test_setup_uv_pins_match_across_all_workflow_jobs(self): + pins = {} + for path in sorted((_PROJECT_ROOT / ".github" / "workflows").iterdir()): + if path.suffix not in {".yaml", ".yml"}: + continue + workflow = yaml.safe_load(path.read_text(encoding="utf-8")) + for name, job in workflow["jobs"].items(): + if "steps" not in job: + continue + for index, step in enumerate(_steps_using(job, "astral-sh/setup-uv")): + pins[f"{path.name}:{name}:{index}"] = step["uses"] + + self.assertTrue(pins, "no setup-uv steps found") + self.assertEqual(len(set(pins.values())), 1, f"setup-uv pins differ across workflow jobs: {pins}") + + +class LockedWorkflowDependenciesTest(unittest.TestCase): + """CI tools use the lockfile without replacing NetBox's environment.""" + + def test_dependency_group_installers_consume_checked_lock_exports(self): + installers = [ + ("test.yaml", "test-netbox", "Install NetBox and plugin", "ci-tests"), + ("test.yaml", "coverage", "Install coverage", "ci-tests"), + ("test.yaml", "test-netbox", "Install and configure netbox-branching", "ci-branching"), + ("test-netbox-main.yaml", "test-netbox-main", "Install NetBox and plugin", "ci-tests"), + ("lint-format.yaml", "format-and-lint", "Install dependencies", "lint"), + ("lint-format.yaml", "format-and-lint", "Check the release configuration", "release"), + ] + for filename, job, name, group in installers: + with self.subTest(workflow=filename, job=job, step=name): + workflow = yaml.safe_load( + (_PROJECT_ROOT / ".github" / "workflows" / filename).read_text(encoding="utf-8") + ) + step = next(step for step in workflow["jobs"][job]["steps"] if step.get("name") == name) + commands = step["run"].replace("\\\n", " ").splitlines() + exports = [line.strip() for line in commands if line.strip().startswith("uv export ")] + self.assertEqual(len(exports), 1) + export, separator, install = exports[0].partition("|") + self.assertEqual(separator, "|") + export_args = shlex.split(export) + install_args = shlex.split(install) + for option in ("--system-certs", "--locked", "--no-emit-project"): + self.assertIn(option, export_args) + if name == "Install NetBox and plugin": + self.assertEqual(export_args[export_args.index("--group") + 1], group) + self.assertIn("--no-default-groups", export_args) + self.assertNotIn("--only-group", export_args) + editable = next(line for line in commands if "-e ." in line) + self.assertIn("--no-deps", shlex.split(editable)) + else: + self.assertEqual(export_args[export_args.index("--only-group") + 1], group) + self.assertEqual(export_args[export_args.index("--format") + 1], "requirements-txt") + self.assertEqual(install_args[:3], ["uv", "pip", "install"]) + for option in ("--system-certs", "--system", "--only-binary=:all:"): + self.assertIn(option, install_args) + self.assertEqual(install_args[install_args.index("-r") + 1], "/dev/stdin") + self.assertEqual(step.get("shell"), "bash", "explicit bash enables pipefail for the export") + self.assertNotIn("--group", install_args) + + def test_branching_matrix_selects_the_locked_dependency_group(self): + jobs = _workflow_jobs() + cells = jobs["test-netbox"]["strategy"]["matrix"]["include"] + selected = [cell["netbox-branching"] for cell in cells if cell.get("netbox-branching")] + self.assertTrue(selected) + self.assertTrue(all(value is True for value in selected)) + pyproject = tomllib.loads((_PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + self.assertTrue(pyproject["dependency-groups"].get("ci-branching")) + + class CoverageCombineWorkflowTest(unittest.TestCase): """Every coverage leg must reach the one job that enforces the gate.""" diff --git a/netbox_interface_name_rules/tests/test_views.py b/netbox_interface_name_rules/tests/test_views.py index 1547141d..c443d03b 100644 --- a/netbox_interface_name_rules/tests/test_views.py +++ b/netbox_interface_name_rules/tests/test_views.py @@ -529,7 +529,8 @@ def test_a_glob_style_pattern_saves_and_warns_that_it_matches_nothing(self): response = self._post("GLC-T*") - self.assertTrue(InterfaceNameRule.objects.filter(module_type_pattern="GLC-T*").exists()) + rule = InterfaceNameRule.objects.get(module_type_pattern="GLC-T*") + self.assertRedirects(response, rule.get_absolute_url()) warnings = [str(m) for m in get_messages(response.wsgi_request) if m.level_tag == "warning"] self.assertTrue(any("matches no module type" in m for m in warnings), warnings) @@ -557,6 +558,7 @@ def test_editing_a_rule_into_a_zero_match_pattern_also_warns(self): rule.refresh_from_db() self.assertEqual(rule.module_type_pattern, "VIEW-T*") + self.assertRedirects(response, rule.get_absolute_url()) warnings = [str(m) for m in get_messages(response.wsgi_request) if m.level_tag == "warning"] self.assertTrue(any("matches no module type" in m for m in warnings), warnings) @@ -628,6 +630,7 @@ def test_a_rejected_submission_is_not_called_ineffective(self): }, ) + self.assertEqual(response.status_code, 200) self.assertFalse(InterfaceNameRule.objects.filter(module_type_pattern="REJECT-T*").exists()) warnings = [str(m) for m in get_messages(response.wsgi_request) if m.level_tag == "warning"] self.assertEqual([m for m in warnings if "matches no module type" in m], [], warnings) @@ -650,7 +653,8 @@ def test_a_device_interface_rule_is_not_called_ineffective(self): follow=True, ) - self.assertTrue(InterfaceNameRule.objects.filter(module_type_pattern="^Ethernet.*$").exists()) + rule = InterfaceNameRule.objects.get(module_type_pattern="^Ethernet.*$") + self.assertRedirects(response, rule.get_absolute_url()) warnings = [str(m) for m in get_messages(response.wsgi_request) if m.level_tag == "warning"] self.assertEqual([m for m in warnings if "matches no module type" in m], [], warnings) @@ -661,7 +665,8 @@ def test_an_equivalent_regex_pattern_warns_about_nothing(self): response = self._post("GLC-T.*") - self.assertTrue(InterfaceNameRule.objects.filter(module_type_pattern="GLC-T.*").exists()) + rule = InterfaceNameRule.objects.get(module_type_pattern="GLC-T.*") + self.assertRedirects(response, rule.get_absolute_url()) warnings = [str(m) for m in get_messages(response.wsgi_request) if m.level_tag == "warning"] self.assertEqual([m for m in warnings if "matches no module type" in m], []) diff --git a/netbox_interface_name_rules/views.py b/netbox_interface_name_rules/views.py index 7a4d467f..55009cf8 100644 --- a/netbox_interface_name_rules/views.py +++ b/netbox_interface_name_rules/views.py @@ -136,20 +136,23 @@ def post(self, request, *args, **kwargs): device_rule = _submitted(request, "applies_to_device_interfaces") started = timezone.now() response = super().post(request, *args, **kwargs) - if not regex_rule or device_rule: - return response + if regex_rule and not device_rule: + self._warn_if_pattern_matches_nothing(request, pattern, started) + return response + + def _warn_if_pattern_matches_nothing(self, request, pattern, started): # Only a rule written by this request saved; a rejected form leaves nothing to report on. saved = self.queryset.model.objects.filter( module_type_pattern=pattern, module_type_is_regex=True, last_updated__gte=started ) if not saved.exists(): - return response + return from .rule_selection import module_types_matching_pattern try: matched = module_types_matching_pattern(pattern) except ValidationError: - return response + return if not matched: messages.warning( request, @@ -157,7 +160,6 @@ def post(self, request, *args, **kwargs): "The field takes a regular expression, not a glob: '*' repeats the character " "before it, so 'GLC-T*' does not match 'GLC-TE'. Use 'GLC-T.*'.", ) - return response class InterfaceNameRuleCreateView(ZeroMatchPatternWarningMixin, generic.ObjectEditView): diff --git a/pyproject.toml b/pyproject.toml index 0790b663..036d1fb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,9 @@ devcontainer = [ "pynetbox", "playwright", ] +ci-branching = [ + "netboxlabs-netbox-branching==1.2.1", +] [tool.pytest.ini_options] pythonpath = ["/opt/netbox/netbox", "."] diff --git a/uv.lock b/uv.lock index 42cb141c..74595ba8 100644 --- a/uv.lock +++ b/uv.lock @@ -704,6 +704,9 @@ dependencies = [ ] [package.dev-dependencies] +ci-branching = [ + { name = "netboxlabs-netbox-branching" }, +] ci-tests = [ { name = "pytest" }, { name = "pytest-cov" }, @@ -758,6 +761,7 @@ reuse = [ requires-dist = [{ name = "google-re2", specifier = ">=1.1.20251105" }] [package.metadata.requires-dev] +ci-branching = [{ name = "netboxlabs-netbox-branching", specifier = "==1.2.1" }] ci-tests = [ { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.0" }, @@ -802,6 +806,15 @@ packaging = [{ name = "build", specifier = ">=1.4" }] release = [{ name = "python-semantic-release", specifier = ">=10.6.2" }] reuse = [{ name = "reuse", specifier = ">=6.2" }] +[[package]] +name = "netboxlabs-netbox-branching" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/ed/9e10c72309f3bb6836386a696ecc1d7e70b379654d80a9aed7c52d187742/netboxlabs_netbox_branching-1.2.1.tar.gz", hash = "sha256:931b247d24fc7c32d89d428944cefa2b74781a4aed12faa66dc618af40425e32", size = 104809, upload-time = "2026-09-09T16:18:45.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/c0/961d8ea212c25725a747b9402551d2c59cc20c5ca4c1c22dd260a3e4bb03/netboxlabs_netbox_branching-1.2.1-py3-none-any.whl", hash = "sha256:56ce02b1ab0aecb39f740f75378ecb46c58dc9463834998ca15a6b7b16d153a2", size = 131702, upload-time = "2026-09-09T16:18:43.916Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" From f4bbcbffb3af5ce8682e52acb5d5c95095bbff50 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Fri, 2 Oct 2026 11:33:13 +0200 Subject: [PATCH 2/3] test: remove command-shape assertions from CI checks --- .../tests/test_ci_workflow.py | 54 ------------------- 1 file changed, 54 deletions(-) diff --git a/netbox_interface_name_rules/tests/test_ci_workflow.py b/netbox_interface_name_rules/tests/test_ci_workflow.py index 25af3285..4bbdba62 100644 --- a/netbox_interface_name_rules/tests/test_ci_workflow.py +++ b/netbox_interface_name_rules/tests/test_ci_workflow.py @@ -2,7 +2,6 @@ # Copyright (C) 2025 Marcin Zieba """Tests for the coverage wiring of the CI test workflow.""" -import shlex import tomllib import unittest from pathlib import Path @@ -40,59 +39,6 @@ def test_setup_uv_pins_match_across_all_workflow_jobs(self): self.assertEqual(len(set(pins.values())), 1, f"setup-uv pins differ across workflow jobs: {pins}") -class LockedWorkflowDependenciesTest(unittest.TestCase): - """CI tools use the lockfile without replacing NetBox's environment.""" - - def test_dependency_group_installers_consume_checked_lock_exports(self): - installers = [ - ("test.yaml", "test-netbox", "Install NetBox and plugin", "ci-tests"), - ("test.yaml", "coverage", "Install coverage", "ci-tests"), - ("test.yaml", "test-netbox", "Install and configure netbox-branching", "ci-branching"), - ("test-netbox-main.yaml", "test-netbox-main", "Install NetBox and plugin", "ci-tests"), - ("lint-format.yaml", "format-and-lint", "Install dependencies", "lint"), - ("lint-format.yaml", "format-and-lint", "Check the release configuration", "release"), - ] - for filename, job, name, group in installers: - with self.subTest(workflow=filename, job=job, step=name): - workflow = yaml.safe_load( - (_PROJECT_ROOT / ".github" / "workflows" / filename).read_text(encoding="utf-8") - ) - step = next(step for step in workflow["jobs"][job]["steps"] if step.get("name") == name) - commands = step["run"].replace("\\\n", " ").splitlines() - exports = [line.strip() for line in commands if line.strip().startswith("uv export ")] - self.assertEqual(len(exports), 1) - export, separator, install = exports[0].partition("|") - self.assertEqual(separator, "|") - export_args = shlex.split(export) - install_args = shlex.split(install) - for option in ("--system-certs", "--locked", "--no-emit-project"): - self.assertIn(option, export_args) - if name == "Install NetBox and plugin": - self.assertEqual(export_args[export_args.index("--group") + 1], group) - self.assertIn("--no-default-groups", export_args) - self.assertNotIn("--only-group", export_args) - editable = next(line for line in commands if "-e ." in line) - self.assertIn("--no-deps", shlex.split(editable)) - else: - self.assertEqual(export_args[export_args.index("--only-group") + 1], group) - self.assertEqual(export_args[export_args.index("--format") + 1], "requirements-txt") - self.assertEqual(install_args[:3], ["uv", "pip", "install"]) - for option in ("--system-certs", "--system", "--only-binary=:all:"): - self.assertIn(option, install_args) - self.assertEqual(install_args[install_args.index("-r") + 1], "/dev/stdin") - self.assertEqual(step.get("shell"), "bash", "explicit bash enables pipefail for the export") - self.assertNotIn("--group", install_args) - - def test_branching_matrix_selects_the_locked_dependency_group(self): - jobs = _workflow_jobs() - cells = jobs["test-netbox"]["strategy"]["matrix"]["include"] - selected = [cell["netbox-branching"] for cell in cells if cell.get("netbox-branching")] - self.assertTrue(selected) - self.assertTrue(all(value is True for value in selected)) - pyproject = tomllib.loads((_PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - self.assertTrue(pyproject["dependency-groups"].get("ci-branching")) - - class CoverageCombineWorkflowTest(unittest.TestCase): """Every coverage leg must reach the one job that enforces the gate.""" From b23cedb4192c7ead4094668a5941ba656af7dd46 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Fri, 2 Oct 2026 11:53:24 +0200 Subject: [PATCH 3/3] fix: require hashes for locked CI dependency installs --- .github/workflows/lint-format.yaml | 4 ++-- .github/workflows/test-netbox-main.yaml | 2 +- .github/workflows/test.yaml | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/lint-format.yaml b/.github/workflows/lint-format.yaml index 31538b05..20a0ee9c 100644 --- a/.github/workflows/lint-format.yaml +++ b/.github/workflows/lint-format.yaml @@ -29,7 +29,7 @@ jobs: shell: bash run: | uv export --system-certs --locked --only-group lint --no-emit-project --format requirements-txt | \ - uv pip install --system-certs --system --only-binary=:all: -r /dev/stdin + uv pip install --system-certs --system --only-binary=:all: --require-hashes -r /dev/stdin - name: Check the lockfile is current run: uv lock --check @@ -48,7 +48,7 @@ jobs: shell: bash run: | uv export --system-certs --locked --only-group release --no-emit-project --format requirements-txt | \ - uv pip install --system-certs --system --only-binary=:all: -r /dev/stdin + uv pip install --system-certs --system --only-binary=:all: --require-hashes -r /dev/stdin python .github/scripts/check_release_config.py fixtures="$(mktemp -d)" trap 'rm -rf "$fixtures"' EXIT diff --git a/.github/workflows/test-netbox-main.yaml b/.github/workflows/test-netbox-main.yaml index e4156906..60db11ac 100644 --- a/.github/workflows/test-netbox-main.yaml +++ b/.github/workflows/test-netbox-main.yaml @@ -75,7 +75,7 @@ jobs: run: | uv pip install --system -r ../netbox/requirements.txt uv export --system-certs --locked --group ci-tests --no-default-groups --no-emit-project --format requirements-txt | \ - uv pip install --system-certs --system --only-binary=:all: -r /dev/stdin + uv pip install --system-certs --system --only-binary=:all: --require-hashes -r /dev/stdin uv pip install --system-certs --system --no-deps -e . - name: Set up NetBox configuration diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 962d5e24..eb701cf3 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -104,7 +104,7 @@ jobs: run: | uv pip install --system -r ../netbox/requirements.txt uv export --system-certs --locked --group ci-tests --no-default-groups --no-emit-project --format requirements-txt | \ - uv pip install --system-certs --system --only-binary=:all: -r /dev/stdin + uv pip install --system-certs --system --only-binary=:all: --require-hashes -r /dev/stdin uv pip install --system-certs --system --no-deps -e . - name: Set up NetBox configuration @@ -137,7 +137,7 @@ jobs: shell: bash run: | uv export --system-certs --locked --only-group ci-branching --no-emit-project --format requirements-txt | \ - uv pip install --system-certs --system --only-binary=:all: -r /dev/stdin + uv pip install --system-certs --system --only-binary=:all: --require-hashes -r /dev/stdin cat >> ../netbox/netbox/netbox/configuration.py << 'EOF' from netbox_branching.utilities import DynamicSchemaDict DATABASES = DynamicSchemaDict({'default': DATABASE}) @@ -223,7 +223,7 @@ jobs: shell: bash run: | uv export --system-certs --locked --only-group ci-tests --no-emit-project --format requirements-txt | \ - uv pip install --system-certs --system --only-binary=:all: -r /dev/stdin + uv pip install --system-certs --system --only-binary=:all: --require-hashes -r /dev/stdin # `coverage report` enforces `fail_under` from pyproject.toml on the combined data. - name: Combine and check coverage