diff --git a/.github/workflows/lint-format.yaml b/.github/workflows/lint-format.yaml index e2ff6766..20a0ee9c 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: --require-hashes -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: --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 6fa95605..60db11ac 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: --require-hashes -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..eb701cf3 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: --require-hashes -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: --require-hashes -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: --require-hashes -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..4bbdba62 100644 --- a/netbox_interface_name_rules/tests/test_ci_workflow.py +++ b/netbox_interface_name_rules/tests/test_ci_workflow.py @@ -20,6 +20,25 @@ 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 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"