# Copyright © 2025-26 l5yth & contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Guard the dependency manifests that Dependabot, CI, and the images consume. Four regressions are pinned here: * ``.github/dependabot.yml`` named ecosystems GitHub does not document (``ruby`` and ``python`` instead of ``bundler`` and ``pip``). The file was invalid, so Dependabot never ran a version update for any ecosystem. * ``web/Gemfile.lock`` was git-ignored, so the web image and CI resolved every gem afresh at build time instead of installing a reviewed set. * ``data/requirements.txt`` carried only ``>=`` floors plus the dev tools, so the ingestor image installed black and pytest and whatever release was newest on build day. * The ``github-actions`` entry kept Dependabot's default limit of 5 open pull requests, so its 2026-10-04 and 2026-10-05 runs computed the ``actions/checkout`` major and then dropped it behind the open updates. """ from __future__ import annotations import fnmatch import re import shutil import subprocess from collections.abc import Iterable from pathlib import Path, PurePosixPath import pytest from packaging.requirements import Requirement from packaging.utils import canonicalize_name from packaging.version import Version REPO_ROOT = Path(__file__).resolve().parents[1] DEPENDABOT = REPO_ROOT / ".github" / "dependabot.yml" GEMFILE_LOCK = REPO_ROOT / "web" / "Gemfile.lock" WEB_DOCKERFILE = REPO_ROOT / "web" / "Dockerfile" RUNTIME_REQUIREMENTS = REPO_ROOT / "data" / "requirements.txt" DEV_REQUIREMENTS = REPO_ROOT / "data" / "requirements-dev.txt" # Every ``package-ecosystem`` YAML value in the table of GitHub's Dependabot # options reference, as retrieved on 2026-10-04: # https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference#package-ecosystem- # Any other value invalidates the whole file, so add one here only once it # appears in that table. DOCUMENTED_ECOSYSTEMS = frozenset( { "bazel", "bun", "bundler", "cargo", "composer", "conda", "deno", "devcontainers", "docker", "docker-compose", "dotnet-sdk", "elm", "github-actions", "gitsubmodule", "gomod", "gradle", "helm", "julia", "maven", "mix", "nix", "npm", "nuget", "opentofu", "pip", "pre-commit", "pub", "rust-toolchain", "sbt", "swift", "terraform", "uv", "vcpkg", } ) # Language dependency manifests (file-name glob, Dependabot ecosystem) that # need an entry for their directory. Lockfiles sit beside their manifests, so # the manifests alone decide the directories. Container base images are # deliberately absent: the image pins are bounded on purpose (ACCEPTANCE DK-A2). MANIFEST_ECOSYSTEMS = ( ("Gemfile", "bundler"), ("requirements*.txt", "pip"), ("pyproject.toml", "pip"), ("package.json", "npm"), ("Cargo.toml", "cargo"), ) # Developer tools that belong in requirements-dev.txt, never in the image. DEV_TOOLS = ("black", "pytest", "pytest-cov") # meshcore 2.3.7 introduced EventType.CONTACT_DELETED; the RF5 handler needs # it, so the pin must never drop below the 2.3.8 floor (ACCEPTANCE EC-A2). MESHCORE_FLOOR = Version("2.3.8") # rns 1.5.0/1.5.1 carried a security fix; the pin must never drop below 1.5.1 # (ACCEPTANCE DP-A14). RNS_FLOOR = Version("1.5.1") # A trailing ``# comment``. A ``#`` that belongs to a value is never preceded # by whitespace in these files, so this cannot cut a value short. _COMMENT = re.compile(r"(^|\s)#.*$") # A workflow step's ``uses: owner/repo[/sub-path]@ref``, capturing the part # before ``@``. _USES = re.compile( r"^[ \t]*(?:-[ \t]+)?uses:[ \t]*[\"']?([^@\s\"'.][^@\s\"']*)@", re.MULTILINE ) # A YAML block-sequence item, capturing its indentation and its body. _ITEM = re.compile(r"^(?P\s+)-\s+(?P.*)$") # A YAML ``key: value`` line; nested keys are matched at any depth. _PAIR = re.compile(r"^\s*(?P[\w-]+):\s*(?P.*)$") # Dependabot's github-actions entry for ``/`` covers the workflows here. _WORKFLOWS = PurePosixPath(".github/workflows") requires_git = pytest.mark.skipif( shutil.which("git") is None or not (REPO_ROOT / ".git").exists(), reason="needs a git checkout", ) def _dependabot_entries(text: str) -> list[dict[str, str]]: """Return the ``updates`` entries of a ``dependabot.yml`` as flat dicts. The file is block-style YAML, so a line scanner keeps the guard free of a YAML dependency. Each ``-`` item at the first item's indentation starts an entry, and every ``key: value`` line inside it (``schedule.interval`` included) is stored under its bare key. Deeper list items (``ignore``, ``directories``) are not entries, and the next top-level key ends the list. A layout this scanner cannot read yields missing keys, so the guards below fail rather than pass on it. Args: text: The contents of a ``dependabot.yml`` file. Returns: One ``{key: value}`` dict per entry, with quotes stripped from values. """ entries: list[dict[str, str]] = [] item_indent = None for raw in text.partition("\nupdates:\n")[2].splitlines(): line = _COMMENT.sub("", raw).rstrip() if line and not line[0].isspace(): break item = _ITEM.match(line) if item and item_indent in (None, len(item.group("indent"))): item_indent = len(item.group("indent")) entries.append({}) line = item.group("body") pair = _PAIR.match(line) if pair and entries: entries[-1][pair.group("key")] = pair.group("value").strip("\"' ") return entries def _required_entries(paths: Iterable[str]) -> set[tuple[str, str]]: """Return the ``(ecosystem, directory)`` pairs the given files need. Args: paths: Repository-relative POSIX paths, e.g. from ``git ls-files``. Returns: One pair per manifest directory, using Dependabot's leading-slash directory form (``/`` for the repository root). """ required: set[tuple[str, str]] = set() for path in map(PurePosixPath, paths): if path.parent == _WORKFLOWS and path.suffix in (".yml", ".yaml"): required.add(("github-actions", "/")) continue for pattern, ecosystem in MANIFEST_ECOSYSTEMS: if fnmatch.fnmatchcase(path.name, pattern): parent = path.parent.as_posix() required.add((ecosystem, "/" if parent == "." else f"/{parent}")) return required def _tracked_files() -> list[str]: """Return every path git tracks, relative to the repository root. Returns: The ``git ls-files`` listing. """ listing = subprocess.run( ["git", "-C", str(REPO_ROOT), "ls-files", "-z"], capture_output=True, check=True, text=True, ) return [path for path in listing.stdout.split("\0") if path] def _lockfile_section(text: str, name: str) -> list[str]: """Return the stripped lines of one top-level ``Gemfile.lock`` section. Args: text: The lockfile contents. name: A section header such as ``PLATFORMS`` or ``BUNDLED WITH``. Returns: The indented lines under ``name``, up to the next unindented line. """ lines: list[str] = [] inside = False for line in text.splitlines(): if not line.startswith(" "): inside = line == name elif inside: lines.append(line.strip()) return lines def _repository_ignore_rule(verbose_output: str) -> str | None: """Return the committed ``.gitignore`` rule that ignores a path, if any. Args: verbose_output: The output of ``git check-ignore --no-index --verbose`` for one path: empty when no rule matches, else ``::`` and a tab before the path. Returns: ``::`` when the deciding rule ignores the path and lives in a ``.gitignore`` inside the repository, otherwise ``None``. A ``!`` pattern re-includes the path. The clone-local ``info/exclude`` and a global excludes file are a developer's own settings, not the repository's: git reports them by their own file name (``exclude``) or by an absolute path, so they never count. """ rule = verbose_output.split("\t", 1)[0].strip() source, _, rest = rule.partition(":") pattern = rest.partition(":")[2] path = PurePosixPath(source) if path.name != ".gitignore" or path.is_absolute() or pattern.startswith("!"): return None return rule def _copy_sources(dockerfile: str, needle: str) -> list[str]: """Return the ``COPY`` sources of a Dockerfile that contain ``needle``. Args: dockerfile: The Dockerfile contents. needle: A substring of the source path, such as ``Gemfile.lock``. Returns: The matching source operands, without ``--flags`` or the destination. """ sources: list[str] = [] for line in dockerfile.splitlines(): words = line.split() if words[:1] == ["COPY"]: sources += [w for w in words[1:-1] if needle in w and w[:2] != "--"] return sources def _requirements(path: Path) -> tuple[list[Requirement], list[str]]: """Parse a pip requirements file into requirements and option lines. Args: path: The requirements file to read. Returns: ``(requirements, options)``: the parsed requirement specifiers, and the ``-``-prefixed option lines (such as ``-r requirements.txt``) with their comments removed. """ requirements: list[Requirement] = [] options: list[str] = [] for raw in path.read_text(encoding="utf-8").splitlines(): line = _COMMENT.sub("", raw).strip() if line.startswith("-"): options.append(line) elif line: requirements.append(Requirement(line)) return requirements, options def _exact_pin(requirement: Requirement) -> Version | None: """Return the version a requirement pins with ``==``, or ``None``. Args: requirement: A parsed requirement. Returns: The pinned version when the specifier is a single ``==`` clause without a wildcard, otherwise ``None``. """ clauses = list(requirement.specifier) if len(clauses) == 1 and clauses[0].operator == "==": if "*" not in clauses[0].version: return Version(clauses[0].version) return None def _pins(path: Path) -> dict[str, Version | None]: """Map each requirement name in ``path`` to its exact pin, if any. Args: path: The requirements file to read. Returns: ``{name: version}`` keyed by the canonical project name (so ``Pytest_Cov`` reads as ``pytest-cov``); the version is ``None`` for a requirement that is not pinned with ``==``. """ requirements, _ = _requirements(path) return {canonicalize_name(req.name): _exact_pin(req) for req in requirements} def _assert_pinned_at_least(name: str, floor: Version) -> None: """Assert that ``data/requirements.txt`` pins ``name`` with ``==`` at ``floor`` or above. Args: name: Project name; matched by its canonical form, as ``_pins`` keys. floor: Lowest acceptable pinned version. """ pinned = _pins(RUNTIME_REQUIREMENTS).get(canonicalize_name(name)) assert pinned is not None, f"{name} is not pinned with == in data/requirements.txt" assert pinned >= floor, f"{name}=={pinned} is below {floor}" def test_dependabot_ecosystems_are_documented() -> None: """Every ``package-ecosystem`` value is one GitHub documents.""" entries = _dependabot_entries(DEPENDABOT.read_text(encoding="utf-8")) ecosystems = {entry.get("package-ecosystem", "") for entry in entries} undocumented = sorted(ecosystems - DOCUMENTED_ECOSYSTEMS) assert entries, "no updates entries parsed from .github/dependabot.yml" assert not undocumented, ( f"undocumented package-ecosystem values in .github/dependabot.yml: " f"{undocumented} (Ruby is `bundler`, Python is `pip`)" ) @requires_git def test_dependabot_covers_every_manifest_directory() -> None: """Each tracked manifest's directory has a weekly entry for its ecosystem.""" entries = _dependabot_entries(DEPENDABOT.read_text(encoding="utf-8")) configured = {(e.get("package-ecosystem"), e.get("directory")) for e in entries} missing = sorted(_required_entries(_tracked_files()) - configured) not_weekly = sorted( f"{e.get('package-ecosystem')} {e.get('directory')}" for e in entries if e.get("interval") != "weekly" ) assert not missing, f"no Dependabot entry for (ecosystem, directory): {missing}" assert not not_weekly, f"Dependabot entries not on a weekly schedule: {not_weekly}" def _action_repositories(text: str) -> set[str]: """Return the ``owner/repo`` of every remote action a workflow file uses. Dependabot updates a repository once for all its sub-path actions (``github/codeql-action/init`` and ``/analyze``), so a sub-path folds into its repository. Local actions (``./``) and ``docker://`` images are not GitHub Actions updates and are left out. Args: text: The contents of a workflow file. Returns: The ``owner/repo`` names, without duplicates. """ return { "/".join(name.split("/")[:2]) for name in _USES.findall(text) if ":" not in name } def test_actions_pr_limit_covers_every_action() -> None: """The ``github-actions`` limit fits one open pull request per action repository. Dependabot drops an update it has computed once the entry already has ``open-pull-requests-limit`` pull requests open (5 by default). A limit at or above the number of action repositories the workflows use keeps every update from waiting behind the others. """ entries = _dependabot_entries(DEPENDABOT.read_text(encoding="utf-8")) actions = [e for e in entries if e.get("package-ecosystem") == "github-actions"] workflows = REPO_ROOT / _WORKFLOWS files = sorted([*workflows.glob("*.yml"), *workflows.glob("*.yaml")]) used = set().union( *(_action_repositories(f.read_text(encoding="utf-8")) for f in files) ) assert len(actions) == 1, "expected one github-actions entry in dependabot.yml" assert used, "no `uses: owner/action@ref` lines parsed from .github/workflows" limit = int(actions[0].get("open-pull-requests-limit", "5")) assert limit >= len(used), ( f"github-actions open-pull-requests-limit is {limit}, " f"below the {len(used)} distinct actions in use" ) @requires_git def test_gemfile_lock_is_not_git_ignored() -> None: """No committed ``.gitignore`` rule ignores ``web/Gemfile.lock``. ``--no-index`` applies the rules even to a tracked file. The global excludes file is replaced by ``/dev/null``, and a match from the clone's ``info/exclude`` does not count, so a developer's own ``Gemfile.lock`` pattern cannot fail this guard. """ command = ["git", "-C", str(REPO_ROOT), "-c", "core.excludesFile=/dev/null"] check = subprocess.run( command + ["check-ignore", "--no-index", "--verbose", "web/Gemfile.lock"], capture_output=True, text=True, ) # Exit status 0 prints the deciding rule, 1 means no rule matched. assert check.returncode in (0, 1), check.stderr rule = _repository_ignore_rule(check.stdout) assert rule is None, f"web/Gemfile.lock is git-ignored by {rule}" def test_gemfile_lock_locks_the_build_platforms() -> None: """The lockfile covers CI's runners and the Alpine image builds. ``ruby/setup-ruby`` installs in deployment mode, which rejects a lockfile without the runner's platform (x86_64 glibc Linux), and the web image compiles every gem for the generic ``ruby`` platform (``BUNDLE_FORCE_RUBY_PLATFORM``). ``BUNDLED WITH`` selects the Bundler that both of them install. """ assert GEMFILE_LOCK.is_file(), ( "web/Gemfile.lock is missing: the web image and CI resolve every gem " "afresh at build time" ) text = GEMFILE_LOCK.read_text(encoding="utf-8") platforms = set(_lockfile_section(text, "PLATFORMS")) assert "ruby" in platforms, f"PLATFORMS lacks ruby: {sorted(platforms)}" runners = platforms & {"x86_64-linux", "x86_64-linux-gnu"} assert runners, f"PLATFORMS lacks x86_64-linux(-gnu) for CI: {sorted(platforms)}" assert _lockfile_section(text, "BUNDLED WITH"), "no BUNDLED WITH version" def test_web_image_copies_the_committed_lockfile() -> None: """Every ``COPY`` of the lockfile into the web image matches a real file.""" sources = _copy_sources(WEB_DOCKERFILE.read_text(encoding="utf-8"), "Gemfile.lock") unmatched = sorted({s for s in sources if not list(REPO_ROOT.glob(s))}) assert sources, "web/Dockerfile never copies web/Gemfile.lock" assert not unmatched, ( f"web/Dockerfile COPY sources {unmatched} match no file, so the image " f"resolves gems at build time" ) def test_runtime_requirements_pin_exact_versions() -> None: """``data/requirements.txt`` pins every runtime dependency with ``==``.""" pins = _pins(RUNTIME_REQUIREMENTS) unpinned = sorted(name for name, version in pins.items() if version is None) assert pins, "no requirements parsed from data/requirements.txt" assert not unpinned, f"data/requirements.txt does not pin with ==: {unpinned}" def test_runtime_requirements_exclude_dev_tools() -> None: """The ingestor image installs no formatter or test runner.""" leaked = sorted(set(_pins(RUNTIME_REQUIREMENTS)) & set(DEV_TOOLS)) assert not leaked, f"dev tools in data/requirements.txt (the image): {leaked}" def test_dev_requirements_extend_runtime_with_pinned_tools() -> None: """``data/requirements-dev.txt`` adds the pinned dev tools to the runtime set.""" assert DEV_REQUIREMENTS.is_file(), "data/requirements-dev.txt is missing" _, options = _requirements(DEV_REQUIREMENTS) pins = _pins(DEV_REQUIREMENTS) unpinned = [tool for tool in DEV_TOOLS if pins.get(tool) is None] assert "-r requirements.txt" in options, "missing -r requirements.txt" assert not unpinned, f"data/requirements-dev.txt does not pin with ==: {unpinned}" def test_meshcore_pin_defines_contact_deleted() -> None: """The pinned ``meshcore`` is at least 2.3.8 (``EventType.CONTACT_DELETED``).""" _assert_pinned_at_least("meshcore", MESHCORE_FLOOR) def test_rns_pin_keeps_the_security_fix() -> None: """The pinned ``rns`` is at least 1.5.1, past the 1.5 security fix.""" _assert_pinned_at_least("rns", RNS_FLOOR) def test_dependabot_parser_reads_only_top_level_update_items() -> None: """Nested list items, comments, and later top-level keys are not entries.""" text = ( "version: 2\n" "updates:\n" ' - package-ecosystem: "npm" # trailing comment\n' " directory: '/web'\n" " schedule:\n" " interval: weekly\n" " ignore:\n" ' - dependency-name: "left-pad"\n' "\n" " - package-ecosystem: cargo\n" "registries:\n" " - package-ecosystem: not-an-entry\n" ) assert _dependabot_entries(text) == [ { "package-ecosystem": "npm", "directory": "/web", "schedule": "", "interval": "weekly", "ignore": "", }, {"package-ecosystem": "cargo"}, ] def test_action_repositories_fold_sub_paths_and_skip_local_actions() -> None: """Actions count once per repository; local actions and images not at all.""" text = """jobs: build: steps: - uses: actions/checkout@v7 - name: Init uses: "github/codeql-action/init@v4" - uses: 'github/codeql-action/analyze@v4' - uses: ./.github/actions/local - uses: docker://alpine@sha256:0123abcd - uses: dtolnay/rust-toolchain@stable """ assert _action_repositories(text) == { "actions/checkout", "github/codeql-action", "dtolnay/rust-toolchain", } def test_required_entries_map_manifests_to_their_directories() -> None: """Manifests map to ecosystem and directory; other files need no entry.""" paths = [ "Gemfile", "web/package.json", "data/requirements-dev.txt", ".github/workflows/ruby.yml", ".github/workflows/README.md", "README.md", ] assert _required_entries(paths) == { ("bundler", "/"), ("npm", "/web"), ("pip", "/data"), ("github-actions", "/"), } def test_repository_ignore_rule_counts_only_committed_gitignores() -> None: """Repository rules count; negations and developer-local files do not.""" rule = ".gitignore:48:Gemfile.lock" assert _repository_ignore_rule(f"{rule}\tweb/Gemfile.lock\n") == rule for output in ( "", "web/.gitignore:3:!Gemfile.lock\tweb/Gemfile.lock\n", ".git/info/exclude:7:Gemfile.lock\tweb/Gemfile.lock\n", "/home/dev/.gitignore:1:Gemfile.lock\tweb/Gemfile.lock\n", ): assert _repository_ignore_rule(output) is None, output def test_exact_pin_accepts_only_a_single_equality_clause() -> None: """``==X`` is a pin; floors, wildcards, ranges, and bare names are not.""" assert _exact_pin(Requirement("meshcore==2.3.15")) == Version("2.3.15") for loose in ("meshcore>=2.3.8", "meshcore==2.3.*", "meshcore>=2,<3", "meshcore"): assert _exact_pin(Requirement(loose)) is None, loose