diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..bb067cd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,73 @@ +# Dependabot configuration for cwl-spawn. +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# Why this file exists: the actions in .github/workflows are pinned to commit SHAs +# (#6), which closes the mutable-tag hole but opens a staleness one — a SHA never +# moves, including past a security fix, and unlike `@v5` nothing updates it. +# Pinning is only safe when something bumps the pins. That something is this file. +# +# The exposure here is narrower than the suite's npm/PyPI publishers — nothing +# here publishes a package — but release.yml holds `contents: write` to create the +# GitHub Release, so a compromised action in that job can write to this repo. + +version: 2 +updates: + # GitHub Actions. Dependabot understands SHA pins: it rewrites both the SHA and + # the trailing `# vX.Y.Z` comment, so the pins stay readable and reviewable. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "America/Los_Angeles" + # Don't propose a release the day it ships; let it sit a week first. A fresh + # action tag is exactly when a compromised or broken one is still unnoticed. + cooldown: + default-days: 7 + open-pull-requests-limit: 5 + labels: + - "dependencies" + commit-message: + prefix: "deps(actions)" + include: "scope" + # No `reviewers:` key on purpose — it's deprecated and inert. Every one of the + # umbrella repo's Dependabot PRs asks for a reviewer and none has ever had a + # review request. Use CODEOWNERS if you want one. + groups: + # One PR for all of them. The pattern is "*", not "actions/*", because + # softprops/action-gh-release (which creates the GitHub Release, with + # `contents: write`) is not under actions/ — with "actions/*" it would fall + # outside the group, which is how updates get quietly ignored. + github-actions: + patterns: + - "*" + + # Python dependencies from pyproject.toml. + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "America/Los_Angeles" + cooldown: + default-days: 7 + open-pull-requests-limit: 5 + labels: + - "dependencies" + commit-message: + prefix: "deps" + include: "scope" + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + ignore: + # ruff 0.16 moved a large set of opinionated rules into the DEFAULT rule set, + # so a bump reddens `ruff check .` with no code change. The `<0.16` cap in + # pyproject.toml is deliberate; lift the ceiling and this ignore together, + # after choosing which new rules to adopt via an explicit select. + - dependency-name: "ruff" + versions: [">=0.16"] diff --git a/CHANGELOG.md b/CHANGELOG.md index fc68cb7..47ac63d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security +- **Added Dependabot, so the SHA-pinned actions actually get bumped** + ([#6](https://github.com/spore-host/cwl-spawn/issues/6)). All 5 `uses:` refs were already pinned to commit SHAs — which + is exactly the situation that needs this: a SHA never moves, including past a + security fix, and unlike `@v5` nothing updates it. Pinning and Dependabot are one + control, not two; shipping only the pin trades a mutable-tag hole for a slow one. + - The new `.github/dependabot.yml` covers `github-actions` and `pip`, weekly with + a 7-day cooldown — a freshly published tag is exactly when a compromised or + broken one is still unnoticed. Group pattern is `*`, not `actions/*`, because + `softprops/action-gh-release` (which creates the GitHub Release under + `contents: write`) would otherwise fall outside the group and stop being + bumped. `ruff >=0.16` is ignored so a bump can't undo the deliberate cap. + - `tests/test_ci_hygiene.py` makes both halves regressions rather than + conventions: reverting a pin or dropping the Dependabot entry now fails + `pytest`, which CI already runs. `pyyaml` joins the `[dev]` extra for it and is + imported unguarded — a `try`/`except` import degrades to a skip, and a skipped + wiring test reports green while asserting nothing. + No behaviour change — CI wiring and tests only. + ### Fixed - **CI was red on `main` and `ruff` is now capped `<0.16`.** ruff 0.16 moved a large set of opinionated rules (`BLE`, `PLW`, `TRY`, `C408`, `EXE`, `B017`, diff --git a/pyproject.toml b/pyproject.toml index 59f45e9..2a8e37a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,11 @@ dependencies = [ # airflow-spawn, miniwdl-spawn and snakemake-executor-plugin-spawn. Raising it is # a deliberate change: pick the rules to adopt via an explicit # `[tool.ruff.lint] select`, don't inherit them. -dev = ["pytest>=7", "ruff>=0.5,<0.16", "mypy>=1.8"] +# `pyyaml` is a test-only dependency: tests/test_ci_hygiene.py parses +# .github/dependabot.yml. It is imported unguarded there on purpose — a +# try/except import degrades to a skip, and a skipped wiring test reports green +# while asserting nothing. +dev = ["pytest>=7", "ruff>=0.5,<0.16", "mypy>=1.8", "pyyaml>=6"] [project.scripts] # cwltool has no plugin entry-point (unlike miniwdl's container_backend), so the diff --git a/tests/test_ci_hygiene.py b/tests/test_ci_hygiene.py new file mode 100644 index 0000000..7354731 --- /dev/null +++ b/tests/test_ci_hygiene.py @@ -0,0 +1,136 @@ +"""Tests that assert on repo wiring rather than on code. + +Wiring is what rots: a pin reverted to `@v5` or a deleted Dependabot entry is a +one-line change whose absence is completely silent — nothing fails, the supply +chain just quietly goes back to being mutable. These make that fail a test. + +`release.yml` holds `contents: write` to create the GitHub Release, so a +compromised action in that job can write to this repo and alter published release +artifacts. (#6) +""" + +from __future__ import annotations + +import re +from pathlib import Path + +# PyYAML is a declared dev dependency (pyproject.toml `[dev]`), imported directly +# rather than behind a try/except: a guarded import degrades to a skip, and a +# skipped wiring test reports green while asserting nothing — the same silent +# no-op these tests exist to catch. +import yaml + +REPO = Path(__file__).resolve().parent.parent +WORKFLOWS = REPO / ".github" / "workflows" + +# owner/action@<40-hex> followed by a `# vX.Y.Z` comment. The comment is required: +# a bare SHA is unreadable, and the version is what makes a bump reviewable — +# without it nobody can tell whether a pin is current or two years stale. +PINNED = re.compile(r"^[^@\s]+@[0-9a-f]{40}\s+#\s*v?\d") + + +def _uses_refs() -> list[tuple[str, int, str]]: + """Every registry action ref in the workflows, as (file, line_no, ref).""" + refs = [] + for path in sorted(WORKFLOWS.glob("*.y*ml")): + for i, line in enumerate(path.read_text().splitlines(), start=1): + stripped = line.strip().removeprefix("- ") + if not stripped.startswith("uses:"): + continue + ref = stripped[len("uses:") :].strip() + if ref.startswith("./"): # a local path, not a registry ref + continue + refs.append((path.name, i, ref)) + return refs + + +def _glob(pattern: str, value: str) -> bool: + """Dependabot's only wildcard is `*`, matching any run of characters.""" + parts = pattern.split("*") + if len(parts) == 1: + return pattern == value + if not value.startswith(parts[0]): + return False + value = value[len(parts[0]) :] + for middle in parts[1:-1]: + idx = value.find(middle) + if idx < 0: + return False + value = value[idx + len(middle) :] + return value.endswith(parts[-1]) + + +def _dependabot() -> dict: + return yaml.safe_load((REPO / ".github" / "dependabot.yml").read_text()) + + +def test_actions_are_pinned_to_shas() -> None: + """Every `uses:` must name a full commit SHA, not a tag or branch. + + A tag is mutable: `@v5` means "whatever v5 points at when the job runs". + `actions/checkout@v6` really did move (df4cb1c 2026-06-02 → d23441a 2026-07-16) + with no signal to consumers, so this is not hypothetical. + """ + refs = _uses_refs() + # Anti-vacuous: a parser that silently stops matching would pass forever. + assert refs, f"no `uses:` lines found under {WORKFLOWS} — this test asserts nothing" + + unpinned = [f"{name}:{line}: {ref}" for name, line, ref in refs if not PINNED.match(ref)] + assert not unpinned, ( + "these actions are not pinned to a full commit SHA with a version comment:\n " + + "\n ".join(unpinned) + + "\nA tag or branch is mutable, so the code CI runs can change with no commit " + "here. Use:\n uses: owner/action@<40-hex-sha> # vX.Y.Z" + ) + + +def test_dependabot_covers_every_action() -> None: + """The other half of pinning: something must bump the pins. + + A SHA never moves, including past a security fix. Pinning without Dependabot + just trades a mutable-tag hole for a staleness one, so the two are one control. + The check that matters is coverage — an ecosystem entry whose group patterns + don't match an action leaves it outside the grouped PR, silently. + """ + config = REPO / ".github" / "dependabot.yml" + assert config.exists(), ( + "no .github/dependabot.yml: the actions here are pinned to SHAs, so without " + "Dependabot nothing ever bumps them" + ) + cfg = _dependabot() + assert cfg.get("version") == 2, f"dependabot version must be 2, got {cfg.get('version')}" + + patterns: list[str] = [] + found_entry = False + for update in cfg.get("updates", []): + if update.get("package-ecosystem") != "github-actions": + continue + found_entry = True + dirs = update.get("directories") or [update.get("directory")] + assert dirs == ["/"], ( + f"the github-actions entry watches {dirs}; workflows live in " + '.github/workflows, which Dependabot finds via directory "/"' + ) + for group in (update.get("groups") or {}).values(): + patterns.extend(group.get("patterns", [])) + assert found_entry, ( + "dependabot.yml has no `github-actions` entry, so the SHA-pinned actions " + "are never bumped" + ) + + for name, _, ref in _uses_refs(): + action = ref.split("@", 1)[0] + assert any(_glob(p, action) for p in patterns), ( + f"{action} (in {name}) is not matched by any Dependabot group pattern " + f"{patterns}, so it would fall outside the grouped PR and its bumps get " + "missed. Widen the pattern." + ) + + +def test_dependabot_covers_python_dependencies() -> None: + """pyproject.toml's dependencies need bumping too, not just the actions.""" + ecosystems = {u.get("package-ecosystem") for u in _dependabot().get("updates", [])} + assert "pip" in ecosystems, ( + "dependabot.yml has no `pip` entry, so pyproject.toml's dependencies are " + f"never updated (found: {sorted(e for e in ecosystems if e)})" + )