diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3f17de2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,70 @@ +# Dependabot configuration for python-sdk. +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# Why this file exists: every action here is now pinned to a commit SHA (#10), +# 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. +# +# This matters most for publish-python.yaml, which holds `id-token: write` in the +# `pypi` environment: whatever runs there can publish to PyPI as us. + +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 + # 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 the + # publish action is pypa/gh-action-pypi-publish — the single most important + # one to keep current, and the one "actions/*" would silently exclude. + 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 set of opinionated rules into the DEFAULT rule set, so + # a bump reddens lint with no code change. The pin is deliberate; lift the + # ceiling and this ignore together, after triaging the new rules. + - dependency-name: "ruff" + versions: [">=0.16"] diff --git a/.github/workflows/publish-python.yaml b/.github/workflows/publish-python.yaml index 024d1d4..86ce7e6 100644 --- a/.github/workflows/publish-python.yaml +++ b/.github/workflows/publish-python.yaml @@ -17,9 +17,9 @@ jobs: environment: pypi steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" @@ -33,4 +33,11 @@ jobs: run: twine check dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + # Pinned to a commit SHA, not `@release/v1`. That was a BRANCH ref, so it + # resolved to whatever the tip of that branch was at the moment this job + # ran — and this job holds `id-token: write` in the `pypi` environment, + # i.e. authority to publish spore-host to PyPI as us. Nothing in this repo + # sat between an upstream force-push and code executing with that token. + # Dependabot bumps this pin (see .github/dependabot.yml) so it stays + # current without being mutable. (#10) + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/.github/workflows/test-python-build.yaml b/.github/workflows/test-python-build.yaml index d8a53ae..1200bdc 100644 --- a/.github/workflows/test-python-build.yaml +++ b/.github/workflows/test-python-build.yaml @@ -9,8 +9,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" - name: Install build tools @@ -28,8 +28,8 @@ jobs: matrix: python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} - name: Install @@ -42,8 +42,8 @@ jobs: # nothing. Run it on one version (lint results don't vary across the matrix). runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Install diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a90f2..92b93df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ Release tags use the `python-vX.Y.Z` prefix. ## [Unreleased] +### Security +- **The PyPI publish job ran a mutable *branch* ref; every action is now pinned to + a commit SHA, with Dependabot to bump the pins** ([#10]). `publish-python.yaml` + grants `id-token: write` in the `pypi` environment — whatever runs there can + publish `spore-host` to PyPI as us — and it invoked + `pypa/gh-action-pypi-publish@release/v1`. That is a branch, not a tag, so it + resolved to whatever the branch tip was at the moment the job ran: nothing in + this repo sat between an upstream force-push and code executing with that + publish authority. All 9 `uses:` refs across both workflows are now full SHAs + with a `# vX.Y.Z` comment. + - A SHA alone would trade a mutable-tag hole for a staleness one — pins don't + move, including past a security fix — so a new `.github/dependabot.yml` bumps + them weekly (7-day cooldown, so a freshly-published tag sits before it's + proposed) and covers `pip` dependencies too. Its group pattern is `*`, not + `actions/*`: the publish action lives under `pypa/`, which `actions/*` would + silently exclude. `ruff >=0.16` is explicitly ignored so a bump can't undo the + deliberate cap below. + - `tests/test_ci_hygiene.py` makes both halves regressions rather than + conventions: reverting a pin or dropping the Dependabot entry now fails + `pytest`, which the existing test matrix already runs. `pyyaml` joins the + `[dev]` extra so those tests can't degrade into a green skip. + No change to the shipped `spore` package — CI wiring and tests only. + ### Fixed - **`ruff` was a declared dev dependency that CI never ran, so it enforced nothing — it's now pinned `<0.16` and actually invoked.** ruff 0.16 moved a @@ -90,6 +113,8 @@ Baseline. Earlier history is in the --- +[#10]: https://github.com/spore-host/python-sdk/issues/10 + [Unreleased]: https://github.com/spore-host/python-sdk/compare/python-v0.1.5...HEAD [0.1.5]: https://github.com/spore-host/python-sdk/compare/python-v0.1.4...python-v0.1.5 [0.1.4]: https://github.com/spore-host/python-sdk/compare/python-v0.1.3...python-v0.1.4 diff --git a/pyproject.toml b/pyproject.toml index 1324e21..8369be9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,12 @@ jupyter = ["ipywidgets>=8.0", "IPython>=8.0"] # suggestions. Same cap as the workflow adapters (airflow-spawn, cwl-spawn, # miniwdl-spawn, snakemake-executor-plugin-spawn). Raising it is a deliberate # change: pick the rules to adopt via an explicit `[tool.ruff.lint] select`. -dev = ["pytest>=7", "pytest-asyncio", "black", "ruff>=0.5,<0.16"] +# PyYAML is here for tests/test_ci_hygiene.py, which parses .github/dependabot.yml. +# It is a test dependency only — the SDK itself must not grow a YAML dependency. +# It is declared rather than guarded with a skip: a test that skips when an import +# is missing reports green while asserting nothing, which is the exact failure mode +# those tests exist to catch. +dev = ["pytest>=7", "pytest-asyncio", "black", "ruff>=0.5,<0.16", "pyyaml>=6"] [project.urls] Homepage = "https://spore.host" diff --git a/tests/test_ci_hygiene.py b/tests/test_ci_hygiene.py new file mode 100644 index 0000000..8237575 --- /dev/null +++ b/tests/test_ci_hygiene.py @@ -0,0 +1,140 @@ +"""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. + +The stakes here are specific. `publish-python.yaml` holds `id-token: write` in the +`pypi` environment, so whatever executes in that job can publish `spore-host` to +PyPI as us. It previously ran `pypa/gh-action-pypi-publish@release/v1` — a BRANCH +ref, resolved at job time — so nothing in this repo sat between an upstream +force-push and code running with that authority (#10). +""" + +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. If this import fails, `pip install -e ".[dev]"` +# didn't run, and that should be loud. +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 test_actions_are_pinned_to_shas() -> None: + """Every `uses:` must name a full commit SHA, not a tag or branch. + + A tag is mutable and a branch more so: `@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 is asserting 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 — and publish-python.yaml runs with PyPI publish authority. 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 = yaml.safe_load(config.read_text()) + 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] + matched = any(_glob(p, action) for p in patterns) + assert matched, ( + f"{action} (in {name}) is not matched by any Dependabot group pattern " + f"{patterns}, so it would open its own PR outside the group, or be " + "missed. Widen the pattern." + ) + + +def test_dependabot_covers_python_dependencies() -> None: + """pyproject.toml's dependencies need bumping too, not just the actions.""" + cfg = yaml.safe_load((REPO / ".github" / "dependabot.yml").read_text()) + ecosystems = {u.get("package-ecosystem") for u in cfg.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)})" + ) + + +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])