Skip to content

Commit d61be07

Browse files
authored
security: add Dependabot so the SHA-pinned actions get bumped (#7)
All 5 `uses:` refs were already pinned to commit SHAs, which is exactly the situation that needs Dependabot: 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 one trades a mutable-tag hole for a slow one. Add .github/dependabot.yml covering github-actions and pip, weekly with a 7-day cooldown (a freshly published tag is exactly when a compromised one is still unnoticed). The group pattern is `*`, not `actions/*`, because softprops/action-gh-release — which creates the GitHub Release under `contents: write` — is not under actions/ and would otherwise fall outside the group. `ruff >=0.16` is ignored so a bump can't undo the deliberate cap in pyproject.toml. The exposure here is narrower than the suite's npm/PyPI publishers: nothing in this repo 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 and alter published release artifacts. tests/test_ci_hygiene.py makes both halves regressions instead of conventions: reverting a pin or dropping the Dependabot entry fails pytest, which CI already runs via `pytest -q`. It asserts it found >0 refs, so a parser that stops matching fails rather than passing vacuously. `pyyaml` joins the [dev] extra 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. Fixes #6
1 parent 67ccb65 commit d61be07

4 files changed

Lines changed: 233 additions & 1 deletion

File tree

‎.github/dependabot.yml‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Dependabot configuration for cwl-spawn.
2+
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
3+
#
4+
# Why this file exists: the actions in .github/workflows are pinned to commit SHAs
5+
# (#6), which closes the mutable-tag hole but opens a staleness one — a SHA never
6+
# moves, including past a security fix, and unlike `@v5` nothing updates it.
7+
# Pinning is only safe when something bumps the pins. That something is this file.
8+
#
9+
# The exposure here is narrower than the suite's npm/PyPI publishers — nothing
10+
# here publishes a package — but release.yml holds `contents: write` to create the
11+
# GitHub Release, so a compromised action in that job can write to this repo.
12+
13+
version: 2
14+
updates:
15+
# GitHub Actions. Dependabot understands SHA pins: it rewrites both the SHA and
16+
# the trailing `# vX.Y.Z` comment, so the pins stay readable and reviewable.
17+
- package-ecosystem: "github-actions"
18+
directory: "/"
19+
schedule:
20+
interval: "weekly"
21+
day: "monday"
22+
time: "09:00"
23+
timezone: "America/Los_Angeles"
24+
# Don't propose a release the day it ships; let it sit a week first. A fresh
25+
# action tag is exactly when a compromised or broken one is still unnoticed.
26+
cooldown:
27+
default-days: 7
28+
open-pull-requests-limit: 5
29+
labels:
30+
- "dependencies"
31+
commit-message:
32+
prefix: "deps(actions)"
33+
include: "scope"
34+
# No `reviewers:` key on purpose — it's deprecated and inert. Every one of the
35+
# umbrella repo's Dependabot PRs asks for a reviewer and none has ever had a
36+
# review request. Use CODEOWNERS if you want one.
37+
groups:
38+
# One PR for all of them. The pattern is "*", not "actions/*", because
39+
# softprops/action-gh-release (which creates the GitHub Release, with
40+
# `contents: write`) is not under actions/ — with "actions/*" it would fall
41+
# outside the group, which is how updates get quietly ignored.
42+
github-actions:
43+
patterns:
44+
- "*"
45+
46+
# Python dependencies from pyproject.toml.
47+
- package-ecosystem: "pip"
48+
directory: "/"
49+
schedule:
50+
interval: "weekly"
51+
day: "monday"
52+
time: "09:00"
53+
timezone: "America/Los_Angeles"
54+
cooldown:
55+
default-days: 7
56+
open-pull-requests-limit: 5
57+
labels:
58+
- "dependencies"
59+
commit-message:
60+
prefix: "deps"
61+
include: "scope"
62+
groups:
63+
minor-and-patch:
64+
update-types:
65+
- "minor"
66+
- "patch"
67+
ignore:
68+
# ruff 0.16 moved a large set of opinionated rules into the DEFAULT rule set,
69+
# so a bump reddens `ruff check .` with no code change. The `<0.16` cap in
70+
# pyproject.toml is deliberate; lift the ceiling and this ignore together,
71+
# after choosing which new rules to adopt via an explicit select.
72+
- dependency-name: "ruff"
73+
versions: [">=0.16"]

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Security
11+
- **Added Dependabot, so the SHA-pinned actions actually get bumped**
12+
([#6](https://github.com/spore-host/cwl-spawn/issues/6)). All 5 `uses:` refs were already pinned to commit SHAs — which
13+
is exactly the situation that needs this: a SHA never moves, including past a
14+
security fix, and unlike `@v5` nothing updates it. Pinning and Dependabot are one
15+
control, not two; shipping only the pin trades a mutable-tag hole for a slow one.
16+
- The new `.github/dependabot.yml` covers `github-actions` and `pip`, weekly with
17+
a 7-day cooldown — a freshly published tag is exactly when a compromised or
18+
broken one is still unnoticed. Group pattern is `*`, not `actions/*`, because
19+
`softprops/action-gh-release` (which creates the GitHub Release under
20+
`contents: write`) would otherwise fall outside the group and stop being
21+
bumped. `ruff >=0.16` is ignored so a bump can't undo the deliberate cap.
22+
- `tests/test_ci_hygiene.py` makes both halves regressions rather than
23+
conventions: reverting a pin or dropping the Dependabot entry now fails
24+
`pytest`, which CI already runs. `pyyaml` joins the `[dev]` extra for it and is
25+
imported unguarded — a `try`/`except` import degrades to a skip, and a skipped
26+
wiring test reports green while asserting nothing.
27+
No behaviour change — CI wiring and tests only.
28+
1029
### Fixed
1130
- **CI was red on `main` and `ruff` is now capped `<0.16`.** ruff 0.16 moved a
1231
large set of opinionated rules (`BLE`, `PLW`, `TRY`, `C408`, `EXE`, `B017`,

‎pyproject.toml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ dependencies = [
2727
# airflow-spawn, miniwdl-spawn and snakemake-executor-plugin-spawn. Raising it is
2828
# a deliberate change: pick the rules to adopt via an explicit
2929
# `[tool.ruff.lint] select`, don't inherit them.
30-
dev = ["pytest>=7", "ruff>=0.5,<0.16", "mypy>=1.8"]
30+
# `pyyaml` is a test-only dependency: tests/test_ci_hygiene.py parses
31+
# .github/dependabot.yml. It is imported unguarded there on purpose — a
32+
# try/except import degrades to a skip, and a skipped wiring test reports green
33+
# while asserting nothing.
34+
dev = ["pytest>=7", "ruff>=0.5,<0.16", "mypy>=1.8", "pyyaml>=6"]
3135

3236
[project.scripts]
3337
# cwltool has no plugin entry-point (unlike miniwdl's container_backend), so the

‎tests/test_ci_hygiene.py‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Tests that assert on repo wiring rather than on code.
2+
3+
Wiring is what rots: a pin reverted to `@v5` or a deleted Dependabot entry is a
4+
one-line change whose absence is completely silent — nothing fails, the supply
5+
chain just quietly goes back to being mutable. These make that fail a test.
6+
7+
`release.yml` holds `contents: write` to create the GitHub Release, so a
8+
compromised action in that job can write to this repo and alter published release
9+
artifacts. (#6)
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import re
15+
from pathlib import Path
16+
17+
# PyYAML is a declared dev dependency (pyproject.toml `[dev]`), imported directly
18+
# rather than behind a try/except: a guarded import degrades to a skip, and a
19+
# skipped wiring test reports green while asserting nothing — the same silent
20+
# no-op these tests exist to catch.
21+
import yaml
22+
23+
REPO = Path(__file__).resolve().parent.parent
24+
WORKFLOWS = REPO / ".github" / "workflows"
25+
26+
# owner/action@<40-hex> followed by a `# vX.Y.Z` comment. The comment is required:
27+
# a bare SHA is unreadable, and the version is what makes a bump reviewable —
28+
# without it nobody can tell whether a pin is current or two years stale.
29+
PINNED = re.compile(r"^[^@\s]+@[0-9a-f]{40}\s+#\s*v?\d")
30+
31+
32+
def _uses_refs() -> list[tuple[str, int, str]]:
33+
"""Every registry action ref in the workflows, as (file, line_no, ref)."""
34+
refs = []
35+
for path in sorted(WORKFLOWS.glob("*.y*ml")):
36+
for i, line in enumerate(path.read_text().splitlines(), start=1):
37+
stripped = line.strip().removeprefix("- ")
38+
if not stripped.startswith("uses:"):
39+
continue
40+
ref = stripped[len("uses:") :].strip()
41+
if ref.startswith("./"): # a local path, not a registry ref
42+
continue
43+
refs.append((path.name, i, ref))
44+
return refs
45+
46+
47+
def _glob(pattern: str, value: str) -> bool:
48+
"""Dependabot's only wildcard is `*`, matching any run of characters."""
49+
parts = pattern.split("*")
50+
if len(parts) == 1:
51+
return pattern == value
52+
if not value.startswith(parts[0]):
53+
return False
54+
value = value[len(parts[0]) :]
55+
for middle in parts[1:-1]:
56+
idx = value.find(middle)
57+
if idx < 0:
58+
return False
59+
value = value[idx + len(middle) :]
60+
return value.endswith(parts[-1])
61+
62+
63+
def _dependabot() -> dict:
64+
return yaml.safe_load((REPO / ".github" / "dependabot.yml").read_text())
65+
66+
67+
def test_actions_are_pinned_to_shas() -> None:
68+
"""Every `uses:` must name a full commit SHA, not a tag or branch.
69+
70+
A tag is mutable: `@v5` means "whatever v5 points at when the job runs".
71+
`actions/checkout@v6` really did move (df4cb1c 2026-06-02 → d23441a 2026-07-16)
72+
with no signal to consumers, so this is not hypothetical.
73+
"""
74+
refs = _uses_refs()
75+
# Anti-vacuous: a parser that silently stops matching would pass forever.
76+
assert refs, f"no `uses:` lines found under {WORKFLOWS} — this test asserts nothing"
77+
78+
unpinned = [f"{name}:{line}: {ref}" for name, line, ref in refs if not PINNED.match(ref)]
79+
assert not unpinned, (
80+
"these actions are not pinned to a full commit SHA with a version comment:\n "
81+
+ "\n ".join(unpinned)
82+
+ "\nA tag or branch is mutable, so the code CI runs can change with no commit "
83+
"here. Use:\n uses: owner/action@<40-hex-sha> # vX.Y.Z"
84+
)
85+
86+
87+
def test_dependabot_covers_every_action() -> None:
88+
"""The other half of pinning: something must bump the pins.
89+
90+
A SHA never moves, including past a security fix. Pinning without Dependabot
91+
just trades a mutable-tag hole for a staleness one, so the two are one control.
92+
The check that matters is coverage — an ecosystem entry whose group patterns
93+
don't match an action leaves it outside the grouped PR, silently.
94+
"""
95+
config = REPO / ".github" / "dependabot.yml"
96+
assert config.exists(), (
97+
"no .github/dependabot.yml: the actions here are pinned to SHAs, so without "
98+
"Dependabot nothing ever bumps them"
99+
)
100+
cfg = _dependabot()
101+
assert cfg.get("version") == 2, f"dependabot version must be 2, got {cfg.get('version')}"
102+
103+
patterns: list[str] = []
104+
found_entry = False
105+
for update in cfg.get("updates", []):
106+
if update.get("package-ecosystem") != "github-actions":
107+
continue
108+
found_entry = True
109+
dirs = update.get("directories") or [update.get("directory")]
110+
assert dirs == ["/"], (
111+
f"the github-actions entry watches {dirs}; workflows live in "
112+
'.github/workflows, which Dependabot finds via directory "/"'
113+
)
114+
for group in (update.get("groups") or {}).values():
115+
patterns.extend(group.get("patterns", []))
116+
assert found_entry, (
117+
"dependabot.yml has no `github-actions` entry, so the SHA-pinned actions "
118+
"are never bumped"
119+
)
120+
121+
for name, _, ref in _uses_refs():
122+
action = ref.split("@", 1)[0]
123+
assert any(_glob(p, action) for p in patterns), (
124+
f"{action} (in {name}) is not matched by any Dependabot group pattern "
125+
f"{patterns}, so it would fall outside the grouped PR and its bumps get "
126+
"missed. Widen the pattern."
127+
)
128+
129+
130+
def test_dependabot_covers_python_dependencies() -> None:
131+
"""pyproject.toml's dependencies need bumping too, not just the actions."""
132+
ecosystems = {u.get("package-ecosystem") for u in _dependabot().get("updates", [])}
133+
assert "pip" in ecosystems, (
134+
"dependabot.yml has no `pip` entry, so pyproject.toml's dependencies are "
135+
f"never updated (found: {sorted(e for e in ecosystems if e)})"
136+
)

0 commit comments

Comments
 (0)