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
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
github-actions:
patterns:
- "*"
28 changes: 15 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,21 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python: ["3.9", "3.13"]
python: ["3.9", "3.14"]
include:
- os: ubuntu-latest
python: "3.10"
- os: ubuntu-latest
python: "3.11"
- os: ubuntu-latest
python: "3.12"
- os: macos-latest
- os: ubuntu-latest
python: "3.13"
- os: macos-latest
python: "3.14"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python }}

Expand All @@ -50,8 +52,8 @@ jobs:
name: Against a real upgrade
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"

Expand Down Expand Up @@ -98,8 +100,8 @@ jobs:
name: README is current
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- run: python -m pip install pillow
Expand All @@ -109,8 +111,8 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- run: python -m pip install ruff
Expand All @@ -121,14 +123,14 @@ jobs:
name: Build distributions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- run: python -m pip install build twine
- run: python -m build
- run: python -m twine check --strict dist/*
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
32 changes: 22 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ jobs:
fail-fast: true
matrix:
os: [ubuntu-latest, windows-latest]
python: ["3.9", "3.13"]
python: ["3.9", "3.14"]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python }}
- run: python -m unittest discover -s tests
Expand All @@ -44,8 +44,8 @@ jobs:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- run: python -m pip install build twine
Expand All @@ -60,7 +60,7 @@ jobs:
echo "tag=$TAG package=$PKG"
test "$TAG" = "$PKG"

- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
Expand All @@ -75,11 +75,11 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2

github-release:
name: Attach distributions to the GitHub release
Expand All @@ -88,12 +88,24 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- uses: softprops/action-gh-release@v3
- name: Generate and verify SHA-256 manifest
shell: bash
run: |
cd dist
sha256sum *.whl *.tar.gz > SHA256SUMS
sha256sum --check SHA256SUMS
- name: Attest release distributions
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-checksums: dist/SHA256SUMS
- uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: dist/*
generate_release_notes: true
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.2] - 2026-08-11

### Security

- Validate distribution names against the Python packaging name specification
and prove each cache destination remains inside the selected cache before
deleting or creating it. A crafted project name or version can no longer
remove a same-named directory outside the cache.
- Pin every third-party GitHub Action to an immutable commit and enable weekly
Dependabot checks for Action updates.

### Changed

- Test Python 3.14 on Linux, Windows, macOS, and in the release gate.
- Publish a SHA-256 manifest and GitHub build-provenance attestations with each
GitHub release.

## [0.1.1] - 2026-08-09

### Security
Expand Down Expand Up @@ -35,5 +52,6 @@ First release.
and colour handling that honours `NO_COLOR`.
- Exit code 2 for a breaking upgrade, 1 for the tool itself failing.

[0.1.2]: https://github.com/CAOShurong/willitbreak/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/CAOShurong/willitbreak/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/CAOShurong/willitbreak/releases/tag/v0.1.0
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,20 @@ python docs/build_docs.py
python docs/build_docs.py --check # what CI runs
```

CI runs on Ubuntu, Windows and macOS across Python 3.9–3.13, checks this
CI runs on Ubuntu, Windows and macOS across Python 3.9–3.14, checks this
README still matches the output, and verifies the exit codes.

## Verify a release

Starting with v0.1.2, every GitHub release includes a `SHA256SUMS` manifest and
GitHub build-provenance attestations for the wheel and source distribution:

```bash
sha256sum --check SHA256SUMS
gh attestation verify willitbreak-0.1.2-py3-none-any.whl \
--repo CAOShurong/willitbreak
```

## License

MIT. See [LICENSE](LICENSE).
13 changes: 12 additions & 1 deletion docs/readme_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,20 @@ python docs/build_docs.py
python docs/build_docs.py --check # what CI runs
```

CI runs on Ubuntu, Windows and macOS across Python 3.9–3.13, checks this
CI runs on Ubuntu, Windows and macOS across Python 3.9–3.14, checks this
README still matches the output, and verifies the exit codes.

## Verify a release

Starting with v0.1.2, every GitHub release includes a `SHA256SUMS` manifest and
GitHub build-provenance attestations for the wheel and source distribution:

```bash
sha256sum --check SHA256SUMS
gh attestation verify willitbreak-0.1.2-py3-none-any.whl \
--repo CAOShurong/willitbreak
```

## License

MIT. See [LICENSE](LICENSE).
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Software Development :: Quality Assurance",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Utilities",
Expand Down
2 changes: 1 addition & 1 deletion src/willitbreak/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from .surface import Surface, Symbol, read_surface
from .usage import Reference, scan_paths, scan_source

__version__ = "0.1.1"
__version__ = "0.1.2"

__all__ = [
"Change",
Expand Down
32 changes: 31 additions & 1 deletion src/willitbreak/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import json
import os
import pathlib
import re
import shutil
import tarfile
import urllib.error
Expand All @@ -35,6 +36,10 @@
PYPI = "https://pypi.org/pypi"
USER_AGENT = "willitbreak (+https://github.com/CAOShurong/willitbreak)"
TIMEOUT = 30
PROJECT_NAME = re.compile(
r"(?:[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])\Z",
re.ASCII | re.IGNORECASE,
)


class FetchError(Exception):
Expand Down Expand Up @@ -87,6 +92,7 @@ def _get_json(url: str) -> dict:


def latest_version(package: str) -> str:
_validate_project_name(package)
data = _get_json(f"{PYPI}/{package}/json")
version = data.get("info", {}).get("version")
if not version:
Expand All @@ -107,6 +113,7 @@ def installed_version(package: str) -> str | None:


def _release_files(package: str, version: str) -> list[dict]:
_validate_project_name(package)
data = _get_json(f"{PYPI}/{package}/{version}/json")
files = data.get("urls") or []
if not files:
Expand Down Expand Up @@ -143,6 +150,29 @@ def _download(url: str) -> bytes:
raise FetchError(f"download failed: {exc}") from exc


def _validate_project_name(package: str) -> None:
"""Reject names that cannot identify a project on a Python index."""
if PROJECT_NAME.fullmatch(package) is None:
raise FetchError(
f"invalid project name {package!r}; expected letters, numbers, '.', '-', "
"or '_' with an alphanumeric first and last character"
)


def _cache_destination(cache: pathlib.Path, package: str, version: str) -> pathlib.Path:
"""Return a cache path that is provably contained by ``cache``."""
_validate_project_name(package)
try:
root = cache.resolve()
destination = (cache / f"{package}-{version}").resolve()
destination.relative_to(root)
except (OSError, RuntimeError, ValueError) as exc:
raise FetchError(
f"cache destination for {package} {version} resolves outside the cache"
) from exc
return destination


def _archive_target(destination: pathlib.Path, member: str) -> pathlib.Path:
"""Resolve one archive member inside the exact destination directory.

Expand Down Expand Up @@ -266,7 +296,7 @@ def fetch_version(
) -> Fetched:
"""Download and unpack one version, reusing the cache when possible."""
cache = cache or cache_root()
destination = cache / f"{package}-{version}"
destination = _cache_destination(cache, package, version)
marker = destination / ".willitbreak-complete"

if not marker.is_file():
Expand Down
57 changes: 56 additions & 1 deletion tests/test_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,63 @@
import tempfile
import unittest
import zipfile
from unittest import mock

from willitbreak.fetch import FetchError, _safe_extract_tar, _safe_extract_zip
from willitbreak.fetch import (
FetchError,
_safe_extract_tar,
_safe_extract_zip,
_validate_project_name,
fetch_version,
)


class CacheBoundaryTests(unittest.TestCase):
def setUp(self) -> None:
self._temp = tempfile.TemporaryDirectory()
self.addCleanup(self._temp.cleanup)
self.root = pathlib.Path(self._temp.name)
self.cache = self.root / "cache"
self.cache.mkdir()

def test_valid_python_project_names_are_accepted(self) -> None:
for package in ("a", "requests2", "zope.interface", "google_cloud-storage"):
with self.subTest(package=package):
_validate_project_name(package)

def test_unicode_casefold_lookalike_is_not_an_ascii_project_name(self) -> None:
with self.assertRaisesRegex(FetchError, "invalid project name"):
_validate_project_name("\N{KELVIN SIGN}")

def test_project_name_cannot_delete_a_cache_sibling(self) -> None:
victim = self.root / "victim-1.0"
victim.mkdir()
sentinel = victim / "KEEP.txt"
sentinel.write_text("keep", encoding="utf-8")

release_files = mock.patch(
"willitbreak.fetch._release_files",
side_effect=FetchError("network must not be reached"),
)
with release_files, self.assertRaisesRegex(FetchError, "invalid project name"):
fetch_version("../victim", "1.0", cache=self.cache)

self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")

def test_version_cannot_resolve_outside_the_cache(self) -> None:
victim = self.root / "victim"
victim.mkdir()
sentinel = victim / "KEEP.txt"
sentinel.write_text("keep", encoding="utf-8")

release_files = mock.patch(
"willitbreak.fetch._release_files",
side_effect=FetchError("network must not be reached"),
)
with release_files, self.assertRaisesRegex(FetchError, "outside the cache"):
fetch_version("demo", "../../../victim", cache=self.cache)

self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")


class SafeExtractionTests(unittest.TestCase):
Expand Down