Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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"]
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions tests/test_ci_hygiene.py
Original file line number Diff line number Diff line change
@@ -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)})"
)
Loading