diff --git a/.github/renovate.json b/.github/renovate.json index a3290e5..811ad63 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -20,7 +20,7 @@ "groupName": "major updates" }, { - "description": "A Python minor (3.14 -> 3.15) is a runtime upgrade for every instance that runs Python as its app runtime: its own runtime Dockerfile must move in step, and its tools must support the new version. This one rule covers the devcontainer's PYTHON_VERSION, the Makefile's PYTHON_VERSION (the uv image tag) and pyproject.toml's requires-python, so they move in one PR. Keep it out of the automerged group, as its own PR for manual review.", + "description": "A Python minor (3.14 -> 3.15) is a runtime upgrade for every instance that runs Python as its app runtime: its own runtime Dockerfile must move in step, and its tools must support the new version. This one rule covers the devcontainer's PYTHON_VERSION, the Makefile's PYTHON_VERSION and pyproject.toml's requires-python, so they move in one PR. The UV_IMAGE tag also carries the Python minor, which no manager rewrites: the Makefile guard fails that PR until the tag and digest are moved by hand. Keep it out of the automerged group, as its own PR for manual review.", "matchDatasources": ["python-version"], "matchDepNames": ["python"], "matchUpdateTypes": ["minor"], @@ -32,6 +32,11 @@ "matchManagers": ["dockerfile", "docker-compose"], "pinDigests": true }, + { + "description": "The uv version appears in the Makefile (UV_VERSION and the UV_IMAGE tag) and the devcontainer (UV_VERSION ARG); tests/unit/test_release_contract.py and a Makefile guard fail if they drift, so they move in one PR.", + "matchDepNames": ["astral-sh/uv", "ghcr.io/astral-sh/uv"], + "groupName": "uv" + }, { "description": "Renovate can't regenerate .devcontainer/devcontainer-lock.json, so features are bumped by hand together with it (see .devcontainer/README.md).", "matchManagers": ["devcontainer"], @@ -69,6 +74,17 @@ "# renovate: datasource=(?.*?) depName=(?.*?)( versioning=(?.*?))?\\s*\\n\\w+_VERSION := (?.*)\\s" ] }, + { + "description": "The uv and syft images pinned by tag and digest in the Makefile (UV_IMAGE, SYFT_IMAGE); Renovate updates both. The UV_IMAGE tag also carries the Python minor and variant, which the Python minor rule does not rewrite: a Makefile guard fails until it is moved by hand.", + "customType": "regex", + "managerFilePatterns": [ + "/^Makefile$/" + ], + "matchStrings": [ + "(?:UV|SYFT)_IMAGE := (?ghcr\\.io/astral-sh/uv|anchore/syft):(?[^@\\s]+)@(?sha256:[a-f0-9]{64})" + ], + "datasourceTemplate": "docker" + }, { "description": "requires-python's lower bound (>=3.14) moves with the Python minor in the Makefile's uv image tag and the devcontainer; the python minor rule groups all three into one PR.", "customType": "regex", diff --git a/Makefile b/Makefile index 351dd0e..b537030 100644 --- a/Makefile +++ b/Makefile @@ -16,8 +16,11 @@ ARCHES := amd64 NAME := $(shell python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["name"])') ASSET := dist/$(NAME)-$(RELEASE_VERSION) -# renovate: datasource=docker depName=anchore/syft -SYFT_VERSION := v1.52.0 +# Pinned by tag and digest; Renovate updates both together (the +# SYFT_IMAGE/UV_IMAGE manager in .github/renovate.json). It runs as root on +# dist/ between `build` and the upload, so a retagged image must not be able +# to rewrite the wheel and sdist. +SYFT_IMAGE := anchore/syft:v1.52.0@sha256:500e2d872ac019436926e8322b4fc1f39441d94d21f6f4046c6ff29b30e8cb02 # release.yml runs build/sbom/publish-packages on the bare runner, which has docker but # no uv; release-check runs in the devcontainer, which has uv. So use the @@ -25,11 +28,18 @@ SYFT_VERSION := v1.52.0 # minor as .devcontainer/Dockerfile; tests/unit/test_release_contract.py # fails if the uv version drifts from it). The OIDC variables are forwarded so # `uv publish --trusted-publishing always` works inside the container. +# UV_IMAGE is pinned by tag and digest (a retagged image would receive the OIDC +# request token); Renovate updates both. UV_VERSION and PYTHON_VERSION above +# stay the source the devcontainer test compares, and the guard below fails the +# Makefile if the image tag drifts from them. # renovate: datasource=github-releases depName=astral-sh/uv UV_VERSION := 0.13.5 # renovate: datasource=python-version depName=python versioning=docker PYTHON_VERSION := 3.14 -UV_IMAGE := ghcr.io/astral-sh/uv:$(UV_VERSION)-python$(PYTHON_VERSION)-trixie-slim +UV_IMAGE := ghcr.io/astral-sh/uv:0.13.5-python3.14-trixie-slim@sha256:d8e732839dfc4225a1ff698803b478b2223c05636c055d3add41ab9dbec0f315 +ifeq ($(findstring :$(UV_VERSION)-python$(PYTHON_VERSION)-,$(UV_IMAGE)),) +$(error UV_IMAGE ($(UV_IMAGE)) does not match UV_VERSION $(UV_VERSION) and PYTHON_VERSION $(PYTHON_VERSION); update the three together) +endif UV := $(shell command -v uv >/dev/null 2>&1 && echo uv || echo \ docker run --rm -u $$(id -u):$$(id -g) -v "$(CURDIR):/src" -w /src \ -e UV_CACHE_DIR=/tmp/uv-cache -e UV_PYTHON_DOWNLOADS=never \ @@ -56,7 +66,7 @@ build: sbom: @set -e; whl=$$(ls dist/*.whl); \ - docker run --rm -v "$(CURDIR)/dist:/dist" anchore/syft:$(SYFT_VERSION) \ + docker run --rm -v "$(CURDIR)/dist:/dist" $(SYFT_IMAGE) \ "file:/$$whl" -o "spdx-json=/$${whl%.whl}.spdx.json" release-assets: diff --git a/README.md b/README.md index 9721374..c300cdf 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,16 @@ with a warning until you opt in, per repository: (PyPI: Your account, Publishing, "Add a new pending publisher"; TestPyPI has its own, separate form on test.pypi.org). Enter the project name from `pyproject.toml`, the GitHub owner and repository, the workflow name - `release.yml`, and leave the environment empty (or use the one - `release.yml` runs under, if you add one). + `release.yml`, and the environment `release`. Don't leave the environment + empty: without it, any branch workflow with `id-token: write` can + publish by OIDC. The environment name must match what the workflow + sends, so do this only once `release.yml` declares `environment: release` + on the publishing jobs. That arrives with the sync from template-base; + check `release.yml` first, otherwise PyPI rejects the OIDC token. Also + create the `release` environment (deployment branches limited to the + default branch) in Settings, Environments before the first release that + carries it. template-base's `docs/TEMPLATE.md` documents the steps once + that sync has landed. 2. Set the repository variable `PYPI_PUBLISH` (Settings, Secrets and variables, Actions, Variables): - unset: `make publish-packages` warns that it skipped and the release only diff --git a/pyproject.toml b/pyproject.toml index 04fcccf..57f9d4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dev = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling==1.33.0"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/tests/unit/test_release_contract.py b/tests/unit/test_release_contract.py index 7f861c9..a875e0e 100644 --- a/tests/unit/test_release_contract.py +++ b/tests/unit/test_release_contract.py @@ -132,6 +132,12 @@ def test_makefile_pins_match_the_devcontainer() -> None: assert python.group(1).startswith(f"{_makefile_var('PYTHON_VERSION')}.") +@pytest.mark.parametrize("name", ["UV_IMAGE", "SYFT_IMAGE"]) +def test_makefile_images_are_pinned_by_digest(name: str) -> None: + """a retagged image must not be able to replace what holds the OIDC token.""" + assert re.fullmatch(r"\S+:\S+@sha256:[0-9a-f]{64}", _makefile_var(name)) + + def test_verify_published_runs_in_the_pinned_uv_image() -> None: """the script gets the pinned image, so the runner needs no Python of its own.""" result = make("-n", "verify-published", publish="pypi")