diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aebafa..b58adeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,25 @@ on: - cron: "0 4 * * *" jobs: + lint: + name: lint / format / types + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: python -m pip install -e .[dev] + # Runs the same hooks contributors get from `pre-commit install`, at the + # same pinned tool versions, so CI and local checks cannot disagree. + # pytest is skipped here because the jobs below already run the suite + # across the full matrix rather than once. + - name: Run pre-commit hooks + env: + SKIP: pytest + run: pre-commit run --all-files --show-diff-on-failure + tests-unit: name: unit / py${{ matrix.python-version }} / ${{ matrix.os }} runs-on: ${{ matrix.os }} @@ -135,9 +154,16 @@ jobs: run: python -m pip install -e .[dev] - name: Run tests with coverage run: coverage run -m pytest -m "not soak" + # Fails when package coverage drops below the fail_under floor in + # pyproject.toml. Previously coverage was measured and uploaded but never + # checked, so it could regress without CI noticing. + - name: Check coverage against the floor + run: coverage report - name: Generate coverage report + if: always() run: coverage xml -o coverage.xml - uses: actions/upload-artifact@v4 + if: always() with: name: coverage-report path: coverage.xml diff --git a/pyproject.toml b/pyproject.toml index 5831d92..6a99980 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,3 +103,19 @@ markers = [ [tool.isort] profile = "black" + + +[tool.coverage.run] +# Scope measurement to the package. Without this, coverage also reports the +# test files themselves, which are ~100% by construction and inflate the total +# into something that cannot regress meaningfully. +source = ["pyisolate"] + + +[tool.coverage.report] +show_missing = true +# A floor, not a target. The suite sits above this; the point is that a change +# which drops package coverage substantially fails CI instead of being noticed +# months later. Kernel-gated tests (Landlock, BPF, cgroup) skip on hosts without +# those features, so the floor is set below the observed value rather than at it. +fail_under = 70 diff --git a/tests/test_ebpf_contract.py b/tests/test_ebpf_contract.py index 977f9a5..d34aa28 100644 --- a/tests/test_ebpf_contract.py +++ b/tests/test_ebpf_contract.py @@ -29,7 +29,9 @@ def _kernel_map_names() -> set[str]: def _kernel_deny_defines() -> dict[str, int]: text = _BPF_SRC.read_text(encoding="utf-8") found = {} - for name, shift in re.findall(r"#define\s+(PYI_DENY_\w+)\s+\(1U\s*<<\s*(\d+)\)", text): + for name, shift in re.findall( + r"#define\s+(PYI_DENY_\w+)\s+\(1U\s*<<\s*(\d+)\)", text + ): found[name] = 1 << int(shift) return found @@ -51,12 +53,18 @@ def test_deny_mask_bits_match_kernel_defines(): def test_compile_deny_mask_denies_everything_without_a_policy(): mask = contract.compile_deny_mask(None) assert mask == ( - contract.DENY_FS | contract.DENY_NET | contract.DENY_PROCESS | contract.DENY_RISKY + contract.DENY_FS + | contract.DENY_NET + | contract.DENY_PROCESS + | contract.DENY_RISKY ) def test_compile_deny_mask_always_denies_process_and_risky(): - for policy in (None, iso.policy.Policy().allow_fs("/tmp").allow_tcp("127.0.0.1:80")): + for policy in ( + None, + iso.policy.Policy().allow_fs("/tmp").allow_tcp("127.0.0.1:80"), + ): mask = contract.compile_deny_mask(policy) assert mask & contract.DENY_PROCESS assert mask & contract.DENY_RISKY diff --git a/tests/test_landlock.py b/tests/test_landlock.py index c4d99eb..f66d3ef 100644 --- a/tests/test_landlock.py +++ b/tests/test_landlock.py @@ -19,10 +19,7 @@ import pyisolate as iso from pyisolate.runtime import landlock from pyisolate.runtime.child import _net_connect_ports -from pyisolate.runtime.process_backend import ( - _extract_fs_read_write, - _extract_fs_tcp, -) +from pyisolate.runtime.process_backend import _extract_fs_read_write, _extract_fs_tcp requires_landlock = pytest.mark.skipif( not landlock.landlock_supported(),