From 45a95ed653f7425b67f1d2bd9abf21aec8b261ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 14:43:20 -0700 Subject: [PATCH 01/56] =?UTF-8?q?=F0=9F=94=A7=20build:=20admit=20release-c?= =?UTF-8?q?reator=200.6.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+release-creator-067.change.md | 2 ++ pylock.toml | 24 +++++++++++----------- pyproject.toml | 12 +++++------ refresh-release-lock/action.yml | 4 ++-- tests/install_locked_tools/test_install.py | 10 ++++----- tests/refresh_release_lock/test_cutoffs.py | 3 +++ tools/nab_cutoffs.py | 1 + 7 files changed, 31 insertions(+), 25 deletions(-) create mode 100644 changelog.d/+release-creator-067.change.md diff --git a/changelog.d/+release-creator-067.change.md b/changelog.d/+release-creator-067.change.md new file mode 100644 index 0000000..de345e6 --- /dev/null +++ b/changelog.d/+release-creator-067.change.md @@ -0,0 +1,2 @@ +Allow release locks to select release-creator 0.6.7 during the seven-day review window. Refresh the package index when +checking or updating a lock so a cached listing cannot hide a newly published version. diff --git a/pylock.toml b/pylock.toml index e5bdc17..fc85f01 100644 --- a/pylock.toml +++ b/pylock.toml @@ -1703,7 +1703,7 @@ sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "release-creator" -version = "0.6.5" +version = "0.6.7" marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.15" dependencies = [ @@ -1717,22 +1717,22 @@ dependencies = [ index = "https://pypi.org/simple/" [packages.sdist] -name = "release_creator-0.6.5.tar.gz" -upload-time = 2026-10-23 19:54:49.281646+00:00 -url = "https://files.pythonhosted.org/packages/a0/da/7195823b3d0ab066f518d8bd987f88a39e0e39da33f629e021346209a7cb/release_creator-0.6.5.tar.gz" -size = 135158 +name = "release_creator-0.6.7.tar.gz" +upload-time = 2026-10-23 21:40:05.750813+00:00 +url = "https://files.pythonhosted.org/packages/69/16/14ae84ee6db2cb86a72b94e3e56f1daa24fb49a69f6cfa0bdafa10e8f4c7/release_creator-0.6.7.tar.gz" +size = 138420 [packages.sdist.hashes] -sha256 = "954247b06b7b9a8c196a782252e3d980d33a9e9b7f9e5aea7647ef6c758d4275" +sha256 = "deb86d4bbe50906fc971a89b39217ba65e9c4fdfbacf17f6a761e7bb7a1361d9" [[packages.wheels]] -name = "release_creator-0.6.5-py3-none-any.whl" -upload-time = 2026-10-23 19:54:47.716775+00:00 -url = "https://files.pythonhosted.org/packages/f1/4c/6215690fc2b48f3d927699bb16f5c8bcf26b8862e378df33a2581a45215b/release_creator-0.6.5-py3-none-any.whl" -size = 46723 +name = "release_creator-0.6.7-py3-none-any.whl" +upload-time = 2026-10-23 21:40:04.474402+00:00 +url = "https://files.pythonhosted.org/packages/1a/ff/d3c78822331f7a98f0835cf531e37e5c25e4c8881139e4c1905fb7381dfa/release_creator-0.6.7-py3-none-any.whl" +size = 48620 [packages.wheels.hashes] -sha256 = "dd109f7fb64808a83833c332b8563c56bd943c4a8de79b440eab475b5ca9a73e" +sha256 = "7b9ab26f3e7e26f26c18a19aa9c7bbf5b6fd64f51c28a97c40fe448969cf1b7d" [[packages]] name = "requests" @@ -2483,7 +2483,7 @@ sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f" [tool.nab] nab-version = "0.0.22" -created-at = 2026-10-23 19:55:55.131127+00:00 +created-at = 2026-10-23 21:41:56.960744+00:00 command-line = [ "nab", "lock", diff --git a/pyproject.toml b/pyproject.toml index 86e74b9..cab26ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,14 +10,14 @@ license-files = [ "LICENSE" ] [dependency-groups] build = [ "Cython>=3.0", "setuptools>=80" ] lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ] -release-notes = [ "release-creator>=0.6.5" ] -release-news = [ "release-creator>=0.6.5", "towncrier>=26.9" ] -release-prepare = [ "release-creator>=0.6.5", "towncrier>=26.9", "prek>=0.2" ] -release-validate = [ "release-creator>=0.6.5", "prek>=0.2" ] +release-notes = [ "release-creator>=0.6.7" ] +release-news = [ "release-creator>=0.6.7", "towncrier>=26.9" ] +release-prepare = [ "release-creator>=0.6.7", "towncrier>=26.9", "prek>=0.2" ] +release-validate = [ "release-creator>=0.6.7", "prek>=0.2" ] release-check = [ "prek>=0.2" ] release-attestations = [ "pypi-attestations>=0.0.30" ] release-shell = [ "prek>=0.2", "yq>=4.4.5" ] -release-hooks = [ "release-creator>=0.6.5", "prek>=0.2" ] +release-hooks = [ "release-creator>=0.6.7", "prek>=0.2" ] release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ] test = [ "certifi", @@ -66,7 +66,7 @@ uploaded-prior-to = "2026-10-18T05:45:09Z" uploaded-prior-to = "2026-10-18T05:45:09Z" [tool.nab.packages.release-creator] -uploaded-prior-to = "2026-10-23T19:54:50Z" +uploaded-prior-to = "2026-10-23T21:40:06Z" [tool.ruff] line-length = 120 diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index 910ad15..84f5522 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -78,7 +78,7 @@ runs: exit 1 ;; esac - uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --groups "${groups[@]}" \ + uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --no-cache --groups "${groups[@]}" \ --output pylock.toml "${refresh[@]}" if [[ "$MODE" == check ]]; then python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" \ @@ -135,7 +135,7 @@ runs: ;; esac cp "$ADDITIONAL_LOCK" "$RUNNER_TEMP/additional-lock-before.toml" - uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock pyproject.toml "${selection[@]}" \ + uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --no-cache pyproject.toml "${selection[@]}" \ --project-matrix-python "$MATRIX_PYTHON" \ --project-matrix-platforms "${platforms[@]}" \ --project-matrix-implementations "${implementations[@]}" \ diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index d4689c5..4c89afb 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -382,8 +382,8 @@ def test_install_locked_tools_rejects_unreviewed_version_under_reviewed_floor(tm package = next(package for package in document["packages"] if package["name"] == "release-creator") package["version"] = "0.6.3" for artifact in [package["sdist"], *package["wheels"]]: - artifact["name"] = artifact["name"].replace("0.6.5", "0.6.3") - artifact["url"] = artifact["url"].replace("0.6.5", "0.6.3") + artifact["name"] = artifact["name"].replace("0.6.7", "0.6.3") + artifact["url"] = artifact["url"].replace("0.6.7", "0.6.3") lock = tmp_path / "pylock.toml" lock.write_text(tomlkit.dumps(document)) @@ -405,8 +405,8 @@ def test_install_locked_tools_rejects_caller_below_shared_floor(tmp_path: Path, package = next(package for package in document["packages"] if package["name"] == "release-creator") package["version"] = version for artifact in [package["sdist"], *package["wheels"]]: - artifact["name"] = artifact["name"].replace("0.6.5", version) - artifact["url"] = artifact["url"].replace("0.6.5", version) + artifact["name"] = artifact["name"].replace("0.6.7", version) + artifact["url"] = artifact["url"].replace("0.6.7", version) artifact["upload-time"] = datetime(2026, 10, 15, tzinfo=UTC) lock = tmp_path / "pylock.toml" lock.write_text(tomlkit.dumps(document)) @@ -426,7 +426,7 @@ def test_install_locked_tools_rejects_caller_below_shared_floor(tmp_path: Path, ) assert result.returncode != 0 - assert "relock: release-creator 0.6.5 required" in result.stderr + assert "relock: release-creator 0.6.7 required" in result.stderr def test_install_locked_tools_constraints_only_checks_lock_age(tmp_path: Path, action_scripts: Path) -> None: diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index b426794..7919d98 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -69,6 +69,7 @@ def test_expire_cutoffs_removes_empty_package_table(cutoff_project: Path) -> Non [ pytest.param("2026-10-23T19:54:50Z", id="0.6.5"), pytest.param("2026-10-23T20:48:24Z", id="0.6.6"), + pytest.param("2026-10-23T21:40:06Z", id="0.6.7"), ], ) def test_expire_cutoffs_removes_each_reviewed_release_creator_floor(reviewed: str) -> None: @@ -210,6 +211,8 @@ def test_check_nab_lock_rejects_forged_upload_time( pytest.param("release-creator", "2026-10-23T19:54:51Z", False, id="extended-release-creator-0.6.5"), pytest.param("release-creator", "2026-10-23T20:48:24Z", True, id="reviewed-release-creator-0.6.6"), pytest.param("release-creator", "2026-10-23T20:48:25Z", False, id="extended-release-creator-0.6.6"), + pytest.param("release-creator", "2026-10-23T21:40:06Z", True, id="reviewed-release-creator-0.6.7"), + pytest.param("release-creator", "2026-10-23T21:40:07Z", False, id="extended-release-creator-0.6.7"), pytest.param("unreviewed", "2026-10-22T00:00:00Z", False, id="new-package"), ], ) diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py index 7fb3366..d5c2561 100644 --- a/tools/nab_cutoffs.py +++ b/tools/nab_cutoffs.py @@ -22,6 +22,7 @@ }, ("release-creator", "0.6.5"): datetime(2026, 10, 23, 19, 54, 50, tzinfo=UTC), ("release-creator", "0.6.6"): datetime(2026, 10, 23, 20, 48, 24, tzinfo=UTC), + ("release-creator", "0.6.7"): datetime(2026, 10, 23, 21, 40, 6, tzinfo=UTC), ("uv-build", "0.13.4"): datetime(2026, 10, 21, tzinfo=UTC), } _REVIEWED_EXCEPTIONS: Final = { From ed56310bf276ef5a6b7fec61f599119652866110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 14:53:47 -0700 Subject: [PATCH 02/56] =?UTF-8?q?=F0=9F=90=9B=20fix(lock):=20check=20exact?= =?UTF-8?q?=20group=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+cooling-marker.security.md | 2 ++ tests/install_locked_tools/test_install.py | 20 ++++++++++++++++++++ tools/check_lock_groups.py | 9 +++++---- 3 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 changelog.d/+cooling-marker.security.md diff --git a/changelog.d/+cooling-marker.security.md b/changelog.d/+cooling-marker.security.md new file mode 100644 index 0000000..679ac14 --- /dev/null +++ b/changelog.d/+cooling-marker.security.md @@ -0,0 +1,2 @@ +Check release lock markers for the exact dependency groups each job installs, so a condition that excludes the build +group cannot bypass cooling. diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 4c89afb..78568a9 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -356,6 +356,26 @@ def test_install_locked_tools_rejects_non_pypi_artifact(tmp_path: Path, source_k assert "non-PyPI artifact" in result.stderr +def test_install_locked_tools_checks_exact_group_marker(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + document = tomlkit.parse((source / "pylock.toml").read_text()) + package = next(package for package in document["packages"] if package["name"] == "cython") + package["marker"] = '"release-check" in dependency_groups and "build" not in dependency_groups' + package["wheels"][0]["url"] = package["wheels"][0]["url"].replace("files.pythonhosted.org", "example.org") + lock = tmp_path / "pylock.toml" + lock.write_text(tomlkit.dumps(document)) + + result = subprocess.run( + ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-check"], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "non-PyPI artifact" in result.stderr + + def test_install_locked_tools_rejects_artifact_version_mismatch(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] document = tomlkit.parse((source / "pylock.toml").read_text()) diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 31be2ce..985d2bc 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -34,11 +34,11 @@ def main() -> None: args.build_constraints and missing == {"build"} ): parser.error(f"missing lock dependency groups: {', '.join(sorted(missing))}") - _check_cooling(lock, groups | {"build"}, local_tool_source=args.local_tool_source) + _check_cooling(lock, groups, local_tool_source=args.local_tool_source) if args.shared_lock: with args.shared_lock.open("rb") as stream: shared = Pylock.from_dict(tomllib.load(stream)) - _check_cooling(shared, groups | {"build"}) + _check_cooling(shared, groups) required = _selected_release_creator(shared, groups) if required is not None and not args.local_tool_source: installed = _selected_release_creator(lock, groups) @@ -54,8 +54,9 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = raise ValueError("lock has no valid creation time or was created in the future") threshold = created - _COOLDOWN for package in lock.packages: - if package.marker is not None and not package.marker.evaluate( - {"dependency_groups": groups}, context="lock_file" + if package.marker is not None and not any( + package.marker.evaluate({"dependency_groups": selection}, context="lock_file") + for selection in (groups, {"build"}) ): continue if local_tool_source and package.name == "release-creator" and package.index is None: From 4aae17febafacb10d735b7d5efc3d67712c6636b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 14:53:56 -0700 Subject: [PATCH 03/56] =?UTF-8?q?=F0=9F=90=9B=20fix(release):=20isolate=20?= =?UTF-8?q?patch=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/prepare-towncrier-release.yaml | 55 ++++++++++++++++--- README.md | 43 ++++++++------- changelog.d/+release-validation.security.md | 1 + tests/release_workflow/test_graph.py | 40 ++++++++------ 4 files changed, 92 insertions(+), 47 deletions(-) create mode 100644 changelog.d/+release-validation.security.md diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 471fb11..0820673 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -65,8 +65,6 @@ jobs: id: hooks run: prek install-hooks background: true - - name: Create a fresh validation worktree - run: git worktree add --detach "$RUNNER_TEMP/release-validation" "$GITHUB_SHA" - name: Build local release tool wheel if: inputs.local-tool-source env: @@ -97,20 +95,61 @@ jobs: with: version: ${{ steps.prepare.outputs.version }} phase: finalize - - uses: $/validate-release-patch - with: - version: ${{ steps.prepare.outputs.version }} - patch: ${{ runner.temp }}/release.patch - working-directory: ${{ runner.temp }}/release-validation - id: patch uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-patch path: ${{ runner.temp }}/release.patch if-no-files-found: error - push: + validate: needs: assemble runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - id: tools + uses: $/install-locked-tools + with: + groups: release-validate + venv: .venv + local-tool-source: ${{ inputs.local-tool-source }} + - name: Build local release tool wheel + if: inputs.local-tool-source + env: + BUILD_CONSTRAINTS: ${{ steps.tools.outputs.build-constraints }} + run: | + uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \ + --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" . + - name: Install local release tool wheel + if: inputs.local-tool-source + run: uv pip install --python .venv/bin/python --no-deps "$RUNNER_TEMP/local-tool-dist/"*.whl + - name: Expose local release command + if: inputs.local-tool-source + run: | + mkdir -p "$RUNNER_TEMP/local-release-cli" + ln -s "$GITHUB_WORKSPACE/.venv/bin/release-creator" "$RUNNER_TEMP/local-release-cli/release-creator" + echo "$RUNNER_TEMP/local-release-cli" >>"$GITHUB_PATH" + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.assemble.outputs.artifact-id }} + path: ${{ runner.temp }}/release-patch + - uses: $/validate-release-patch + with: + version: ${{ needs.assemble.outputs.version }} + patch: ${{ runner.temp }}/release-patch/release.patch + push: + needs: [assemble, validate] + runs-on: ubuntu-26.04 environment: name: release-auth deployment: ${{ inputs.release-auth-deployment }} diff --git a/README.md b/README.md index aad4266..ae8efd6 100644 --- a/README.md +++ b/README.md @@ -188,30 +188,31 @@ head. GitHub accepts reruns for 30 days. A failed rerun is retried on the next s ## Start with a caller The [release preparation workflow](.github/workflows/prepare-release.yaml) in this repository calls the reusable -Towncrier workflow. Its `assemble` job creates a patch and validates it in a fresh worktree without the App key. Its -`push` job downloads that artifact by ID and checks its paths and modes before minting a repository-scoped App token. -Set the tag style in the caller and the changelog path and fragment directory in committed Towncrier configuration. Pin -the reusable workflow to a release commit SHA; `SHA` in these examples means that commit. +Towncrier workflow. Its `assemble` job uploads a patch; a separate keyless `validate` job downloads and checks that +artifact. The `push` job downloads the same artifact by ID and checks its paths and modes before minting a +repository-scoped App token. Set the tag style in the caller and the changelog path and fragment directory in committed +Towncrier configuration. Pin the reusable workflow to a release commit SHA; `SHA` in these examples means that commit. -Assembly needs release history to select a tag. The writer can check out the source commit with `fetch-depth: 1`. Both +Assembly needs release history to select a tag. The writer can check out the source commit with `fetch-depth: 1`. All jobs use `persist-credentials: false`. Assembly installs the caller's locked release tools and needs `contents: read` -and `pull-requests: read` to resolve PR authors. It starts `prek install-hooks` while creating the validation worktree -and building the changelog, then waits for the hooks before formatting and validating the patch. The writer uses the -`release-auth` environment with `RELEASE_APP_PRIVATE_KEY` and `RELEASE_APP_CLIENT_ID`. Restrict that environment to -`main`. Install the App on this repository only, with Contents write and Metadata read, and grant the necessary branch -and tag ruleset bypass. +and `pull-requests: read` to resolve PR authors. It starts `prek install-hooks` while building the changelog, then waits +for the hooks before formatting and uploading the patch. The validation job runs `prek` on that uploaded patch. The +writer uses the `release-auth` environment with `RELEASE_APP_PRIVATE_KEY` and `RELEASE_APP_CLIENT_ID`. Restrict that +environment to `main`. Install the App on this repository only, with Contents write and Metadata read, and grant the +necessary branch and tag ruleset bypass. ```text main + fragments │ ▼ -prepare-towncrier ──► validate in fresh worktree ──► upload patch by ID - │ - ▼ - verify patch ──► mint App token - │ - ▼ - commit-and-tag ──► main + tag +prepare-towncrier ──► upload patch ──► validate artifact + │ │ + └─────────┬─────────┘ + ▼ + verify paths and modes + │ + ▼ + mint App token ──► commit-and-tag ──► main + tag ``` The tag starts the caller's publisher. Keep PyPI trusted publishing in that top-level workflow, in a separate job with @@ -283,10 +284,10 @@ build package versions; otherwise the Action fails instead of writing an ambiguo ## Prepare and tag with one reusable workflow -`prepare-towncrier-release.yaml` runs two jobs. `assemble` selects a tag and writes a Towncrier patch without the App -key, applies it in a fresh worktree, and runs `prek`. `push` downloads the patch by artifact ID and checks its paths and -file modes, reads the caller's `release-auth` environment, and uses a repository-scoped App token to push the release -commit and tag together. Only a dispatch from `main` can reach the writer. +`prepare-towncrier-release.yaml` runs three jobs. `assemble` selects a tag and uploads a Towncrier patch without the App +key. `validate` applies the uploaded artifact in a separate job and runs `prek`. `push` downloads that artifact by ID, +checks its paths and file modes, reads the caller's `release-auth` environment, and uses a repository-scoped App token +to push the release commit and tag together. Only a dispatch from `main` can reach the writer. ```yaml jobs: diff --git a/changelog.d/+release-validation.security.md b/changelog.d/+release-validation.security.md new file mode 100644 index 0000000..10b57e3 --- /dev/null +++ b/changelog.d/+release-validation.security.md @@ -0,0 +1 @@ +Validate the uploaded release patch in a separate keyless job before the App-key writer pushes it. diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index a8b7b01..93dd742 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -6,6 +6,7 @@ import sys from pathlib import Path +import pytest import yaml @@ -22,40 +23,43 @@ def test_prepare_jobs_transfer_the_uploaded_patch() -> None: (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() ) jobs = workflow["jobs"] - assert list(jobs) == ["assemble", "push"] + assert list(jobs) == ["assemble", "validate", "push"] assert jobs["assemble"]["outputs"]["artifact-id"] == "${{ steps.patch.outputs.artifact-id }}" assert ( next(step for step in jobs["assemble"]["steps"] if step.get("id") == "patch")["with"]["name"] == "release-patch" ) - assert ( - next(step for step in jobs["push"]["steps"] if "download-artifact" in step.get("uses", ""))["with"][ - "artifact-ids" - ] - == "${{ needs.assemble.outputs.artifact-id }}" - ) + for job in ("validate", "push"): + assert ( + next(step for step in jobs[job]["steps"] if "download-artifact" in step.get("uses", ""))["with"][ + "artifact-ids" + ] + == "${{ needs.assemble.outputs.artifact-id }}" + ) -def test_prepare_validates_patch_in_fresh_worktree() -> None: +def test_prepare_validates_uploaded_patch_before_writer() -> None: workflow = yaml.safe_load( (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() ) - steps = workflow["jobs"]["assemble"]["steps"] - worktree = next(step for step in steps if step.get("name") == "Create a fresh validation worktree") - validation = next(step for step in steps if step.get("uses") == "$/validate-release-patch") - assert "git worktree add --detach" in worktree["run"] - assert validation["with"]["working-directory"] == "${{ runner.temp }}/release-validation" + jobs = workflow["jobs"] + assert jobs["validate"]["permissions"] == {"contents": "read"} + assert jobs["validate"]["needs"] == "assemble" + assert jobs["push"]["needs"] == ["assemble", "validate"] + assert not any(step.get("uses") == "$/validate-release-patch" for step in jobs["assemble"]["steps"]) + validation = next(step for step in jobs["validate"]["steps"] if step.get("uses") == "$/validate-release-patch") + assert validation["with"]["patch"] == "${{ runner.temp }}/release-patch/release.patch" assert ( - steps.index(worktree) - < steps.index(validation) - < next(index for index, step in enumerate(steps) if step.get("id") == "patch") + next(step for step in jobs["push"]["steps"] if step.get("uses") == "$/validate-release-patch")["with"]["checks"] + == "false" ) -def test_prepare_builds_local_tool_once_with_locked_backends() -> None: +@pytest.mark.parametrize("job", ["assemble", "validate"]) +def test_prepare_builds_local_tool_with_locked_backends(job: str) -> None: workflow = yaml.safe_load( (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() ) - steps = workflow["jobs"]["assemble"]["steps"] + steps = workflow["jobs"][job]["steps"] builds = [step for step in steps if step.get("name") == "Build local release tool wheel"] assert len(builds) == 1 assert builds[0]["env"]["BUILD_CONSTRAINTS"] == "${{ steps.tools.outputs.build-constraints }}" From 0437c824e5732dedd9cc9fae9ec6f588c24a6a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 14:53:09 -0700 Subject: [PATCH 04/56] =?UTF-8?q?=F0=9F=91=B7=20ci:=20check=20upgrade=20ar?= =?UTF-8?q?tifacts=20separately?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The writer previously checked the merged diff, so either artifact could modify files owned by the other. Check each patch against HEAD in a temporary index before applying it. Remove four unused validate-python-release inputs that suggested they affected the tagged build. --- .github/workflows/upgrade-release-tools.yaml | 43 +++++++++----- .../workflows/validate-python-release.yaml | 16 ------ README.md | 13 ++--- tests/upgrade_release_tools/test_upgrade.py | 56 +++++++++++++------ tests/validate_python_release/test_tagged.py | 9 +-- 5 files changed, 74 insertions(+), 63 deletions(-) diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index da9ce44..88f7af7 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -154,15 +154,11 @@ jobs: with: name: release-tools-hooks path: ${{ runner.temp }}/release-tools-hooks - - name: Apply lock patch - if: needs.generate-lock.outputs.changed == 'true' - run: git apply --index "$RUNNER_TEMP/release-tools-lock/lock.patch" - - name: Apply hook patch - if: needs.generate-hooks.outputs.changed == 'true' - run: git apply --index "$RUNNER_TEMP/release-tools-hooks/hooks.patch" - - name: Check dependency patch paths + - name: Check artifact patch paths env: ADDITIONAL_LOCK: ${{ inputs.additional-lock }} + LOCK_CHANGED: ${{ needs.generate-lock.outputs.changed }} + HOOKS_CHANGED: ${{ needs.generate-hooks.outputs.changed }} run: | set -euo pipefail if [[ -n "$ADDITIONAL_LOCK" && @@ -170,14 +166,33 @@ jobs: echo "::error::invalid additional lock: $ADDITIONAL_LOCK" >&2 exit 1 fi - git diff --cached --name-only --no-renames -z >"$RUNNER_TEMP/dependency-paths" - while IFS= read -r -d '' path; do - if [[ "$path" != pylock.toml && "$path" != pyproject.toml && - "$path" != .pre-commit-config.yaml && "$path" != "$ADDITIONAL_LOCK" ]]; then - echo "::error::unexpected dependency path: $path" >&2 - exit 1 + for artifact in lock hooks; do + if [[ "$artifact" == lock ]]; then + [[ "$LOCK_CHANGED" == true ]] || continue + patch="$RUNNER_TEMP/release-tools-lock/lock.patch" + else + [[ "$HOOKS_CHANGED" == true ]] || continue + patch="$RUNNER_TEMP/release-tools-hooks/hooks.patch" fi - done <"$RUNNER_TEMP/dependency-paths" + GIT_INDEX_FILE="$RUNNER_TEMP/$artifact.index" git read-tree HEAD + GIT_INDEX_FILE="$RUNNER_TEMP/$artifact.index" git apply --cached "$patch" + GIT_INDEX_FILE="$RUNNER_TEMP/$artifact.index" \ + git diff --cached --name-only --no-renames -z >"$RUNNER_TEMP/$artifact-paths" + while IFS= read -r -d '' path; do + if [[ "$artifact" == lock && "$path" != pylock.toml && "$path" != pyproject.toml && + "$path" != "$ADDITIONAL_LOCK" ]] || + [[ "$artifact" == hooks && "$path" != .pre-commit-config.yaml ]]; then + echo "::error::unexpected $artifact patch path: $path" >&2 + exit 1 + fi + done <"$RUNNER_TEMP/$artifact-paths" + done + - name: Apply lock patch + if: needs.generate-lock.outputs.changed == 'true' + run: git apply --index "$RUNNER_TEMP/release-tools-lock/lock.patch" + - name: Apply hook patch + if: needs.generate-hooks.outputs.changed == 'true' + run: git apply --index "$RUNNER_TEMP/release-tools-hooks/hooks.patch" - name: Check dependency patch file mode run: | set -euo pipefail diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index 13229c4..f4c3b6e 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -2,22 +2,6 @@ name: Validate Python release on: workflow_call: inputs: - changelog: - description: Deprecated; notes are rendered by the caller - type: string - default: "" - artifact-base-url: - description: Deprecated; notes are rendered by the caller - type: string - default: "" - artifact-label: - description: Deprecated; notes are rendered by the caller - type: string - default: PyPI release - local-tool-source: - description: Deprecated; distribution builds do not install release-creator - type: boolean - default: false source: description: Package source directory default: . diff --git a/README.md b/README.md index ae8efd6..96517e2 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,11 @@ The Action expects a pull request base ref and a Markdown or reStructuredText ch Call `upgrade-release-tools.yaml` from a scheduled or manual workflow. Its read-only jobs refresh the universal `pylock.toml` and the frozen pre-commit hooks in parallel. Nab reads the seven-day package cutoff and any reviewed package exceptions from `[tool.nab]`. The hook updater applies the same cooldown to hook revisions and their -`additional_dependencies`. Each job uploads a patch. The writer accepts changes to `pylock.toml`, an explicitly -configured secondary lock, and `.pre-commit-config.yaml`, plus removal of expired Nab cutoffs in `pyproject.toml`. It -independently checks those cutoff removals before minting the App token, then opens or updates one dependency PR from -`automation/update-release-tools` to `main`. A lock or hook update runs the normal PR checks before merge. +`additional_dependencies`. Each job uploads a patch. The lock patch may change `pylock.toml`, an explicitly configured +secondary lock, and `pyproject.toml` for expired Nab cutoffs. The hook patch may change only `.pre-commit-config.yaml`. +The writer checks each patch before applying it and verifies cutoff removals before minting the App token. It then opens +or updates one dependency PR from `automation/update-release-tools` to `main`. A lock or hook update runs the normal PR +checks before merge. The writer uses a repository-only release App with Contents and Pull requests write access. Install the App on this repository, set `RELEASE_APP_CLIENT_ID` as a repository variable, and store `RELEASE_APP_PRIVATE_KEY` in the @@ -245,9 +246,7 @@ The caller needs `release-check` for prek and a `build` group for backend constr Publish only after the reusable workflow succeeds. Download the distribution with pinned `download-artifact` [v8.0.2](https://github.com/actions/download-artifact/blob/9000827ccba6bdab643e8b6fd33ac0654aef8333/action.yml#L37-L45), which checks the artifact digest. Render GitHub notes in the caller's tag workflow after fetching GitHub's contributor -metadata; this reusable workflow does not render notes. Keep PyPI OIDC in the caller's `release` environment job. Older -callers may still pass `changelog`, `artifact-base-url`, `artifact-label`, or `local-tool-source`. These inputs are -accepted for compatibility but have no effect; remove them when updating the caller workflow. +metadata; this reusable workflow does not render notes. Keep PyPI OIDC in the caller's `release` environment job. ## Build a distribution for a pull request diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index e56e9b2..74813d0 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -224,17 +224,27 @@ def test_upgrade_check_mode_rejects_lock_drift(lock_repository: Path, tmp_path: @pytest.mark.parametrize( - ("path", "accepted"), + ("path", "artifact", "additional_lock", "accepted"), [ - pytest.param("pylock.toml", True, id="lock"), - pytest.param("pyproject.toml", True, id="nab-policy"), - pytest.param(".pre-commit-config.yaml", True, id="hooks"), - pytest.param("uv.lock", False, id="uv-lock"), - pytest.param("other.txt", False, id="unrelated-file"), + pytest.param("pylock.toml", "lock", "", True, id="lock"), + pytest.param("pyproject.toml", "lock", "", True, id="nab-policy"), + pytest.param("pylock.zipapp.toml", "lock", "pylock.zipapp.toml", True, id="secondary-lock"), + pytest.param(".pre-commit-config.yaml", "hooks", "", True, id="hooks"), + pytest.param(".pre-commit-config.yaml", "lock", "", False, id="hook-in-lock-artifact"), + pytest.param("pylock.toml", "hooks", "", False, id="lock-in-hook-artifact"), + pytest.param("pyproject.toml", "hooks", "", False, id="policy-in-hook-artifact"), + pytest.param("uv.lock", "lock", "", False, id="uv-lock"), + pytest.param("other.txt", "lock", "", False, id="unrelated-file"), ], ) -def test_upgrade_rejects_unrelated_patch( - lock_repository: Path, tmp_path: Path, publish_scripts: Path, path: str, accepted: bool +def test_upgrade_checks_each_artifact_path( + lock_repository: Path, + tmp_path: Path, + publish_scripts: Path, + path: str, + artifact: str, + additional_lock: str, + accepted: bool, ) -> None: (lock_repository / path).write_text("before\n") run(lock_repository, "git", "add", ".") @@ -242,14 +252,20 @@ def test_upgrade_rejects_unrelated_patch( (lock_repository / path).write_text("after\n") patch = run(lock_repository, "git", "diff", "--binary", "--", path).stdout run(lock_repository, "git", "restore", path) - patch_dir = tmp_path / "release-tools-lock" + patch_dir = tmp_path / f"release-tools-{artifact}" patch_dir.mkdir() - (patch_dir / "lock.patch").write_text(patch) - applied = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) - assert applied.returncode == 0, applied.stderr - result = execute(lock_repository, publish_scripts / "publish-check-dependency-patch-paths.sh", tmp_path) + (patch_dir / f"{artifact}.patch").write_text(patch) + result = execute( + lock_repository, + publish_scripts / "publish-check-artifact-patch-paths.sh", + tmp_path, + ADDITIONAL_LOCK=additional_lock, + LOCK_CHANGED=str(artifact == "lock").lower(), + HOOKS_CHANGED=str(artifact == "hooks").lower(), + ) assert (result.returncode == 0) == accepted - assert ("unexpected dependency path" in result.stderr) == (not accepted) + assert (f"unexpected {artifact} patch path: {path}" in result.stderr) == (not accepted) + assert run(lock_repository, "git", "status", "--porcelain").stdout == "" @pytest.mark.parametrize("additional_lock", ["other.txt", "../pylock.other.toml"], ids=["arbitrary", "parent"]) @@ -264,9 +280,11 @@ def test_upgrade_rejects_unsafe_additional_lock( result = execute( lock_repository, - publish_scripts / "publish-check-dependency-patch-paths.sh", + publish_scripts / "publish-check-artifact-patch-paths.sh", tmp_path, ADDITIONAL_LOCK=additional_lock, + LOCK_CHANGED="true", + HOOKS_CHANGED="false", ) assert result.returncode != 0 @@ -327,13 +345,15 @@ def test_upgrade_combines_lock_and_hook_patches(lock_repository: Path, tmp_path: hooks_artifact.mkdir() (hooks_artifact / "hooks.patch").write_text(hooks_patch) - applied_lock = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) - applied_hooks = execute(lock_repository, publish_scripts / "publish-apply-hook-patch.sh", tmp_path) checked = execute( lock_repository, - publish_scripts / "publish-check-dependency-patch-paths.sh", + publish_scripts / "publish-check-artifact-patch-paths.sh", tmp_path, + LOCK_CHANGED="true", + HOOKS_CHANGED="true", ) + applied_lock = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) + applied_hooks = execute(lock_repository, publish_scripts / "publish-apply-hook-patch.sh", tmp_path) assert (applied_lock.returncode, applied_hooks.returncode, checked.returncode) == (0, 0, 0) assert run(lock_repository, "git", "diff", "--cached", "--name-only").stdout.splitlines() == [ diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index c5a636b..245bf9b 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -52,14 +52,7 @@ def test_validator_isolates_release_tools_from_build() -> None: (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text(), Loader=yaml.BaseLoader, ) - assert set(workflow["on"]["workflow_call"]["inputs"]) == { - "source", - "artifact-prefix", - "changelog", - "artifact-base-url", - "artifact-label", - "local-tool-source", - } + assert set(workflow["on"]["workflow_call"]["inputs"]) == {"source", "artifact-prefix"} assert set(workflow["jobs"]) == {"source-check", "constraints", "build", "validate"} assert workflow["jobs"]["build"]["needs"] == "constraints" assert not any(step.get("uses", "").startswith("$/install-") for step in workflow["jobs"]["build"]["steps"]) From a2abbe49013d470260f1fb57d6ad69ee29690aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 14:54:03 -0700 Subject: [PATCH 05/56] =?UTF-8?q?=F0=9F=93=9D=20docs:=20record=20updater?= =?UTF-8?q?=20path=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+update-allowlists.security.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/+update-allowlists.security.md diff --git a/changelog.d/+update-allowlists.security.md b/changelog.d/+update-allowlists.security.md new file mode 100644 index 0000000..37f6457 --- /dev/null +++ b/changelog.d/+update-allowlists.security.md @@ -0,0 +1 @@ +Apply separate path allowlists to lock and hook update patches before the App writes the dependency branch. From 69d73c5d7feb2e2f765780d38e6602cd939ce0ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 14:55:53 -0700 Subject: [PATCH 06/56] =?UTF-8?q?=F0=9F=90=9B=20fix(lock):=20verify=20call?= =?UTF-8?q?er=20files=20against=20PyPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caller lock timestamps can be forged to bypass the seven-day cutoff. Compare selected files with PyPI's Simple JSON listing before installing caller groups, and reject alternate Nab indexes and nab.toml. --- install-locked-tools/action.yml | 12 +- tests/install_locked_tools/test_install.py | 19 +-- tests/install_locked_tools/test_metadata.py | 137 ++++++++++++++++++++ tests/refresh_release_lock/test_cutoffs.py | 9 ++ tools/check_lock_groups.py | 66 +++++++++- tools/nab_cutoffs.py | 11 ++ 6 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 tests/install_locked_tools/test_metadata.py diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index dd9ad1b..8c9f26a 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -58,7 +58,7 @@ runs: case "$LOCK_SOURCE" in caller) lock=pylock.toml - lock_check=(--shared-lock "$GITHUB_ACTION_PATH/../pylock.toml") + lock_check=(--shared-lock "$GITHUB_ACTION_PATH/../pylock.toml" --verify-pypi --project pyproject.toml) ;; shared) lock="$GITHUB_ACTION_PATH/../pylock.toml" @@ -88,7 +88,8 @@ runs: printf '%s\n' "${groups[@]}" >"$RUNNER_TEMP/selected-lock-groups" else uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" --build-constraints + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" --build-constraints \ + "${lock_check[@]}" fi printf 'lock=%s\n' "$lock" >>"$GITHUB_OUTPUT" - id: constraints @@ -102,8 +103,13 @@ runs: run: | set -euo pipefail if [[ -n "$CONSTRAINTS_LOCK" && "$CONSTRAINTS_LOCK" != "$BUILD_LOCK" ]]; then + lock_check=() + if [[ "$CONSTRAINTS_LOCK" != "$GITHUB_ACTION_PATH/../pylock.toml" ]]; then + lock_check=(--verify-pypi --project pyproject.toml) + fi uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$CONSTRAINTS_LOCK" --build-constraints + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$CONSTRAINTS_LOCK" --build-constraints \ + "${lock_check[@]}" fi if [[ -n "$CONSTRAINTS_LOCK" ]]; then BUILD_LOCK=$CONSTRAINTS_LOCK; fi args=() diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 78568a9..bdd7a80 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -451,6 +451,7 @@ def test_install_locked_tools_rejects_caller_below_shared_floor(tmp_path: Path, def test_install_locked_tools_constraints_only_checks_lock_age(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] + (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) (tmp_path / "pylock.toml").write_text( (source / "pylock.toml").read_text().replace("created-at = 2026-10-23", "created-at = 2099-10-23", 1) ) @@ -586,21 +587,22 @@ def test_install_locked_tools_exporter_does_not_build_pyyaml(tmp_path: Path, act def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] + (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) (tmp_path / "pylock.toml").write_bytes((source / "pylock.toml").read_bytes()) (tmp_path / "pylock.toml").write_text( (tmp_path / "pylock.toml").read_text() + """ [[packages]] -name = "probe-only" -version = "1.0" +name = "six" +version = "1.16.0" marker = '\"build\" in dependency_groups' index = "https://pypi.org/simple/" [[packages.wheels]] -name = "probe_only-1.0-py3-none-any.whl" -url = "https://files.pythonhosted.org/probe_only-1.0-py3-none-any.whl" -upload-time = 2026-10-01T00:00:00Z -hashes.sha256 = """ - + f'"{"0" * 64}"\n', +name = "six-1.16.0-py2.py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl" +upload-time = 2021-05-05T14:18:17.237677Z +hashes.sha256 = "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" +""", ) result = install( tmp_path, @@ -610,11 +612,12 @@ def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_pat venv=tmp_path / "tools", ) assert result.returncode == 0, result.stderr - assert "probe-only==1.0 " in next(tmp_path.glob("build-constraints-*.txt")).read_text() + assert "six==1.16.0 " in next(tmp_path.glob("build-constraints-*.txt")).read_text() def test_install_locked_tools_accepts_lock_without_build_group(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] + (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) document = tomlkit.parse((source / "pylock.toml").read_text()) document["dependency-groups"].remove("build") (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py new file mode 100644 index 0000000..82ee13e --- /dev/null +++ b/tests/install_locked_tools/test_metadata.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import json +import runpy +import sys +from io import BytesIO +from pathlib import Path +from textwrap import dedent +from typing import Any + +import pytest +from pytest_mock import MockerFixture + + +@pytest.fixture +def lock_case(tmp_path: Path) -> tuple[Path, Path]: + lock = tmp_path / "pylock.toml" + lock.write_text( + dedent( + """\ + lock-version = "1.0" + requires-python = ">=3.15" + dependency-groups = ["release-check"] + created-by = "test" + [tool.nab] + created-at = 2026-10-20T00:00:00Z + [[packages]] + name = "six" + version = "1.16.0" + marker = '"release-check" in dependency_groups' + index = "https://pypi.org/simple/" + [[packages.wheels]] + name = "six-1.16.0-py2.py3-none-any.whl" + url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl" + upload-time = 2021-05-05T14:18:17.237677Z + hashes.sha256 = "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + """ + ) + ) + project = tmp_path / "pyproject.toml" + project.write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') + return lock, project + + +@pytest.fixture +def pypi_listing() -> dict[str, Any]: + return { + "meta": {"api-version": "1.4"}, + "name": "six", + "files": [ + { + "filename": "six-1.16.0-py2.py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", + "upload-time": "2021-05-05T14:18:17.237677Z", + "hashes": {"sha256": "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + } + ], + } + + +def test_lock_metadata_matches_pypi( + lock_case: tuple[Path, Path], pypi_listing: dict[str, Any], mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + request = mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) + + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert request.call_args.args[0].full_url == "https://pypi.org/simple/six/" + + +@pytest.mark.parametrize( + "change", + ["missing-file", "missing-hash", "wrong-hash", "missing-date", "forged-date", "wrong-url"], +) +def test_lock_metadata_rejects_pypi_mismatch( + lock_case: tuple[Path, Path], + pypi_listing: dict[str, Any], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + change: str, +) -> None: + file = pypi_listing["files"][0] + if change == "missing-file": + pypi_listing["files"] = [] + elif change == "missing-hash": + file["hashes"] = {} + elif change == "wrong-hash": + file["hashes"]["sha256"] = "0" * 64 + elif change == "missing-date": + del file["upload-time"] + elif change == "forged-date": + lock = lock_case[0] + lock.write_text(lock.read_text().replace("2021-05-05T14:18:17.237677Z", "2020-05-05T14:18:17.237677Z")) + else: + file["url"] = "https://files.pythonhosted.org/other.whl" + mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert "release lock differs from PyPI metadata" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "change", ["lock-index", "project-index", "package-index", "rule-index", "index-settings", "nab-toml"] +) +def test_lock_metadata_rejects_unapproved_indexes( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], change: str +) -> None: + lock, project = lock_case + if change == "lock-index": + lock.write_text( + lock.read_text().replace('index = "https://pypi.org/simple/"', 'index = "https://example.org/"') + ) + elif change == "project-index": + project.write_text(project.read_text() + '[[tool.nab.indexes]]\nname = "other"\nurl = "https://example.org/"\n') + elif change == "package-index": + project.write_text(project.read_text() + '[tool.nab.packages.six]\nindex = "other"\n') + elif change == "rule-index": + project.write_text(project.read_text() + '[[tool.nab.package-rules]]\nmatch = ["six"]\nindex = "other"\n') + elif change == "index-settings": + project.write_text(project.read_text() + "[tool.nab.index.pypi]\nallow-insecure-host = true\n") + else: + (project.parent / "nab.toml").write_text("[tool.nab]\nuploaded-prior-to = false\n") + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--project", str(project)) + + assert "index" in capsys.readouterr().err + + +def _run_check(lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, *options: str) -> None: + source = Path(__file__).resolve().parents[2] + monkeypatch.syspath_prepend(str(source / "tools")) + monkeypatch.setattr(sys, "argv", ["check_lock_groups.py", str(lock_case[0]), "release-check", *options]) + runpy.run_path(str(source / "tools/check_lock_groups.py"), run_name="__main__") diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index 7919d98..af0089a 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -277,6 +277,15 @@ def test_recent_cutoff_rejects_index_override() -> None: assert "Nab index cutoff for pypi" in result.stderr +def test_recent_cutoff_rejects_custom_index() -> None: + result = _check_recent_cutoffs( + '[tool.nab]\nuploaded-prior-to = "P7D"\n' + '[[tool.nab.indexes]]\nname = "other"\nurl = "https://example.org/simple/"\n' + ) + assert result.returncode != 0 + assert "custom Nab indexes" in result.stderr + + def test_recent_cutoff_rejects_project_nab_toml(tmp_path: Path) -> None: (tmp_path / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') (tmp_path / "nab.toml").write_text("[packages.example]\nuploaded-prior-to = false\n") diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 985d2bc..7e17d82 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -3,12 +3,16 @@ from __future__ import annotations import argparse +import json import tomllib +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime, timedelta from pathlib import Path from urllib.parse import urlsplit +from urllib.request import Request, urlopen -from nab_cutoffs import reviewed_cutoff +from nab_cutoffs import reviewed_cutoff, validate_indexes from packaging.pylock import Pylock, PylockValidationError from packaging.utils import canonicalize_name, parse_sdist_filename, parse_wheel_filename from packaging.version import InvalidVersion, Version @@ -23,6 +27,8 @@ def main() -> None: parser.add_argument("--shared-lock", type=Path) parser.add_argument("--local-tool-source", action="store_true") parser.add_argument("--build-constraints", action="store_true") + parser.add_argument("--verify-pypi", action="store_true") + parser.add_argument("--project", type=Path) args = parser.parse_args() try: groups = {"build"} if args.build_constraints else set(args.groups) @@ -30,11 +36,18 @@ def main() -> None: parser.error("at least one dependency group is required") with args.lock.open("rb") as stream: lock = Pylock.from_dict(tomllib.load(stream)) + if args.project: + if (args.project.parent / "nab.toml").exists(): + raise ValueError("project nab.toml can override the reviewed index policy") + with args.project.open("rb") as stream: + validate_indexes(tomllib.load(stream)["tool"]["nab"]) if (missing := groups - set(lock.dependency_groups or ())) and not ( args.build_constraints and missing == {"build"} ): parser.error(f"missing lock dependency groups: {', '.join(sorted(missing))}") _check_cooling(lock, groups, local_tool_source=args.local_tool_source) + if args.verify_pypi: + _verify_pypi(lock, groups, local_tool_source=args.local_tool_source) if args.shared_lock: with args.shared_lock.open("rb") as stream: shared = Pylock.from_dict(tomllib.load(stream)) @@ -44,7 +57,7 @@ def main() -> None: installed = _selected_release_creator(lock, groups) if installed is None or installed < required: parser.error(f"relock: release-creator {required} required") - except (InvalidVersion, OSError, PylockValidationError, ValueError) as error: + except (InvalidVersion, KeyError, OSError, PylockValidationError, TypeError, ValueError) as error: parser.error(str(error)) @@ -61,8 +74,8 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = continue if local_tool_source and package.name == "release-creator" and package.index is None: continue - if package.index is None: - raise ValueError(f"release lock has an uncooled non-index package: {package.name}") + if package.index != "https://pypi.org/simple/": + raise ValueError(f"release lock has an unapproved index: {package.name}: {package.index}") artifacts = ([package.sdist] if package.sdist is not None else []) + list(package.wheels or ()) if not artifacts: raise ValueError(f"release lock has no dated artifacts: {package.name}") @@ -85,6 +98,51 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = raise ValueError(f"release lock has an uncooled artifact: {artifact.name}") +def _verify_pypi(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> None: + expected: dict[str, dict[str, tuple[str | None, str, datetime | None]]] = defaultdict(dict) + for package in lock.packages: + if package.marker is not None and not any( + package.marker.evaluate({"dependency_groups": selection}, context="lock_file") + for selection in (groups, {"build"}) + ): + continue + if local_tool_source and package.name == "release-creator" and package.index is None: + continue + for artifact in ([package.sdist] if package.sdist is not None else []) + list(package.wheels or ()): + expected[canonicalize_name(package.name)][urlsplit(artifact.url or "").path.rsplit("/", 1)[-1]] = ( + artifact.url, + artifact.hashes["sha256"], + artifact.upload_time, + ) + with ThreadPoolExecutor(max_workers=8) as pool: + for name, actual in pool.map(_pypi_files, expected): + for filename, metadata in expected[name].items(): + if actual.get(filename) != metadata: + raise ValueError(f"release lock differs from PyPI metadata: {name}: {filename}") + + +def _pypi_files(name: str) -> tuple[str, dict[str, tuple[str | None, str | None, datetime | None]]]: + request = Request( + f"https://pypi.org/simple/{name}/", + headers={"Accept": "application/vnd.pypi.simple.v1+json", "Cache-Control": "no-cache"}, + ) + with urlopen(request, timeout=30) as response: + listing = json.load(response) + if ( + not str(listing.get("meta", {}).get("api-version", "")).startswith("1.") + or canonicalize_name(listing.get("name", "")) != name + ): + raise ValueError(f"invalid PyPI listing for {name}") + files: dict[str, tuple[str | None, str | None, datetime | None]] = {} + for file in listing["files"]: + files[file["filename"]] = ( + file.get("url"), + file.get("hashes", {}).get("sha256"), + datetime.fromisoformat(file["upload-time"]) if "upload-time" in file else None, + ) + return name, files + + def _selected_release_creator(lock: Pylock, groups: set[str]) -> Version | None: return next( ( diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py index d5c2561..6f2ce09 100644 --- a/tools/nab_cutoffs.py +++ b/tools/nab_cutoffs.py @@ -56,6 +56,7 @@ def validate_recent_cutoffs(project: str, threshold: datetime) -> None: for name, settings in nab.get("index", {}).items(): if "uploaded-prior-to" in settings: raise ValueError(f"Nab index cutoff for {name} bypasses the seven-day policy") + validate_indexes(nab) for name, value in candidates: if value is False: raise ValueError(f"Nab cutoff for {name} weakens the seven-day policy: {value}") @@ -74,6 +75,15 @@ def validate_recent_cutoffs(project: str, threshold: datetime) -> None: raise ValueError(f"unreviewed recent Nab cutoff for {name}: {timestamp.isoformat()}") +def validate_indexes(nab: dict) -> None: + if any(index != {"name": "pypi", "url": "https://pypi.org/simple/"} for index in nab.get("indexes", [])): + raise ValueError("custom Nab indexes are not approved for release locks") + if nab.get("index") or any("index" in settings for settings in nab.get("packages", {}).values()): + raise ValueError("custom Nab indexes are not approved for release locks") + if any("index" in settings for settings in nab.get("package-rules", [])): + raise ValueError("custom Nab indexes are not approved for release locks") + + def remove_expired_cutoffs(project: str, threshold: datetime) -> str: import tomlkit @@ -144,5 +154,6 @@ def _expired(value: str | datetime | bool, threshold: datetime, reviewed: set[da "remove_cutoffs", "remove_expired_cutoffs", "reviewed_cutoff", + "validate_indexes", "validate_recent_cutoffs", ] From 572feb9a15013d441aa4f284e42718c42ee7b6fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 14:57:58 -0700 Subject: [PATCH 07/56] =?UTF-8?q?=F0=9F=90=9B=20fix(lock):=20check=20dupli?= =?UTF-8?q?cate=20artifacts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+lock-metadata.security.md | 2 ++ tests/install_locked_tools/test_metadata.py | 40 ++++++++++++++++++--- tools/check_lock_groups.py | 17 +++++---- 3 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 changelog.d/+lock-metadata.security.md diff --git a/changelog.d/+lock-metadata.security.md b/changelog.d/+lock-metadata.security.md new file mode 100644 index 0000000..1f84241 --- /dev/null +++ b/changelog.d/+lock-metadata.security.md @@ -0,0 +1,2 @@ +Check release lock artifacts against PyPI metadata before installing release tools, including their hashes, upload +times, and index origin. diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index 82ee13e..d53a345 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -6,11 +6,22 @@ from io import BytesIO from pathlib import Path from textwrap import dedent -from typing import Any +from typing import NotRequired, TypedDict import pytest from pytest_mock import MockerFixture +_PyPIFile = TypedDict( + "_PyPIFile", + {"filename": str, "url": str, "upload-time": NotRequired[str], "hashes": dict[str, str]}, +) + + +class _PyPIListing(TypedDict): + meta: dict[str, str] + name: str + files: list[_PyPIFile] + @pytest.fixture def lock_case(tmp_path: Path) -> tuple[Path, Path]: @@ -43,7 +54,7 @@ def lock_case(tmp_path: Path) -> tuple[Path, Path]: @pytest.fixture -def pypi_listing() -> dict[str, Any]: +def pypi_listing() -> _PyPIListing: return { "meta": {"api-version": "1.4"}, "name": "six", @@ -59,7 +70,7 @@ def pypi_listing() -> dict[str, Any]: def test_lock_metadata_matches_pypi( - lock_case: tuple[Path, Path], pypi_listing: dict[str, Any], mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch + lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch ) -> None: request = mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) @@ -68,13 +79,34 @@ def test_lock_metadata_matches_pypi( assert request.call_args.args[0].full_url == "https://pypi.org/simple/six/" +def test_lock_metadata_checks_each_duplicate_artifact( + lock_case: tuple[Path, Path], + pypi_listing: _PyPIListing, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + lock = lock_case[0] + head, package = lock.read_text().split("[[packages]]") + lock.write_text( + f"{head}[[packages]]{package.replace(pypi_listing['files'][0]['hashes']['sha256'], '0' * 64)}" + f"[[packages]]{package}" + ) + mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert "release lock differs from PyPI metadata" in capsys.readouterr().err + + @pytest.mark.parametrize( "change", ["missing-file", "missing-hash", "wrong-hash", "missing-date", "forged-date", "wrong-url"], ) def test_lock_metadata_rejects_pypi_mismatch( lock_case: tuple[Path, Path], - pypi_listing: dict[str, Any], + pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 7e17d82..6a68ebf 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -99,7 +99,7 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = def _verify_pypi(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> None: - expected: dict[str, dict[str, tuple[str | None, str, datetime | None]]] = defaultdict(dict) + expected: dict[str, list[tuple[str, str | None, str, datetime | None]]] = defaultdict(list) for package in lock.packages: if package.marker is not None and not any( package.marker.evaluate({"dependency_groups": selection}, context="lock_file") @@ -109,15 +109,18 @@ def _verify_pypi(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> if local_tool_source and package.name == "release-creator" and package.index is None: continue for artifact in ([package.sdist] if package.sdist is not None else []) + list(package.wheels or ()): - expected[canonicalize_name(package.name)][urlsplit(artifact.url or "").path.rsplit("/", 1)[-1]] = ( - artifact.url, - artifact.hashes["sha256"], - artifact.upload_time, + expected[canonicalize_name(package.name)].append( + ( + urlsplit(artifact.url or "").path.rsplit("/", 1)[-1], + artifact.url, + artifact.hashes["sha256"], + artifact.upload_time, + ) ) with ThreadPoolExecutor(max_workers=8) as pool: for name, actual in pool.map(_pypi_files, expected): - for filename, metadata in expected[name].items(): - if actual.get(filename) != metadata: + for filename, url, digest, upload_time in expected[name]: + if actual.get(filename) != (url, digest, upload_time): raise ValueError(f"release lock differs from PyPI metadata: {name}: {filename}") From 1a88b304b5f9c5bfe9b058e697cfa6c87ec02e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 14:57:50 -0700 Subject: [PATCH 08/56] =?UTF-8?q?=F0=9F=A7=AA=20test:=20measure=20Action?= =?UTF-8?q?=20subprocess=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Action tests launch Python tools in isolated environments, so ordinary coverage misses them. Start coverage in those processes and test the cooldown, lock, and PyPI guards that security review identified. --- .github/workflows/check.yaml | 6 +--- .gitignore | 1 + pyproject.toml | 10 ++++++ .../test_cooldown.py | 33 +++++++++++++++++-- tests/conftest.py | 10 +++++- tests/coverage_bootstrap/sitecustomize.py | 10 ++++++ tests/install_locked_tools/test_install.py | 15 +++++++-- tests/refresh_release_lock/test_cutoffs.py | 19 ++++++++++- tests/verify_pypi_files/test_verify.py | 10 ++++++ tox.toml | 14 +++++++- 10 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 tests/coverage_bootstrap/sitecustomize.py diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index d344915..322bc0b 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -105,12 +105,8 @@ jobs: with: groups: release-news release-shell venv: .release-tools - - name: Install current test tools - run: | - uv venv --python 3.15 .venv - uv pip install --python .venv/bin/python --group test - name: Run Action tests - run: .venv/bin/pytest -q -n auto + run: uvx --with tox-uv tox run -e test type: runs-on: ubuntu-26.04 permissions: diff --git a/.gitignore b/.gitignore index 7e20f52..67691e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .venv/ __pycache__/ +.coverage* uv.lock diff --git a/pyproject.toml b/pyproject.toml index cab26ba..177de56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ] test = [ "certifi", "check-wheel-contents>=0.6.3", + "coverage[toml]>=7.10", "hatchling>=1.28", "pytest>=9", "pytest-httpserver", @@ -71,6 +72,15 @@ uploaded-prior-to = "2026-10-23T21:40:06Z" [tool.ruff] line-length = 120 +[tool.coverage.run] +branch = true +patch = [ "subprocess" ] +source_dirs = [ "tools" ] + +[tool.coverage.report] +fail_under = 85 +show_missing = true + [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = [ "INP001" ] # Test modules intentionally have no package marker. diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index df5907c..63ff3b5 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -109,6 +109,30 @@ def test_cooldown_rejects_changed_head( assert len(server.log) == 1 +def test_cooldown_rejects_short_minimum( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + result = run_cooldown(action_scripts, tmp_path, environment, minimum_days="6") + assert result.returncode != 0 + assert "hook cooldown must be at least seven days" in result.stderr + assert not server.log + + +def test_cooldown_rejects_bot_without_observed_run( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull(server, author="pre-commit-ci[bot]", observed_days=None) + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v1") + expect_historical_runs(server, older_head=None) + result = run_cooldown(action_scripts, tmp_path, environment) + assert result.returncode != 0 + assert "hook revision has not been observed" in result.stderr + + def test_cooldown_rejects_backdated_tag_on_recent_pull_request( tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: @@ -264,6 +288,7 @@ def run_cooldown( *, event_name: str = "pull_request", event_head: str = "a" * 40, + minimum_days: str = "7", ) -> subprocess.CompletedProcess[str]: path = tmp_path / "event.json" path.write_text(json.dumps({"number": 7, "pull_request": {"head": {"sha": event_head}}})) @@ -276,7 +301,7 @@ def run_cooldown( action_scripts, "check-hook-update-cooldown", version="", - MINIMUM_DAYS="7", + MINIMUM_DAYS=minimum_days, GITHUB_EVENT_NAME=event_name, GITHUB_EVENT_PATH=str(path), GITHUB_OUTPUT=str(tmp_path / "outputs"), @@ -289,7 +314,7 @@ def expect_pull( *, author: str, head: str = "a" * 40, - observed_days: int = 10, + observed_days: int | None = 10, updated_days: int = 0, hook_update: bool = True, ) -> None: @@ -313,7 +338,9 @@ def expect_pull( query_string=f"event=pull_request&head_sha={head}&per_page=100", ).respond_with_json( { - "workflow_runs": [ + "workflow_runs": [] + if observed_days is None + else [ { "event": "pull_request", "head_repository": {"full_name": "tox-dev/fixture"}, diff --git a/tests/conftest.py b/tests/conftest.py index 04a3f19..9e5d18f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -472,7 +472,7 @@ def _condition( def _safe_environment() -> dict[str, str]: - return { + environment = { name: os.environ[name] for name in ( "HOME", @@ -486,9 +486,17 @@ def _safe_environment() -> dict[str, str]: "SSL_CERT_FILE", "GH_HOST", "GH_ENTERPRISE_TOKEN", + "COVERAGE_PROCESS_START", + "COVERAGE_PROCESS_CONFIG", ) if name in os.environ } + if "COVERAGE_PROCESS_CONFIG" in environment or "COVERAGE_PROCESS_START" in environment: + import coverage + + environment["GW_COVERAGE_SITE_PACKAGES"] = str(Path(coverage.__file__).parent.parent) + environment["PYTHONPATH"] = str(Path(__file__).parent / "coverage_bootstrap") + return environment def _inputs( diff --git a/tests/coverage_bootstrap/sitecustomize.py b/tests/coverage_bootstrap/sitecustomize.py new file mode 100644 index 0000000..b131738 --- /dev/null +++ b/tests/coverage_bootstrap/sitecustomize.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import os +import sys + +if "GW_COVERAGE_SITE_PACKAGES" in os.environ: + sys.path.append(os.environ["GW_COVERAGE_SITE_PACKAGES"]) + from coverage import process_startup + + process_startup() diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index bdd7a80..37d48f0 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -210,10 +210,17 @@ def test_install_locked_tools_checks_caller_release_creator(tmp_path: Path, loca assert (result.returncode == 0) is local_source, result.stderr -def test_local_tool_source_accepts_versionless_package(tmp_path: Path) -> None: +@pytest.mark.parametrize( + ("package_name", "accepted"), + [ + pytest.param("release-creator", True, id="local-release-creator"), + pytest.param("towncrier", False, id="other-local-package"), + ], +) +def test_local_tool_source_only_accepts_release_creator(tmp_path: Path, package_name: str, accepted: bool) -> None: source = Path(__file__).resolve().parents[2] document = tomlkit.parse((source / "pylock.toml").read_text()) - package = next(item for item in document["packages"] if item["name"] == "release-creator") + package = next(item for item in document["packages"] if item["name"] == package_name) for field in ("version", "index", "sdist", "wheels"): package.pop(field) package["directory"] = {"path": "."} @@ -232,7 +239,9 @@ def test_local_tool_source_accepts_versionless_package(tmp_path: Path) -> None: capture_output=True, check=False, ) - assert result.returncode == 0, result.stderr + assert (result.returncode == 0) is accepted, result.stderr + if not accepted: + assert f"uncooled non-index package: {package_name}" in result.stderr @pytest.mark.parametrize( diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index af0089a..2da70bc 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -3,7 +3,7 @@ import subprocess import sys import tomllib -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest @@ -83,6 +83,23 @@ def test_expire_cutoffs_removes_each_reviewed_release_creator_floor(reviewed: st assert "packages" not in tomllib.loads(updated)["tool"]["nab"] +@pytest.mark.parametrize( + ("seconds_before", "expired"), + [ + pytest.param(1, False, id="before-threshold"), + pytest.param(0, True, id="at-threshold"), + ], +) +def test_expire_cutoffs_uses_reviewed_threshold(seconds_before: int, expired: bool) -> None: + project = ( + '[tool.nab]\nuploaded-prior-to = "P7D"\n' + '[tool.nab.packages.release-creator]\nuploaded-prior-to = "2026-10-23T19:54:50Z"\n' + ) + threshold = datetime(2026, 10, 23, 19, 54, 50, tzinfo=UTC) - timedelta(seconds=seconds_before) + updated = remove_expired_cutoffs(project, threshold) + assert ("packages" not in tomllib.loads(updated)["tool"]["nab"]) is expired + + def test_expire_cutoffs_accepts_toml_datetime(cutoff_project: Path) -> None: cutoff_project.write_text( cutoff_project.read_text().replace( diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py index 5b7d9dd..23ca82d 100644 --- a/tests/verify_pypi_files/test_verify.py +++ b/tests/verify_pypi_files/test_verify.py @@ -51,6 +51,16 @@ def test_verify_files_matches_complete_release(distribution: Path, mocker: Mocke assert result == [distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz"] +def test_verify_files_rejects_unsupported_simple_api(distribution: Path, mocker: MockerFixture) -> None: + mocker.patch.object( + _MODULE, + "urlopen", + return_value=io.BytesIO(json.dumps({"meta": {"api-version": "2.0"}, "files": _listing(distribution)}).encode()), + ) + with pytest.raises(ValueError, match="unsupported PyPI Simple API version"): + verify_files("fixture", "1.0", distribution, 0) + + def test_verify_files_ignores_legacy_egg_from_older_release(distribution: Path, mocker: MockerFixture) -> None: files = _listing(distribution) files.append({"filename": "fixture-0.8.1-py2.4.egg", "hashes": {"sha256": "0" * 64}, "yanked": False}) diff --git a/tox.toml b/tox.toml index acea0f7..477131d 100644 --- a/tox.toml +++ b/tox.toml @@ -1,5 +1,17 @@ requires = ["tox>=4.64.7", "tox-uv>=1.36.1"] -env_list = ["type"] +env_list = ["test", "type"] + +[env.test] +description = "test Actions and measure Python tools" +base_python = "3.15" +skip_install = true +dependency_groups = ["test"] +commands = [ + ["coverage", "erase"], + ["coverage", "run", "-m", "pytest", "-q", "-n", "auto"], + ["coverage", "combine"], + ["coverage", "report", "-m"], +] [env.type] description = "type-check Action tests" From 13e6ba2aadc8a4bde53feee3fdfd8f790f8cab2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:01:52 -0700 Subject: [PATCH 09/56] =?UTF-8?q?=F0=9F=A7=AA=20test:=20use=20PyPI=20for?= =?UTF-8?q?=20upgrade=20fixture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/upgrade_release_tools/test_upgrade.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 74813d0..91d3f20 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -68,7 +68,7 @@ def test_hook_refresh_uses_seven_day_cutoff_for_hooks_and_dependencies() -> None @pytest.fixture -def lock_repository(tmp_path: Path, test_package_index: str) -> Path: +def lock_repository(tmp_path: Path) -> Path: repository = tmp_path / "repository" repository.mkdir() (repository / "pyproject.toml").write_text( @@ -77,8 +77,7 @@ def lock_repository(tmp_path: Path, test_package_index: str) -> Path: '[tool.nab]\nmode = "universal"\nuploaded-prior-to = "P7D"\n' 'decision-order = "stable"\nbase-group = "project-runtime"\ndefault-groups = []\n' '[tool.nab.matrix]\npython = ">=3.15,<3.16"\n' - 'platforms = ["linux_x86_64", "linux_aarch64", "macos_arm64", "windows_amd64"]\n' - f'[[tool.nab.indexes]]\nname = "fixture"\nurl = "{test_package_index}"\nserialization = "html"\n', + 'platforms = ["linux_x86_64", "linux_aarch64", "macos_arm64", "windows_amd64"]\n', ) run(repository, "git", "init", "-b", "main") run(repository, "git", "config", "user.name", "Release test") From c84300a77f03d3697763cfa65b0eef62febb0cbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:03:58 -0700 Subject: [PATCH 10/56] =?UTF-8?q?=F0=9F=90=9B=20fix(lock):=20reject=20miss?= =?UTF-8?q?ing=20indexes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/build_python_distribution/test_build.py | 2 +- tests/install_locked_tools/test_install.py | 1 + tools/check_lock_groups.py | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py index 2fa286a..5f1e400 100644 --- a/tests/build_python_distribution/test_build.py +++ b/tests/build_python_distribution/test_build.py @@ -94,7 +94,7 @@ def test_build_python_distribution_rejects_backend_hash_mismatch( UV_CACHE_DIR=str(tmp_path / "uv-cache"), ) assert result.returncode != 0 - assert "Hash mismatch" in result.stderr + assert "release lock differs from PyPI metadata: hatchling" in result.stderr def test_build_python_distribution_does_not_run_hooks( diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 37d48f0..1bf18d0 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -470,6 +470,7 @@ def test_install_locked_tools_constraints_only_checks_lock_age(tmp_path: Path, a def test_install_locked_tools_constraints_only_accepts_no_build_group(tmp_path: Path, action_scripts: Path) -> None: + (tmp_path / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') (tmp_path / "pylock.toml").write_text( 'lock-version = "1.0"\nrequires-python = ">=3.15"\ncreated-by = "test"\n' 'dependency-groups = ["project-runtime"]\npackages = []\n' diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 6a68ebf..dc22c68 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -74,6 +74,8 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = continue if local_tool_source and package.name == "release-creator" and package.index is None: continue + if package.index is None: + raise ValueError(f"release lock has an uncooled non-index package: {package.name}") if package.index != "https://pypi.org/simple/": raise ValueError(f"release lock has an unapproved index: {package.name}: {package.index}") artifacts = ([package.sdist] if package.sdist is not None else []) + list(package.wheels or ()) From a3ab8b83f11b94f4eef9005c6ddce61c022548e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:03:41 -0700 Subject: [PATCH 11/56] =?UTF-8?q?=F0=9F=A7=AA=20test:=20use=20PyPI=20metad?= =?UTF-8?q?ata=20in=20export=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/export_build_constraints/test_export.py | 74 ++++++++++++++++--- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index 623a436..7eabd9f 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -2,6 +2,8 @@ import subprocess import sys +import tomllib +from datetime import UTC, datetime from pathlib import Path from textwrap import dedent @@ -13,6 +15,7 @@ def test_export_build_constraints_uses_checked_in_locks(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] (tmp_path / "pylock.toml").write_bytes((source / "pylock.toml").read_bytes()) + (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) outputs: dict[str, str] = {} result = action(tmp_path, tmp_path, action_scripts, "export-build-constraints", outputs, version="") assert result.returncode == 0, result.stderr @@ -127,7 +130,7 @@ def test_export_build_constraints_selects_explicit_targets(tmp_path: Path, platf def test_export_build_constraints_action_selects_secondary_lock(tmp_path: Path, action_scripts: Path) -> None: - write_matrix_lock(tmp_path / "pylock.build.toml") + expected = write_published_matrix_lock(tmp_path / "pylock.build.toml") outputs: dict[str, str] = {} result = action( @@ -144,7 +147,29 @@ def test_export_build_constraints_action_selects_secondary_lock(tmp_path: Path, assert result.returncode == 0, result.stderr output = Path(outputs["path"]) - assert output.read_text() == f"example==1.0 --hash=sha256:{'0' * 64}\n" + assert output.read_text() == expected + + +def test_export_build_constraints_action_rejects_wrong_pypi_digest(tmp_path: Path, action_scripts: Path) -> None: + lock = tmp_path / "pylock.build.toml" + write_published_matrix_lock(lock) + document = tomlkit.parse(lock.read_text()) + document["packages"][0]["wheels"][0]["hashes"]["sha256"] = "0" * 64 + lock.write_text(tomlkit.dumps(document)) + + result = action( + tmp_path, + tmp_path, + action_scripts, + "export-build-constraints", + version="", + LOCK="pylock.build.toml", + TARGET_PLATFORM="macos_arm64", + TARGETS="3.15.0:cpython", + ) + + assert result.returncode != 0 + assert "release lock differs from PyPI metadata: packaging:" in result.stderr @pytest.mark.parametrize("groups", ["", "release-check"], ids=["export-only", "with-installed-group"]) @@ -152,8 +177,10 @@ def test_export_build_constraints_rejects_uncooled_secondary_lock( tmp_path: Path, action_scripts: Path, groups: str ) -> None: lock = tmp_path / "pylock.build.toml" - write_matrix_lock(lock) - lock.write_text(lock.read_text().replace("2026-10-01T00:00:00Z", "2026-10-22T00:00:00Z")) + write_published_matrix_lock(lock) + document = tomlkit.parse(lock.read_text()) + document["packages"][0]["wheels"][0]["upload-time"] = datetime(2099, 1, 1, tzinfo=UTC) + lock.write_text(tomlkit.dumps(document)) result = action( tmp_path, tmp_path, @@ -172,21 +199,25 @@ def test_export_build_constraints_rejects_uncooled_secondary_lock( def test_export_build_constraints_rejects_different_target_versions(tmp_path: Path, action_scripts: Path) -> None: lock = tmp_path / "pylock.build.toml" - write_matrix_lock(lock) + write_published_matrix_lock(lock) lock.write_text( - lock.read_text().replace('version = "1.0"', 'version = "1.0"\nmarker = \'implementation_name == "pypy"\'') + lock.read_text().replace( + 'index = "https://pypi.org/simple/"', + 'marker = \'implementation_name == "pypy"\'\nindex = "https://pypi.org/simple/"', + 1, + ) + dedent( """\ [[packages]] - name = "example" - version = "2.0" + name = "packaging" + version = "25.0" marker = 'implementation_name == "cpython"' index = "https://pypi.org/simple/" [[packages.wheels]] - name = "example-2.0-py3-none-any.whl" - url = "https://files.pythonhosted.org/example-2.0-py3-none-any.whl" - upload-time = 2026-10-01T00:00:00Z - hashes.sha256 = "1111111111111111111111111111111111111111111111111111111111111111" + name = "packaging-25.0-py3-none-any.whl" + url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl" + upload-time = 2025-04-19T11:48:57.875608Z + hashes.sha256 = "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484" """, ), ) @@ -241,6 +272,25 @@ def test_export_build_constraints_uses_full_python_version(tmp_path: Path, versi assert ("example==1.0" in output.read_text()) == selected +def write_published_matrix_lock(path: Path) -> str: + write_matrix_lock(path) + source = Path(__file__).resolve().parents[2] + (path.parent / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) + package = next( + package + for package in tomllib.loads((source / "pylock.toml").read_text())["packages"] + if package["name"] == "packaging" + ) + document = tomlkit.parse(path.read_text()) + document["packages"][0]["name"] = package["name"] + document["packages"][0]["version"] = package["version"] + document["packages"][0]["wheels"] = package["wheels"] + document["packages"][0]["sdist"] = package["sdist"] + path.write_text(tomlkit.dumps(document)) + hashes = {artifact["hashes"]["sha256"] for artifact in [*package["wheels"], package["sdist"]]} + return f"packaging=={package['version']} {' '.join(f'--hash=sha256:{digest}' for digest in sorted(hashes))}\n" + + def write_matrix_lock(path: Path) -> None: path.write_text( dedent( From 8bf88e48dffcd597e58428fe56af1d774b145dbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:04:49 -0700 Subject: [PATCH 12/56] =?UTF-8?q?=F0=9F=A7=AA=20test:=20stub=20PyPI=20meta?= =?UTF-8?q?data=20for=20Actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrent Action tests repeatedly queried PyPI and failed when its endpoint was unreachable. Serve a fixed test listing to the lock checker while keeping production URL and verification unchanged. --- tests/conftest.py | 61 ++++++++++++++++++++++- tests/coverage_bootstrap/sitecustomize.py | 21 ++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9e5d18f..19afd68 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -138,6 +138,57 @@ def artifact_cache() -> dict[str, bytes]: return {} +@pytest.fixture(scope="session") +def pypi_metadata(tmp_path_factory: pytest.TempPathFactory) -> Path: + packages = tomllib.loads((Path(__file__).resolve().parents[1] / "pylock.toml").read_text())["packages"] + listings: dict[str, dict[str, dict[str, str | dict[str, str]]]] = {} + for package in packages: + files = listings.setdefault(canonicalize_name(package["name"]), {}) + for artifact in [*package.get("wheels", []), *([package["sdist"]] if "sdist" in package else [])]: + filename = artifact.get("name") or urlsplit(artifact["url"]).path.rsplit("/", 1)[-1] + files[filename] = { + "filename": filename, + "url": artifact["url"], + "hashes": artifact["hashes"], + "upload-time": artifact["upload-time"].isoformat(), + } + for filename, url, digest, uploaded in ( + ( + "six-1.16.0-py2.py3-none-any.whl", + "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", + "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", + "2021-05-05T14:18:17.237677+00:00", + ), + ( + "example-1.0-py3-none-any.whl", + "https://files.pythonhosted.org/example-1.0-py3-none-any.whl", + "0" * 64, + "2026-10-01T00:00:00+00:00", + ), + ( + "example-2.0-py3-none-any.whl", + "https://files.pythonhosted.org/example-2.0-py3-none-any.whl", + "1" * 64, + "2026-10-01T00:00:00+00:00", + ), + ): + name = filename.split("-", 1)[0] + listings.setdefault(name, {})[filename] = { + "filename": filename, + "url": url, + "hashes": {"sha256": digest}, + "upload-time": uploaded, + } + path = tmp_path_factory.mktemp("pypi-metadata") / "listings.json" + path.write_text(json.dumps({name: list(files.values()) for name, files in listings.items()})) + return path + + +@pytest.fixture(autouse=True) +def pypi_metadata_environment(pypi_metadata: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GW_PYPI_METADATA_FILE", str(pypi_metadata)) + + @pytest.fixture(scope="session") def nab_executable(tmp_path_factory: pytest.TempPathFactory) -> Path: root = Path(__file__).resolve().parents[1] @@ -294,6 +345,12 @@ def action( version: str, **extra_env: str, ) -> subprocess.CompletedProcess[str]: + if ( + name in {"install-locked-tools", "export-build-constraints"} + and (repository / "pylock.toml").exists() + and not (repository / "pyproject.toml").exists() + ): + (repository / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') action_config = yaml.safe_load((Path(__file__).resolve().parents[1] / name / "action.yml").read_text()) head = run(repository, "git", "rev-parse", "HEAD").stdout.strip() if (repository / ".git").exists() else "0" * 40 input_values = { @@ -488,14 +545,16 @@ def _safe_environment() -> dict[str, str]: "GH_ENTERPRISE_TOKEN", "COVERAGE_PROCESS_START", "COVERAGE_PROCESS_CONFIG", + "GW_PYPI_METADATA_FILE", ) if name in os.environ } + if "GW_PYPI_METADATA_FILE" in environment: + environment["PYTHONPATH"] = str(Path(__file__).parent / "coverage_bootstrap") if "COVERAGE_PROCESS_CONFIG" in environment or "COVERAGE_PROCESS_START" in environment: import coverage environment["GW_COVERAGE_SITE_PACKAGES"] = str(Path(coverage.__file__).parent.parent) - environment["PYTHONPATH"] = str(Path(__file__).parent / "coverage_bootstrap") return environment diff --git a/tests/coverage_bootstrap/sitecustomize.py b/tests/coverage_bootstrap/sitecustomize.py index b131738..ebfde1e 100644 --- a/tests/coverage_bootstrap/sitecustomize.py +++ b/tests/coverage_bootstrap/sitecustomize.py @@ -2,6 +2,27 @@ import os import sys +from pathlib import Path + +if "GW_PYPI_METADATA_FILE" in os.environ and Path(sys.argv[0]).name == "check_lock_groups.py": + import json + from io import BytesIO + from unittest.mock import patch + from urllib.parse import urlsplit + from urllib.request import Request + + _LISTINGS = json.loads(Path(os.environ["GW_PYPI_METADATA_FILE"]).read_text()) + + def _pypi_urlopen(request: Request, **_options: object) -> BytesIO: + url = urlsplit(request.full_url) + if url.scheme != "https" or url.netloc != "pypi.org" or not url.path.startswith("/simple/"): + raise ValueError(f"unexpected PyPI metadata request: {request.full_url}") + name = url.path.removeprefix("/simple/").strip("/") + return BytesIO( + json.dumps({"meta": {"api-version": "1.4"}, "name": name, "files": _LISTINGS.get(name, [])}).encode() + ) + + patch("urllib.request.urlopen", _pypi_urlopen).start() if "GW_COVERAGE_SITE_PACKAGES" in os.environ: sys.path.append(os.environ["GW_COVERAGE_SITE_PACKAGES"]) From e2259e1d509d57f0d1032a036c08eaf36d173634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:06:43 -0700 Subject: [PATCH 13/56] =?UTF-8?q?=F0=9F=A7=AA=20test:=20cover=20dynamic=20?= =?UTF-8?q?build=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/conftest.py | 64 ++++++++++++++++------- tests/coverage_bootstrap/sitecustomize.py | 2 +- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 19afd68..36c163d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ from html import escape from pathlib import Path from textwrap import dedent -from typing import Final +from typing import Final, TypedDict, cast from urllib.parse import urlsplit from urllib.request import urlopen @@ -23,6 +23,11 @@ from pytest_httpserver import HTTPServer from werkzeug.wrappers import Request, Response +_PyPIFile = TypedDict( + "_PyPIFile", + {"filename": str, "url": str, "hashes": dict[str, str], "upload-time": str}, +) + @pytest.fixture def github_api(tmp_path: Path) -> Iterator[tuple[HTTPServer, dict[str, str]]]: @@ -140,18 +145,7 @@ def artifact_cache() -> dict[str, bytes]: @pytest.fixture(scope="session") def pypi_metadata(tmp_path_factory: pytest.TempPathFactory) -> Path: - packages = tomllib.loads((Path(__file__).resolve().parents[1] / "pylock.toml").read_text())["packages"] - listings: dict[str, dict[str, dict[str, str | dict[str, str]]]] = {} - for package in packages: - files = listings.setdefault(canonicalize_name(package["name"]), {}) - for artifact in [*package.get("wheels", []), *([package["sdist"]] if "sdist" in package else [])]: - filename = artifact.get("name") or urlsplit(artifact["url"]).path.rsplit("/", 1)[-1] - files[filename] = { - "filename": filename, - "url": artifact["url"], - "hashes": artifact["hashes"], - "upload-time": artifact["upload-time"].isoformat(), - } + listings = _lock_metadata(Path(__file__).resolve().parents[1] / "pylock.toml") for filename, url, digest, uploaded in ( ( "six-1.16.0-py2.py3-none-any.whl", @@ -159,6 +153,12 @@ def pypi_metadata(tmp_path_factory: pytest.TempPathFactory) -> Path: "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", "2021-05-05T14:18:17.237677+00:00", ), + ( + "packaging-25.0-py3-none-any.whl", + "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", + "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "2025-04-19T11:48:57.875608+00:00", + ), ( "example-1.0-py3-none-any.whl", "https://files.pythonhosted.org/example-1.0-py3-none-any.whl", @@ -173,14 +173,16 @@ def pypi_metadata(tmp_path_factory: pytest.TempPathFactory) -> Path: ), ): name = filename.split("-", 1)[0] - listings.setdefault(name, {})[filename] = { - "filename": filename, - "url": url, - "hashes": {"sha256": digest}, - "upload-time": uploaded, - } + listings.setdefault(name, []).append( + { + "filename": filename, + "url": url, + "hashes": {"sha256": digest}, + "upload-time": uploaded, + } + ) path = tmp_path_factory.mktemp("pypi-metadata") / "listings.json" - path.write_text(json.dumps({name: list(files.values()) for name, files in listings.items()})) + path.write_text(json.dumps(listings)) return path @@ -328,6 +330,13 @@ def build_repository(source_project: Path, nab_executable: Path, monkeypatch: py "--output", "pylock.toml", ) + listings = cast("dict[str, list[_PyPIFile]]", json.loads(Path(os.environ["GW_PYPI_METADATA_FILE"]).read_text())) + for name, files in _lock_metadata(source_project / "pylock.toml").items(): + existing = {file["filename"] for file in listings.get(name, [])} + listings.setdefault(name, []).extend(file for file in files if file["filename"] not in existing) + metadata = source_project.parent / "pypi-metadata.json" + metadata.write_text(json.dumps(listings)) + monkeypatch.setenv("GW_PYPI_METADATA_FILE", str(metadata)) run(source_project, "git", "add", ".") run(source_project, "git", "commit", "-m", "Add package source") run(source_project, "git", "push", "origin", "main") @@ -634,6 +643,21 @@ def _expression( raise ValueError(msg) +def _lock_metadata(lock: Path) -> dict[str, list[_PyPIFile]]: + listings: dict[str, dict[str, _PyPIFile]] = {} + for package in tomllib.loads(lock.read_text())["packages"]: + files = listings.setdefault(canonicalize_name(package["name"]), {}) + for artifact in [*package.get("wheels", []), *([package["sdist"]] if "sdist" in package else [])]: + filename = artifact.get("name") or urlsplit(artifact["url"]).path.rsplit("/", 1)[-1] + files[filename] = { + "filename": filename, + "url": artifact["url"], + "hashes": artifact["hashes"], + "upload-time": artifact["upload-time"].isoformat(), + } + return {name: list(files.values()) for name, files in listings.items()} + + __all__ = [ "action", "run", diff --git a/tests/coverage_bootstrap/sitecustomize.py b/tests/coverage_bootstrap/sitecustomize.py index ebfde1e..b26ea5c 100644 --- a/tests/coverage_bootstrap/sitecustomize.py +++ b/tests/coverage_bootstrap/sitecustomize.py @@ -13,7 +13,7 @@ _LISTINGS = json.loads(Path(os.environ["GW_PYPI_METADATA_FILE"]).read_text()) - def _pypi_urlopen(request: Request, **_options: object) -> BytesIO: + def _pypi_urlopen(request: Request, **_options: float) -> BytesIO: url = urlsplit(request.full_url) if url.scheme != "https" or url.netloc != "pypi.org" or not url.path.startswith("/simple/"): raise ValueError(f"unexpected PyPI metadata request: {request.full_url}") From b4734ddacfd235ad2c1b664fc9f6678c88da1691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:08:19 -0700 Subject: [PATCH 14/56] =?UTF-8?q?=F0=9F=A7=AA=20test(lock):=20cover=20meta?= =?UTF-8?q?data=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/install_locked_tools/test_metadata.py | 50 +++++++++++++++++++++ tools/check_lock_groups.py | 2 - 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index d53a345..094adae 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -79,6 +79,56 @@ def test_lock_metadata_matches_pypi( assert request.call_args.args[0].full_url == "https://pypi.org/simple/six/" +def test_lock_metadata_requires_group( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + source = Path(__file__).resolve().parents[2] + monkeypatch.syspath_prepend(str(source / "tools")) + monkeypatch.setattr(sys, "argv", ["check_lock_groups.py", str(lock_case[0])]) + command = runpy.run_path(str(source / "tools/check_lock_groups.py"), run_name="lock_check") + + with pytest.raises(SystemExit, match="2"): + command["main"]() + + assert "at least one dependency group is required" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "change", [pytest.param("api-version", id="api-version"), pytest.param("project-name", id="project-name")] +) +def test_lock_metadata_rejects_invalid_listing( + lock_case: tuple[Path, Path], + pypi_listing: _PyPIListing, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + change: str, +) -> None: + if change == "api-version": + pypi_listing["meta"]["api-version"] = "2.0" + else: + pypi_listing["name"] = "other" + mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert "invalid PyPI listing for six" in capsys.readouterr().err + + +def test_lock_metadata_allows_local_release_creator( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + lock = lock_case[0] + lock.write_text( + lock.read_text().split("[[packages]]", 1)[0] + + '[[packages]]\nname = "release-creator"\nmarker = \'"release-check" in dependency_groups\'\n' + + 'directory = { path = "." }\n' + ) + + _run_check(lock_case, monkeypatch, "--local-tool-source", "--verify-pypi", "--project", str(lock_case[1])) + + def test_lock_metadata_checks_each_duplicate_artifact( lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index dc22c68..448fa6d 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -79,8 +79,6 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = if package.index != "https://pypi.org/simple/": raise ValueError(f"release lock has an unapproved index: {package.name}: {package.index}") artifacts = ([package.sdist] if package.sdist is not None else []) + list(package.wheels or ()) - if not artifacts: - raise ValueError(f"release lock has no dated artifacts: {package.name}") cutoff = max(threshold, reviewed_cutoff(package.name, str(package.version)) or threshold) for artifact in artifacts: url = urlsplit(artifact.url or "") From 84e7ed6dae57d54b6fba1c4ab1ef59b563b6d5f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:08:27 -0700 Subject: [PATCH 15/56] =?UTF-8?q?=F0=9F=A7=AA=20test:=20cover=20PyPI=20fil?= =?UTF-8?q?e=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/verify_pypi_files/test_verify.py | 142 +++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py index 23ca82d..290cfc7 100644 --- a/tests/verify_pypi_files/test_verify.py +++ b/tests/verify_pypi_files/test_verify.py @@ -4,6 +4,7 @@ import importlib.util import io import json +import runpy import ssl from email.message import Message from http.client import IncompleteRead @@ -27,6 +28,8 @@ _SPEC.loader.exec_module(_MODULE) verify_files = cast("Callable[[str, str, Path, int], list[Path]]", _MODULE.verify_files) +type _Json = None | bool | int | float | str | list[_Json] | dict[str, _Json] + class _ListingFile(TypedDict): filename: str @@ -61,6 +64,145 @@ def test_verify_files_rejects_unsupported_simple_api(distribution: Path, mocker: verify_files("fixture", "1.0", distribution, 0) +def test_main_prints_distribution_paths( + distribution: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] +) -> None: + _serve(mocker, distribution) + mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution), "--wait-seconds", "0"]) + + cast("Callable[[], None]", _MODULE.main)() + + assert capsys.readouterr().out.splitlines() == [ + str(distribution / "fixture-1.0-py3-none-any.whl"), + str(distribution / "fixture-1.0.tar.gz"), + ] + + +def test_main_reports_invalid_directory( + tmp_path: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] +) -> None: + mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(tmp_path / "missing")]) + + with pytest.raises(SystemExit) as raised: + cast("Callable[[], None]", _MODULE.main)() + + assert raised.value.code == 2 + assert "release files require a directory" in capsys.readouterr().err + + +def test_script_entrypoint_prints_distribution_paths( + distribution: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] +) -> None: + mocker.patch("urllib.request.urlopen", return_value=_body(_listing(distribution))) + mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution), "--wait-seconds", "0"]) + + runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/verify_pypi_files.py"), run_name="__main__") + + assert capsys.readouterr().out.splitlines() == [ + str(distribution / "fixture-1.0-py3-none-any.whl"), + str(distribution / "fixture-1.0.tar.gz"), + ] + + +@pytest.mark.parametrize( + "change", [pytest.param("negative-wait", id="negative-wait"), pytest.param("missing-dir", id="missing-dir")] +) +def test_verify_files_rejects_invalid_arguments(tmp_path: Path, change: str) -> None: + directory = tmp_path if change == "negative-wait" else tmp_path / "missing" + + with pytest.raises(ValueError, match="release files require a directory and nonnegative wait"): + verify_files("fixture", "1.0", directory, -1 if change == "negative-wait" else 0) + + +def test_verify_files_rejects_empty_directory(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="distribution directory is empty"): + verify_files("fixture", "1.0", tmp_path, 0) + + +def test_verify_files_rejects_directory_entry(distribution: Path) -> None: + (distribution / "nested").mkdir() + + with pytest.raises(ValueError, match="invalid distribution path"): + verify_files("fixture", "1.0", distribution, 0) + + +def test_verify_files_ignores_other_release_wheel(distribution: Path, mocker: MockerFixture) -> None: + files = _listing(distribution) + files.append({"filename": "fixture-0.9-py3-none-any.whl", "hashes": {"sha256": "0" * 64}, "yanked": False}) + mocker.patch.object(_MODULE, "urlopen", return_value=_body(files)) + + assert verify_files("fixture", "1.0", distribution, 0) == [ + distribution / "fixture-1.0-py3-none-any.whl", + distribution / "fixture-1.0.tar.gz", + ] + + +@pytest.mark.parametrize("status", [pytest.param(400, id="client-error"), pytest.param(503, id="server-timeout")]) +def test_verify_files_propagates_http_error(distribution: Path, mocker: MockerFixture, status: int) -> None: + mocker.patch.object( + _MODULE, "urlopen", side_effect=HTTPError("https://pypi.org/simple/fixture/", status, "error", Message(), None) + ) + + with pytest.raises(HTTPError) as raised: + verify_files("fixture", "1.0", distribution, 0) + + assert raised.value.code == status + + +def test_verify_files_propagates_exhausted_connection_error(distribution: Path, mocker: MockerFixture) -> None: + mocker.patch.object(_MODULE, "urlopen", side_effect=URLError("offline")) + + with pytest.raises(URLError, match="offline"): + verify_files("fixture", "1.0", distribution, 0) + + +def test_verify_files_reports_missing_release_after_404(distribution: Path, mocker: MockerFixture) -> None: + mocker.patch.object( + _MODULE, "urlopen", side_effect=HTTPError("https://pypi.org/simple/fixture/", 404, "missing", Message(), None) + ) + + with pytest.raises(ValueError, match="PyPI is missing files"): + verify_files("fixture", "1.0", distribution, 0) + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param([], id="not-object"), + pytest.param({"meta": {"api-version": "1.0"}, "files": {}}, id="files-not-list"), + pytest.param({"meta": None, "files": []}, id="missing-metadata"), + pytest.param({"meta": {"api-version": "2.0"}, "files": []}, id="unsupported-version"), + ], +) +def test_verify_files_rejects_invalid_listing(distribution: Path, mocker: MockerFixture, payload: _Json) -> None: + mocker.patch.object(_MODULE, "urlopen", return_value=io.BytesIO(json.dumps(payload).encode())) + + with pytest.raises( + (TypeError, ValueError), match="invalid Simple API response|unsupported PyPI Simple API version" + ): + verify_files("fixture", "1.0", distribution, 0) + + +@pytest.mark.parametrize( + "entry", + [ + pytest.param(None, id="not-object"), + pytest.param({"hashes": {"sha256": "0" * 64}}, id="missing-filename"), + pytest.param({"filename": "fixture-1.0.tar.gz", "hashes": None}, id="missing-hashes"), + pytest.param({"filename": "fixture-1.0.tar.gz", "hashes": {"sha256": None}}, id="missing-sha256"), + ], +) +def test_verify_files_rejects_invalid_file_entry(distribution: Path, mocker: MockerFixture, entry: _Json) -> None: + mocker.patch.object( + _MODULE, + "urlopen", + return_value=io.BytesIO(json.dumps({"meta": {"api-version": "1.0"}, "files": [entry]}).encode()), + ) + + with pytest.raises(TypeError, match="invalid file entry"): + verify_files("fixture", "1.0", distribution, 0) + + def test_verify_files_ignores_legacy_egg_from_older_release(distribution: Path, mocker: MockerFixture) -> None: files = _listing(distribution) files.append({"filename": "fixture-0.8.1-py2.4.egg", "hashes": {"sha256": "0" * 64}, "yanked": False}) From c0aa82b174a659cc4bac6197863da739e1627f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:09:58 -0700 Subject: [PATCH 16/56] =?UTF-8?q?=F0=9F=A7=AA=20test:=20cover=20Nab=20lock?= =?UTF-8?q?=20comparison=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/refresh_release_lock/test_cutoffs.py | 83 ++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index 2da70bc..699829f 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -10,6 +10,7 @@ import tomlkit sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from tools.check_nab_lock import main as check_nab_lock from tools.nab_cutoffs import remove_expired_cutoffs @@ -159,6 +160,88 @@ def test_verify_cutoff_patch_rejects_early_floor_removal(cutoff_project: Path) - assert "changes beyond expired Nab cutoffs" in result.stderr +@pytest.fixture +def nab_lock_pair(tmp_path: Path) -> tuple[Path, Path]: + committed = tmp_path / "committed.toml" + regenerated = tmp_path / "regenerated.toml" + committed.write_text( + 'environments = ["python_version == 3.15"]\npackages = ["safe"]\n' + '[tool.nab]\ncreated-at = 2026-10-01T00:00:00Z\ncommand-line = ["--upgrade"]\n' + ) + regenerated.write_bytes(committed.read_bytes()) + return committed, regenerated + + +def test_check_nab_lock_restores_committed_bytes(nab_lock_pair: tuple[Path, Path]) -> None: + committed, regenerated = nab_lock_pair + regenerated.write_text(regenerated.read_text().replace('command-line = ["--upgrade"]', "command-line = []")) + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"), + str(committed), + str(regenerated), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert regenerated.read_bytes() == committed.read_bytes() + + +def test_check_nab_lock_environments_only_preserves_other_changes( + nab_lock_pair: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + committed, regenerated = nab_lock_pair + changed = regenerated.read_text().replace('packages = ["safe"]', 'packages = ["changed"]') + regenerated.write_text(changed) + monkeypatch.setattr(sys, "argv", ["check-nab-lock", str(committed), str(regenerated)]) + with pytest.raises(SystemExit) as error: + check_nab_lock() + assert error.value.code == 2 + monkeypatch.setattr(sys, "argv", ["check-nab-lock", "--environments-only", str(committed), str(regenerated)]) + check_nab_lock() + assert regenerated.read_text() == changed + + +def test_check_nab_lock_environments_only_rejects_target_change(nab_lock_pair: tuple[Path, Path]) -> None: + committed, regenerated = nab_lock_pair + regenerated.write_text(regenerated.read_text().replace("python_version == 3.15", "python_version == 3.14")) + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"), + "--environments-only", + str(committed), + str(regenerated), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "changed its target environments" in result.stderr + + +def test_check_nab_lock_reports_invalid_encoding(nab_lock_pair: tuple[Path, Path]) -> None: + committed, regenerated = nab_lock_pair + committed.write_bytes(b"\xff") + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"), + str(committed), + str(regenerated), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "utf-8" in result.stderr + + def test_check_nab_lock_rejects_package_change(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py" committed = tmp_path / "committed.toml" From 06798c8316a0d569283ff911d7b51cd8997dcca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:11:52 -0700 Subject: [PATCH 17/56] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(lock):=20sh?= =?UTF-8?q?are=20package=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/check_lock_groups.py | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 448fa6d..be28b47 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -6,6 +6,7 @@ import json import tomllib from collections import defaultdict +from collections.abc import Iterator from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime, timedelta from pathlib import Path @@ -13,12 +14,10 @@ from urllib.request import Request, urlopen from nab_cutoffs import reviewed_cutoff, validate_indexes -from packaging.pylock import Pylock, PylockValidationError +from packaging.pylock import Package, Pylock, PylockValidationError from packaging.utils import canonicalize_name, parse_sdist_filename, parse_wheel_filename from packaging.version import InvalidVersion, Version -_COOLDOWN = timedelta(days=7) - def main() -> None: parser = argparse.ArgumentParser(description=__doc__) @@ -65,15 +64,8 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = created = lock.tool.get("nab", {}).get("created-at") if lock.tool else None if not isinstance(created, datetime) or created.tzinfo is None or created > datetime.now(tz=UTC): raise ValueError("lock has no valid creation time or was created in the future") - threshold = created - _COOLDOWN - for package in lock.packages: - if package.marker is not None and not any( - package.marker.evaluate({"dependency_groups": selection}, context="lock_file") - for selection in (groups, {"build"}) - ): - continue - if local_tool_source and package.name == "release-creator" and package.index is None: - continue + threshold = created - timedelta(days=7) + for package in _selected_packages(lock, groups, local_tool_source=local_tool_source): if package.index is None: raise ValueError(f"release lock has an uncooled non-index package: {package.name}") if package.index != "https://pypi.org/simple/": @@ -100,14 +92,7 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = def _verify_pypi(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> None: expected: dict[str, list[tuple[str, str | None, str, datetime | None]]] = defaultdict(list) - for package in lock.packages: - if package.marker is not None and not any( - package.marker.evaluate({"dependency_groups": selection}, context="lock_file") - for selection in (groups, {"build"}) - ): - continue - if local_tool_source and package.name == "release-creator" and package.index is None: - continue + for package in _selected_packages(lock, groups, local_tool_source=local_tool_source): for artifact in ([package.sdist] if package.sdist is not None else []) + list(package.wheels or ()): expected[canonicalize_name(package.name)].append( ( @@ -124,6 +109,18 @@ def _verify_pypi(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> raise ValueError(f"release lock differs from PyPI metadata: {name}: {filename}") +def _selected_packages(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> Iterator[Package]: + for package in lock.packages: + if package.marker is not None and not any( + package.marker.evaluate({"dependency_groups": selection}, context="lock_file") + for selection in (groups, {"build"}) + ): + continue + if local_tool_source and package.name == "release-creator" and package.index is None: + continue + yield package + + def _pypi_files(name: str) -> tuple[str, dict[str, tuple[str | None, str | None, datetime | None]]]: request = Request( f"https://pypi.org/simple/{name}/", From dd0c9158585e9b2910a6627f6fe7e3eb90d4760d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:11:58 -0700 Subject: [PATCH 18/56] =?UTF-8?q?=F0=9F=A7=AA=20test:=20cover=20release=20?= =?UTF-8?q?tool=20edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/export_build_constraints/test_export.py | 120 ++++++++++++++++++ .../test_expose_commands.py | 30 +++++ tests/refresh_release_lock/test_cutoffs.py | 103 +++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 tests/install_locked_tools/test_expose_commands.py diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index 7eabd9f..72106d8 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -129,6 +129,126 @@ def test_export_build_constraints_selects_explicit_targets(tmp_path: Path, platf assert output.read_text() == f"example==1.0 --hash=sha256:{'0' * 64}\n" +@pytest.mark.parametrize( + ("arguments", "message"), + [ + pytest.param(["{output}", "{lock}", "{lock}", "{lock}"], "at most one shared", id="three-locks"), + pytest.param( + ["--shared-policy", "{project}", "{output}", "{lock}"], + "shared-policy requires a shared and caller lock", + id="policy-without-caller", + ), + pytest.param( + ["--target-platform", "unknown", "--targets", "3.15.0:cpython", "{output}", "{lock}"], + "unknown target platform", + id="unknown-platform", + ), + pytest.param( + ["--target-platform", "linux_x86_64", "--targets", "3.15:cpython", "{output}", "{lock}"], + "invalid target", + id="incomplete-python-version", + ), + pytest.param( + ["--target-platform", "linux_x86_64", "--targets", " , ", "{output}", "{lock}"], + "at least one target", + id="empty-targets", + ), + ], +) +def test_export_build_constraints_rejects_invalid_arguments(tmp_path: Path, arguments: list[str], message: str) -> None: + source = Path(__file__).resolve().parents[2] + lock = tmp_path / "pylock.build.toml" + write_matrix_lock(lock) + paths = {"output": str(tmp_path / "constraints.txt"), "lock": str(lock), "project": str(source / "pyproject.toml")} + result = subprocess.run( + [ + sys.executable, + str(source / "tools/build_constraints.py"), + *(argument.format_map(paths) for argument in arguments), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert message in result.stderr + + +@pytest.mark.parametrize( + ("change", "message"), + [ + pytest.param("missing-build-group", "dependency groups but no build group", id="missing-build-group"), + pytest.param("local-package", "must be an index version", id="local-package"), + pytest.param("missing-hash", "has no SHA256 hash", id="missing-hash"), + ], +) +def test_export_build_constraints_rejects_untrusted_build_packages(tmp_path: Path, change: str, message: str) -> None: + source = Path(__file__).resolve().parents[2] + lock = tmp_path / "pylock.build.toml" + write_matrix_lock(lock) + content = lock.read_text() + if change == "missing-build-group": + content = content.replace("[[packages]]", 'dependency-groups = ["release"]\n[[packages]]') + elif change == "local-package": + content = content.replace('index = "https://pypi.org/simple/"', 'directory = { path = "." }') + content = content.replace("[[packages.wheels]]", "") + content = ( + content[: content.index('name = "example-1.0-py3-none-any.whl"')] + + "[tool.nab]\ncreated-at = 2026-10-23T00:00:00Z\n" + ) + else: + content = content.replace('hashes.sha256 = "' + "0" * 64 + '"', 'hashes.sha512 = "' + "0" * 128 + '"') + lock.write_text(content) + + result = subprocess.run( + [ + sys.executable, + str(source / "tools/build_constraints.py"), + "--target-platform", + "macos_arm64", + "--targets", + "3.15.0:cpython", + str(tmp_path / "constraints.txt"), + str(lock), + ], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert message in result.stderr + + +def test_export_build_constraints_rejects_conflicting_versions_without_policy(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + shared = source / "pylock.toml" + caller = tmp_path / "pylock.toml" + document = tomlkit.parse(shared.read_text()) + package = next(package for package in document["packages"] if package["name"] == "setuptools") + package["version"] = "81.0.0" + for artifact in [package["sdist"], *package["wheels"]]: + artifact["name"] = artifact["name"].replace("84.0.0", "81.0.0") + artifact["url"] = artifact["url"].replace("84.0.0", "81.0.0") + caller.write_text(tomlkit.dumps(document)) + + result = subprocess.run( + [ + sys.executable, + str(source / "tools/build_constraints.py"), + str(tmp_path / "constraints.txt"), + str(shared), + str(caller), + ], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "conflicting build dependency versions for setuptools" in result.stderr + + def test_export_build_constraints_action_selects_secondary_lock(tmp_path: Path, action_scripts: Path) -> None: expected = write_published_matrix_lock(tmp_path / "pylock.build.toml") diff --git a/tests/install_locked_tools/test_expose_commands.py b/tests/install_locked_tools/test_expose_commands.py new file mode 100644 index 0000000..0a6318e --- /dev/null +++ b/tests/install_locked_tools/test_expose_commands.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from pytest_mock import MockerFixture + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from tools import expose_locked_commands + + +def test_expose_locked_commands_copies_windows_executable(tmp_path: Path, mocker: MockerFixture) -> None: + environment = tmp_path / "environment" + scripts = environment / "Scripts" + scripts.mkdir(parents=True) + (scripts / "release.exe").write_bytes(b"tool") + github_path = tmp_path / "github-path" + platform = mocker.patch.object(expose_locked_commands, "os") + platform.name = "nt" + platform.environ = {"RUNNER_TEMP": str(tmp_path), "GITHUB_PATH": str(github_path)} + platform.X_OK = os.X_OK + platform.access.return_value = True + mocker.patch.object(sys, "argv", ["expose_locked_commands", str(environment)]) + + expose_locked_commands.main() + + installed = Path(github_path.read_text().strip()) / "release.exe" + assert installed.read_bytes() == b"tool" + assert not installed.is_symlink() diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index 699829f..4f6820a 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib import subprocess import sys import tomllib @@ -8,6 +9,7 @@ import pytest import tomlkit +from pytest_mock import MockerFixture sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from tools.check_nab_lock import main as check_nab_lock @@ -433,6 +435,107 @@ def test_expire_cutoffs_preserves_disabled_package(cutoff_project: Path) -> None assert tomllib.loads(cutoff_project.read_text())["tool"]["nab"]["packages"]["new"]["uploaded-prior-to"] is False +@pytest.mark.parametrize( + ("value", "message"), + [ + pytest.param("true", "invalid Nab cutoff", id="boolean"), + pytest.param('"2026-01-01T00:00:00"', "has no timezone", id="naive-time"), + ], +) +def test_recent_cutoff_rejects_invalid_package_time(value: str, message: str) -> None: + result = _check_recent_cutoffs( + f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.example]\nuploaded-prior-to = {value}\n' + ) + assert result.returncode != 0 + assert message in result.stderr + + +def test_recent_cutoff_cli_rejects_missing_project(tmp_path: Path) -> None: + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/check_recent_cutoffs.py"), + str(tmp_path / "missing"), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "No such file" in result.stderr + + +def test_expire_cutoffs_removes_reviewed_rule() -> None: + project = ( + '[tool.nab]\nuploaded-prior-to = "P7D"\n' + '[[tool.nab.package-rules]]\nmatch = ["release-creator"]\n' + 'uploaded-prior-to = "2026-10-23T19:54:50Z"\n' + ) + updated = remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) + assert "package-rules" not in tomllib.loads(updated)["tool"]["nab"] + + +def test_expire_cutoffs_rejects_naive_reviewed_time() -> None: + project = "[tool.nab]\n[tool.nab.packages.nab]\nuploaded-prior-to = 2026-10-18T05:45:09\n" + with pytest.raises(ValueError, match="has no timezone"): + remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) + + +def test_expire_cutoffs_keeps_disabled_reviewed_package() -> None: + project = "[tool.nab]\n[tool.nab.packages.nab]\nuploaded-prior-to = false\n" + assert remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) == project + + +def test_expire_cutoffs_cli_writes_expired_rule( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "tools")) + module = importlib.import_module("expire_cutoffs") + project = tmp_path / "pyproject.toml" + project.write_text( + '[tool.nab]\nuploaded-prior-to = "P7D"\n' + '[[tool.nab.package-rules]]\nmatch = ["release-creator"]\n' + 'uploaded-prior-to = "2026-10-23T19:54:50Z"\n' + ) + mocker.patch.object( + module, "datetime", mocker.Mock(now=mocker.Mock(return_value=datetime(2026, 11, 1, tzinfo=UTC))) + ) + monkeypatch.setattr(sys, "argv", ["expire_cutoffs", str(project)]) + + module.main() + + assert "package-rules" not in tomllib.loads(project.read_text())["tool"]["nab"] + + +def test_expire_cutoffs_cli_rejects_missing_project(tmp_path: Path) -> None: + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/expire_cutoffs.py"), + str(tmp_path / "missing"), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "No such file" in result.stderr + + +def test_expire_cutoffs_retains_other_rule_settings() -> None: + project = ( + '[tool.nab]\nuploaded-prior-to = "P7D"\n' + '[tool.nab.packages.nab]\nuploaded-prior-to = "2026-10-18T05:45:09Z"\nindex = "pypi"\n' + '[[tool.nab.package-rules]]\nmatch = ["nab-index"]\n' + 'uploaded-prior-to = "2026-10-18T05:45:09Z"\npriority = 1\n' + ) + + updated = tomllib.loads(remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC))) + + assert updated["tool"]["nab"]["packages"]["nab"] == {"index": "pypi"} + assert updated["tool"]["nab"]["package-rules"] == [{"match": ["nab-index"], "priority": 1}] + + def _check_recent_cutoffs(project: str) -> subprocess.CompletedProcess[str]: return subprocess.run( [ From ff4f85cd7b8931cf505ac11ba626ebc2479b3e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:14:28 -0700 Subject: [PATCH 19/56] =?UTF-8?q?=F0=9F=A7=AA=20test:=20cover=20cutoff=20C?= =?UTF-8?q?LI=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/export_build_constraints/test_export.py | 31 +++++----- tests/refresh_release_lock/test_cutoffs.py | 57 +++++++++++++++---- 2 files changed, 65 insertions(+), 23 deletions(-) diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index 72106d8..f21344b 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -1,11 +1,14 @@ from __future__ import annotations +import runpy import subprocess import sys import tomllib +from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path from textwrap import dedent +from typing import cast import pytest import tomlkit @@ -155,23 +158,25 @@ def test_export_build_constraints_selects_explicit_targets(tmp_path: Path, platf ), ], ) -def test_export_build_constraints_rejects_invalid_arguments(tmp_path: Path, arguments: list[str], message: str) -> None: +def test_export_build_constraints_rejects_invalid_arguments( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + arguments: list[str], + message: str, +) -> None: source = Path(__file__).resolve().parents[2] lock = tmp_path / "pylock.build.toml" write_matrix_lock(lock) paths = {"output": str(tmp_path / "constraints.txt"), "lock": str(lock), "project": str(source / "pyproject.toml")} - result = subprocess.run( - [ - sys.executable, - str(source / "tools/build_constraints.py"), - *(argument.format_map(paths) for argument in arguments), - ], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode != 0 - assert message in result.stderr + script = source / "tools/build_constraints.py" + command = runpy.run_path(str(script), run_name="build_constraints_imported") + monkeypatch.setattr(sys, "argv", [str(script), *(argument.format_map(paths) for argument in arguments)]) + + with pytest.raises(SystemExit, match="2"): + cast("Callable[[], None]", command["main"])() + + assert message in capsys.readouterr().err @pytest.mark.parametrize( diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index 4f6820a..ed4553a 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -1,11 +1,14 @@ from __future__ import annotations import importlib +import runpy import subprocess import sys import tomllib +from collections.abc import Callable from datetime import UTC, datetime, timedelta from pathlib import Path +from typing import cast import pytest import tomlkit @@ -132,6 +135,28 @@ def test_verify_cutoff_patch_rejects_deliberate_hold_removal(cutoff_project: Pat assert "changes beyond expired Nab cutoffs" in result.stderr +def test_verify_cutoff_patch_accepts_only_unchanged_metadata( + cutoff_project: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + source = Path(__file__).resolve().parents[2] / "tools" + monkeypatch.syspath_prepend(str(source)) + script = source / "verify_cutoff_patch.py" + command = runpy.run_path(str(script), run_name="verify_cutoff_imported") + monkeypatch.chdir(cutoff_project.parent) + monkeypatch.setattr(sys, "argv", [str(script)]) + main = cast("Callable[[], None]", command["main"]) + + main() + cutoff_project.write_text(cutoff_project.read_text().replace('name = "example"', 'name = "intruder"')) + + with pytest.raises(SystemExit, match="2"): + main() + + assert "changes beyond expired Nab cutoffs" in capsys.readouterr().err + + def test_verify_cutoff_patch_rejects_other_metadata(cutoff_project: Path) -> None: source = Path(__file__).resolve().parents[2] / "tools" subprocess.run([sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], check=True) @@ -388,18 +413,30 @@ def test_recent_cutoff_rejects_custom_index() -> None: assert "custom Nab indexes" in result.stderr -def test_recent_cutoff_rejects_project_nab_toml(tmp_path: Path) -> None: - (tmp_path / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') - (tmp_path / "nab.toml").write_text("[packages.example]\nuploaded-prior-to = false\n") - result = subprocess.run( - [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/check_recent_cutoffs.py"), "pyproject.toml"], - cwd=tmp_path, - text=True, - capture_output=True, - check=False, +def test_recent_cutoff_rejects_index_settings_without_cutoff() -> None: + result = _check_recent_cutoffs( + '[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.index.pypi]\nallow-insecure-host = true\n' ) assert result.returncode != 0 - assert "project nab.toml can override" in result.stderr + assert "custom Nab indexes" in result.stderr + + +def test_recent_cutoff_rejects_project_nab_toml( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') + (tmp_path / "nab.toml").write_text("[packages.example]\nuploaded-prior-to = false\n") + source = Path(__file__).resolve().parents[2] / "tools" + monkeypatch.syspath_prepend(str(source)) + script = source / "check_recent_cutoffs.py" + command = runpy.run_path(str(script), run_name="recent_cutoffs_imported") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", [str(script), "pyproject.toml"]) + + with pytest.raises(SystemExit, match="2"): + cast("Callable[[], None]", command["main"])() + + assert "project nab.toml can override" in capsys.readouterr().err def test_verify_cutoff_patch_reports_invalid_boolean(cutoff_project: Path) -> None: From 1a97315c3ca10c3004c382346a759a1f392dc05c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:16:39 -0700 Subject: [PATCH 20/56] =?UTF-8?q?=F0=9F=A7=AA=20test:=20require=20full=20t?= =?UTF-8?q?ool=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 177de56..5562509 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ patch = [ "subprocess" ] source_dirs = [ "tools" ] [tool.coverage.report] -fail_under = 85 +fail_under = 100 show_missing = true [tool.ruff.lint.per-file-ignores] From d69c7cec33c5bffd82a456bbe0d5fbd3c060a5da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:30:49 -0700 Subject: [PATCH 21/56] =?UTF-8?q?=F0=9F=94=92=20fix:=20check=20base=20lock?= =?UTF-8?q?=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+base-build-selection.security.md | 2 ++ tests/install_locked_tools/test_metadata.py | 17 +++++++++++++++++ tools/check_lock_groups.py | 6 ++++-- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 changelog.d/+base-build-selection.security.md diff --git a/changelog.d/+base-build-selection.security.md b/changelog.d/+base-build-selection.security.md new file mode 100644 index 0000000..e0527bf --- /dev/null +++ b/changelog.d/+base-build-selection.security.md @@ -0,0 +1,2 @@ +Check packages selected from a lock without dependency groups under the same marker selection used to export build +constraints. diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index 094adae..28c8528 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -93,6 +93,23 @@ def test_lock_metadata_requires_group( assert "at least one dependency group is required" in capsys.readouterr().err +def test_lock_metadata_checks_base_selection_without_groups( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + lock = lock_case[0] + lock.write_text( + lock.read_text() + .replace('dependency-groups = ["release-check"]\n', "") + .replace('"release-check" in dependency_groups', '"build" not in dependency_groups') + .replace("files.pythonhosted.org", "example.org") + ) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--build-constraints") + + assert "non-PyPI artifact" in capsys.readouterr().err + + @pytest.mark.parametrize( "change", [pytest.param("api-version", id="api-version"), pytest.param("project-name", id="project-name")] ) diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index be28b47..42abb65 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -44,6 +44,8 @@ def main() -> None: args.build_constraints and missing == {"build"} ): parser.error(f"missing lock dependency groups: {', '.join(sorted(missing))}") + if args.build_constraints and not lock.dependency_groups: + groups.clear() _check_cooling(lock, groups, local_tool_source=args.local_tool_source) if args.verify_pypi: _verify_pypi(lock, groups, local_tool_source=args.local_tool_source) @@ -110,10 +112,10 @@ def _verify_pypi(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> def _selected_packages(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> Iterator[Package]: + selections = (groups, {"build"}) if "build" in (lock.dependency_groups or ()) else (groups,) for package in lock.packages: if package.marker is not None and not any( - package.marker.evaluate({"dependency_groups": selection}, context="lock_file") - for selection in (groups, {"build"}) + package.marker.evaluate({"dependency_groups": selection}, context="lock_file") for selection in selections ): continue if local_tool_source and package.name == "release-creator" and package.index is None: From f44543d6ac3103dfc028791c07a6101f127262d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:30:49 -0700 Subject: [PATCH 22/56] =?UTF-8?q?=F0=9F=94=92=20fix:=20reject=20bot=20hook?= =?UTF-8?q?=20config=20edits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- changelog.d/+hook-config-cooldown.security.md | 2 + check-hook-update-cooldown/action.yml | 20 ++++++++ .../test_cooldown.py | 46 ++++++++++++++++--- 4 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 changelog.d/+hook-config-cooldown.security.md diff --git a/README.md b/README.md index 96517e2..933ca16 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,8 @@ with `actions: read`, `contents: read`, and `pull-requests: read`, then include its PR number and head SHA from the event, compares them with the live PR, and requires each proposed revision to have appeared in a GitHub PR workflow run at least `minimum-days` earlier. The default is seven days. A force push does not reset an unchanged revision's age or give a new revision the old one's age. Human PRs pass without installing hook -tools. +tools. Bot PRs may change hook revisions only. The repository's `automation/update-release-tools` branch is exempt +because its separate generator freezes the hook revisions and Python dependencies before opening the PR. ```yaml hook-cooldown: diff --git a/changelog.d/+hook-config-cooldown.security.md b/changelog.d/+hook-config-cooldown.security.md new file mode 100644 index 0000000..b37a721 --- /dev/null +++ b/changelog.d/+hook-config-cooldown.security.md @@ -0,0 +1,2 @@ +Reject bot hook updates that change configuration beyond revisions. Keep the trusted weekly updater on its reviewed +branch. diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index 0f698f1..9625e9f 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -31,6 +31,12 @@ runs: .head.sha == $head and (.user.login | type == "string") ' "$RUNNER_TEMP/hook-update-pr.json" >/dev/null + if jq -e --arg repository "$REPOSITORY" ' + .user.type == "Bot" and .head.repo.full_name == $repository and + .head.ref == "automation/update-release-tools" + ' "$RUNNER_TEMP/hook-update-pr.json" >/dev/null; then + exit 0 + fi if [[ $(jq -r .user.type "$RUNNER_TEMP/hook-update-pr.json") == Bot ]]; then gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' \ >"$RUNNER_TEMP/hook-update-files" @@ -81,6 +87,20 @@ runs: base64 --decode >"$RUNNER_TEMP/proposed-hooks.yaml" cp "$RUNNER_TEMP/base-hooks.yaml" "$RUNNER_TEMP/eligible-hooks.yaml" git init -q "$RUNNER_TEMP/hook-cooldown" + - name: Reject non-revision hook changes + if: steps.identity.outputs.bot == 'true' + shell: bash + run: | + set -euo pipefail + for config in base proposed; do + uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -c ' + .repos |= map(if has("rev") then .rev = null else . end) + ' "$RUNNER_TEMP/$config-hooks.yaml" | jq -S . >"$RUNNER_TEMP/$config-hook-content.json" + done + if ! cmp -s "$RUNNER_TEMP/base-hook-content.json" "$RUNNER_TEMP/proposed-hook-content.json"; then + echo '::error::hook update changes fields beyond revisions' >&2 + exit 1 + fi - name: Select eligible hook revisions if: steps.identity.outputs.bot == 'true' shell: bash diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index 63ff3b5..b702b49 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -99,6 +99,33 @@ def test_cooldown_skips_bot_without_hook_changes( ] +def test_cooldown_trusts_its_generated_update_branch( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + expect_pull(server, author="release-app[bot]", branch="automation/update-release-tools") + + result = run_cooldown(action_scripts, tmp_path, environment) + + assert result.returncode == 0, result.stderr + assert [request.path for request, _ in server.log] == ["/api/v3/repos/tox-dev/fixture/pulls/7"] + + +def test_cooldown_rejects_hook_configuration_change( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + repository = hook_repository(tmp_path) + expect_pull(server, author="pre-commit-ci[bot]") + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v1", hook=" - id: sample\n entry: untrusted\n") + + result = run_cooldown(action_scripts, tmp_path, environment) + + assert result.returncode != 0 + assert "changes fields beyond revisions" in result.stderr + + def test_cooldown_rejects_changed_head( tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: @@ -317,21 +344,22 @@ def expect_pull( observed_days: int | None = 10, updated_days: int = 0, hook_update: bool = True, + branch: str = "bot-update", ) -> None: server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7", method="GET").respond_with_json( { "state": "open", "base": {"repo": {"full_name": "tox-dev/fixture"}, "sha": "b" * 40}, - "head": {"sha": head, "ref": "bot-update"}, + "head": {"sha": head, "ref": branch, "repo": {"full_name": "tox-dev/fixture"}}, "user": {"login": author, "type": "Bot" if author.endswith("[bot]") else "User"}, "updated_at": (datetime.now(UTC) - timedelta(days=updated_days)).strftime("%Y-%m-%dT%H:%M:%SZ"), } ) - if author.endswith("[bot]"): + if author.endswith("[bot]") and branch != "automation/update-release-tools": server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json( [{"filename": ".pre-commit-config.yaml" if hook_update else "pyproject.toml"}] ) - if author.endswith("[bot]") and hook_update: + if author.endswith("[bot]") and hook_update and branch != "automation/update-release-tools": server.expect_request( "/api/v3/repos/tox-dev/fixture/actions/runs", method="GET", @@ -356,7 +384,14 @@ def expect_pull( ) -def expect_config(server: HTTPServer, sha: str, repository: Path, revision: str | tuple[str, ...]) -> None: +def expect_config( + server: HTTPServer, + sha: str, + repository: Path, + revision: str | tuple[str, ...], + *, + hook: str = " - id: sample\n", +) -> None: revisions = (revision,) if isinstance(revision, str) else revision server.expect_request( "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", method="GET", query_string=f"ref={sha}" @@ -366,8 +401,7 @@ def expect_config(server: HTTPServer, sha: str, repository: Path, revision: str ( "repos:\n" + "".join( - f" - repo: {repository.as_uri()}\n rev: {item}\n hooks:\n - id: sample\n" - for item in revisions + f" - repo: {repository.as_uri()}\n rev: {item}\n hooks:\n{hook}" for item in revisions ) ).encode() ).decode() From 106ccfc3c7997c0fe531ed5dbd44ddc84efaee58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:30:49 -0700 Subject: [PATCH 23/56] =?UTF-8?q?=F0=9F=94=A7=20fix:=20download=20release?= =?UTF-8?q?=20artifacts=20by=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/validate-python-release.yaml | 14 ++++++++++---- changelog.d/+artifact-ids.change.md | 1 + 2 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 changelog.d/+artifact-ids.change.md diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index f4c3b6e..b4cd486 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -36,6 +36,8 @@ jobs: "$RUNNER_TEMP/release-tools/bin/prek" run --all-files constraints: runs-on: ubuntu-26.04 + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} permissions: contents: read steps: @@ -54,7 +56,8 @@ jobs: env: SOURCE: ${{ steps.constraints.outputs.build-constraints }} run: cp "$SOURCE" "$RUNNER_TEMP/build-constraints.txt" - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + - id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ inputs.artifact-prefix }}build-constraints path: ${{ runner.temp }}/build-constraints.txt @@ -62,6 +65,8 @@ jobs: build: needs: constraints runs-on: ubuntu-26.04 + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} permissions: contents: read steps: @@ -86,7 +91,7 @@ jobs: enable-cache: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: ${{ inputs.artifact-prefix }}build-constraints + artifact-ids: ${{ needs.constraints.outputs.artifact-id }} path: ${{ runner.temp }} - name: Require empty distribution directory run: test ! -d dist || test -z "$(find dist -mindepth 1 -maxdepth 1 -print -quit)" @@ -116,7 +121,8 @@ jobs: echo "::error::wheel and sdist versions must match $TAG" >&2 exit 1 fi - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + - id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ inputs.artifact-prefix }}python-package-distributions path: dist/* @@ -138,7 +144,7 @@ jobs: enable-cache: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: ${{ inputs.artifact-prefix }}python-package-distributions + artifact-ids: ${{ needs.build.outputs.artifact-id }} path: dist - name: Install distribution validators run: | diff --git a/changelog.d/+artifact-ids.change.md b/changelog.d/+artifact-ids.change.md new file mode 100644 index 0000000..1da9314 --- /dev/null +++ b/changelog.d/+artifact-ids.change.md @@ -0,0 +1 @@ +Download tagged release build artifacts by the IDs produced by their upload jobs. From 41b67222e940ba0bd6409ed754bd1b17a34dfe4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:36:09 -0700 Subject: [PATCH 24/56] fix: expose distribution artifact ID --- .github/workflows/validate-python-release.yaml | 4 ++++ README.md | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index b4cd486..349963d 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -1,6 +1,10 @@ name: Validate Python release on: workflow_call: + outputs: + distributions-artifact-id: + description: Upload ID for the validated wheel and sdist artifact + value: ${{ jobs.build.outputs.artifact-id }} inputs: source: description: Package source directory diff --git a/README.md b/README.md index 933ca16..b84b7a2 100644 --- a/README.md +++ b/README.md @@ -246,8 +246,10 @@ The caller needs `release-check` for prek and a `build` group for backend constr Publish only after the reusable workflow succeeds. Download the distribution with pinned `download-artifact` [v8.0.2](https://github.com/actions/download-artifact/blob/9000827ccba6bdab643e8b6fd33ac0654aef8333/action.yml#L37-L45), -which checks the artifact digest. Render GitHub notes in the caller's tag workflow after fetching GitHub's contributor -metadata; this reusable workflow does not render notes. Keep PyPI OIDC in the caller's `release` environment job. +using `artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }}`. The download checks the artifact digest and +selects the wheel and sdist from that build run. Render GitHub notes in the caller's tag workflow after fetching +GitHub's contributor metadata; this reusable workflow does not render notes. Keep PyPI OIDC in the caller's `release` +environment job. ## Build a distribution for a pull request From 31070d165c5d81c1cdfee3a1d4ebf7c2ea90fbc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:38:40 -0700 Subject: [PATCH 25/56] perf: trust the pinned shared lock --- tools/check_lock_groups.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 42abb65..aba4007 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -52,7 +52,6 @@ def main() -> None: if args.shared_lock: with args.shared_lock.open("rb") as stream: shared = Pylock.from_dict(tomllib.load(stream)) - _check_cooling(shared, groups) required = _selected_release_creator(shared, groups) if required is not None and not args.local_tool_source: installed = _selected_release_creator(lock, groups) From f594ba73dc57fac3f17ca971b8caaa0628b9dd61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 15:41:50 -0700 Subject: [PATCH 26/56] test: derive lock fixtures from the lock --- tests/install_locked_tools/test_install.py | 33 +++++++++++++--------- tests/verify_pypi_files/test_verify.py | 9 +++++- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 1bf18d0..d7caaa2 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -245,25 +245,29 @@ def test_local_tool_source_only_accepts_release_creator(tmp_path: Path, package_ @pytest.mark.parametrize( - ("replace", "message"), + ("kind", "message"), [ pytest.param( - ("created-at = 2026-10-23", "created-at = 2099-10-23"), + "future-creation", "created in the future", id="future-creation", ), pytest.param( - ("upload-time = 2026-", "upload-time = 2099-"), + "recent-artifact", "uncooled artifact", id="recent-artifact", ), ], ) -def test_install_locked_tools_rejects_uncooled_lock(tmp_path: Path, replace: tuple[str, str], message: str) -> None: +def test_install_locked_tools_rejects_uncooled_lock(tmp_path: Path, kind: str, message: str) -> None: source = Path(__file__).resolve().parents[2] - lock = (source / "pylock.toml").read_text() - assert replace[0] in lock - (tmp_path / "pylock.toml").write_text(lock.replace(*replace)) + document = tomlkit.parse((source / "pylock.toml").read_text()) + if kind == "future-creation": + document["tool"]["nab"]["created-at"] = datetime(2099, 10, 23, tzinfo=UTC) + else: + package = next(package for package in document["packages"] if package["name"] == "cython") + package["wheels"][0]["upload-time"] = datetime(2099, 10, 23, tzinfo=UTC) + (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) result = subprocess.run( ["python", str(source / "tools/check_lock_groups.py"), str(tmp_path / "pylock.toml"), "release-check"], @@ -390,7 +394,7 @@ def test_install_locked_tools_rejects_artifact_version_mismatch(tmp_path: Path) document = tomlkit.parse((source / "pylock.toml").read_text()) package = next(package for package in document["packages"] if package["name"] == "setuptools") artifact = package["wheels"][0] - artifact["url"] = artifact["url"].replace("84.0.0", "0.0.0") + artifact["url"] = artifact["url"].replace(str(package["version"]), "0.0.0") lock = tmp_path / "pylock.toml" lock.write_text(tomlkit.dumps(document)) @@ -432,10 +436,11 @@ def test_install_locked_tools_rejects_caller_below_shared_floor(tmp_path: Path, source = Path(__file__).resolve().parents[2] document = tomlkit.parse((source / "pylock.toml").read_text()) package = next(package for package in document["packages"] if package["name"] == "release-creator") + current_version = str(package["version"]) package["version"] = version for artifact in [package["sdist"], *package["wheels"]]: - artifact["name"] = artifact["name"].replace("0.6.7", version) - artifact["url"] = artifact["url"].replace("0.6.7", version) + artifact["name"] = artifact["name"].replace(current_version, version) + artifact["url"] = artifact["url"].replace(current_version, version) artifact["upload-time"] = datetime(2026, 10, 15, tzinfo=UTC) lock = tmp_path / "pylock.toml" lock.write_text(tomlkit.dumps(document)) @@ -455,15 +460,15 @@ def test_install_locked_tools_rejects_caller_below_shared_floor(tmp_path: Path, ) assert result.returncode != 0 - assert "relock: release-creator 0.6.7 required" in result.stderr + assert f"relock: release-creator {current_version} required" in result.stderr def test_install_locked_tools_constraints_only_checks_lock_age(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) - (tmp_path / "pylock.toml").write_text( - (source / "pylock.toml").read_text().replace("created-at = 2026-10-23", "created-at = 2099-10-23", 1) - ) + document = tomlkit.parse((source / "pylock.toml").read_text()) + document["tool"]["nab"]["created-at"] = datetime(2099, 10, 23, tzinfo=UTC) + (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) result = action(tmp_path, tmp_path, action_scripts, "install-locked-tools", version="") assert result.returncode != 0 assert "created in the future" in result.stderr diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py index 290cfc7..c59f31d 100644 --- a/tests/verify_pypi_files/test_verify.py +++ b/tests/verify_pypi_files/test_verify.py @@ -137,7 +137,14 @@ def test_verify_files_ignores_other_release_wheel(distribution: Path, mocker: Mo ] -@pytest.mark.parametrize("status", [pytest.param(400, id="client-error"), pytest.param(503, id="server-timeout")]) +@pytest.mark.parametrize( + "status", + [ + pytest.param(400, id="client-error"), + pytest.param(403, id="forbidden"), + pytest.param(503, id="server-timeout"), + ], +) def test_verify_files_propagates_http_error(distribution: Path, mocker: MockerFixture, status: int) -> None: mocker.patch.object( _MODULE, "urlopen", side_effect=HTTPError("https://pypi.org/simple/fixture/", status, "error", Message(), None) From 42a8cf444dc68736799ca69f7d3a74d3a12ea84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 16:05:05 -0700 Subject: [PATCH 27/56] refactor: trim unused action inputs --- README.md | 4 +- build-python-distribution/action.yml | 9 +--- changelog.d/+action-interface.breaking.md | 3 ++ recheck-hook-update-cooldown/action.yml | 13 +---- reconcile-pypi-artifacts/action.yml | 8 --- tests/build_python_distribution/test_build.py | 5 -- .../test_workflow.py | 21 -------- .../test_recheck.py | 3 +- .../test_reconcile.py | 10 ++-- tests/upgrade_release_tools/test_upgrade.py | 54 ------------------- 10 files changed, 16 insertions(+), 114 deletions(-) create mode 100644 changelog.d/+action-interface.breaking.md diff --git a/README.md b/README.md index b84b7a2..1c7b991 100644 --- a/README.md +++ b/README.md @@ -184,8 +184,8 @@ Run the PR workflow on `opened`, `reopened`, and `synchronize`; the cooldown che push. The Action has no output or write side effect. Schedule `recheck-hook-update-cooldown` daily with `actions: write`, `contents: read`, and `pull-requests: read`. It reruns eligible failed cooldown jobs at the current head. GitHub accepts reruns for 30 days. A failed rerun is retried on the next schedule while eligible. The -[caller here](.github/workflows/recheck-hook-cooldown.yaml) shows the trigger and permissions. Set `workflow` or -`job-name` only when the caller uses different names. +[caller here](.github/workflows/recheck-hook-cooldown.yaml) shows the trigger and permissions. The Action reruns the +`hook-cooldown` job in `check.yaml`. ## Start with a caller diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml index a1a9492..bf28edb 100644 --- a/build-python-distribution/action.yml +++ b/build-python-distribution/action.yml @@ -1,9 +1,5 @@ name: Build Python distribution description: Build a wheel and sdist for a keyless PR check -inputs: - source: - description: Distribution source directory - default: . runs: using: composite steps: @@ -23,14 +19,13 @@ runs: - name: Build wheel and sdist shell: bash env: - SOURCE: ${{ inputs.source }} BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE build_python=$(uv python find "$RUNNER_TEMP/build-python") export SOURCE_DATE_EPOCH - SOURCE_DATE_EPOCH=$(git -C "$SOURCE" log -1 --format=%ct) + SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) uv build --python "$build_python" \ --python-preference only-managed --sdist --wheel \ - --build-constraints "$BUILD_CONSTRAINTS" --require-build-hashes "$SOURCE" --out-dir dist + --build-constraints "$BUILD_CONSTRAINTS" --require-build-hashes . --out-dir dist diff --git a/changelog.d/+action-interface.breaking.md b/changelog.d/+action-interface.breaking.md new file mode 100644 index 0000000..79dd9f3 --- /dev/null +++ b/changelog.d/+action-interface.breaking.md @@ -0,0 +1,3 @@ +Remove the unused `build-python-distribution.source`, `recheck-hook-update-cooldown.workflow`, and +`recheck-hook-update-cooldown.job-name` inputs. The build Action uses the repository root; the cooldown reruns +`hook-cooldown` in `check.yaml`. diff --git a/recheck-hook-update-cooldown/action.yml b/recheck-hook-update-cooldown/action.yml index ce1a2f0..e1b8d4d 100644 --- a/recheck-hook-update-cooldown/action.yml +++ b/recheck-hook-update-cooldown/action.yml @@ -1,12 +1,6 @@ name: Recheck cooled hook updates description: Recheck failed bot hook-update cooldown jobs each day inputs: - workflow: - description: Workflow file containing the required hook-cooldown job - default: check.yaml - job-name: - description: Name of the required cooldown job to rerun - default: hook-cooldown minimum-days: description: Minimum age of an eligible hook revision default: "7" @@ -29,8 +23,6 @@ runs: shell: bash env: GH_TOKEN: ${{ github.token }} - WORKFLOW: ${{ inputs.workflow }} - JOB_NAME: ${{ inputs.job-name }} MINIMUM_DAYS: ${{ inputs.minimum-days }} run: | set -euo pipefail @@ -50,7 +42,7 @@ runs: fi head=$(jq -r .head <<<"$pull") branch=$(jq -r .branch <<<"$pull") - runs=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/workflows/$WORKFLOW/runs" \ + runs=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/workflows/check.yaml/runs" \ -f event=pull_request -f head_sha="$head" -f per_page=100 | jq -sc --arg repository "$GITHUB_REPOSITORY" --arg branch "$branch" --arg head "$head" \ --argjson number "$number" ' @@ -73,8 +65,7 @@ runs: fi job=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/runs/$(jq -r .id <<<"$run")/jobs" \ -f filter=latest -f per_page=100 | - jq -sc --arg name "$JOB_NAME" \ - '[.[].jobs[] | select(.name == $name and .conclusion == "failure")] | first // empty') + jq -sc '[.[].jobs[] | select(.name == "hook-cooldown" and .conclusion == "failure")] | first // empty') if [[ -n "$job" ]]; then if ! gh run rerun "$(jq -r .id <<<"$run")" --job "$(jq -r .id <<<"$job")" \ --repo "$GITHUB_REPOSITORY"; then diff --git a/reconcile-pypi-artifacts/action.yml b/reconcile-pypi-artifacts/action.yml index b42272e..48ef3a2 100644 --- a/reconcile-pypi-artifacts/action.yml +++ b/reconcile-pypi-artifacts/action.yml @@ -13,14 +13,6 @@ inputs: runs: using: composite steps: - - name: Check artifact inputs - shell: bash - env: - PROJECT: ${{ inputs.project }} - VERSION: ${{ inputs.version }} - DIST: ${{ inputs.dist }} - run: | - [[ -n "$PROJECT" && -n "$VERSION" && -d "$DIST" ]] || exit 1 - name: Install locked provenance verifier uses: $/install-locked-tools with: diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py index 5f1e400..764321f 100644 --- a/tests/build_python_distribution/test_build.py +++ b/tests/build_python_distribution/test_build.py @@ -17,7 +17,6 @@ def test_build_python_distribution_creates_wheel_and_sdist( action_scripts, "build-python-distribution", version="0.2.0", - SOURCE=".", RUNNER_TEMP=str(tmp_path), ) assert result.returncode == 0, result.stderr @@ -41,7 +40,6 @@ def test_build_python_distribution_repeats_exact_bytes( action_scripts, "build-python-distribution", version="0.2.0", - SOURCE=".", RUNNER_TEMP=str(temporary), ) assert result.returncode == 0, result.stderr @@ -65,7 +63,6 @@ def test_build_python_distribution_rejects_existing_artifact( action_scripts, "build-python-distribution", version="0.2.0", - SOURCE=".", RUNNER_TEMP=str(tmp_path), ) assert result.returncode != 0 @@ -89,7 +86,6 @@ def test_build_python_distribution_rejects_backend_hash_mismatch( action_scripts, "build-python-distribution", version="0.2.0", - SOURCE=".", RUNNER_TEMP=str(tmp_path), UV_CACHE_DIR=str(tmp_path / "uv-cache"), ) @@ -110,7 +106,6 @@ def test_build_python_distribution_does_not_run_hooks( action_scripts, "build-python-distribution", version="0.2.0", - SOURCE=".", RUNNER_TEMP=str(tmp_path), ) assert result.returncode == 0, result.stderr diff --git a/tests/check_hook_update_cooldown/test_workflow.py b/tests/check_hook_update_cooldown/test_workflow.py index 983b7fe..8532de0 100644 --- a/tests/check_hook_update_cooldown/test_workflow.py +++ b/tests/check_hook_update_cooldown/test_workflow.py @@ -9,27 +9,6 @@ import yaml -def test_cooldown_job_is_read_only_and_required() -> None: - workflow = yaml.load( - (Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text(), - Loader=yaml.BaseLoader, - ) - job = workflow["jobs"]["hook-cooldown"] - assert job["if"] == "github.event_name == 'pull_request'" - assert job["permissions"] == {"actions": "read", "contents": "read", "pull-requests": "read"} - assert job["steps"][0] == {"uses": "$/check-hook-update-cooldown"} - assert "hook-cooldown" in workflow["jobs"]["gate"]["needs"] - - -def test_cooldown_installs_only_required_hook_tools() -> None: - action = yaml.safe_load((Path(__file__).resolve().parents[2] / "check-hook-update-cooldown/action.yml").read_text()) - assert next(step for step in action["runs"]["steps"] if step.get("uses") == "$/install-locked-tools") == { - "uses": "$/install-locked-tools", - "if": "steps.identity.outputs.bot == 'true'", - "with": {"groups": "release-hook-check", "lock-source": "shared", "venv": "${{ runner.temp }}/hook-tools"}, - } - - @pytest.mark.parametrize( ("event_name", "cooldown_result", "accepted"), [ diff --git a/tests/recheck_hook_update_cooldown/test_recheck.py b/tests/recheck_hook_update_cooldown/test_recheck.py index 62f8920..a87dab7 100644 --- a/tests/recheck_hook_update_cooldown/test_recheck.py +++ b/tests/recheck_hook_update_cooldown/test_recheck.py @@ -25,7 +25,7 @@ def test_recheck_schedule_has_only_required_permissions() -> None: ("observed_age", "job_age", "job_conclusion", "job_name", "minimum_days", "reruns"), [ pytest.param(8, 8, "failure", "hook-cooldown", 7, 1, id="matured-failure"), - pytest.param(8, 8, "failure", "Hook update cooldown", 7, 1, id="named-job"), + pytest.param(8, 8, "failure", "Hook update cooldown", 7, 0, id="other-job"), pytest.param(6, 6, "failure", "hook-cooldown", 7, 0, id="recent-pull"), pytest.param(8, 0, "failure", "hook-cooldown", 7, 1, id="failed-rerun"), pytest.param(31, 31, "failure", "hook-cooldown", 7, 0, id="expired-run"), @@ -116,7 +116,6 @@ def test_recheck_reruns_failed_job( "recheck-hook-update-cooldown", version="", GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"], - JOB_NAME=job_name, MINIMUM_DAYS=str(minimum_days), **environment, ) diff --git a/tests/reconcile_pypi_artifacts/test_reconcile.py b/tests/reconcile_pypi_artifacts/test_reconcile.py index 9e0ae0b..1b2cc7f 100644 --- a/tests/reconcile_pypi_artifacts/test_reconcile.py +++ b/tests/reconcile_pypi_artifacts/test_reconcile.py @@ -10,11 +10,11 @@ @pytest.mark.parametrize( - ("project", "version", "distribution_exists"), + ("project", "version", "distribution_exists", "error"), [ - pytest.param("", "1.0", True, id="missing-project"), - pytest.param("fixture", "", True, id="missing-version"), - pytest.param("fixture", "1.0", False, id="missing-distribution"), + pytest.param("", "1.0", True, "name is invalid", id="missing-project"), + pytest.param("fixture", "", True, "Invalid version", id="missing-version"), + pytest.param("fixture", "1.0", False, "release files require a directory", id="missing-distribution"), ], ) def test_reconcile_rejects_invalid_inputs( @@ -23,6 +23,7 @@ def test_reconcile_rejects_invalid_inputs( project: str, version: str, distribution_exists: bool, + error: str, ) -> None: distribution = tmp_path / "dist" if distribution_exists: @@ -37,6 +38,7 @@ def test_reconcile_rejects_invalid_inputs( DIST=str(distribution), ) assert result.returncode != 0 + assert error in result.stderr @pytest.mark.parametrize( diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 91d3f20..5d13f6e 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -28,45 +28,6 @@ def publish_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: return scripts -def test_upgrade_pull_request_uses_repository_app() -> None: - workflow = yaml.load( - (Path(__file__).resolve().parents[2] / ".github/workflows/upgrade-release-tools.yaml").read_text(), - Loader=yaml.BaseLoader, - ) - publish = workflow["jobs"]["publish"] - assert workflow["on"]["workflow_call"]["inputs"]["lock-groups"]["required"] == "true" - assert workflow["jobs"]["generate-lock"]["steps"][2]["with"]["lock-groups"] == "${{ inputs.lock-groups }}" - assert publish["environment"] == "release-auth" - assert publish["permissions"] == {"contents": "read"} - token = next(step for step in publish["steps"] if step.get("id") == "token") - assert token["with"]["repositories"] == "${{ steps.repository.outputs.name }}" - assert token["with"]["permission-contents"] == "write" - assert token["with"]["permission-pull-requests"] == "write" - steps = publish["steps"] - assert next(index for index, step in enumerate(steps) if step.get("uses") == "$/verify-nab-cutoff-patch") < next( - index for index, step in enumerate(steps) if step.get("id") == "token" - ) - for step in publish["steps"]: - if step.get("name") in { - "Push dependency branch", - "Find dependency pull request", - "Update dependency pull request", - "Open dependency pull request", - }: - assert step["env"]["GH_TOKEN"] == "${{ steps.token.outputs.token }}" - - -def test_hook_refresh_uses_seven_day_cutoff_for_hooks_and_dependencies() -> None: - workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows/upgrade-release-tools.yaml").read_text() - ) - steps = workflow["jobs"]["generate-hooks"]["steps"] - revisions = next(step for step in steps if step.get("name") == "Refresh frozen hook revisions") - dependencies = next(step for step in steps if step.get("name") == "Refresh exact hook dependencies") - assert "--cooldown-days 7" in revisions["run"] - assert "--exclude-newer '7 days'" in dependencies["run"] - - @pytest.fixture def lock_repository(tmp_path: Path) -> Path: repository = tmp_path / "repository" @@ -207,21 +168,6 @@ def test_upgrade_emits_patch_for_allowed_change( assert f"diff --git a/{changed_path} b/{changed_path}" in (tmp_path / "lock.patch").read_text() -def test_upgrade_check_mode_rejects_lock_drift(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: - (lock_repository / "pylock.toml").write_text('lock-version = "1.0"\n') - run(lock_repository, "git", "add", ".") - run(lock_repository, "git", "commit", "-m", "Add lock") - (lock_repository / "pylock.toml").write_text('lock-version = "1.1"\n') - result = execute( - lock_repository, - upgrade_scripts / "refresh-release-lock-4.sh", - tmp_path, - MODE="check", - GITHUB_OUTPUT=str(tmp_path / "output"), - ) - assert result.returncode != 0 - - @pytest.mark.parametrize( ("path", "artifact", "additional_lock", "accepted"), [ From 1d463a06d0248ae797a363ed3c8d05970a1e01e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 16:06:54 -0700 Subject: [PATCH 28/56] refactor: simplify release inputs --- .github/workflows/upgrade-release-tools.yaml | 2 +- prepare-towncrier/action.yml | 11 ++++------- tests/verify_pypi_files/test_verify.py | 7 ++++--- tools/verify_pypi_files.py | 3 +-- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 88f7af7..e7785f9 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -111,7 +111,7 @@ jobs: uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek update --config .pre-commit-config.yaml --freeze --cooldown-days 7 --no-progress - name: Refresh exact hook dependencies run: >- - uv run --no-project --python "$RUNNER_TEMP/hook-tools" release-creator hooks --exclude-newer '7 days' --python-version 3.15 + uv run --no-project --python "$RUNNER_TEMP/hook-tools" release-creator hooks - id: hooks name: Write hook patch run: | diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index 34d5100..736a089 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -45,21 +45,18 @@ runs: BUMP: ${{ inputs.bump }} TAG_PREFIX: ${{ inputs.tag-prefix }} VERSION: ${{ inputs.version }} - EXPLICIT_VERSION: ${{ inputs.version }} FRAGMENTS: ${{ steps.config.outputs.fragments }} run: | set -euo pipefail - if [[ -z "$VERSION" ]]; then + if [[ -n "$VERSION" ]]; then + git tag --list | release-creator tags --next auto --directory "$FRAGMENTS" >/dev/null + git tag --list | release-creator tags "$VERSION" --require-newer + else args=(--next "$BUMP" --directory "$FRAGMENTS") if [[ -n "$TAG_PREFIX" ]]; then args+=(--prefix "$TAG_PREFIX") fi VERSION=$(git tag --list | release-creator tags "${args[@]}") - else - git tag --list | release-creator tags --next auto --directory "$FRAGMENTS" >/dev/null - fi - if [[ -n "$EXPLICIT_VERSION" ]]; then - git tag --list | release-creator tags "$VERSION" --require-newer fi printf 'version=%s\n' "$VERSION" >>"$GITHUB_OUTPUT" - name: Link fragments and authors diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py index c59f31d..d1f6d71 100644 --- a/tests/verify_pypi_files/test_verify.py +++ b/tests/verify_pypi_files/test_verify.py @@ -68,7 +68,7 @@ def test_main_prints_distribution_paths( distribution: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] ) -> None: _serve(mocker, distribution) - mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution), "--wait-seconds", "0"]) + mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution)]) cast("Callable[[], None]", _MODULE.main)() @@ -94,7 +94,7 @@ def test_script_entrypoint_prints_distribution_paths( distribution: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] ) -> None: mocker.patch("urllib.request.urlopen", return_value=_body(_listing(distribution))) - mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution), "--wait-seconds", "0"]) + mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution)]) runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/verify_pypi_files.py"), run_name="__main__") @@ -369,7 +369,8 @@ def test_main_reports_exhausted_incomplete_read( distribution: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] ) -> None: mocker.patch.object(_MODULE, "urlopen", side_effect=IncompleteRead(b"partial", 10)) - mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution), "--wait-seconds", "0"]) + mocker.patch.object(_MODULE.time, "monotonic", side_effect=[0, 121]) + mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution)]) with pytest.raises(SystemExit) as raised: cast("Callable[[], None]", _MODULE.main)() diff --git a/tools/verify_pypi_files.py b/tools/verify_pypi_files.py index db34f12..e7e351c 100644 --- a/tools/verify_pypi_files.py +++ b/tools/verify_pypi_files.py @@ -34,10 +34,9 @@ def main() -> None: parser.add_argument("project") parser.add_argument("version") parser.add_argument("dist", type=Path) - parser.add_argument("--wait-seconds", type=int, default=120) args = parser.parse_args() try: - for path in verify_files(args.project, args.version, args.dist, args.wait_seconds): + for path in verify_files(args.project, args.version, args.dist, 120): print(path) except (OSError, TypeError, ValueError, IncompleteRead) as error: parser.error(str(error)) From 1da538a833c5de219fca15efd301ceb878af5bba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 16:08:56 -0700 Subject: [PATCH 29/56] fix: retry PyPI lock metadata reads --- changelog.d/+pypi-metadata-retry.bugfix.md | 1 + tests/install_locked_tools/test_metadata.py | 32 +++++++++++++++++++++ tools/check_lock_groups.py | 21 ++++++++++++-- 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 changelog.d/+pypi-metadata-retry.bugfix.md diff --git a/changelog.d/+pypi-metadata-retry.bugfix.md b/changelog.d/+pypi-metadata-retry.bugfix.md new file mode 100644 index 0000000..0eb83eb --- /dev/null +++ b/changelog.d/+pypi-metadata-retry.bugfix.md @@ -0,0 +1 @@ +Retry transient PyPI metadata connection errors before rejecting a release lock. diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index 28c8528..326aa0d 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -7,6 +7,7 @@ from pathlib import Path from textwrap import dedent from typing import NotRequired, TypedDict +from urllib.error import URLError import pytest from pytest_mock import MockerFixture @@ -79,6 +80,37 @@ def test_lock_metadata_matches_pypi( assert request.call_args.args[0].full_url == "https://pypi.org/simple/six/" +def test_lock_metadata_retries_transient_network_error( + lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + request = mocker.patch( + "urllib.request.urlopen", + side_effect=[URLError("handshake timed out"), BytesIO(json.dumps(pypi_listing).encode())], + ) + sleep = mocker.patch("time.sleep") + + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert request.call_count == 2 + sleep.assert_called_once_with(1) + + +def test_lock_metadata_stops_after_three_network_errors( + lock_case: tuple[Path, Path], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + request = mocker.patch("urllib.request.urlopen", side_effect=URLError("handshake timed out")) + mocker.patch("time.sleep") + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert request.call_count == 3 + assert "handshake timed out" in capsys.readouterr().err + + def test_lock_metadata_requires_group( lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index aba4007..316bb10 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -4,12 +4,15 @@ import argparse import json +import time import tomllib from collections import defaultdict from collections.abc import Iterator from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime, timedelta +from http.client import IncompleteRead from pathlib import Path +from urllib.error import HTTPError, URLError from urllib.parse import urlsplit from urllib.request import Request, urlopen @@ -57,7 +60,7 @@ def main() -> None: installed = _selected_release_creator(lock, groups) if installed is None or installed < required: parser.error(f"relock: release-creator {required} required") - except (InvalidVersion, KeyError, OSError, PylockValidationError, TypeError, ValueError) as error: + except (IncompleteRead, InvalidVersion, KeyError, OSError, PylockValidationError, TypeError, ValueError) as error: parser.error(str(error)) @@ -127,8 +130,20 @@ def _pypi_files(name: str) -> tuple[str, dict[str, tuple[str | None, str | None, f"https://pypi.org/simple/{name}/", headers={"Accept": "application/vnd.pypi.simple.v1+json", "Cache-Control": "no-cache"}, ) - with urlopen(request, timeout=30) as response: - listing = json.load(response) + attempt = 0 + while True: + try: + with urlopen(request, timeout=10) as response: + listing = json.load(response) + break + except HTTPError as error: + if (error.code != 429 and error.code < 500) or attempt == 2: + raise + except IncompleteRead, TimeoutError, URLError: + if attempt == 2: + raise + time.sleep(2**attempt) + attempt += 1 if ( not str(listing.get("meta", {}).get("api-version", "")).startswith("1.") or canonicalize_name(listing.get("name", "")) != name From e1038e390f4f5fb9385bc5816e3f0149f4d77024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 16:14:19 -0700 Subject: [PATCH 30/56] test: cover PyPI HTTP retries --- tests/install_locked_tools/test_metadata.py | 49 ++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index 326aa0d..eac9f46 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -7,7 +7,7 @@ from pathlib import Path from textwrap import dedent from typing import NotRequired, TypedDict -from urllib.error import URLError +from urllib.error import HTTPError, URLError import pytest from pytest_mock import MockerFixture @@ -111,6 +111,53 @@ def test_lock_metadata_stops_after_three_network_errors( assert "handshake timed out" in capsys.readouterr().err +@pytest.mark.parametrize("status", [pytest.param(429, id="rate-limit"), pytest.param(503, id="server-error")]) +def test_lock_metadata_retries_transient_http_error( + lock_case: tuple[Path, Path], + pypi_listing: _PyPIListing, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + status: int, +) -> None: + request = mocker.patch( + "urllib.request.urlopen", + side_effect=[ + HTTPError("https://pypi.org/simple/six/", status, "transient", None, None), + BytesIO(json.dumps(pypi_listing).encode()), + ], + ) + mocker.patch("time.sleep") + + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert request.call_count == 2 + + +@pytest.mark.parametrize( + ("status", "calls"), + [pytest.param(404, 1, id="not-found"), pytest.param(503, 3, id="repeated-server-error")], +) +def test_lock_metadata_rejects_http_error( + lock_case: tuple[Path, Path], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + status: int, + calls: int, +) -> None: + request = mocker.patch( + "urllib.request.urlopen", + side_effect=HTTPError("https://pypi.org/simple/six/", status, "unavailable", None, None), + ) + mocker.patch("time.sleep") + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert request.call_count == calls + assert f"HTTP Error {status}" in capsys.readouterr().err + + def test_lock_metadata_requires_group( lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: From e942158fbab96309214eeccf13cf23dd03b802c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 16:16:06 -0700 Subject: [PATCH 31/56] Fix PyPI retry test headers --- tests/install_locked_tools/test_metadata.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index eac9f46..0a52235 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -3,6 +3,7 @@ import json import runpy import sys +from email.message import Message from io import BytesIO from pathlib import Path from textwrap import dedent @@ -122,7 +123,7 @@ def test_lock_metadata_retries_transient_http_error( request = mocker.patch( "urllib.request.urlopen", side_effect=[ - HTTPError("https://pypi.org/simple/six/", status, "transient", None, None), + HTTPError("https://pypi.org/simple/six/", status, "transient", Message(), None), BytesIO(json.dumps(pypi_listing).encode()), ], ) @@ -147,7 +148,7 @@ def test_lock_metadata_rejects_http_error( ) -> None: request = mocker.patch( "urllib.request.urlopen", - side_effect=HTTPError("https://pypi.org/simple/six/", status, "unavailable", None, None), + side_effect=HTTPError("https://pypi.org/simple/six/", status, "unavailable", Message(), None), ) mocker.patch("time.sleep") From 200b7b48f67eae227871d3aa3ccd5172e1520ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 16:20:41 -0700 Subject: [PATCH 32/56] perf: shrink the release lock reader --- .github/workflows/check.yaml | 2 +- .github/workflows/update-tools.yaml | 2 +- README.md | 16 +++++++-------- changelog.d/+small-lock-reader.performance.md | 1 + install-locked-tools/action.yml | 2 +- pylock.toml | 5 +++-- pyproject.toml | 1 + refresh-release-lock/action.yml | 2 ++ tests/install_locked_tools/test_install.py | 20 +++++++++++++++++-- 9 files changed, 36 insertions(+), 15 deletions(-) create mode 100644 changelog.d/+small-lock-reader.performance.md diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 322bc0b..ffa97c4 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -156,7 +156,7 @@ jobs: - uses: $/refresh-release-lock with: lock-groups: >- - build lock-tools release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check + build lock-tools lock-reader release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check mode: check gate: name: ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Required checks pass (non-PR)' }} diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml index 49a6580..0f58f0d 100644 --- a/.github/workflows/update-tools.yaml +++ b/.github/workflows/update-tools.yaml @@ -13,7 +13,7 @@ jobs: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: lock-groups: >- - build lock-tools release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check + build lock-tools lock-reader release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check commit-message: "🔧 build: refresh release tool lock" pull-request-title: "🔧 build: refresh release tool lock" pull-request-body: "Refresh the locked tools used by release preparation and CI." diff --git a/README.md b/README.md index 1c7b991..3014e04 100644 --- a/README.md +++ b/README.md @@ -102,18 +102,18 @@ jobs: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: lock-groups: >- - build lock-tools release-notes release-news release-prepare + build lock-tools lock-reader release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check ``` The required `lock-groups` input lists the caller's release dependency groups, separated by spaces or newlines. Include -`build` and `lock-tools` where declared; exclude test, type, and documentation groups. This example lists this -repository's groups; a caller supplies its own. The project configures its Python and platform targets in `[tool.nab]`. -Tests install test, type, and documentation groups afresh so they detect upstream dependency changes. The Action tests -fetch immutable, hash-checked release-tool artifacts from the shared lock; their separate package fixture resolves -test-only tools afresh. The updater also refreshes a caller's secondary build-matrix lock when `additional-lock` and its -matrix inputs are set. +`build`, `lock-tools`, and `lock-reader` where declared; exclude test, type, and documentation groups. This example +lists this repository's groups; a caller supplies its own. The project configures its Python and platform targets in +`[tool.nab]`. Tests install test, type, and documentation groups afresh so they detect upstream dependency changes. The +Action tests fetch immutable, hash-checked release-tool artifacts from the shared lock; their separate package fixture +resolves test-only tools afresh. The updater also refreshes a caller's secondary build-matrix lock when +`additional-lock` and its matrix inputs are set. The optional `commit-message`, `pull-request-title`, and `pull-request-body` inputs retain existing PR wording. The workflow does not update `uv.lock` or a checked-in constraints file. Build constraints are generated from the lock's @@ -149,7 +149,7 @@ release-tools-preview: - uses: tox-dev/github-workflows/refresh-release-lock@SHA with: lock-groups: >- - build lock-tools release-notes release-news release-prepare + build lock-tools lock-reader release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check mode: check diff --git a/changelog.d/+small-lock-reader.performance.md b/changelog.d/+small-lock-reader.performance.md new file mode 100644 index 0000000..1d83d9f --- /dev/null +++ b/changelog.d/+small-lock-reader.performance.md @@ -0,0 +1 @@ +Install only the lock reader during release jobs; keep Nab in lock refresh jobs. diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 8c9f26a..6f07d5d 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -42,7 +42,7 @@ runs: $(cat "$RUNNER_TEMP/lock-tools/.lock-digest") == "${digest%% *}" ]]; then exit 0 fi - uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-tools \ + uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-reader \ --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" printf '%s\n' "${digest%% *}" >"$RUNNER_TEMP/lock-tools/.lock-digest" - id: selection diff --git a/pylock.toml b/pylock.toml index fc85f01..e9c0013 100644 --- a/pylock.toml +++ b/pylock.toml @@ -10,6 +10,7 @@ environments = [ requires-python = ">=3.15" dependency-groups = [ "build", + "lock-reader", "lock-tools", "project-runtime", "release-attestations", @@ -1266,7 +1267,7 @@ sha256 = "6f77daa98279ef0eedb9e8f2248aee4c50e01b92b470b68886995e5056d79971" [[packages]] name = "packaging" version = "26.3" -marker = "\"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +marker = "\"lock-reader\" in dependency_groups or \"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" @@ -2491,6 +2492,7 @@ command-line = [ "--groups", "build", "lock-tools", + "lock-reader", "release-notes", "release-news", "release-prepare", @@ -2502,7 +2504,6 @@ command-line = [ "release-hook-check", "--output", "pylock.toml", - "--upgrade", ] input-path = "pyproject.toml" mode = "universal" diff --git a/pyproject.toml b/pyproject.toml index 5562509..179d90a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ license-files = [ "LICENSE" ] [dependency-groups] build = [ "Cython>=3.0", "setuptools>=80" ] lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ] +lock-reader = [ "packaging>=26.3" ] release-notes = [ "release-creator>=0.6.7" ] release-news = [ "release-creator>=0.6.7", "towncrier>=26.9" ] release-prepare = [ "release-creator>=0.6.7", "towncrier>=26.9", "prek>=0.2" ] diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index 84f5522..44eb2e4 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -34,7 +34,9 @@ runs: steps: - uses: $/install-locked-tools with: + groups: lock-tools lock-source: shared + venv: ${{ runner.temp }}/lock-tools - name: Check recent package cutoffs shell: bash run: uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/check_recent_cutoffs.py" pyproject.toml diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index d7caaa2..b6dbbba 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -93,12 +93,12 @@ def test_backend_constraints_match_shared_lock(tmp_path: Path) -> None: assert "--hash=sha256:" in constraints -def test_install_lock_tools_rejects_hash_mismatch(tmp_path: Path, action_scripts: Path) -> None: +def test_install_lock_reader_rejects_hash_mismatch(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] action_root = tmp_path / "install-locked-tools" action_root.mkdir() lock = (source / "pylock.toml").read_text() - package = next(package for package in tomllib.loads(lock)["packages"] if package["name"] == "nab") + package = next(package for package in tomllib.loads(lock)["packages"] if package["name"] == "packaging") (tmp_path / "pylock.toml").write_text(lock.replace(package["wheels"][0]["hashes"]["sha256"], "0" * 64, 1)) result = action( tmp_path, @@ -135,6 +135,22 @@ def test_install_locked_tools_exposes_release_commands(tmp_path: Path, action_sc assert command.returncode == 0, command.stderr +def test_install_locked_tools_uses_minimal_reader(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + result = install(source, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") + + assert result.returncode == 0, result.stderr + reader = tmp_path / "lock-tools" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + packages = subprocess.run( + [str(reader), "-c", "import importlib.util; print(importlib.util.find_spec('nab') is None)"], + capture_output=True, + text=True, + check=False, + ) + assert packages.returncode == 0, packages.stderr + assert packages.stdout.strip() == "True" + + def test_install_locked_tools_preserves_runner_python(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] result = install(source, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") From a3eee2cbd0e04a41d9a61ac856e58a5051c397bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 17:03:45 -0700 Subject: [PATCH 33/56] =?UTF-8?q?=F0=9F=90=9B=20fix(lock):=20check=20every?= =?UTF-8?q?=20release=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/prepare-towncrier-release.yaml | 4 +- recheck-hook-update-cooldown/action.yml | 29 +++-- .../test_expose_commands.py | 19 +++ tests/install_locked_tools/test_install.py | 76 +++++++++++- tests/install_locked_tools/test_metadata.py | 110 +++++++++++++++++- .../test_recheck.py | 36 +++--- tests/validate_news/test_lock_groups.py | 84 +++++++++++++ tools/check_lock_groups.py | 63 +++++++--- tools/expose_locked_commands.py | 2 +- tools/lock_groups.py | 41 +++++++ validate-news/action.yml | 28 +++++ 11 files changed, 444 insertions(+), 48 deletions(-) create mode 100644 tests/validate_news/test_lock_groups.py create mode 100644 tools/lock_groups.py diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 0820673..0e29ac6 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -63,6 +63,8 @@ jobs: local-tool-source: ${{ inputs.local-tool-source }} - name: Install prek hooks id: hooks + env: + UV_EXCLUDE_NEWER: 7 days run: prek install-hooks background: true - name: Build local release tool wheel @@ -120,7 +122,7 @@ jobs: - id: tools uses: $/install-locked-tools with: - groups: release-validate + groups: release-validate${{ inputs.local-tool-source && ' project-runtime' || '' }} venv: .venv local-tool-source: ${{ inputs.local-tool-source }} - name: Build local release tool wheel diff --git a/recheck-hook-update-cooldown/action.yml b/recheck-hook-update-cooldown/action.yml index e1b8d4d..c6ed2ec 100644 --- a/recheck-hook-update-cooldown/action.yml +++ b/recheck-hook-update-cooldown/action.yml @@ -1,9 +1,12 @@ name: Recheck cooled hook updates description: Recheck failed bot hook-update cooldown jobs each day inputs: - minimum-days: - description: Minimum age of an eligible hook revision - default: "7" + workflow: + description: Workflow file containing the hook cooldown job + default: check.yaml + job-name: + description: Name of the hook cooldown job + default: hook-cooldown runs: using: composite steps: @@ -23,16 +26,11 @@ runs: shell: bash env: GH_TOKEN: ${{ github.token }} - MINIMUM_DAYS: ${{ inputs.minimum-days }} + WORKFLOW: ${{ inputs.workflow }} + JOB_NAME: ${{ inputs.job-name }} run: | set -euo pipefail - case "$MINIMUM_DAYS" in - '' | 0* | *[!0-9]*) exit 1 ;; - esac - if ((MINIMUM_DAYS < 7)); then - echo '::error::hook cooldown must be at least seven days' >&2 - exit 1 - fi + [[ -n "$WORKFLOW" && -n "$JOB_NAME" ]] || exit 1 while IFS= read -r pull; do number=$(jq -r .number <<<"$pull") gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$number/files" --jq '.[].filename' \ @@ -42,7 +40,7 @@ runs: fi head=$(jq -r .head <<<"$pull") branch=$(jq -r .branch <<<"$pull") - runs=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/workflows/check.yaml/runs" \ + runs=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/workflows/$WORKFLOW/runs" \ -f event=pull_request -f head_sha="$head" -f per_page=100 | jq -sc --arg repository "$GITHUB_REPOSITORY" --arg branch "$branch" --arg head "$head" \ --argjson number "$number" ' @@ -51,8 +49,8 @@ runs: .head_branch == $branch and .head_sha == $head and any(.pull_requests[]; .number == $number) )]') - if ! jq -e --argjson minimum_days "$MINIMUM_DAYS" \ - '[.[].created_at | fromdateiso8601] | min <= now - $minimum_days * 86400' \ + if ! jq -e \ + '[.[].created_at | fromdateiso8601] | min <= now - 7 * 86400' \ <<<"$runs" >/dev/null; then continue fi @@ -65,7 +63,8 @@ runs: fi job=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/runs/$(jq -r .id <<<"$run")/jobs" \ -f filter=latest -f per_page=100 | - jq -sc '[.[].jobs[] | select(.name == "hook-cooldown" and .conclusion == "failure")] | first // empty') + jq -sc --arg job_name "$JOB_NAME" \ + '[.[].jobs[] | select(.name == $job_name and .conclusion == "failure")] | first // empty') if [[ -n "$job" ]]; then if ! gh run rerun "$(jq -r .id <<<"$run")" --job "$(jq -r .id <<<"$job")" \ --repo "$GITHUB_REPOSITORY"; then diff --git a/tests/install_locked_tools/test_expose_commands.py b/tests/install_locked_tools/test_expose_commands.py index 0a6318e..3054245 100644 --- a/tests/install_locked_tools/test_expose_commands.py +++ b/tests/install_locked_tools/test_expose_commands.py @@ -4,6 +4,7 @@ import sys from pathlib import Path +import pytest from pytest_mock import MockerFixture sys.path.insert(0, str(Path(__file__).resolve().parents[2])) @@ -28,3 +29,21 @@ def test_expose_locked_commands_copies_windows_executable(tmp_path: Path, mocker installed = Path(github_path.read_text().strip()) / "release.exe" assert installed.read_bytes() == b"tool" assert not installed.is_symlink() + + +def test_expose_locked_commands_keeps_system_git_and_gh(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + scripts = tmp_path / "environment/bin" + scripts.mkdir(parents=True) + for name in ("git", "gh", "prek"): + command = scripts / name + command.write_text("#!/bin/sh\nexit 0\n") + command.chmod(0o755) + github_path = tmp_path / "github-path" + monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) + monkeypatch.setenv("GITHUB_PATH", str(github_path)) + monkeypatch.setattr(sys, "argv", ["expose_locked_commands", str(scripts.parent)]) + + expose_locked_commands.main() + + overlay = Path(github_path.read_text().strip()) + assert sorted(path.name for path in overlay.iterdir()) == ["prek"] diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index b6dbbba..a7a6534 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -5,6 +5,7 @@ import subprocess import sys import tomllib +from copy import deepcopy from datetime import UTC, datetime, timedelta from pathlib import Path @@ -257,7 +258,7 @@ def test_local_tool_source_only_accepts_release_creator(tmp_path: Path, package_ ) assert (result.returncode == 0) is accepted, result.stderr if not accepted: - assert f"uncooled non-index package: {package_name}" in result.stderr + assert f"direct-source package: {package_name}" in result.stderr @pytest.mark.parametrize( @@ -429,10 +430,11 @@ def test_install_locked_tools_rejects_unreviewed_version_under_reviewed_floor(tm source = Path(__file__).resolve().parents[2] document = tomlkit.parse((source / "pylock.toml").read_text()) package = next(package for package in document["packages"] if package["name"] == "release-creator") + current_version = str(package["version"]) package["version"] = "0.6.3" for artifact in [package["sdist"], *package["wheels"]]: - artifact["name"] = artifact["name"].replace("0.6.7", "0.6.3") - artifact["url"] = artifact["url"].replace("0.6.7", "0.6.3") + artifact["name"] = artifact["name"].replace(current_version, "0.6.3") + artifact["url"] = artifact["url"].replace(current_version, "0.6.3") lock = tmp_path / "pylock.toml" lock.write_text(tomlkit.dumps(document)) @@ -533,6 +535,74 @@ def test_caller_version_check_uses_shared_package_marker(tmp_path: Path) -> None assert "relock: release-creator" in result.stderr +def test_caller_version_check_covers_other_platform(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + document = tomlkit.parse((source / "pylock.toml").read_text()) + package = next(item for item in document["packages"] if item["name"] == "release-creator") + package["marker"] = '"release-shell" in dependency_groups and sys_platform == "win32"' + shared = tmp_path / "shared.toml" + shared.write_text(tomlkit.dumps(document)) + caller = tmp_path / "caller.toml" + caller.write_text( + 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' + 'dependency-groups = ["release-shell"]\ncreated-by = "test"\npackages = []\n' + "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" + ) + + result = subprocess.run( + [ + "python", + str(source / "tools/check_lock_groups.py"), + str(caller), + "release-shell", + "--shared-lock", + str(shared), + ], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "relock: release-creator" in result.stderr + + +def test_caller_version_check_uses_highest_shared_platform_floor(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] + document = tomlkit.parse((source / "pylock.toml").read_text()) + package = next(item for item in document["packages"] if item["name"] == "release-creator") + current = deepcopy(package) + current["marker"] = '"release-news" in dependency_groups and sys_platform == "linux"' + package["marker"] = '"release-news" in dependency_groups and sys_platform == "win32"' + package["version"] = "0.6.6" + for artifact in [package["sdist"], *package["wheels"]]: + artifact["name"] = str(artifact["name"]).replace("0.6.7", "0.6.6") + artifact["url"] = str(artifact["url"]).replace("0.6.7", "0.6.6") + artifact["upload-time"] = datetime(2026, 10, 15, tzinfo=UTC) + caller = tmp_path / "caller.toml" + caller.write_text(tomlkit.dumps(document)) + document["packages"].append(current) + shared = tmp_path / "shared.toml" + shared.write_text(tomlkit.dumps(document)) + + result = subprocess.run( + [ + "python", + str(source / "tools/check_lock_groups.py"), + str(caller), + "release-news", + "--shared-lock", + str(shared), + ], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "relock: release-creator 0.6.7 required" in result.stderr + + def test_install_locked_tools_shared_group_ignores_missing_caller_group(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] lock = (source / "pylock.toml").read_text().replace(' "release-attestations",\n', "", 1) diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index 0a52235..90972c7 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -15,7 +15,13 @@ _PyPIFile = TypedDict( "_PyPIFile", - {"filename": str, "url": str, "upload-time": NotRequired[str], "hashes": dict[str, str]}, + { + "filename": str, + "url": str, + "upload-time": NotRequired[str], + "hashes": dict[str, str], + "yanked": NotRequired[bool], + }, ) @@ -81,6 +87,22 @@ def test_lock_metadata_matches_pypi( assert request.call_args.args[0].full_url == "https://pypi.org/simple/six/" +def test_lock_metadata_rejects_yanked_artifact( + lock_case: tuple[Path, Path], + pypi_listing: _PyPIListing, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + pypi_listing["files"][0]["yanked"] = True + mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert "release lock differs from PyPI metadata" in capsys.readouterr().err + + def test_lock_metadata_retries_transient_network_error( lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -226,6 +248,92 @@ def test_lock_metadata_allows_local_release_creator( _run_check(lock_case, monkeypatch, "--local-tool-source", "--verify-pypi", "--project", str(lock_case[1])) +@pytest.mark.parametrize( + ("source", "message"), + [ + pytest.param( + 'archive = { url = "https://evil.invalid/six.whl", hashes = { sha256 = "' + "0" * 64 + '" } }', + "direct-source", + id="archive", + ), + pytest.param('directory = { path = "." }', "direct-source", id="directory"), + pytest.param( + 'vcs = { type = "git", url = "https://evil.invalid/six.git", commit-id = "' + "a" * 40 + '" }', + "direct-source", + id="vcs", + ), + ], +) +def test_lock_metadata_rejects_unchecked_source( + lock_case: tuple[Path, Path], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + source: str, + message: str, +) -> None: + lock = lock_case[0] + lock.write_text(lock.read_text().split('index = "https://pypi.org/simple/"', 1)[0] + source + "\n") + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert message in capsys.readouterr().err + + +def test_lock_metadata_rejects_indexless_wheel( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + lock = lock_case[0] + lock.write_text(lock.read_text().replace('index = "https://pypi.org/simple/"\n', "")) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--project", str(lock_case[1])) + + assert "uncooled non-index package" in capsys.readouterr().err + + +def test_lock_metadata_checks_other_locked_platform( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + lock = lock_case[0] + lock.write_text( + lock.read_text() + .replace( + 'dependency-groups = ["release-check"]', + 'dependency-groups = ["release-check"]\nenvironments = [\'sys_platform == "win32"\']', + ) + .replace( + "marker = '\"release-check\" in dependency_groups'", + 'marker = \'"release-check" in dependency_groups and sys_platform == "win32"\'', + ) + .replace("files.pythonhosted.org", "evil.invalid") + ) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--project", str(lock_case[1])) + + assert "non-PyPI artifact" in capsys.readouterr().err + + +def test_lock_metadata_checks_nested_group_marker( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + lock = lock_case[0] + lock.write_text( + lock.read_text() + .replace( + "marker = '\"release-check\" in dependency_groups'", + 'marker = \'("release-check" in dependency_groups and sys_platform == "win32") or "test" in dependency_groups\'', + ) + .replace("files.pythonhosted.org", "evil.invalid") + ) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--project", str(lock_case[1])) + + assert "non-PyPI artifact" in capsys.readouterr().err + + def test_lock_metadata_checks_each_duplicate_artifact( lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, diff --git a/tests/recheck_hook_update_cooldown/test_recheck.py b/tests/recheck_hook_update_cooldown/test_recheck.py index a87dab7..0c1aeff 100644 --- a/tests/recheck_hook_update_cooldown/test_recheck.py +++ b/tests/recheck_hook_update_cooldown/test_recheck.py @@ -22,16 +22,24 @@ def test_recheck_schedule_has_only_required_permissions() -> None: @pytest.mark.parametrize( - ("observed_age", "job_age", "job_conclusion", "job_name", "minimum_days", "reruns"), + ("observed_age", "job_age", "job_conclusion", "job_name", "configured_job", "workflow", "reruns"), [ - pytest.param(8, 8, "failure", "hook-cooldown", 7, 1, id="matured-failure"), - pytest.param(8, 8, "failure", "Hook update cooldown", 7, 0, id="other-job"), - pytest.param(6, 6, "failure", "hook-cooldown", 7, 0, id="recent-pull"), - pytest.param(8, 0, "failure", "hook-cooldown", 7, 1, id="failed-rerun"), - pytest.param(31, 31, "failure", "hook-cooldown", 7, 0, id="expired-run"), - pytest.param(8, 8, "success", "hook-cooldown", 7, 0, id="successful-job"), - pytest.param(8, 8, "failure", "hook-cooldown", 14, 0, id="custom-cooldown-pending"), - pytest.param(15, 15, "failure", "hook-cooldown", 14, 1, id="custom-cooldown-matured"), + pytest.param(8, 8, "failure", "hook-cooldown", "hook-cooldown", "check.yaml", 1, id="matured-failure"), + pytest.param(8, 8, "failure", "Hook update cooldown", "hook-cooldown", "check.yaml", 0, id="other-job"), + pytest.param( + 8, + 8, + "failure", + "Hook update cooldown", + "Hook update cooldown", + "pr-gate.yaml", + 1, + id="configured-job-and-workflow", + ), + pytest.param(6, 6, "failure", "hook-cooldown", "hook-cooldown", "check.yaml", 0, id="recent-pull"), + pytest.param(8, 0, "failure", "hook-cooldown", "hook-cooldown", "check.yaml", 1, id="failed-rerun"), + pytest.param(31, 31, "failure", "hook-cooldown", "hook-cooldown", "check.yaml", 0, id="expired-run"), + pytest.param(8, 8, "success", "hook-cooldown", "hook-cooldown", "check.yaml", 0, id="successful-job"), ], ) @pytest.mark.parametrize( @@ -46,7 +54,8 @@ def test_recheck_reruns_failed_job( job_age: int, job_conclusion: str, job_name: str, - minimum_days: int, + configured_job: str, + workflow: str, reruns: int, bot_login: str, ) -> None: @@ -71,7 +80,7 @@ def test_recheck_reruns_failed_job( [{"filename": ".pre-commit-config.yaml"}] ) server.expect_request( - "/api/v3/repos/tox-dev/fixture/actions/workflows/check.yaml/runs", method="GET" + f"/api/v3/repos/tox-dev/fixture/actions/workflows/{workflow}/runs", method="GET" ).respond_with_json( { "workflow_runs": [ @@ -88,7 +97,7 @@ def test_recheck_reruns_failed_job( ] } ) - if minimum_days <= observed_age < 30: + if observed_age >= 7 and observed_age < 30: server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42/jobs", method="GET").respond_with_json( { "jobs": [ @@ -116,7 +125,8 @@ def test_recheck_reruns_failed_job( "recheck-hook-update-cooldown", version="", GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"], - MINIMUM_DAYS=str(minimum_days), + JOB_NAME=configured_job, + WORKFLOW=workflow, **environment, ) assert result.returncode == 0, result.stderr diff --git a/tests/validate_news/test_lock_groups.py b/tests/validate_news/test_lock_groups.py new file mode 100644 index 0000000..2deb1f5 --- /dev/null +++ b/tests/validate_news/test_lock_groups.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import runpy +import subprocess +from pathlib import Path + +import pytest + + +def test_lock_groups_reads_recorded_selection(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("[dependency-groups]\nrelease-news = []\nrelease-check = []\n") + lock = tmp_path / "pylock.toml" + lock.write_text( + '[tool.nab]\ncommand-line = ["nab", "lock", "--groups", "release-news", "release-check", ' + '"--output", "pylock.toml"]\n' + ) + + result = subprocess.run( + ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout == "release-news release-check\n" + + +@pytest.mark.parametrize( + ("command", "message"), + [ + pytest.param('["nab", "lock", "--output", "pylock.toml"]', "cannot read Nab lock groups", id="missing-flag"), + pytest.param('"nab lock --groups release-news"', "must be a list of strings", id="string-command"), + pytest.param('["nab", 42]', "must be a list of strings", id="non-string-argument"), + pytest.param( + '["nab", "lock", "--groups", "--output", "pylock.toml"]', + "Nab lock records no selected groups", + id="empty-groups", + ), + ], +) +def test_lock_groups_rejects_missing_selection(tmp_path: Path, command: str, message: str) -> None: + lock = tmp_path / "pylock.toml" + lock.write_text(f"[tool.nab]\ncommand-line = {command}\n") + + result = subprocess.run( + ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert message in result.stderr + + +def test_lock_groups_import_has_no_cli_side_effect(capsys: pytest.CaptureFixture[str]) -> None: + runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), run_name="lock_groups") + + assert capsys.readouterr() == ("", "") + + +@pytest.mark.parametrize( + ("groups", "message"), + [ + pytest.param('"release-news"', "omits release groups", id="omitted-release-group"), + pytest.param('"release-news", "release-check", "unknown"', "invalid or undeclared", id="undeclared-group"), + pytest.param('"release-news\\nmode=refresh", "release-check"', "invalid or undeclared", id="output-injection"), + ], +) +def test_lock_groups_rejects_untrusted_selection(tmp_path: Path, groups: str, message: str) -> None: + (tmp_path / "pyproject.toml").write_text("[dependency-groups]\nrelease-news = []\nrelease-check = []\n") + lock = tmp_path / "pylock.toml" + lock.write_text(f'[tool.nab]\ncommand-line = ["nab", "lock", "--groups", {groups}]\n') + + result = subprocess.run( + ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert message in result.stderr diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 316bb10..a765ae9 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -12,11 +12,14 @@ from datetime import UTC, datetime, timedelta from http.client import IncompleteRead from pathlib import Path +from typing import cast from urllib.error import HTTPError, URLError from urllib.parse import urlsplit from urllib.request import Request, urlopen from nab_cutoffs import reviewed_cutoff, validate_indexes +from packaging._parser import Variable +from packaging.markers import Marker from packaging.pylock import Package, Pylock, PylockValidationError from packaging.utils import canonicalize_name, parse_sdist_filename, parse_wheel_filename from packaging.version import InvalidVersion, Version @@ -55,9 +58,9 @@ def main() -> None: if args.shared_lock: with args.shared_lock.open("rb") as stream: shared = Pylock.from_dict(tomllib.load(stream)) - required = _selected_release_creator(shared, groups) + required = max(_selected_release_creators(shared, groups), default=None) if required is not None and not args.local_tool_source: - installed = _selected_release_creator(lock, groups) + installed = min(_selected_release_creators(lock, groups), default=None) if installed is None or installed < required: parser.error(f"relock: release-creator {required} required") except (IncompleteRead, InvalidVersion, KeyError, OSError, PylockValidationError, TypeError, ValueError) as error: @@ -70,6 +73,8 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = raise ValueError("lock has no valid creation time or was created in the future") threshold = created - timedelta(days=7) for package in _selected_packages(lock, groups, local_tool_source=local_tool_source): + if package.archive is not None or package.vcs is not None or package.directory is not None: + raise ValueError(f"release lock has a direct-source package: {package.name}") if package.index is None: raise ValueError(f"release lock has an uncooled non-index package: {package.name}") if package.index != "https://pypi.org/simple/": @@ -117,14 +122,48 @@ def _selected_packages(lock: Pylock, groups: set[str], *, local_tool_source: boo selections = (groups, {"build"}) if "build" in (lock.dependency_groups or ()) else (groups,) for package in lock.packages: if package.marker is not None and not any( - package.marker.evaluate({"dependency_groups": selection}, context="lock_file") for selection in selections + _possibly_selected(package.marker, selection) for selection in selections ): continue - if local_tool_source and package.name == "release-creator" and package.index is None: + if ( + local_tool_source + and package.name == "release-creator" + and package.directory is not None + and package.directory.path == "." + and package.index is None + and package.archive is None + and package.vcs is None + and package.sdist is None + and not package.wheels + ): continue yield package +def _possibly_selected(marker: Marker, groups: set[str]) -> bool: + def expression(nodes: list[object]) -> bool: + alternatives: list[bool] = [] + current = True + for node in nodes: + if node == "or": + alternatives.append(current) + current = True + elif node != "and": + if isinstance(node, list): + current &= expression(node) + else: + left, operator, right = cast("tuple[object, object, object]", node) + if isinstance(right, Variable) and str(right) == "dependency_groups" and str(operator) == "in": + current &= str(left) in groups + elif ( + isinstance(right, Variable) and str(right) == "dependency_groups" and str(operator) == "not in" + ): + current &= str(left) not in groups + return any((*alternatives, current)) + + return expression(marker._markers) + + def _pypi_files(name: str) -> tuple[str, dict[str, tuple[str | None, str | None, datetime | None]]]: request = Request( f"https://pypi.org/simple/{name}/", @@ -151,6 +190,8 @@ def _pypi_files(name: str) -> tuple[str, dict[str, tuple[str | None, str | None, raise ValueError(f"invalid PyPI listing for {name}") files: dict[str, tuple[str | None, str | None, datetime | None]] = {} for file in listing["files"]: + if file.get("yanked", False): + continue files[file["filename"]] = ( file.get("url"), file.get("hashes", {}).get("sha256"), @@ -159,16 +200,10 @@ def _pypi_files(name: str) -> tuple[str, dict[str, tuple[str | None, str | None, return name, files -def _selected_release_creator(lock: Pylock, groups: set[str]) -> Version | None: - return next( - ( - Version(str(package.version)) - for package in lock.packages - if package.name == "release-creator" - and (package.marker is None or package.marker.evaluate({"dependency_groups": groups}, context="lock_file")) - ), - None, - ) +def _selected_release_creators(lock: Pylock, groups: set[str]) -> Iterator[Version]: + for package in lock.packages: + if package.name == "release-creator" and (package.marker is None or _possibly_selected(package.marker, groups)): + yield Version(str(package.version)) if __name__ == "__main__": diff --git a/tools/expose_locked_commands.py b/tools/expose_locked_commands.py index be65c4c..e78f6cb 100644 --- a/tools/expose_locked_commands.py +++ b/tools/expose_locked_commands.py @@ -17,7 +17,7 @@ def main() -> None: and (os.name != "nt" or command.suffix.casefold() == ".exe") and os.access(command, os.X_OK) and not name.startswith(("activate", "deactivate", "easy_install", "ensurepip", "pip", "pydoc", "python")) - and name not in {"uv", "uvx", "wheel"} + and name not in {"bash", "curl", "gh", "git", "openssl", "sh", "ssh", "uv", "uvx", "wheel"} ): if os.name == "nt": shutil.copy2(command, overlay / command.name) diff --git a/tools/lock_groups.py b/tools/lock_groups.py new file mode 100644 index 0000000..6ba1a66 --- /dev/null +++ b/tools/lock_groups.py @@ -0,0 +1,41 @@ +"""Print the dependency groups used to create a Nab lock.""" + +from __future__ import annotations + +import argparse +import tomllib +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("lock", type=Path) + args = parser.parse_args() + try: + with args.lock.open("rb") as stream: + lock = tomllib.load(stream) + command = lock["tool"]["nab"]["command-line"] + if not isinstance(command, list) or not all(isinstance(value, str) for value in command): + raise ValueError("Nab command-line must be a list of strings") + start = command.index("--groups") + 1 + groups = command[ + start : next( + (index for index in range(start, len(command)) if command[index].startswith("--")), len(command) + ) + ] + if not groups: + parser.error("Nab lock records no selected groups") + with args.lock.with_name("pyproject.toml").open("rb") as stream: + declared = tomllib.load(stream)["dependency-groups"] + if not all(group.isascii() and group.replace("-", "").isalnum() and group in declared for group in groups): + raise ValueError("Nab lock selects an invalid or undeclared group") + required = {name for name in declared if name.startswith("release")} + if missing := required - set(groups): + raise ValueError(f"Nab lock omits release groups: {', '.join(sorted(missing))}") + print(" ".join(groups)) + except (KeyError, OSError, TypeError, ValueError) as error: + parser.error(f"cannot read Nab lock groups: {error}") + + +if __name__ == "__main__": + main() diff --git a/validate-news/action.yml b/validate-news/action.yml index 83280f7..b5e3f27 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -19,3 +19,31 @@ runs: echo '::error::add a changelog fragment or apply the skip news label' >&2 exit 1 fi + - id: lock-change + name: Check whether release inputs changed + shell: bash + env: + BASE_REF: ${{ github.base_ref }} + run: | + changed=$(git diff --name-only "origin/$BASE_REF...HEAD" -- pyproject.toml pylock.toml) + if [[ -z "$changed" ]]; then + echo 'changed=false' >>"$GITHUB_OUTPUT" + exit 0 + fi + if [[ ! -f pylock.toml ]]; then + if git cat-file -e "origin/$BASE_REF:pylock.toml" 2>/dev/null; then + echo '::error::the release lock was removed' >&2 + exit 1 + fi + echo 'changed=false' >>"$GITHUB_OUTPUT" + exit 0 + fi + groups=$(python3 "$GITHUB_ACTION_PATH/../tools/lock_groups.py" pylock.toml) + echo 'changed=true' >>"$GITHUB_OUTPUT" + echo "groups=$groups" >>"$GITHUB_OUTPUT" + - name: Check the release lock against the project + if: steps.lock-change.outputs.changed == 'true' + uses: $/refresh-release-lock + with: + lock-groups: ${{ steps.lock-change.outputs.groups }} + mode: check From 0d5ebdde781ddd26ca03138bb8b63800f27d64d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 17:11:22 -0700 Subject: [PATCH 34/56] =?UTF-8?q?=F0=9F=90=9B=20fix(lock):=20pin=20PyPI=20?= =?UTF-8?q?metadata=20trust?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pylock.toml | 2 +- pyproject.toml | 2 +- tests/install_locked_tools/test_metadata.py | 4 ++++ tools/check_lock_groups.py | 4 +++- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pylock.toml b/pylock.toml index e9c0013..d4bd820 100644 --- a/pylock.toml +++ b/pylock.toml @@ -111,7 +111,7 @@ sha256 = "ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7" [[packages]] name = "certifi" version = "2026.7.22" -marker = "\"release-attestations\" in dependency_groups" +marker = "\"lock-reader\" in dependency_groups or \"release-attestations\" in dependency_groups" requires-python = ">=3.7" index = "https://pypi.org/simple/" diff --git a/pyproject.toml b/pyproject.toml index 179d90a..24ed6bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ license-files = [ "LICENSE" ] [dependency-groups] build = [ "Cython>=3.0", "setuptools>=80" ] lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ] -lock-reader = [ "packaging>=26.3" ] +lock-reader = [ "certifi>=2026.7.22", "packaging>=26.3" ] release-notes = [ "release-creator>=0.6.7" ] release-news = [ "release-creator>=0.6.7", "towncrier>=26.9" ] release-prepare = [ "release-creator>=0.6.7", "towncrier>=26.9", "prek>=0.2" ] diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index 90972c7..959eb0e 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -2,6 +2,7 @@ import json import runpy +import ssl import sys from email.message import Message from io import BytesIO @@ -10,6 +11,7 @@ from typing import NotRequired, TypedDict from urllib.error import HTTPError, URLError +import certifi import pytest from pytest_mock import MockerFixture @@ -81,10 +83,12 @@ def test_lock_metadata_matches_pypi( lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch ) -> None: request = mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) + context = mocker.patch.object(ssl, "create_default_context", autospec=True) _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) assert request.call_args.args[0].full_url == "https://pypi.org/simple/six/" + assert context.call_args.kwargs == {"cafile": certifi.where()} def test_lock_metadata_rejects_yanked_artifact( diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index a765ae9..18c61f3 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -4,6 +4,7 @@ import argparse import json +import ssl import time import tomllib from collections import defaultdict @@ -17,6 +18,7 @@ from urllib.parse import urlsplit from urllib.request import Request, urlopen +import certifi from nab_cutoffs import reviewed_cutoff, validate_indexes from packaging._parser import Variable from packaging.markers import Marker @@ -172,7 +174,7 @@ def _pypi_files(name: str) -> tuple[str, dict[str, tuple[str | None, str | None, attempt = 0 while True: try: - with urlopen(request, timeout=10) as response: + with urlopen(request, timeout=10, context=ssl.create_default_context(cafile=certifi.where())) as response: listing = json.load(response) break except HTTPError as error: From 5ca2d56fe921abbe1b84be9d96abf59dc2b92adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 17:13:28 -0700 Subject: [PATCH 35/56] =?UTF-8?q?=F0=9F=94=92=20fix:=20pin=20PyPI=20verifi?= =?UTF-8?q?cation=20trust=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- tests/verify_pypi_files/test_verify.py | 11 +++++++++++ tools/verify_pypi_files.py | 5 ++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 24ed6bf..83f641c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ release-news = [ "release-creator>=0.6.7", "towncrier>=26.9" ] release-prepare = [ "release-creator>=0.6.7", "towncrier>=26.9", "prek>=0.2" ] release-validate = [ "release-creator>=0.6.7", "prek>=0.2" ] release-check = [ "prek>=0.2" ] -release-attestations = [ "pypi-attestations>=0.0.30" ] +release-attestations = [ "certifi>=2026.7.22", "pypi-attestations>=0.0.30" ] release-shell = [ "prek>=0.2", "yq>=4.4.5" ] release-hooks = [ "release-creator>=0.6.7", "prek>=0.2" ] release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ] diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py index d1f6d71..96d25cf 100644 --- a/tests/verify_pypi_files/test_verify.py +++ b/tests/verify_pypi_files/test_verify.py @@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, TypedDict, cast from urllib.error import HTTPError, URLError +import certifi import pytest if TYPE_CHECKING: @@ -54,6 +55,16 @@ def test_verify_files_matches_complete_release(distribution: Path, mocker: Mocke assert result == [distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz"] +def test_verify_files_uses_pinned_trust_store(distribution: Path, mocker: MockerFixture) -> None: + request = mocker.patch.object(_MODULE, "urlopen", return_value=_body(_listing(distribution))) + context = mocker.patch.object(ssl, "create_default_context", autospec=True) + + verify_files("fixture", "1.0", distribution, 0) + + assert request.call_args.kwargs["context"] is context.return_value + assert context.call_args.kwargs == {"cafile": certifi.where()} + + def test_verify_files_rejects_unsupported_simple_api(distribution: Path, mocker: MockerFixture) -> None: mocker.patch.object( _MODULE, diff --git a/tools/verify_pypi_files.py b/tools/verify_pypi_files.py index e7e351c..ea7c970 100644 --- a/tools/verify_pypi_files.py +++ b/tools/verify_pypi_files.py @@ -13,6 +13,7 @@ from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +import certifi from packaging.utils import ( InvalidSdistFilename, InvalidWheelFilename, @@ -130,7 +131,9 @@ def _listing(project: str) -> list[_SimpleFile]: headers={"Accept": "application/vnd.pypi.simple.v1+json", "Cache-Control": "no-cache"}, ) try: - with urlopen(request, timeout=30) as response: # ruff: ignore[suspicious-url-open-usage] # Fixed PyPI origin. + with urlopen( # ruff: ignore[suspicious-url-open-usage] # Fixed PyPI origin. + request, timeout=30, context=ssl.create_default_context(cafile=certifi.where()) + ) as response: payload = json.load(response) except HTTPError as error: if error.code == 404: From b3123aa4360e45b28b764bffe18624f9d4e96c7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 17:15:09 -0700 Subject: [PATCH 36/56] =?UTF-8?q?=F0=9F=94=92=20fix:=20check=20updater=20P?= =?UTF-8?q?R=20hook=20age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +-- check-hook-update-cooldown/action.yml | 6 ---- .../test_cooldown.py | 31 ++++++++++++++----- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3014e04..da7cbfb 100644 --- a/README.md +++ b/README.md @@ -165,8 +165,8 @@ with `actions: read`, `contents: read`, and `pull-requests: read`, then include its PR number and head SHA from the event, compares them with the live PR, and requires each proposed revision to have appeared in a GitHub PR workflow run at least `minimum-days` earlier. The default is seven days. A force push does not reset an unchanged revision's age or give a new revision the old one's age. Human PRs pass without installing hook -tools. Bot PRs may change hook revisions only. The repository's `automation/update-release-tools` branch is exempt -because its separate generator freezes the hook revisions and Python dependencies before opening the PR. +tools. Bot PRs may change hook revisions only. The generated `automation/update-release-tools` PR follows the same +seven-day observation rule, so its current hook revisions may remain pending after the lock refresh. ```yaml hook-cooldown: diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index 9625e9f..0cf1ee4 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -31,12 +31,6 @@ runs: .head.sha == $head and (.user.login | type == "string") ' "$RUNNER_TEMP/hook-update-pr.json" >/dev/null - if jq -e --arg repository "$REPOSITORY" ' - .user.type == "Bot" and .head.repo.full_name == $repository and - .head.ref == "automation/update-release-tools" - ' "$RUNNER_TEMP/hook-update-pr.json" >/dev/null; then - exit 0 - fi if [[ $(jq -r .user.type "$RUNNER_TEMP/hook-update-pr.json") == Bot ]]; then gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' \ >"$RUNNER_TEMP/hook-update-files" diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index b702b49..34a9f38 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -99,16 +99,31 @@ def test_cooldown_skips_bot_without_hook_changes( ] -def test_cooldown_trusts_its_generated_update_branch( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +@pytest.mark.parametrize( + ("observed_days", "accepted"), + [pytest.param(0, False, id="new"), pytest.param(10, True, id="cooled")], +) +def test_cooldown_checks_generated_update_branch( + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + observed_days: int, + accepted: bool, ) -> None: server, environment = github_api - expect_pull(server, author="release-app[bot]", branch="automation/update-release-tools") + repository = hook_repository(tmp_path) + expect_pull( + server, author="release-app[bot]", branch="automation/update-release-tools", observed_days=observed_days + ) + expect_config(server, "b" * 40, repository, "v0") + expect_config(server, "a" * 40, repository, "v1") + if not accepted: + expect_historical_runs(server, older_head=None) result = run_cooldown(action_scripts, tmp_path, environment) - assert result.returncode == 0, result.stderr - assert [request.path for request, _ in server.log] == ["/api/v3/repos/tox-dev/fixture/pulls/7"] + assert (result.returncode == 0) is accepted, result.stderr + assert len(server.log) >= 4 def test_cooldown_rejects_hook_configuration_change( @@ -355,11 +370,11 @@ def expect_pull( "updated_at": (datetime.now(UTC) - timedelta(days=updated_days)).strftime("%Y-%m-%dT%H:%M:%SZ"), } ) - if author.endswith("[bot]") and branch != "automation/update-release-tools": + if author.endswith("[bot]"): server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json( [{"filename": ".pre-commit-config.yaml" if hook_update else "pyproject.toml"}] ) - if author.endswith("[bot]") and hook_update and branch != "automation/update-release-tools": + if author.endswith("[bot]") and hook_update: server.expect_request( "/api/v3/repos/tox-dev/fixture/actions/runs", method="GET", @@ -372,7 +387,7 @@ def expect_pull( { "event": "pull_request", "head_repository": {"full_name": "tox-dev/fixture"}, - "head_branch": "bot-update", + "head_branch": branch, "head_sha": head, "pull_requests": [{"number": 7}], "created_at": (datetime.now(UTC) - timedelta(days=observed_days)).strftime( From d03b69f7cd35cf4af8a3cdb737995d7c901752af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 17:23:26 -0700 Subject: [PATCH 37/56] =?UTF-8?q?=F0=9F=93=9D=20docs:=20correct=20release?= =?UTF-8?q?=20change=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+action-interface.breaking.md | 4 +--- changelog.d/+hook-config-cooldown.security.md | 3 +-- changelog.d/+lock-metadata.security.md | 2 +- changelog.d/+pypi-verifier-trust.security.md | 1 + changelog.d/+release-lock-preview.security.md | 1 + changelog.d/+updater-hook-age.security.md | 1 + 6 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 changelog.d/+pypi-verifier-trust.security.md create mode 100644 changelog.d/+release-lock-preview.security.md create mode 100644 changelog.d/+updater-hook-age.security.md diff --git a/changelog.d/+action-interface.breaking.md b/changelog.d/+action-interface.breaking.md index 79dd9f3..93bf953 100644 --- a/changelog.d/+action-interface.breaking.md +++ b/changelog.d/+action-interface.breaking.md @@ -1,3 +1 @@ -Remove the unused `build-python-distribution.source`, `recheck-hook-update-cooldown.workflow`, and -`recheck-hook-update-cooldown.job-name` inputs. The build Action uses the repository root; the cooldown reruns -`hook-cooldown` in `check.yaml`. +Remove the unused `build-python-distribution.source` input. The build Action uses the repository root. diff --git a/changelog.d/+hook-config-cooldown.security.md b/changelog.d/+hook-config-cooldown.security.md index b37a721..7de81a8 100644 --- a/changelog.d/+hook-config-cooldown.security.md +++ b/changelog.d/+hook-config-cooldown.security.md @@ -1,2 +1 @@ -Reject bot hook updates that change configuration beyond revisions. Keep the trusted weekly updater on its reviewed -branch. +Reject bot hook updates that change configuration beyond revisions. diff --git a/changelog.d/+lock-metadata.security.md b/changelog.d/+lock-metadata.security.md index 1f84241..27b3628 100644 --- a/changelog.d/+lock-metadata.security.md +++ b/changelog.d/+lock-metadata.security.md @@ -1,2 +1,2 @@ Check release lock artifacts against PyPI metadata before installing release tools, including their hashes, upload -times, and index origin. +times, and index origin. Use a pinned CA bundle for the metadata request. diff --git a/changelog.d/+pypi-verifier-trust.security.md b/changelog.d/+pypi-verifier-trust.security.md new file mode 100644 index 0000000..d3b94a5 --- /dev/null +++ b/changelog.d/+pypi-verifier-trust.security.md @@ -0,0 +1 @@ +Use a pinned CA bundle when comparing published PyPI files with local release artifacts. diff --git a/changelog.d/+release-lock-preview.security.md b/changelog.d/+release-lock-preview.security.md new file mode 100644 index 0000000..12d3355 --- /dev/null +++ b/changelog.d/+release-lock-preview.security.md @@ -0,0 +1 @@ +Check changed release locks against project requirements in pull requests, including every declared release group. diff --git a/changelog.d/+updater-hook-age.security.md b/changelog.d/+updater-hook-age.security.md new file mode 100644 index 0000000..b3fbbcf --- /dev/null +++ b/changelog.d/+updater-hook-age.security.md @@ -0,0 +1 @@ +Require the generated release-tool update PR to pass the same seven-day hook revision check as other bot PRs. From da27712c6acedf142b4dcdc9d44f563c85b5d4ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 17:37:34 -0700 Subject: [PATCH 38/56] fix: verify version outside assembly --- .../workflows/prepare-towncrier-release.yaml | 17 ++++++++ README.md | 7 +-- changelog.d/+independent-version.security.md | 1 + tests/release_workflow/test_graph.py | 43 +++++++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 changelog.d/+independent-version.security.md diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 0e29ac6..b8677c7 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -141,6 +141,23 @@ jobs: mkdir -p "$RUNNER_TEMP/local-release-cli" ln -s "$GITHUB_WORKSPACE/.venv/bin/release-creator" "$RUNNER_TEMP/local-release-cli/release-creator" echo "$RUNNER_TEMP/local-release-cli" >>"$GITHUB_PATH" + - id: config + uses: $/read-towncrier-config + - name: Verify selected release version + env: + BUMP: ${{ inputs.bump }} + TAG_PREFIX: ${{ inputs.tag-prefix }} + FRAGMENTS: ${{ steps.config.outputs.fragments }} + VERSION: ${{ needs.assemble.outputs.version }} + run: | + set -euo pipefail + args=(--next "$BUMP" --directory "$FRAGMENTS") + if [[ -n "$TAG_PREFIX" ]]; then args+=(--prefix "$TAG_PREFIX"); fi + expected=$(git tag --list | release-creator tags "${args[@]}") + if [[ "$VERSION" != "$expected" ]]; then + echo "::error::assembled release version differs from $expected" >&2 + exit 1 + fi - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.assemble.outputs.artifact-id }} diff --git a/README.md b/README.md index da7cbfb..d06856a 100644 --- a/README.md +++ b/README.md @@ -287,9 +287,10 @@ build package versions; otherwise the Action fails instead of writing an ambiguo ## Prepare and tag with one reusable workflow `prepare-towncrier-release.yaml` runs three jobs. `assemble` selects a tag and uploads a Towncrier patch without the App -key. `validate` applies the uploaded artifact in a separate job and runs `prek`. `push` downloads that artifact by ID, -checks its paths and file modes, reads the caller's `release-auth` environment, and uses a repository-scoped App token -to push the release commit and tag together. Only a dispatch from `main` can reach the writer. +key. `validate` calculates the next tag again from the checked-out source, then applies the uploaded artifact and runs +`prek`. `push` downloads that artifact by ID, checks its paths and file modes, reads the caller's `release-auth` +environment, and uses a repository-scoped App token to push the release commit and tag together. Only a dispatch from +`main` can reach the writer. ```yaml jobs: diff --git a/changelog.d/+independent-version.security.md b/changelog.d/+independent-version.security.md new file mode 100644 index 0000000..bb6b620 --- /dev/null +++ b/changelog.d/+independent-version.security.md @@ -0,0 +1 @@ +Recalculate the release version in the keyless validation job before the App-token writer accepts an assembled patch. diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 93dd742..b739ee4 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -54,6 +54,49 @@ def test_prepare_validates_uploaded_patch_before_writer() -> None: ) +@pytest.mark.parametrize( + ("version", "prefix", "accepted"), + [ + pytest.param("1.2.3", "", True, id="calculated-version"), + pytest.param("v1.2.3", "v", True, id="prefixed-version"), + pytest.param("9.9.9", "", False, id="forged-version"), + ], +) +def test_prepare_validation_calculates_version_independently( + tmp_path: Path, version: str, prefix: str, accepted: bool +) -> None: + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() + ) + steps = workflow["jobs"]["validate"]["steps"] + verify = next(step for step in steps if step.get("name") == "Verify selected release version") + command = tmp_path / "bin/release-creator" + command.parent.mkdir() + command.write_text(f"#!{sys.executable}\nimport sys\nprint('v1.2.3' if '--prefix' in sys.argv else '1.2.3')\n") + command.chmod(0o755) + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", verify["run"]], + cwd=tmp_path, + env={ + **os.environ, + "PATH": f"{command.parent}{os.pathsep}{os.environ['PATH']}", + "BUMP": "auto", + "TAG_PREFIX": prefix, + "FRAGMENTS": "changelog.d", + "VERSION": version, + }, + capture_output=True, + text=True, + check=False, + ) + + assert (result.returncode == 0) is accepted + if not accepted: + assert "assembled release version differs from 1.2.3" in result.stderr + + @pytest.mark.parametrize("job", ["assemble", "validate"]) def test_prepare_builds_local_tool_with_locked_backends(job: str) -> None: workflow = yaml.safe_load( From 726545f36782b48c654b6071e3292745591c692f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 17:41:50 -0700 Subject: [PATCH 39/56] fix: download generated patches by ID --- .github/workflows/upgrade-release-tools.yaml | 12 ++++++++---- tests/upgrade_release_tools/test_upgrade.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index e7785f9..ea03be3 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -55,6 +55,7 @@ jobs: contents: read outputs: changed: ${{ steps.lock.outputs.changed }} + artifact-id: ${{ steps.upload.outputs.artifact-id }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -75,7 +76,8 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + - id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.lock.outputs.changed == 'true' with: name: release-tools-lock @@ -91,6 +93,7 @@ jobs: contents: read outputs: changed: ${{ steps.hooks.outputs.changed }} + artifact-id: ${{ steps.upload.outputs.artifact-id }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -122,7 +125,8 @@ jobs: fi echo 'changed=true' >>"$GITHUB_OUTPUT" git diff --binary -- .pre-commit-config.yaml >"$RUNNER_TEMP/hooks.patch" - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + - id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.hooks.outputs.changed == 'true' with: name: release-tools-hooks @@ -147,12 +151,12 @@ jobs: - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 if: needs.generate-lock.outputs.changed == 'true' with: - name: release-tools-lock + artifact-ids: ${{ needs.generate-lock.outputs.artifact-id }} path: ${{ runner.temp }}/release-tools-lock - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 if: needs.generate-hooks.outputs.changed == 'true' with: - name: release-tools-hooks + artifact-ids: ${{ needs.generate-hooks.outputs.artifact-id }} path: ${{ runner.temp }}/release-tools-hooks - name: Check artifact patch paths env: diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 5d13f6e..4724410 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -307,6 +307,25 @@ def test_upgrade_combines_lock_and_hook_patches(lock_repository: Path, tmp_path: ] +@pytest.mark.parametrize("kind", ["lock", "hooks"], ids=["lock", "hooks"]) +def test_upgrade_downloads_the_generated_patch(kind: str) -> None: + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows/upgrade-release-tools.yaml").read_text() + ) + generator = workflow["jobs"][f"generate-{kind}"] + download = next( + step + for step in workflow["jobs"]["publish"]["steps"] + if step.get("with", {}).get("path") == f"${{{{ runner.temp }}}}/release-tools-{kind}" + ) + + assert generator["outputs"]["artifact-id"] == "${{ steps.upload.outputs.artifact-id }}" + assert next(step for step in generator["steps"] if step.get("id") == "upload")["with"]["name"] == ( + f"release-tools-{kind}" + ) + assert download["with"]["artifact-ids"] == f"${{{{ needs.generate-{kind}.outputs.artifact-id }}}}" + + def test_upgrade_preserves_requested_pull_request_body( lock_repository: Path, tmp_path: Path, publish_scripts: Path ) -> None: From b4dd0ef7fbb8a5d2ed746cafd2b656418eee59e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 17:45:48 -0700 Subject: [PATCH 40/56] fix: verify bundled secondary locks --- README.md | 2 + install-locked-tools/action.yml | 26 +++++++ .../test_secondary_lock.py | 78 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 tests/install_locked_tools/test_secondary_lock.py diff --git a/README.md b/README.md index d06856a..06e2721 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ constraints generated from the lock's `build` group under the runner's temporary caller's lock then needs the selected groups but does not need a release-creator package entry. - `constraints-lock`: lock used to export build constraints; defaults to the selected install lock. Use a secondary lock when the build matrix differs from the release-tool matrix. +- `verify-lock` and `verify-groups`: check another lock's selected packages against PyPI and the seven-day cutoff + without installing them. Use this before bundling packages from a separate runtime lock, such as a zipapp. - `target-platform` and `targets`: Nab platform ID and space-separated `full-version:implementation` pairs for build constraints. Pass both when exporting constraints for a specific build target; otherwise the runner selects the target. diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 6f07d5d..af120cf 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -16,6 +16,12 @@ inputs: constraints-lock: description: Lock used to export build constraints; defaults to the selected install lock default: "" + verify-lock: + description: Another lock whose selected packages will be included in a release artifact + default: "" + verify-groups: + description: Whitespace-separated groups to verify in verify-lock + default: "" target-platform: description: Nab platform ID for the exported build constraints default: "" @@ -92,6 +98,26 @@ runs: "${lock_check[@]}" fi printf 'lock=%s\n' "$lock" >>"$GITHUB_OUTPUT" + - name: Verify additional release lock + if: inputs.verify-lock != '' || inputs.verify-groups != '' + shell: bash + env: + VERIFY_LOCK: ${{ inputs.verify-lock }} + VERIFY_GROUPS: ${{ inputs.verify-groups }} + run: | + set -euo pipefail + groups=() + while IFS= read -r line; do + read -ra selected <<<"$line" + groups+=("${selected[@]}") + done <<<"$VERIFY_GROUPS" + if [[ -z "$VERIFY_LOCK" || ${#groups[@]} -eq 0 ]]; then + echo '::error::verify-lock and verify-groups must be set together' >&2 + exit 1 + fi + uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$VERIFY_LOCK" "${groups[@]}" \ + --verify-pypi --project pyproject.toml - id: constraints name: Export locked build requirements shell: bash diff --git a/tests/install_locked_tools/test_secondary_lock.py b/tests/install_locked_tools/test_secondary_lock.py new file mode 100644 index 0000000..0ee8781 --- /dev/null +++ b/tests/install_locked_tools/test_secondary_lock.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +from conftest import action + + +def test_verify_secondary_lock_accepts_cooled_selection(tmp_path: Path, action_scripts: Path) -> None: + lock = tmp_path / "pylock.zipapp.toml" + lock.write_text( + 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' + 'dependency-groups = ["project-runtime"]\ncreated-by = "test"\npackages = []\n' + "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" + ) + + result = action( + Path(__file__).resolve().parents[2], + tmp_path, + action_scripts, + "install-locked-tools", + version="", + LOCK_SOURCE="shared", + VERIFY_LOCK=str(lock), + VERIFY_GROUPS="project-runtime", + ) + + assert result.returncode == 0, result.stderr + + +def test_verify_secondary_lock_rejects_direct_source(tmp_path: Path, action_scripts: Path) -> None: + lock = tmp_path / "pylock.zipapp.toml" + lock.write_text( + dedent( + """\ + lock-version = "1.0" + requires-python = ">=3.15" + dependency-groups = ["project-runtime"] + created-by = "test" + [tool.nab] + created-at = 2026-10-19T00:00:00Z + [[packages]] + name = "six" + version = "1.16.0" + marker = '"project-runtime" in dependency_groups' + archive = { url = "https://evil.invalid/six.whl", hashes = { sha256 = "0000000000000000000000000000000000000000000000000000000000000000" } } + """ + ) + ) + + result = action( + Path(__file__).resolve().parents[2], + tmp_path, + action_scripts, + "install-locked-tools", + version="", + LOCK_SOURCE="shared", + VERIFY_LOCK=str(lock), + VERIFY_GROUPS="project-runtime", + ) + + assert result.returncode != 0 + assert "direct-source package: six" in result.stderr + + +def test_verify_secondary_lock_requires_group(tmp_path: Path, action_scripts: Path) -> None: + result = action( + Path(__file__).resolve().parents[2], + tmp_path, + action_scripts, + "install-locked-tools", + version="", + LOCK_SOURCE="shared", + VERIFY_LOCK="pylock.zipapp.toml", + ) + + assert result.returncode != 0 + assert "verify-lock and verify-groups must be set together" in result.stderr From 2637a3325e5b87e46c6e04ff9ed67303568f5239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 17:47:32 -0700 Subject: [PATCH 41/56] docs: show secondary lock verification --- README.md | 11 +++++++++++ changelog.d/+secondary-runtime-lock.feature.md | 2 ++ 2 files changed, 13 insertions(+) create mode 100644 changelog.d/+secondary-runtime-lock.feature.md diff --git a/README.md b/README.md index 06e2721..5aefcda 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,17 @@ The `build-constraints` output names the hashed constraints file. The Action add `actions/checkout` and `astral-sh/setup-uv` with Python 3.15 before it. It bootstraps Nab from the shared, hash-checked lock. +For a zipapp that bundles wheels from a separate runtime lock, check that lock in the same job before building it: + +```yaml + - uses: tox-dev/github-workflows/install-locked-tools@SHA + with: + groups: release-build + venv: ${{ runner.temp }}/release-tools + verify-lock: pylock.zipapp.toml + verify-groups: project-runtime +``` + ## Check pull request news `validate-news` reads `tool.towncrier` from `pyproject.toml`. It checks every added or modified fragment in the diff --git a/changelog.d/+secondary-runtime-lock.feature.md b/changelog.d/+secondary-runtime-lock.feature.md new file mode 100644 index 0000000..8f1f4e2 --- /dev/null +++ b/changelog.d/+secondary-runtime-lock.feature.md @@ -0,0 +1,2 @@ +`install-locked-tools` can verify a separate runtime lock against PyPI and the seven-day cutoff before its packages are +bundled into a release artifact. From 3bfe4a789cd74f418cba2282f4faedf01d4f002a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 17:53:43 -0700 Subject: [PATCH 42/56] fix: verify secondary build tool groups --- README.md | 3 +++ .../+secondary-runtime-lock.feature.md | 4 +-- export-build-constraints/action.yml | 5 ++++ tests/conftest.py | 9 ++++++- .../test_secondary_lock.py | 27 ++++++++++++------- 5 files changed, 36 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5aefcda..6bda798 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,9 @@ For a zipapp that bundles wheels from a separate runtime lock, check that lock i verify-groups: project-runtime ``` +`export-build-constraints` also accepts `verify-groups`. Pass it when its secondary lock supplies build tools outside +the `build` group, such as profile-guided optimization tools in a `pgo` group. + ## Check pull request news `validate-news` reads `tool.towncrier` from `pyproject.toml`. It checks every added or modified fragment in the diff --git a/changelog.d/+secondary-runtime-lock.feature.md b/changelog.d/+secondary-runtime-lock.feature.md index 8f1f4e2..0da5f2d 100644 --- a/changelog.d/+secondary-runtime-lock.feature.md +++ b/changelog.d/+secondary-runtime-lock.feature.md @@ -1,2 +1,2 @@ -`install-locked-tools` can verify a separate runtime lock against PyPI and the seven-day cutoff before its packages are -bundled into a release artifact. +`install-locked-tools` and `export-build-constraints` can verify selected groups from a separate lock against PyPI and +the seven-day cutoff before its packages enter a release artifact. diff --git a/export-build-constraints/action.yml b/export-build-constraints/action.yml index 6d908a3..1304ebb 100644 --- a/export-build-constraints/action.yml +++ b/export-build-constraints/action.yml @@ -10,6 +10,9 @@ inputs: targets: description: Space-separated pairs such as 3.15.0:cpython; selected package versions must match default: "" + verify-groups: + description: Additional lock groups whose packages enter the release build + default: "" outputs: path: description: Path to the generated constraints for this lock selection @@ -22,5 +25,7 @@ runs: with: lock-source: shared constraints-lock: ${{ inputs.lock }} + verify-lock: ${{ inputs.verify-groups != '' && inputs.lock || '' }} + verify-groups: ${{ inputs.verify-groups }} target-platform: ${{ inputs.target-platform }} targets: ${{ inputs.targets }} diff --git a/tests/conftest.py b/tests/conftest.py index 36c163d..dbf1c1d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -608,7 +608,14 @@ def _expression( if " && " in expression and " || " in expression: condition, alternatives = expression.split(" && ", 1) true_value, false_value = alternatives.split(" || ", 1) - return (true_value if _condition(condition, environment, inputs, step_outputs) else false_value).strip("'") + return _expression( + true_value if _condition(condition, environment, inputs, step_outputs) else false_value, + environment, + inputs, + step_outputs, + ) + if expression.startswith("'") and expression.endswith("'"): + return expression[1:-1] if " == " in expression or " != " in expression: return str(_condition(expression, environment, inputs, step_outputs)).lower() if " || " in expression: diff --git a/tests/install_locked_tools/test_secondary_lock.py b/tests/install_locked_tools/test_secondary_lock.py index 0ee8781..2fc623d 100644 --- a/tests/install_locked_tools/test_secondary_lock.py +++ b/tests/install_locked_tools/test_secondary_lock.py @@ -3,6 +3,7 @@ from pathlib import Path from textwrap import dedent +import pytest from conftest import action @@ -28,22 +29,31 @@ def test_verify_secondary_lock_accepts_cooled_selection(tmp_path: Path, action_s assert result.returncode == 0, result.stderr -def test_verify_secondary_lock_rejects_direct_source(tmp_path: Path, action_scripts: Path) -> None: +@pytest.mark.parametrize( + ("action_name", "group"), + [ + pytest.param("install-locked-tools", "project-runtime", id="zipapp-runtime"), + pytest.param("export-build-constraints", "pgo", id="profile-build"), + ], +) +def test_verify_secondary_lock_rejects_direct_source( + tmp_path: Path, action_scripts: Path, action_name: str, group: str +) -> None: lock = tmp_path / "pylock.zipapp.toml" lock.write_text( dedent( - """\ + f"""\ lock-version = "1.0" requires-python = ">=3.15" - dependency-groups = ["project-runtime"] + dependency-groups = ["build", "{group}"] created-by = "test" [tool.nab] created-at = 2026-10-19T00:00:00Z [[packages]] name = "six" version = "1.16.0" - marker = '"project-runtime" in dependency_groups' - archive = { url = "https://evil.invalid/six.whl", hashes = { sha256 = "0000000000000000000000000000000000000000000000000000000000000000" } } + marker = '"{group}" in dependency_groups' + archive = {{ url = "https://evil.invalid/six.whl", hashes = {{ sha256 = "0000000000000000000000000000000000000000000000000000000000000000" }} }} """ ) ) @@ -52,11 +62,10 @@ def test_verify_secondary_lock_rejects_direct_source(tmp_path: Path, action_scri Path(__file__).resolve().parents[2], tmp_path, action_scripts, - "install-locked-tools", + action_name, version="", - LOCK_SOURCE="shared", - VERIFY_LOCK=str(lock), - VERIFY_GROUPS="project-runtime", + VERIFY_GROUPS=group, + **({"LOCK_SOURCE": "shared", "VERIFY_LOCK": str(lock)} if group == "project-runtime" else {"LOCK": str(lock)}), ) assert result.returncode != 0 From 1b37603f6a9f57919f6e064d60749ed7d978d117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 22:50:46 -0700 Subject: [PATCH 43/56] =?UTF-8?q?=F0=9F=90=9B=20fix(hooks):=20verify=20man?= =?UTF-8?q?aged=20update=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+managed-hook-pins.security.md | 2 + check-hook-update-cooldown/action.yml | 37 +- .../test_cooldown.py | 58 ++- .../test_hook_config.py | 361 ++++++++++++++++++ tests/conftest.py | 2 + tools/check_hook_config.py | 218 +++++++++++ 6 files changed, 661 insertions(+), 17 deletions(-) create mode 100644 changelog.d/+managed-hook-pins.security.md create mode 100644 tests/check_hook_update_cooldown/test_hook_config.py create mode 100644 tools/check_hook_config.py diff --git a/changelog.d/+managed-hook-pins.security.md b/changelog.d/+managed-hook-pins.security.md new file mode 100644 index 0000000..399fc8e --- /dev/null +++ b/changelog.d/+managed-hook-pins.security.md @@ -0,0 +1,2 @@ +Verify the configured release App before accepting its dependency update PR, and check changed hook pins against cooled +PyPI files while retaining hook revision cooldowns. diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index 0cf1ee4..e635960 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -13,6 +13,7 @@ runs: env: MINIMUM_DAYS: ${{ inputs.minimum-days }} GH_TOKEN: ${{ github.token }} + RELEASE_APP_CLIENT_ID: ${{ vars.RELEASE_APP_CLIENT_ID }} run: | set -euo pipefail REPOSITORY=$GITHUB_REPOSITORY @@ -28,7 +29,7 @@ runs: gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" >"$RUNNER_TEMP/hook-update-pr.json" jq -e --arg repository "$REPOSITORY" --arg head "$HEAD_SHA" ' .state == "open" and .base.repo.full_name == $repository and - .head.sha == $head and + .head.repo.full_name == $repository and .head.sha == $head and (.user.login | type == "string") ' "$RUNNER_TEMP/hook-update-pr.json" >/dev/null if [[ $(jq -r .user.type "$RUNNER_TEMP/hook-update-pr.json") == Bot ]]; then @@ -37,6 +38,22 @@ runs: fi if [[ -f "$RUNNER_TEMP/hook-update-files" ]] && grep -Fxq .pre-commit-config.yaml "$RUNNER_TEMP/hook-update-files"; then + if [[ $(jq -r .head.ref "$RUNNER_TEMP/hook-update-pr.json") == automation/update-release-tools ]]; then + author=$(jq -r .user.login "$RUNNER_TEMP/hook-update-pr.json") + [[ "$author" == *'[bot]' && -n "$RELEASE_APP_CLIENT_ID" ]] || { + echo '::error::release tool branch requires the configured release App' >&2 + exit 1 + } + slug=${author%'[bot]'} + gh api "apps/$slug" >"$RUNNER_TEMP/release-app.json" + jq -e --arg slug "$slug" --arg client "$RELEASE_APP_CLIENT_ID" ' + .slug == $slug and .client_id == $client + ' "$RUNNER_TEMP/release-app.json" >/dev/null || { + echo '::error::release tool branch has a different App identity' >&2 + exit 1 + } + echo 'managed=true' >>"$GITHUB_OUTPUT" + fi gh api --paginate -X GET "repos/$REPOSITORY/actions/runs" -f event=pull_request \ -f head_sha="$HEAD_SHA" -f per_page=100 >"$RUNNER_TEMP/hook-update-runs.jsonl" if jq -se --arg repository "$REPOSITORY" \ @@ -62,7 +79,7 @@ runs: - uses: $/install-locked-tools if: steps.identity.outputs.bot == 'true' with: - groups: release-hook-check + groups: release-hook-check lock-reader lock-source: shared venv: ${{ runner.temp }}/hook-tools - name: Read the base and proposed hook configurations @@ -84,17 +101,15 @@ runs: - name: Reject non-revision hook changes if: steps.identity.outputs.bot == 'true' shell: bash + env: + MANAGED: ${{ steps.identity.outputs.managed }} + MINIMUM_DAYS: ${{ inputs.minimum-days }} run: | set -euo pipefail - for config in base proposed; do - uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -c ' - .repos |= map(if has("rev") then .rev = null else . end) - ' "$RUNNER_TEMP/$config-hooks.yaml" | jq -S . >"$RUNNER_TEMP/$config-hook-content.json" - done - if ! cmp -s "$RUNNER_TEMP/base-hook-content.json" "$RUNNER_TEMP/proposed-hook-content.json"; then - echo '::error::hook update changes fields beyond revisions' >&2 - exit 1 - fi + uv run --no-project --python "$RUNNER_TEMP/hook-tools" python \ + "$GITHUB_ACTION_PATH/../tools/check_hook_config.py" \ + "$RUNNER_TEMP/base-hooks.yaml" "$RUNNER_TEMP/proposed-hooks.yaml" \ + --managed "$MANAGED" --minimum-days "$MINIMUM_DAYS" - name: Select eligible hook revisions if: steps.identity.outputs.bot == 'true' shell: bash diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index 34a9f38..08bc714 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -120,12 +120,45 @@ def test_cooldown_checks_generated_update_branch( if not accepted: expect_historical_runs(server, older_head=None) - result = run_cooldown(action_scripts, tmp_path, environment) + result = run_cooldown(action_scripts, tmp_path, environment | {"RELEASE_APP_CLIENT_ID": "Iv1.release"}) assert (result.returncode == 0) is accepted, result.stderr assert len(server.log) >= 4 +@pytest.mark.parametrize( + ("client_id", "head_repository"), + [ + pytest.param("Iv1.other", "tox-dev/fixture", id="another-app"), + pytest.param("Iv1.release", "other/fixture", id="fork-head"), + ], +) +def test_cooldown_rejects_untrusted_updater_identity( + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + client_id: str, + head_repository: str, +) -> None: + server, environment = github_api + expect_pull( + server, + author="release-app[bot]", + branch="automation/update-release-tools", + app_client_id=client_id, + head_repository=head_repository, + ) + + result = run_cooldown(action_scripts, tmp_path, environment | {"RELEASE_APP_CLIENT_ID": "Iv1.release"}) + + assert result.returncode != 0 + assert ( + "different App identity" in result.stderr + if head_repository == "tox-dev/fixture" + else [request.path for request, _ in server.log] == ["/api/v3/repos/tox-dev/fixture/pulls/7"] + ) + + def test_cooldown_rejects_hook_configuration_change( tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: @@ -151,13 +184,20 @@ def test_cooldown_rejects_changed_head( assert len(server.log) == 1 -def test_cooldown_rejects_short_minimum( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +@pytest.mark.parametrize( + "minimum_days", + [ + pytest.param("6", id="short"), + pytest.param("07", id="leading-zero"), + pytest.param("bad", id="non-numeric"), + ], +) +def test_cooldown_rejects_invalid_minimum( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], minimum_days: str ) -> None: server, environment = github_api - result = run_cooldown(action_scripts, tmp_path, environment, minimum_days="6") + result = run_cooldown(action_scripts, tmp_path, environment, minimum_days=minimum_days) assert result.returncode != 0 - assert "hook cooldown must be at least seven days" in result.stderr assert not server.log @@ -360,12 +400,14 @@ def expect_pull( updated_days: int = 0, hook_update: bool = True, branch: str = "bot-update", + app_client_id: str = "Iv1.release", + head_repository: str = "tox-dev/fixture", ) -> None: server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7", method="GET").respond_with_json( { "state": "open", "base": {"repo": {"full_name": "tox-dev/fixture"}, "sha": "b" * 40}, - "head": {"sha": head, "ref": branch, "repo": {"full_name": "tox-dev/fixture"}}, + "head": {"sha": head, "ref": branch, "repo": {"full_name": head_repository}}, "user": {"login": author, "type": "Bot" if author.endswith("[bot]") else "User"}, "updated_at": (datetime.now(UTC) - timedelta(days=updated_days)).strftime("%Y-%m-%dT%H:%M:%SZ"), } @@ -375,6 +417,10 @@ def expect_pull( [{"filename": ".pre-commit-config.yaml" if hook_update else "pyproject.toml"}] ) if author.endswith("[bot]") and hook_update: + if branch == "automation/update-release-tools": + server.expect_request("/api/v3/apps/release-app", method="GET").respond_with_json( + {"slug": "release-app", "client_id": app_client_id} + ) server.expect_request( "/api/v3/repos/tox-dev/fixture/actions/runs", method="GET", diff --git a/tests/check_hook_update_cooldown/test_hook_config.py b/tests/check_hook_update_cooldown/test_hook_config.py new file mode 100644 index 0000000..01d5501 --- /dev/null +++ b/tests/check_hook_update_cooldown/test_hook_config.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import json +import runpy +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from io import BytesIO +from pathlib import Path +from typing import cast +from unittest.mock import MagicMock +from urllib.request import urlopen + +import pytest +from pytest_mock import MockerFixture + + +@pytest.fixture +def pypi_file() -> dict[str, object]: + return { + "url": "https://files.pythonhosted.org/example-1.2.0.whl", + "digests": {"sha256": "a" * 64}, + "upload_time_iso_8601": (datetime.now(UTC) - timedelta(days=10)).isoformat(), + } + + +@pytest.fixture +def pypi_request(mocker: MockerFixture, pypi_file: dict[str, object]) -> MagicMock: + return cast("MagicMock", mocker.create_autospec(urlopen, return_value=_response("example", pypi_file))) + + +def _response(name: str, pypi_file: dict[str, object]) -> BytesIO: + return BytesIO(json.dumps({"info": {"name": name, "version": "1.2.0"}, "urls": [pypi_file]}).encode()) + + +@pytest.fixture +def check_config(mocker: MockerFixture, pypi_request: MagicMock) -> Callable[[Path, Path, bool, int], None]: + namespace = runpy.run_path( + str(Path(__file__).resolve().parents[2] / "tools/check_hook_config.py"), run_name="check_hook_config" + ) + mocker.patch.dict(namespace["check_config"].__globals__, {"urlopen": pypi_request}) + return cast("Callable[[Path, Path, bool, int], None]", namespace["check_config"]) + + +@pytest.fixture +def configs(tmp_path: Path) -> tuple[Path, Path]: + base = tmp_path / "base.yaml" + proposed = tmp_path / "proposed.yaml" + base.write_text( + "repos:\n - repo: https://example.org/hook\n rev: old\n hooks:\n" + " - id: sample\n additional_dependencies:\n - example==1.0.0\n" + ) + return base, proposed + + +def test_app_update_accepts_cooled_direct_pin( + configs: tuple[Path, Path], check_config: Callable[..., None], pypi_request: MagicMock +) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("rev: old", "rev: new").replace("example==1.0.0", "example==1.2.0")) + + check_config(base, proposed, True, 7) + assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/example/1.2.0/json" + + +@pytest.mark.parametrize( + ("field", "value"), + [ + pytest.param("upload_time_iso_8601", "recent", id="recent"), + pytest.param("yanked", True, id="yanked"), + pytest.param("url", "https://example.org/wheel.whl", id="foreign-host"), + pytest.param("digests", {}, id="missing-hash"), + ], +) +def test_app_update_rejects_untrusted_pypi_file( + configs: tuple[Path, Path], + check_config: Callable[..., None], + pypi_file: dict[str, object], + pypi_request: MagicMock, + field: str, + value: object, +) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("example==1.0.0", "example==1.2.0")) + pypi_file[field] = (datetime.now(UTC) - timedelta(days=1)).isoformat() if value == "recent" else value + pypi_request.return_value = _response("example", pypi_file) + + with pytest.raises(ValueError, match="uncooled or untrusted PyPI artifact"): + check_config(base, proposed, True, 7) + + +def test_app_update_accepts_cooled_managed_pin( + configs: tuple[Path, Path], check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock +) -> None: + base, proposed = configs + proposed.write_text( + base.read_text().replace( + " - example==1.0.0\n", + ' - example==1.0.0\n - "other==1.2.0" # release-creator managed\n', + ) + ) + + pypi_request.return_value = _response("other", pypi_file) + check_config(base, proposed, True, 7) + assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/other/1.2.0/json" + + +@pytest.mark.parametrize( + ("replacement", "message"), + [ + pytest.param("example>=1.2.0", "not an exact PyPI pin", id="range"), + pytest.param("other==1.2.0", "direct dependencies beyond an exact pin", id="another-package"), + pytest.param("example @ https://example.org/wheel.whl", "direct dependencies beyond an exact pin", id="url"), + ], +) +def test_app_update_rejects_unsafe_direct_change( + configs: tuple[Path, Path], check_config: Callable[..., None], replacement: str, message: str +) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("example==1.0.0", replacement)) + + with pytest.raises(ValueError, match=message): + check_config(base, proposed, True, 7) + + +def test_other_bot_cannot_change_pin(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("example==1.0.0", "example==1.2.0")) + + with pytest.raises(ValueError, match="beyond revisions"): + check_config(base, proposed, False, 7) + + +def test_app_update_keeps_direct_range( + configs: tuple[Path, Path], check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock +) -> None: + base, proposed = configs + base.write_text(base.read_text().replace("example==1.0.0", "example>=1")) + proposed.write_text( + base.read_text().replace( + " - example>=1\n", ' - example>=1\n - "other==1.2.0" # release-creator managed\n' + ) + ) + + pypi_request.return_value = _response("other", pypi_file) + check_config(base, proposed, True, 7) + assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/other/1.2.0/json" + + +def test_app_update_rejects_managed_pin_removal(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + base.write_text( + base.read_text().replace( + " - example==1.0.0\n", + ' - example==1.0.0\n - "other==1.0.0" # release-creator managed\n', + ) + ) + proposed.write_text(configs[0].read_text().replace(' - "other==1.0.0" # release-creator managed\n', "")) + + with pytest.raises(ValueError, match="removes managed dependencies"): + check_config(base, proposed, True, 7) + + +def test_app_update_accepts_managed_pin_refresh( + configs: tuple[Path, Path], check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock +) -> None: + base, proposed = configs + base.write_text( + base.read_text().replace( + " - example==1.0.0\n", + ' - example==1.0.0\n - "other==1.0.0" # release-creator managed\n', + ) + ) + proposed.write_text(base.read_text().replace("other==1.0.0", "other==1.2.0")) + + pypi_request.return_value = _response("other", pypi_file) + check_config(base, proposed, True, 7) + assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/other/1.2.0/json" + + +def test_app_update_accepts_flow_mapping_managed_marker( + configs: tuple[Path, Path], check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock +) -> None: + base, proposed = configs + proposed.write_text( + base.read_text().replace( + " - id: sample\n additional_dependencies:\n - example==1.0.0\n", + " - {id: sample, additional_dependencies: [\n" + " example==1.0.0,\n" + ' "other==1.2.0" # release-creator managed\n' + " ]}\n", + ) + ) + + pypi_request.return_value = _response("other", pypi_file) + check_config(base, proposed, True, 7) + assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/other/1.2.0/json" + + +def test_app_update_rejects_flow_comment_as_pin_marker( + configs: tuple[Path, Path], check_config: Callable[..., None] +) -> None: + base, proposed = configs + proposed.write_text( + base.read_text().replace( + "additional_dependencies:\n - example==1.0.0", + "additional_dependencies: [example==1.0.0, other==1.2.0] # release-creator managed", + ) + ) + + with pytest.raises(ValueError, match="changes direct dependencies"): + check_config(base, proposed, True, 7) + + +def test_app_update_rejects_duplicate_keys(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace(" rev: old", " rev: old\n rev: new")) + + with pytest.raises(ValueError, match="duplicate mapping keys"): + check_config(base, proposed, True, 7) + + +@pytest.mark.parametrize( + ("source", "message"), + [ + pytest.param("", "must be a mapping", id="empty-file"), + pytest.param("[]\n", "must be a mapping", id="list-root"), + pytest.param("repos: bad\n", "repositories must be lists", id="scalar-repos"), + pytest.param("repos:\n - bad\n", "repository must be a mapping", id="scalar-repository"), + pytest.param( + "repos:\n - repo: https://example.org/hook\n", "repository must contain hooks", id="missing-hooks" + ), + pytest.param( + "repos:\n - repo: https://example.org/hook\n hooks:\n - bad\n", + "hook must be a mapping", + id="scalar-hook", + ), + pytest.param( + "repos:\n - repo: https://example.org/hook\n hooks:\n" + " - id: sample\n additional_dependencies: bad\n", + "additional_dependencies must be a list", + id="scalar-dependencies", + ), + pytest.param( + "repos:\n - repo: https://example.org/hook\n hooks:\n" + " - id: sample\n additional_dependencies: [example==1.0.0, {name: bad}]\n", + "additional_dependencies must be a list", + id="mapping-dependency", + ), + ], +) +def test_app_update_rejects_malformed_hook_config( + configs: tuple[Path, Path], check_config: Callable[..., None], source: str, message: str +) -> None: + base, proposed = configs + proposed.write_text(source) + + with pytest.raises((TypeError, ValueError), match=message): + check_config(base, proposed, True, 7) + + +def test_app_update_rejects_changed_hook_count(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text(base.read_text() + " - id: added\n") + + with pytest.raises(ValueError, match="beyond revisions and managed dependencies"): + check_config(base, proposed, True, 7) + + +def test_app_update_rejects_changed_aliased_hook(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + source = base.read_text().replace("- repo:", "- &repository\n repo:") + base.write_text(source.replace(" - example==1.0.0\n", " - example==1.0.0\n - *repository\n")) + proposed.write_text(base.read_text().replace("id: sample", "id: changed")) + + with pytest.raises(ValueError, match="beyond revisions and managed dependencies"): + check_config(base, proposed, True, 7) + + +def test_app_update_rejects_duplicate_dependency(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text( + base.read_text().replace( + " - example==1.0.0\n", + ' - example==1.0.0\n - "example==1.2.0" # release-creator managed\n', + ) + ) + + with pytest.raises(ValueError, match="duplicates a dependency"): + check_config(base, proposed, True, 7) + + +@pytest.mark.parametrize( + "metadata", + [ + pytest.param({"info": {"name": "other", "version": "1.2.0"}, "urls": [{}]}, id="wrong-name"), + pytest.param({"info": {"name": "example", "version": "1.1.0"}, "urls": [{}]}, id="wrong-version"), + pytest.param({"info": {"name": "example", "version": "1.2.0"}, "urls": []}, id="no-files"), + pytest.param({"info": {"name": "example", "version": "1.2.0"}, "urls": "bad"}, id="files-type"), + ], +) +def test_app_update_rejects_invalid_pypi_listing( + configs: tuple[Path, Path], + check_config: Callable[..., None], + pypi_request: MagicMock, + metadata: dict[str, object], +) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("example==1.0.0", "example==1.2.0")) + pypi_request.return_value = BytesIO(json.dumps(metadata).encode()) + + with pytest.raises(ValueError, match="invalid PyPI hook dependency"): + check_config(base, proposed, True, 7) + + +def test_app_cannot_change_hook_command(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("id: sample", "id: other")) + + with pytest.raises(ValueError, match="beyond revisions and managed dependencies"): + check_config(base, proposed, True, 7) + + +def test_app_cannot_add_unmarked_dependency(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text(base.read_text() + " - example==1.2.0\n") + + with pytest.raises(ValueError, match="changes direct dependencies"): + check_config(base, proposed, True, 7) + + +def test_app_cannot_shorter_cooldown(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: + base, proposed = configs + proposed.write_text(base.read_text()) + + with pytest.raises(ValueError, match="at least seven days"): + check_config(base, proposed, True, 6) + + +def test_cli_accepts_revision_only_change( + configs: tuple[Path, Path], mocker: MockerFixture, capsys: pytest.CaptureFixture[str] +) -> None: + base, proposed = configs + proposed.write_text(base.read_text().replace("rev: old", "rev: new")) + mocker.patch("sys.argv", ["check_hook_config.py", str(base), str(proposed), "--managed", "false"]) + + runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/check_hook_config.py"), run_name="__main__") + + assert capsys.readouterr() == ("", "") + + +def test_cli_reports_invalid_config( + configs: tuple[Path, Path], mocker: MockerFixture, capsys: pytest.CaptureFixture[str] +) -> None: + base, proposed = configs + proposed.write_text("repos: bad\n") + mocker.patch("sys.argv", ["check_hook_config.py", str(base), str(proposed), "--managed", "true"]) + + with pytest.raises(SystemExit, match="2"): + runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/check_hook_config.py"), run_name="__main__") + + assert "hook repositories must be lists" in capsys.readouterr().err diff --git a/tests/conftest.py b/tests/conftest.py index dbf1c1d..1e64299 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -642,6 +642,8 @@ def _expression( return environment.get("GITHUB_EVENT_PULL_REQUEST_NUMBER", "") if expression == "github.token": return environment.get("GH_TOKEN", environment.get("GH_ENTERPRISE_TOKEN", "")) + if expression == "vars.RELEASE_APP_CLIENT_ID": + return environment.get("RELEASE_APP_CLIENT_ID", "") if expression == "github.event.pull_request.user.type": return "Bot" if environment.get("BOT_AUTHOR") == "true" else "User" if expression == "contains(github.event.pull_request.labels.*.name, 'skip news')": diff --git a/tools/check_hook_config.py b/tools/check_hook_config.py new file mode 100644 index 0000000..bb6d131 --- /dev/null +++ b/tools/check_hook_config.py @@ -0,0 +1,218 @@ +"""Check the hook changes allowed in a bot pull request.""" + +from __future__ import annotations + +import argparse +import json +import re +import ssl +from collections.abc import Iterator +from copy import deepcopy +from datetime import UTC, datetime, timedelta +from pathlib import Path +from re import Pattern +from typing import Final, cast +from urllib.parse import quote, urlsplit +from urllib.request import Request, urlopen + +import certifi +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name +from packaging.version import Version +from yaml import MappingNode, ScalarNode, SequenceNode, YAMLError, compose, safe_load +from yaml.error import Mark + +_MANAGED_MARKER: Final[Pattern[str]] = re.compile( + r""" + ^ \s* ,? \s* + (?P [#] \s+ release-creator \s+ managed ) + (?: \s | $ ) + """, + re.VERBOSE, +) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("base", type=Path) + parser.add_argument("proposed", type=Path) + parser.add_argument("--managed", choices=("true", "false", ""), default="") + parser.add_argument("--minimum-days", type=int, default=7) + args = parser.parse_args() + try: + check_config(args.base, args.proposed, args.managed == "true", args.minimum_days) + except (InvalidRequirement, KeyError, OSError, TypeError, ValueError, YAMLError) as error: + parser.error(str(error)) + + +def check_config(base: Path, proposed: Path, managed: bool, minimum_days: int) -> None: + if minimum_days < 7: + raise ValueError("hook cooldown must be at least seven days") + base_source = base.read_text(encoding="utf-8") + proposed_source = proposed.read_text(encoding="utf-8") + _reject_duplicate_keys(base_source) + _reject_duplicate_keys(proposed_source) + old = safe_load(base_source) + new = safe_load(proposed_source) + if not isinstance(old, dict) or not isinstance(new, dict): + raise TypeError("hook configuration must be a mapping") + old_content = deepcopy(old) + new_content = deepcopy(new) + old_repos = old_content.get("repos") + new_repos = new_content.get("repos") + if not isinstance(old_repos, list) or not isinstance(new_repos, list): + raise TypeError("hook repositories must be lists") + for repository in (*old_repos, *new_repos): + if not isinstance(repository, dict): + raise TypeError("hook repository must be a mapping") + repository.pop("rev", None) + if managed: + old_pins = list(_dependency_groups(base_source)) + new_pins = list(_dependency_groups(proposed_source)) + if len(old_pins) != len(new_pins): + raise ValueError("hook update changes fields beyond revisions and managed dependencies") + for old_group, new_group in zip(old_pins, new_pins, strict=True): + _check_dependency_group(old_group, new_group, minimum_days) + for repository in (*old_repos, *new_repos): + for hook in repository.get("hooks", []): + hook.pop("additional_dependencies", None) + if old_content != new_content: + raise ValueError( + "hook update changes fields beyond revisions and managed dependencies" + if managed + else "hook update changes fields beyond revisions" + ) + + +def _dependency_groups(source: str) -> Iterator[list[tuple[str, bool]]]: + root = cast("MappingNode", compose(source)) + repositories = cast("SequenceNode", _value(root, "repos")) + for repository in repositories.value: + hooks = _value(cast("MappingNode", repository), "hooks") + if not isinstance(hooks, SequenceNode): + raise TypeError("hook repository must contain hooks") + for hook in hooks.value: + if not isinstance(hook, MappingNode): + raise TypeError("hook must be a mapping") + dependencies = _value(hook, "additional_dependencies") + if dependencies is None: + yield [] + continue + if not isinstance(dependencies, SequenceNode) or not all( + isinstance(item, ScalarNode) for item in dependencies.value + ): + raise TypeError("additional_dependencies must be a list of package strings") + yield [(item.value, _managed_marker(source, item)) for item in dependencies.value] + + +def _managed_marker(source: str, item: ScalarNode) -> bool: + suffix = source[cast("Mark", item.end_mark).index :].split("\n", 1)[0] + return _MANAGED_MARKER.match(suffix) is not None + + +def _reject_duplicate_keys(source: str) -> None: + root = compose(source) + pending = [root] + visited: set[int] = set() + while pending: + node = pending.pop() + if node is None or id(node) in visited: + continue + visited.add(id(node)) + if isinstance(node, MappingNode): + names = [key.value for key, _ in node.value if isinstance(key, ScalarNode)] + if len(names) != len(set(names)): + raise ValueError("hook configuration contains duplicate mapping keys") + pending.extend(child for pair in node.value for child in pair) + elif isinstance(node, SequenceNode): + pending.extend(node.value) + + +def _value(node: MappingNode, name: str) -> object: + return next((value for key, value in node.value if isinstance(key, ScalarNode) and key.value == name), None) + + +def _check_dependency_group(old: list[tuple[str, bool]], new: list[tuple[str, bool]], minimum_days: int) -> None: + old_direct = [item for item, managed in old if not managed] + new_direct = [item for item, managed in new if not managed] + if len(old_direct) != len(new_direct): + raise ValueError("hook update changes direct dependencies") + for previous, proposed in zip(old_direct, new_direct, strict=True): + if previous == proposed: + continue + old_requirement = Requirement(previous) + new_requirement = Requirement(proposed) + if ( + canonicalize_name(old_requirement.name) != canonicalize_name(new_requirement.name) + or old_requirement.extras != new_requirement.extras + or old_requirement.marker != new_requirement.marker + or old_requirement.url is not None + or new_requirement.url is not None + or not _exact(old_requirement) + ): + raise ValueError("hook update changes direct dependencies beyond an exact pin") + _check_pypi(new_requirement, minimum_days) + previous_managed = {_requirement_key(Requirement(item)) for item, marked in old if marked} + proposed_managed = {_requirement_key(Requirement(item)) for item, marked in new if marked} + if previous_managed - proposed_managed: + raise ValueError("hook update removes managed dependencies") + names = [canonicalize_name(Requirement(item).name) for item, _ in new] + if len(names) != len(set(names)): + raise ValueError("hook update duplicates a dependency") + for item, marked in new: + if marked and item not in {previous for previous, old_marked in old if old_marked}: + _check_pypi(Requirement(item), minimum_days) + + +def _requirement_key(requirement: Requirement) -> tuple[str, frozenset[str], str]: + return canonicalize_name(requirement.name), frozenset(requirement.extras), str(requirement.marker) + + +def _exact(requirement: Requirement) -> bool: + return ( + requirement.url is None + and len(requirement.specifier) == 1 + and (specifier := next(iter(requirement.specifier))).operator == "==" + and "*" not in specifier.version + ) + + +def _check_pypi(requirement: Requirement, minimum_days: int) -> None: + if not _exact(requirement): + raise ValueError(f"hook dependency is not an exact PyPI pin: {requirement}") + version = Version(next(iter(requirement.specifier)).version) + name = canonicalize_name(requirement.name) + request = Request( + f"https://pypi.org/pypi/{quote(name)}/{quote(str(version))}/json", + headers={"Cache-Control": "no-cache"}, + ) + with urlopen(request, timeout=10, context=ssl.create_default_context(cafile=certifi.where())) as response: + listing = json.load(response) + files = listing.get("urls") + if ( + canonicalize_name(listing.get("info", {}).get("name", "")) != name + or Version(listing.get("info", {}).get("version", "0")) != version + or not isinstance(files, list) + or not files + ): + raise ValueError(f"invalid PyPI hook dependency: {requirement}") + cutoff = datetime.now(UTC) - timedelta(days=minimum_days) + for file in files: + url = urlsplit(file.get("url", "")) + uploaded = datetime.fromisoformat(file.get("upload_time_iso_8601", "")) + if ( + file.get("yanked") + or url.scheme != "https" + or url.netloc != "files.pythonhosted.org" + or not file.get("digests", {}).get("sha256") + or uploaded.tzinfo is None + or uploaded > cutoff + ): + raise ValueError(f"hook dependency has an uncooled or untrusted PyPI artifact: {requirement}") + + +if __name__ == "__main__": + main() + + +__all__ = ["check_config", "main"] From 5eae88d8b05957377f2989cdcebd7cd7a461ffe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 22:50:49 -0700 Subject: [PATCH 44/56] =?UTF-8?q?=F0=9F=90=9B=20fix(locks):=20check=20seco?= =?UTF-8?q?ndary=20PR=20locks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 +++++----- ...emove-unused-working-directory.breaking.md | 2 + changelog.d/+secondary-pr-lock.security.md | 2 + read-towncrier-config/action.yml | 5 -- tests/export_build_constraints/test_export.py | 14 +++-- .../test_secondary_lock.py | 30 ++++++++++ tests/validate_news/test_lock_groups.py | 4 +- tests/validate_news/test_news.py | 58 +++++++++++++++++++ tests/validate_release_patch/test_validate.py | 9 +-- tools/lock_groups.py | 2 +- validate-news/action.yml | 30 +++++++++- validate-release-patch/action.yml | 13 ----- 12 files changed, 150 insertions(+), 48 deletions(-) create mode 100644 changelog.d/+remove-unused-working-directory.breaking.md create mode 100644 changelog.d/+secondary-pr-lock.security.md diff --git a/README.md b/README.md index 6bda798..cff4600 100644 --- a/README.md +++ b/README.md @@ -118,17 +118,17 @@ jobs: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: lock-groups: >- - build lock-tools lock-reader release-notes release-news release-prepare + build release-notes release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check ``` The required `lock-groups` input lists the caller's release dependency groups, separated by spaces or newlines. Include -`build`, `lock-tools`, and `lock-reader` where declared; exclude test, type, and documentation groups. This example -lists this repository's groups; a caller supplies its own. The project configures its Python and platform targets in -`[tool.nab]`. Tests install test, type, and documentation groups afresh so they detect upstream dependency changes. The -Action tests fetch immutable, hash-checked release-tool artifacts from the shared lock; their separate package fixture -resolves test-only tools afresh. The updater also refreshes a caller's secondary build-matrix lock when +`build` and the release groups used by the caller. `lock-tools` and `lock-reader` come from this Action's lock; include +them only when the caller also uses them. Exclude test, type, and documentation groups. The project configures targets +in `[tool.nab]`. Tests install test, type, and documentation groups afresh so they detect upstream dependency changes. +The Action tests fetch immutable, hash-checked release-tool artifacts from the shared lock; their separate package +fixture resolves test-only tools afresh. The updater also refreshes a caller's secondary build-matrix lock when `additional-lock` and its matrix inputs are set. The optional `commit-message`, `pull-request-title`, and `pull-request-body` inputs retain existing PR wording. The @@ -140,6 +140,8 @@ For a second lock such as `pylock.build.toml`, set `additional-lock`, `additiona groups in that lock; with no groups, the updater selects build requirements. Set `additional-lock-selection: project` for a lock that selects the project's runtime dependencies instead. The lock must already exist, and the updater writes both locks in the same dependency PR. The writer accepts only those locks, `pyproject.toml`, and the hook configuration. +Pass the same `additional-lock` and matrix inputs to `validate-news` in the PR workflow. It checks changes to either +lock against the project, including a PR that changes only the secondary lock. `refresh-release-lock` is the read-only half of the updater. In `refresh` mode, it upgrades the selected release groups under Nab's configured cutoff and writes `$RUNNER_TEMP/lock.patch` if either lock or a reviewed expired Nab cutoff @@ -181,8 +183,9 @@ with `actions: read`, `contents: read`, and `pull-requests: read`, then include its PR number and head SHA from the event, compares them with the live PR, and requires each proposed revision to have appeared in a GitHub PR workflow run at least `minimum-days` earlier. The default is seven days. A force push does not reset an unchanged revision's age or give a new revision the old one's age. Human PRs pass without installing hook -tools. Bot PRs may change hook revisions only. The generated `automation/update-release-tools` PR follows the same -seven-day observation rule, so its current hook revisions may remain pending after the lock refresh. +tools. Other bot PRs may change hook revisions only. On `automation/update-release-tools`, the Action verifies the bot +against `RELEASE_APP_CLIENT_ID` and permits exact, cooled PyPI dependency pins generated by the release App. It keeps +the same seven-day revision observation rule, so current revisions may remain pending after the lock refresh. ```yaml hook-cooldown: @@ -276,11 +279,9 @@ from the distribution build. ```yaml - uses: tox-dev/github-workflows/build-python-distribution@SHA - with: - source: . ``` -`source` defaults to `.`. The Action has no named output; subsequent steps read `dist/`. +The Action builds the checkout root and has no named output; subsequent steps read `dist/`. ## Export constraints for a build matrix @@ -353,8 +354,8 @@ committed bytes so an untrusted release patch cannot change the allowed paths or run: printf '%s\n' "$CHANGELOG" ``` -`working-directory` defaults to `.`; pass a different checkout when validating a patch in a linked worktree. Outputs are -`changelog`, `fragments`, and `format` (`markdown` or `rst`). The Action does not modify the checkout or GitHub state. +Outputs are `changelog`, `fragments`, and `format` (`markdown` or `rst`). The Action does not modify the checkout or +GitHub state. ## Prepare Towncrier release @@ -409,8 +410,6 @@ and mode checks with `checks: "false"`, without installing release tools. - `version` **(required)**: Exact tag chosen by preparation. `release-creator notes` must find one valid section for it. - `patch` **(required)**: Local path of the downloaded patch artifact, not an artifact name or URL. -- `working-directory`: Checkout where the patch is applied; default `.`. Assembly uses a fresh linked worktree here so - validation cannot pass because of files left by preparation. - `checks`: `true` (default) runs `release-creator` and `prek`; `false` only applies and checks the validated patch. **Output:** No named output. **Working-tree effect:** Applies and stages the patch. The Action reads the changelog path, diff --git a/changelog.d/+remove-unused-working-directory.breaking.md b/changelog.d/+remove-unused-working-directory.breaking.md new file mode 100644 index 0000000..4ec64c7 --- /dev/null +++ b/changelog.d/+remove-unused-working-directory.breaking.md @@ -0,0 +1,2 @@ +Remove the unused `working-directory` input from the release patch and Towncrier configuration Actions. They operate on +the current checkout. diff --git a/changelog.d/+secondary-pr-lock.security.md b/changelog.d/+secondary-pr-lock.security.md new file mode 100644 index 0000000..60b98dd --- /dev/null +++ b/changelog.d/+secondary-pr-lock.security.md @@ -0,0 +1,2 @@ +Check secondary release locks during pull request news validation when callers pass their secondary lock and matrix +inputs. diff --git a/read-towncrier-config/action.yml b/read-towncrier-config/action.yml index 070e73d..0cf8a4a 100644 --- a/read-towncrier-config/action.yml +++ b/read-towncrier-config/action.yml @@ -1,9 +1,5 @@ name: Read Towncrier configuration description: Read release paths, format, and fragment categories from the committed Towncrier configuration -inputs: - working-directory: - description: Checkout containing the configuration - default: . outputs: changelog: description: Changelog path relative to the repository @@ -20,7 +16,6 @@ runs: - name: Read committed Towncrier configuration id: config shell: bash - working-directory: ${{ inputs.working-directory }} run: | set -euo pipefail config_dir=$(mktemp -d "$RUNNER_TEMP/towncrier-config.XXXXXX") diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index f21344b..83cce9b 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -72,12 +72,13 @@ def test_export_build_constraints_prefers_compatible_caller_build_pin( document = tomlkit.parse(shared.read_text()) for package in document["packages"]: if package["name"] == "setuptools": + current = package["version"] package["version"] = version for wheel in package["wheels"]: - wheel["name"] = wheel["name"].replace("84.0.0", version) - wheel["url"] = wheel["url"].replace("84.0.0", version) - package["sdist"]["name"] = package["sdist"]["name"].replace("84.0.0", version) - package["sdist"]["url"] = package["sdist"]["url"].replace("84.0.0", version) + wheel["name"] = wheel["name"].replace(current, version) + wheel["url"] = wheel["url"].replace(current, version) + package["sdist"]["name"] = package["sdist"]["name"].replace(current, version) + package["sdist"]["url"] = package["sdist"]["url"].replace(current, version) caller.write_text(tomlkit.dumps(document)) output = tmp_path / "constraints.txt" result = subprocess.run( @@ -231,10 +232,11 @@ def test_export_build_constraints_rejects_conflicting_versions_without_policy(tm caller = tmp_path / "pylock.toml" document = tomlkit.parse(shared.read_text()) package = next(package for package in document["packages"] if package["name"] == "setuptools") + current = package["version"] package["version"] = "81.0.0" for artifact in [package["sdist"], *package["wheels"]]: - artifact["name"] = artifact["name"].replace("84.0.0", "81.0.0") - artifact["url"] = artifact["url"].replace("84.0.0", "81.0.0") + artifact["name"] = artifact["name"].replace(current, "81.0.0") + artifact["url"] = artifact["url"].replace(current, "81.0.0") caller.write_text(tomlkit.dumps(document)) result = subprocess.run( diff --git a/tests/install_locked_tools/test_secondary_lock.py b/tests/install_locked_tools/test_secondary_lock.py index 2fc623d..1860701 100644 --- a/tests/install_locked_tools/test_secondary_lock.py +++ b/tests/install_locked_tools/test_secondary_lock.py @@ -85,3 +85,33 @@ def test_verify_secondary_lock_requires_group(tmp_path: Path, action_scripts: Pa assert result.returncode != 0 assert "verify-lock and verify-groups must be set together" in result.stderr + + +def test_verify_secondary_lock_rejects_forged_pypi_hash( + tmp_path: Path, action_scripts: Path, pypi_metadata_environment: None +) -> None: + lock = tmp_path / "pylock.zipapp.toml" + lock.write_text( + 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' + 'dependency-groups = ["project-runtime"]\ncreated-by = "test"\n' + "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" + '[[packages]]\nname = "six"\nversion = "1.16.0"\n' + 'marker = "\'project-runtime\' in dependency_groups"\nindex = "https://pypi.org/simple/"\n' + '[[packages.wheels]]\nname = "six-1.16.0-py2.py3-none-any.whl"\n' + 'url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl"\n' + 'upload-time = 2021-05-05T14:18:17.237677Z\nhashes.sha256 = "' + "0" * 64 + '"\n' + ) + + result = action( + Path(__file__).resolve().parents[2], + tmp_path, + action_scripts, + "install-locked-tools", + version="", + LOCK_SOURCE="shared", + VERIFY_LOCK=str(lock), + VERIFY_GROUPS="project-runtime", + ) + + assert result.returncode != 0 + assert "release lock differs from PyPI metadata" in result.stderr diff --git a/tests/validate_news/test_lock_groups.py b/tests/validate_news/test_lock_groups.py index 2deb1f5..8756174 100644 --- a/tests/validate_news/test_lock_groups.py +++ b/tests/validate_news/test_lock_groups.py @@ -8,7 +8,9 @@ def test_lock_groups_reads_recorded_selection(tmp_path: Path) -> None: - (tmp_path / "pyproject.toml").write_text("[dependency-groups]\nrelease-news = []\nrelease-check = []\n") + (tmp_path / "pyproject.toml").write_text( + "[dependency-groups]\nrelease = []\nrelease-news = []\nrelease-check = []\n" + ) lock = tmp_path / "pylock.toml" lock.write_text( '[tool.nab]\ncommand-line = ["nab", "lock", "--groups", "release-news", "release-check", ' diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index 3a67a5a..e6f6bb3 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -1,8 +1,11 @@ from __future__ import annotations +import os +import subprocess from pathlib import Path import pytest +import yaml from conftest import action, run @@ -125,3 +128,58 @@ def test_validate_news_requires_pr_base(repository: Path, tmp_path: Path, action ) assert result.returncode != 0 assert "news validation needs a PR base" in result.stderr + + +def test_validate_news_forwards_secondary_lock_inputs() -> None: + config = yaml.safe_load((Path(__file__).resolve().parents[2] / "validate-news/action.yml").read_text()) + refresh = next(step for step in config["runs"]["steps"] if step.get("uses") == "$/refresh-release-lock") + secondary = [name for name in config["inputs"] if name.startswith("additional-")] + + assert secondary + assert all(refresh["with"][name] == "${{ inputs." + name + " }}" for name in secondary) + assert refresh["with"]["mode"] == "check" + + +@pytest.mark.parametrize( + ("additional_lock", "changed"), + [ + pytest.param("pylock.zipapp.toml", "true", id="selected-secondary-lock"), + pytest.param("", "false", id="unselected-secondary-lock"), + ], +) +def test_validate_news_detects_secondary_lock_only_change( + repository: Path, tmp_path: Path, action_scripts: Path, additional_lock: str, changed: str +) -> None: + (repository / "pyproject.toml").write_text( + (repository / "pyproject.toml").read_text() + "\n[dependency-groups]\nrelease-news = []\n" + ) + (repository / "pylock.toml").write_text( + '[tool.nab]\ncommand-line = ["nab", "lock", "--groups", "release-news", "--output", "pylock.toml"]\n' + ) + (repository / "pylock.zipapp.toml").write_text("lock-version = '1.0'\n") + run(repository, "git", "add", ".") + run(repository, "git", "commit", "-m", "Add release locks") + run(repository, "git", "push", "origin", "main") + run(repository, "git", "checkout", "-b", "change-secondary-lock") + (repository / "pylock.zipapp.toml").write_text("lock-version = '1.0'\ncreated-by = 'test'\n") + run(repository, "git", "add", "pylock.zipapp.toml") + run(repository, "git", "commit", "-m", "Refresh secondary lock") + output = tmp_path / "lock-change-output" + + result = subprocess.run( + ["bash", str(action_scripts / "validate-news-2.sh")], + cwd=repository, + env=os.environ + | { + "BASE_REF": "main", + "ADDITIONAL_LOCK": additional_lock, + "GITHUB_OUTPUT": str(output), + "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "validate-news"), + }, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert f"changed={changed}\n" in output.read_text() diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py index 0b64f7b..5bba7f3 100644 --- a/tests/validate_release_patch/test_validate.py +++ b/tests/validate_release_patch/test_validate.py @@ -28,13 +28,10 @@ def test_validate_applies_patch_in_fresh_worktree(repository: Path, tmp_path: Pa assert run(repository, "git", "diff", "--cached", "--name-only").stdout == source_staged -def test_validate_steps_honor_working_directory() -> None: +def test_validate_uses_current_checkout() -> None: config = yaml.safe_load((Path(__file__).resolve().parents[2] / "validate-release-patch/action.yml").read_text()) - assert all( - step.get("working-directory", step.get("with", {}).get("working-directory")) - == "${{ inputs.working-directory }}" - for step in config["runs"]["steps"] - ) + assert "working-directory" not in config.get("inputs", {}) + assert all("working-directory" not in step for step in config["runs"]["steps"]) def track_rst_changelog(repository: Path) -> None: diff --git a/tools/lock_groups.py b/tools/lock_groups.py index 6ba1a66..fbcf1d6 100644 --- a/tools/lock_groups.py +++ b/tools/lock_groups.py @@ -29,7 +29,7 @@ def main() -> None: declared = tomllib.load(stream)["dependency-groups"] if not all(group.isascii() and group.replace("-", "").isalnum() and group in declared for group in groups): raise ValueError("Nab lock selects an invalid or undeclared group") - required = {name for name in declared if name.startswith("release")} + required = {name for name in declared if name.startswith("release-")} if missing := required - set(groups): raise ValueError(f"Nab lock omits release groups: {', '.join(sorted(missing))}") print(" ".join(groups)) diff --git a/validate-news/action.yml b/validate-news/action.yml index b5e3f27..e649d93 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -1,5 +1,24 @@ name: Validate changelog news description: Require a fragment for human changes and validate any fragment supplied by a bot +inputs: + additional-lock: + description: Secondary pylock basename to check on a pull request + default: "" + additional-lock-groups: + description: Groups selected by the secondary lock + default: "" + additional-lock-selection: + description: Project or build-or-groups selection for the secondary lock + default: build-or-groups + additional-matrix-python: + description: Python range for the secondary lock + default: "" + additional-matrix-platforms: + description: Space-separated Nab platform IDs for the secondary lock + default: "" + additional-matrix-implementations: + description: Space-separated implementations for the secondary lock + default: "" runs: using: composite steps: @@ -24,8 +43,11 @@ runs: shell: bash env: BASE_REF: ${{ github.base_ref }} + ADDITIONAL_LOCK: ${{ inputs.additional-lock }} run: | - changed=$(git diff --name-only "origin/$BASE_REF...HEAD" -- pyproject.toml pylock.toml) + paths=(pyproject.toml pylock.toml) + if [[ -n "$ADDITIONAL_LOCK" ]]; then paths+=("$ADDITIONAL_LOCK"); fi + changed=$(git diff --name-only "origin/$BASE_REF...HEAD" -- "${paths[@]}") if [[ -z "$changed" ]]; then echo 'changed=false' >>"$GITHUB_OUTPUT" exit 0 @@ -46,4 +68,10 @@ runs: uses: $/refresh-release-lock with: lock-groups: ${{ steps.lock-change.outputs.groups }} + additional-lock: ${{ inputs.additional-lock }} + additional-lock-groups: ${{ inputs.additional-lock-groups }} + additional-lock-selection: ${{ inputs.additional-lock-selection }} + additional-matrix-python: ${{ inputs.additional-matrix-python }} + additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} + additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} mode: check diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index c7804a8..9031cb1 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -7,9 +7,6 @@ inputs: patch: description: Path to the downloaded patch required: true - working-directory: - description: Checkout to validate and apply the patch in - default: . checks: description: Run release-creator and prek after applying the patch default: "true" @@ -18,7 +15,6 @@ runs: steps: - name: Verify release source shell: bash - working-directory: ${{ inputs.working-directory }} run: | set -euo pipefail git fetch origin main @@ -28,7 +24,6 @@ runs: fi - name: Apply release patch shell: bash - working-directory: ${{ inputs.working-directory }} env: PATCH: ${{ inputs.patch }} CHECKS: ${{ inputs.checks }} @@ -41,11 +36,8 @@ runs: git apply --index "$PATCH" - id: config uses: $/read-towncrier-config - with: - working-directory: ${{ inputs.working-directory }} - name: Check release patch paths shell: bash - working-directory: ${{ inputs.working-directory }} env: CHANGELOG: ${{ steps.config.outputs.changelog }} FRAGMENTS: ${{ steps.config.outputs.fragments }} @@ -73,11 +65,9 @@ runs: - name: Check consumed fragments if: inputs.checks == 'true' shell: bash - working-directory: ${{ inputs.working-directory }} run: release-creator validate-fragment-deletions - name: Check release patch file modes shell: bash - working-directory: ${{ inputs.working-directory }} run: | set -euo pipefail git diff --cached --raw --no-renames -z >"$RUNNER_TEMP/release-modes" @@ -113,7 +103,6 @@ runs: - name: Check RST conflict markers if: inputs.checks == 'true' && steps.config.outputs.format == 'rst' shell: bash - working-directory: ${{ inputs.working-directory }} env: CHANGELOG: ${{ steps.config.outputs.changelog }} run: | @@ -131,7 +120,6 @@ runs: - name: Check rendered release section if: inputs.checks == 'true' shell: bash - working-directory: ${{ inputs.working-directory }} env: VERSION: ${{ inputs.version }} CHANGELOG: ${{ steps.config.outputs.changelog }} @@ -139,7 +127,6 @@ runs: - name: Run prek on release patch if: inputs.checks == 'true' shell: bash - working-directory: ${{ inputs.working-directory }} run: | set -euo pipefail if ! prek run --all-files; then From e1318f5ff4d56cba8a0bed374225238b28f92be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 22:52:21 -0700 Subject: [PATCH 45/56] =?UTF-8?q?=F0=9F=90=9B=20fix(hooks):=20pass=20App?= =?UTF-8?q?=20ID=20from=20caller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yaml | 2 ++ README.md | 2 ++ check-hook-update-cooldown/action.yml | 5 ++++- tests/check_hook_update_cooldown/test_cooldown.py | 4 ++++ tests/conftest.py | 2 -- 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index ffa97c4..e94e0e7 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -135,6 +135,8 @@ jobs: pull-requests: read steps: - uses: $/check-hook-update-cooldown + with: + release-app-client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} release-tools-preview: if: github.event_name == 'pull_request' runs-on: ubuntu-26.04 diff --git a/README.md b/README.md index cff4600..a8f0ae9 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,8 @@ hook-cooldown: pull-requests: read steps: - uses: tox-dev/github-workflows/check-hook-update-cooldown@SHA + with: + release-app-client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} ``` Run the PR workflow on `opened`, `reopened`, and `synchronize`; the cooldown check must succeed on PRs and may skip on diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index e635960..963ffb6 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -4,6 +4,9 @@ inputs: minimum-days: description: Minimum age of a proposed hook revision default: "7" + release-app-client-id: + description: Client ID of the App allowed to update managed hook dependencies + default: "" runs: using: composite steps: @@ -13,7 +16,7 @@ runs: env: MINIMUM_DAYS: ${{ inputs.minimum-days }} GH_TOKEN: ${{ github.token }} - RELEASE_APP_CLIENT_ID: ${{ vars.RELEASE_APP_CLIENT_ID }} + RELEASE_APP_CLIENT_ID: ${{ inputs.release-app-client-id }} run: | set -euo pipefail REPOSITORY=$GITHUB_REPOSITORY diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index 08bc714..d0c042b 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -21,6 +21,10 @@ def test_action_uses_scoped_job_token() -> None: "${{ github.token }}", "${{ github.token }}", ] + assert action["runs"]["steps"][0]["env"]["RELEASE_APP_CLIENT_ID"] == "${{ inputs.release-app-client-id }}" + workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text()) + caller = workflow["jobs"]["hook-cooldown"]["steps"][0] + assert caller["with"]["release-app-client-id"] == "${{ vars.RELEASE_APP_CLIENT_ID }}" @pytest.mark.parametrize( diff --git a/tests/conftest.py b/tests/conftest.py index 1e64299..dbf1c1d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -642,8 +642,6 @@ def _expression( return environment.get("GITHUB_EVENT_PULL_REQUEST_NUMBER", "") if expression == "github.token": return environment.get("GH_TOKEN", environment.get("GH_ENTERPRISE_TOKEN", "")) - if expression == "vars.RELEASE_APP_CLIENT_ID": - return environment.get("RELEASE_APP_CLIENT_ID", "") if expression == "github.event.pull_request.user.type": return "Bot" if environment.get("BOT_AUTHOR") == "true" else "User" if expression == "contains(github.event.pull_request.labels.*.name, 'skip news')": From e147974be922b8279cbe84ea41256164001c4338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 22:54:06 -0700 Subject: [PATCH 46/56] =?UTF-8?q?=F0=9F=A7=AA=20test(locks):=20derive=20sh?= =?UTF-8?q?ared=20tool=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/install_locked_tools/test_install.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index a7a6534..fbcbaad 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -572,12 +572,13 @@ def test_caller_version_check_uses_highest_shared_platform_floor(tmp_path: Path) document = tomlkit.parse((source / "pylock.toml").read_text()) package = next(item for item in document["packages"] if item["name"] == "release-creator") current = deepcopy(package) + current_version = str(package["version"]) current["marker"] = '"release-news" in dependency_groups and sys_platform == "linux"' package["marker"] = '"release-news" in dependency_groups and sys_platform == "win32"' - package["version"] = "0.6.6" + package["version"] = "0.1.0" for artifact in [package["sdist"], *package["wheels"]]: - artifact["name"] = str(artifact["name"]).replace("0.6.7", "0.6.6") - artifact["url"] = str(artifact["url"]).replace("0.6.7", "0.6.6") + artifact["name"] = str(artifact["name"]).replace(current_version, "0.1.0") + artifact["url"] = str(artifact["url"]).replace(current_version, "0.1.0") artifact["upload-time"] = datetime(2026, 10, 15, tzinfo=UTC) caller = tmp_path / "caller.toml" caller.write_text(tomlkit.dumps(document)) @@ -600,7 +601,7 @@ def test_caller_version_check_uses_highest_shared_platform_floor(tmp_path: Path) ) assert result.returncode != 0 - assert "relock: release-creator 0.6.7 required" in result.stderr + assert f"relock: release-creator {current_version} required" in result.stderr def test_install_locked_tools_shared_group_ignores_missing_caller_group(tmp_path: Path, action_scripts: Path) -> None: From a6a87a29ff79c87ccbb3e5d5dedaca1f2b2c2a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 22:55:00 -0700 Subject: [PATCH 47/56] =?UTF-8?q?=F0=9F=90=9B=20fix(locks):=20isolate=20re?= =?UTF-8?q?ader=20environment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install-locked-tools/action.yml | 22 +++++++++++----------- tests/install_locked_tools/test_install.py | 13 ++++++++++++- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index af120cf..f626c0a 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -37,20 +37,20 @@ runs: steps: - name: Create lock-tool environment shell: bash - run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools" + run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-reader" - name: Install lock reader shell: bash run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE digest=$(sha256sum "$GITHUB_ACTION_PATH/../pylock.toml") - if [[ -f "$RUNNER_TEMP/lock-tools/.lock-digest" && - $(cat "$RUNNER_TEMP/lock-tools/.lock-digest") == "${digest%% *}" ]]; then + if [[ -f "$RUNNER_TEMP/lock-reader/.lock-digest" && + $(cat "$RUNNER_TEMP/lock-reader/.lock-digest") == "${digest%% *}" ]]; then exit 0 fi - uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-tools" --group lock-reader \ + uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-reader" --group lock-reader \ --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" - printf '%s\n' "${digest%% *}" >"$RUNNER_TEMP/lock-tools/.lock-digest" + printf '%s\n' "${digest%% *}" >"$RUNNER_TEMP/lock-reader/.lock-digest" - id: selection name: Select locked groups and build constraints shell: bash @@ -88,12 +88,12 @@ runs: uv venv --allow-existing --python 3.15 "$VENV" source_check=() if [[ "$LOCAL_TOOL_SOURCE" == true ]]; then source_check=(--local-tool-source); fi - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${groups[@]}" \ "${lock_check[@]}" "${source_check[@]}" printf '%s\n' "${groups[@]}" >"$RUNNER_TEMP/selected-lock-groups" else - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" --build-constraints \ "${lock_check[@]}" fi @@ -115,7 +115,7 @@ runs: echo '::error::verify-lock and verify-groups must be set together' >&2 exit 1 fi - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$VERIFY_LOCK" "${groups[@]}" \ --verify-pypi --project pyproject.toml - id: constraints @@ -133,7 +133,7 @@ runs: if [[ "$CONSTRAINTS_LOCK" != "$GITHUB_ACTION_PATH/../pylock.toml" ]]; then lock_check=(--verify-pypi --project pyproject.toml) fi - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$CONSTRAINTS_LOCK" --build-constraints \ "${lock_check[@]}" fi @@ -155,7 +155,7 @@ runs: digest=$(sha256sum <<<"$digest") output="$RUNNER_TEMP/build-constraints-${digest%% *}.txt" if [[ ! -f "$output" ]]; then - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ "$GITHUB_ACTION_PATH/../tools/build_constraints.py" "${args[@]}" \ "$output" "${locks[@]}" fi @@ -183,5 +183,5 @@ runs: env: VENV: ${{ inputs.venv }} run: | - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ + uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ "$GITHUB_ACTION_PATH/../tools/expose_locked_commands.py" "$VENV" diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index fbcbaad..b187598 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -123,6 +123,17 @@ def test_install_locked_tools_reuses_existing_environment(tmp_path: Path, action assert (tmp_path / "lock-tools" / ("Scripts/nab.exe" if os.name == "nt" else "bin/nab")).is_file() +def test_install_locked_tools_keeps_reader_after_nab_sync(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + first = install(source, action_scripts, tmp_path, groups="lock-tools", venv=tmp_path / "lock-tools") + second = install(source, action_scripts, tmp_path, groups="release-news", venv=tmp_path / "news-tools") + + assert first.returncode == second.returncode == 0, first.stderr + second.stderr + reader = tmp_path / "lock-reader" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + imported = subprocess.run([str(reader), "-c", "import certifi"], capture_output=True, text=True, check=False) + assert imported.returncode == 0, imported.stderr + + def test_install_locked_tools_exposes_release_commands(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] environment = tmp_path / "tools" @@ -141,7 +152,7 @@ def test_install_locked_tools_uses_minimal_reader(tmp_path: Path, action_scripts result = install(source, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") assert result.returncode == 0, result.stderr - reader = tmp_path / "lock-tools" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + reader = tmp_path / "lock-reader" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") packages = subprocess.run( [str(reader), "-c", "import importlib.util; print(importlib.util.find_spec('nab') is None)"], capture_output=True, From 91874e1712fba28eca553ab4782e8873e8841704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 22:55:13 -0700 Subject: [PATCH 48/56] =?UTF-8?q?=F0=9F=93=9D=20docs:=20note=20isolated=20?= =?UTF-8?q?lock=20reader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+lock-reader.bugfix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/+lock-reader.bugfix.md diff --git a/changelog.d/+lock-reader.bugfix.md b/changelog.d/+lock-reader.bugfix.md new file mode 100644 index 0000000..7169c2c --- /dev/null +++ b/changelog.d/+lock-reader.bugfix.md @@ -0,0 +1 @@ +Keep the lock reader's certificate package installed when a job switches between Nab tools and other release groups. From 61060dab0a88edffe62e4b61a1eeb3b75f8c51c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 22:57:58 -0700 Subject: [PATCH 49/56] =?UTF-8?q?=F0=9F=90=9B=20fix(hooks):=20cool=20CI=20?= =?UTF-8?q?hook=20installs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check.yaml | 2 ++ .github/workflows/release.yaml | 2 ++ .github/workflows/validate-python-release.yaml | 2 ++ changelog.d/+cool-release-hooks.security.md | 1 + tests/release_workflow/test_graph.py | 15 +++++++++++++++ 5 files changed, 22 insertions(+) create mode 100644 changelog.d/+cool-release-hooks.security.md diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index e94e0e7..b97bd34 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -38,6 +38,8 @@ jobs: groups: release-check venv: .release-tools - name: Check repository formatting + env: + UV_EXCLUDE_NEWER: 7 days run: prek run --all-files shell: runs-on: ubuntu-26.04 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ba50213..39f9895 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -26,6 +26,8 @@ jobs: groups: release-check venv: .venv - name: Check tagged source + env: + UV_EXCLUDE_NEWER: 7 days run: .venv/bin/prek run --all-files source: runs-on: ubuntu-26.04 diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index 349963d..65905c1 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -36,6 +36,8 @@ jobs: groups: release-check venv: ${{ runner.temp }}/release-tools - name: Run tagged source checks + env: + UV_EXCLUDE_NEWER: 7 days run: >- "$RUNNER_TEMP/release-tools/bin/prek" run --all-files constraints: diff --git a/changelog.d/+cool-release-hooks.security.md b/changelog.d/+cool-release-hooks.security.md new file mode 100644 index 0000000..2acc387 --- /dev/null +++ b/changelog.d/+cool-release-hooks.security.md @@ -0,0 +1 @@ +Apply the seven-day package cutoff when release and CI jobs install prek hook environments. diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index b739ee4..d649809 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -18,6 +18,21 @@ def test_prepare_workflow_calls_reusable_preparation() -> None: assert workflow["jobs"]["prepare"]["uses"] == "$/.github/workflows/prepare-towncrier-release.yaml" +@pytest.mark.parametrize( + ("workflow", "job", "step_name"), + [ + pytest.param("check.yaml", "check", "Check repository formatting", id="pull-request"), + pytest.param("release.yaml", "check", "Check tagged source", id="tagged-release"), + pytest.param("validate-python-release.yaml", "source-check", "Run tagged source checks", id="distribution"), + pytest.param("prepare-towncrier-release.yaml", "assemble", "Install prek hooks", id="prepare"), + ], +) +def test_release_hook_installation_has_cooldown(workflow: str, job: str, step_name: str) -> None: + document = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows" / workflow).read_text()) + step = next(item for item in document["jobs"][job]["steps"] if item.get("name") == step_name) + assert step["env"]["UV_EXCLUDE_NEWER"] == "7 days" + + def test_prepare_jobs_transfer_the_uploaded_patch() -> None: workflow = yaml.safe_load( (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() From a89b2e4c2edfe99d26abdd87c46f9ffbcad88f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 22:58:40 -0700 Subject: [PATCH 50/56] =?UTF-8?q?=F0=9F=90=9B=20fix(release):=20retain=20f?= =?UTF-8?q?inalized=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+finalize-version.bugfix.md | 1 + prepare-towncrier/action.yml | 10 +++++++++- tests/prepare_towncrier/test_prepare.py | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 changelog.d/+finalize-version.bugfix.md diff --git a/changelog.d/+finalize-version.bugfix.md b/changelog.d/+finalize-version.bugfix.md new file mode 100644 index 0000000..0bfdbbd --- /dev/null +++ b/changelog.d/+finalize-version.bugfix.md @@ -0,0 +1 @@ +Return the selected version from the Towncrier finalization phase and reject a finalization without one. diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index 736a089..653ad4c 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -39,15 +39,23 @@ runs: uses: $/read-towncrier-config - id: version name: Select release version - if: inputs.phase != 'finalize' shell: bash env: BUMP: ${{ inputs.bump }} TAG_PREFIX: ${{ inputs.tag-prefix }} VERSION: ${{ inputs.version }} FRAGMENTS: ${{ steps.config.outputs.fragments }} + PHASE: ${{ inputs.phase }} run: | set -euo pipefail + if [[ "$PHASE" == finalize ]]; then + [[ -n "$VERSION" ]] || { + echo '::error::finalize requires a version from the build phase' >&2 + exit 1 + } + printf 'version=%s\n' "$VERSION" >>"$GITHUB_OUTPUT" + exit 0 + fi if [[ -n "$VERSION" ]]; then git tag --list | release-creator tags --next auto --directory "$FRAGMENTS" >/dev/null git tag --list | release-creator tags "$VERSION" --require-newer diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 3ad6f54..fa15c53 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -41,6 +41,26 @@ def test_prepare_finalize_phase_writes_patch(repository: Path, tmp_path: Path, a assert "0.2.0" in (tmp_path / "release.patch").read_text() +def test_prepare_finalize_phase_preserves_version_output( + repository: Path, tmp_path: Path, action_scripts: Path +) -> None: + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build") + assert result.returncode == 0, result.stderr + (tmp_path / "output").unlink() + + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="finalize") + + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text() == "version=0.2.0\n" + + +def test_prepare_finalize_phase_requires_version(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="", PHASE="finalize") + + assert result.returncode != 0 + assert "finalize requires a version" in result.stderr + + def test_prepare_custom_changelog(repository: Path, tmp_path: Path, action_scripts: Path) -> None: project = repository / "pyproject.toml" project.write_text(project.read_text().replace("CHANGELOG.md", "HISTORY.md")) From 387945898556198d98c4611e0a52e4c1e9f35ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 23:03:47 -0700 Subject: [PATCH 51/56] =?UTF-8?q?=F0=9F=90=9B=20fix(locks):=20reject=20mal?= =?UTF-8?q?formed=20PyPI=20listings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+pypi-listing-shape.bugfix.md | 1 + tests/install_locked_tools/test_install.py | 2 +- tests/install_locked_tools/test_metadata.py | 23 +++++++++++++++++++++ tools/check_lock_groups.py | 7 ++++++- 4 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 changelog.d/+pypi-listing-shape.bugfix.md diff --git a/changelog.d/+pypi-listing-shape.bugfix.md b/changelog.d/+pypi-listing-shape.bugfix.md new file mode 100644 index 0000000..b6442a6 --- /dev/null +++ b/changelog.d/+pypi-listing-shape.bugfix.md @@ -0,0 +1 @@ +Report malformed PyPI Simple API listings as lock verification errors instead of Python tracebacks. diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index b187598..183eed7 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -690,7 +690,7 @@ def test_install_locked_tools_exporter_does_not_build_pyyaml(tmp_path: Path, act ) assert result.returncode == 0, result.stderr imported = subprocess.run( - [str(tmp_path / "lock-tools/bin/python"), "-c", "import yaml"], + [str(tmp_path / "lock-reader/bin/python"), "-c", "import yaml"], text=True, capture_output=True, check=False, diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index 959eb0e..eb53732 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -239,6 +239,29 @@ def test_lock_metadata_rejects_invalid_listing( assert "invalid PyPI listing for six" in capsys.readouterr().err +@pytest.mark.parametrize( + "listing", + [ + pytest.param([], id="non-object-root"), + pytest.param({"meta": [], "files": []}, id="non-object-metadata"), + pytest.param({"meta": {"api-version": "1.4"}, "name": "six", "files": ["bad"]}, id="non-object-file"), + ], +) +def test_lock_metadata_rejects_non_object_listing( + lock_case: tuple[Path, Path], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + listing: object, +) -> None: + mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(listing).encode())) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert "invalid PyPI listing for six" in capsys.readouterr().err + + def test_lock_metadata_allows_local_release_creator( lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 18c61f3..fe5034c 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -186,12 +186,17 @@ def _pypi_files(name: str) -> tuple[str, dict[str, tuple[str | None, str | None, time.sleep(2**attempt) attempt += 1 if ( - not str(listing.get("meta", {}).get("api-version", "")).startswith("1.") + not isinstance(listing, dict) + or not isinstance(listing.get("meta"), dict) + or not isinstance(listing.get("files"), list) + or not str(listing["meta"].get("api-version", "")).startswith("1.") or canonicalize_name(listing.get("name", "")) != name ): raise ValueError(f"invalid PyPI listing for {name}") files: dict[str, tuple[str | None, str | None, datetime | None]] = {} for file in listing["files"]: + if not isinstance(file, dict): + raise TypeError(f"invalid PyPI listing for {name}") if file.get("yanked", False): continue files[file["filename"]] = ( From 0136421a24f02b76593313cc3e8578345ca384da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 23:03:47 -0700 Subject: [PATCH 52/56] =?UTF-8?q?=F0=9F=94=92=20fix(news):=20check=20Nab?= =?UTF-8?q?=20policy=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+news-nab-policy.security.md | 1 + tests/validate_news/test_news.py | 19 ++++++++++++------- validate-news/action.yml | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 changelog.d/+news-nab-policy.security.md diff --git a/changelog.d/+news-nab-policy.security.md b/changelog.d/+news-nab-policy.security.md new file mode 100644 index 0000000..311ab7c --- /dev/null +++ b/changelog.d/+news-nab-policy.security.md @@ -0,0 +1 @@ +Validate the lock when a pull request changes `nab.toml`, including callers that use `validate-news` directly. diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index e6f6bb3..d5a45ca 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -141,14 +141,15 @@ def test_validate_news_forwards_secondary_lock_inputs() -> None: @pytest.mark.parametrize( - ("additional_lock", "changed"), + ("additional_lock", "changed_path", "changed"), [ - pytest.param("pylock.zipapp.toml", "true", id="selected-secondary-lock"), - pytest.param("", "false", id="unselected-secondary-lock"), + pytest.param("pylock.zipapp.toml", "pylock.zipapp.toml", "true", id="selected-secondary-lock"), + pytest.param("", "pylock.zipapp.toml", "false", id="unselected-secondary-lock"), + pytest.param("", "nab.toml", "true", id="nab-policy"), ], ) -def test_validate_news_detects_secondary_lock_only_change( - repository: Path, tmp_path: Path, action_scripts: Path, additional_lock: str, changed: str +def test_validate_news_detects_release_input_change( + repository: Path, tmp_path: Path, action_scripts: Path, additional_lock: str, changed_path: str, changed: str ) -> None: (repository / "pyproject.toml").write_text( (repository / "pyproject.toml").read_text() + "\n[dependency-groups]\nrelease-news = []\n" @@ -161,8 +162,12 @@ def test_validate_news_detects_secondary_lock_only_change( run(repository, "git", "commit", "-m", "Add release locks") run(repository, "git", "push", "origin", "main") run(repository, "git", "checkout", "-b", "change-secondary-lock") - (repository / "pylock.zipapp.toml").write_text("lock-version = '1.0'\ncreated-by = 'test'\n") - run(repository, "git", "add", "pylock.zipapp.toml") + (repository / changed_path).write_text( + "[tool.nab]\nuploaded-prior-to = false\n" + if changed_path == "nab.toml" + else "lock-version = '1.0'\ncreated-by = 'test'\n" + ) + run(repository, "git", "add", changed_path) run(repository, "git", "commit", "-m", "Refresh secondary lock") output = tmp_path / "lock-change-output" diff --git a/validate-news/action.yml b/validate-news/action.yml index e649d93..3b3f137 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -45,7 +45,7 @@ runs: BASE_REF: ${{ github.base_ref }} ADDITIONAL_LOCK: ${{ inputs.additional-lock }} run: | - paths=(pyproject.toml pylock.toml) + paths=(pyproject.toml pylock.toml nab.toml) if [[ -n "$ADDITIONAL_LOCK" ]]; then paths+=("$ADDITIONAL_LOCK"); fi changed=$(git diff --name-only "origin/$BASE_REF...HEAD" -- "${paths[@]}") if [[ -z "$changed" ]]; then From d5f1a84440832bd3575be1ce394df341cbc5fc25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 23:03:47 -0700 Subject: [PATCH 53/56] =?UTF-8?q?=F0=9F=93=9D=20docs:=20explain=20removed?= =?UTF-8?q?=20release=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++++++ changelog.d/+removed-release-inputs.breaking.md | 3 +++ tests/release_workflow/test_graph.py | 7 ++++++- 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 changelog.d/+removed-release-inputs.breaking.md diff --git a/README.md b/README.md index a8f0ae9..11c7a4c 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,14 @@ head. GitHub accepts reruns for 30 days. A failed rerun is retried on the next s [caller here](.github/workflows/recheck-hook-cooldown.yaml) shows the trigger and permissions. The Action reruns the `hook-cooldown` job in `check.yaml`. +If the job has a different name or workflow filename, pass both to the scheduled caller: + +```yaml +with: + workflow: ci.yaml + job-name: check-hook-updates +``` + ## Start with a caller The [release preparation workflow](.github/workflows/prepare-release.yaml) in this repository calls the reusable diff --git a/changelog.d/+removed-release-inputs.breaking.md b/changelog.d/+removed-release-inputs.breaking.md new file mode 100644 index 0000000..903446e --- /dev/null +++ b/changelog.d/+removed-release-inputs.breaking.md @@ -0,0 +1,3 @@ +Remove `recheck-hook-update-cooldown.minimum-days` and the unused `validate-python-release` inputs `changelog`, +`artifact-base-url`, `artifact-label`, and `local-tool-source`. Recheck uses a fixed seven-day minimum; release notes +are rendered by the caller's notes job. diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index d649809..4418f06 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -87,7 +87,12 @@ def test_prepare_validation_calculates_version_independently( verify = next(step for step in steps if step.get("name") == "Verify selected release version") command = tmp_path / "bin/release-creator" command.parent.mkdir() - command.write_text(f"#!{sys.executable}\nimport sys\nprint('v1.2.3' if '--prefix' in sys.argv else '1.2.3')\n") + expected_arguments = ["tags", "--next", "auto", "--directory", "changelog.d"] + if prefix: + expected_arguments.extend(["--prefix", prefix]) + command.write_text( + f"#!{sys.executable}\nimport sys\nassert sys.argv[1:] == {expected_arguments!r}\nprint({f'{prefix}1.2.3'!r})\n" + ) command.chmod(0o755) subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) From 54b95111f5b57b3d9f15800d5a9a626fe524caf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 23:09:56 -0700 Subject: [PATCH 54/56] =?UTF-8?q?=F0=9F=94=A7=20build:=20lock=20release-cr?= =?UTF-8?q?eator=201.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+release-creator-067.change.md | 3 +-- changelog.d/+release-creator-100.change.md | 2 ++ pylock.toml | 27 ++++++++++++---------- pyproject.toml | 12 +++++----- tests/refresh_release_lock/test_cutoffs.py | 3 +++ tools/nab_cutoffs.py | 1 + 6 files changed, 28 insertions(+), 20 deletions(-) create mode 100644 changelog.d/+release-creator-100.change.md diff --git a/changelog.d/+release-creator-067.change.md b/changelog.d/+release-creator-067.change.md index de345e6..469f1fb 100644 --- a/changelog.d/+release-creator-067.change.md +++ b/changelog.d/+release-creator-067.change.md @@ -1,2 +1 @@ -Allow release locks to select release-creator 0.6.7 during the seven-day review window. Refresh the package index when -checking or updating a lock so a cached listing cannot hide a newly published version. +Refresh the package index when checking or updating a lock so a cached listing cannot hide a newly published version. diff --git a/changelog.d/+release-creator-100.change.md b/changelog.d/+release-creator-100.change.md new file mode 100644 index 0000000..d30c224 --- /dev/null +++ b/changelog.d/+release-creator-100.change.md @@ -0,0 +1,2 @@ +Lock the released release-creator 1.0.0 CLI used by the weekly hook updater and allow its reviewed artifacts during the +seven-day cooldown. diff --git a/pylock.toml b/pylock.toml index d4bd820..39ff55a 100644 --- a/pylock.toml +++ b/pylock.toml @@ -1704,11 +1704,12 @@ sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "release-creator" -version = "0.6.7" +version = "1.0.0" marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.15" dependencies = [ { name = "docutils" }, + { name = "markdown-it-py" }, { name = "mdformat" }, { name = "mdformat-gfm" }, { name = "packaging" }, @@ -1718,22 +1719,22 @@ dependencies = [ index = "https://pypi.org/simple/" [packages.sdist] -name = "release_creator-0.6.7.tar.gz" -upload-time = 2026-10-23 21:40:05.750813+00:00 -url = "https://files.pythonhosted.org/packages/69/16/14ae84ee6db2cb86a72b94e3e56f1daa24fb49a69f6cfa0bdafa10e8f4c7/release_creator-0.6.7.tar.gz" -size = 138420 +name = "release_creator-1.0.0.tar.gz" +upload-time = 2026-10-24 06:07:45.789370+00:00 +url = "https://files.pythonhosted.org/packages/e2/92/dfc1c6e8f52d50d91a05f77f67592c2408cabd9d96079fedbe551f24530d/release_creator-1.0.0.tar.gz" +size = 134186 [packages.sdist.hashes] -sha256 = "deb86d4bbe50906fc971a89b39217ba65e9c4fdfbacf17f6a761e7bb7a1361d9" +sha256 = "1174e3a3fd39e9117eb9c34e3fbc58a8e32c49f5f65d01f39195231b967807f6" [[packages.wheels]] -name = "release_creator-0.6.7-py3-none-any.whl" -upload-time = 2026-10-23 21:40:04.474402+00:00 -url = "https://files.pythonhosted.org/packages/1a/ff/d3c78822331f7a98f0835cf531e37e5c25e4c8881139e4c1905fb7381dfa/release_creator-0.6.7-py3-none-any.whl" -size = 48620 +name = "release_creator-1.0.0-py3-none-any.whl" +upload-time = 2026-10-24 06:07:44.366184+00:00 +url = "https://files.pythonhosted.org/packages/6a/17/b1cd851932786f3e9e9f2187d150bc0860e04adf76cbfd45e6531715bf97/release_creator-1.0.0-py3-none-any.whl" +size = 53396 [packages.wheels.hashes] -sha256 = "7b9ab26f3e7e26f26c18a19aa9c7bbf5b6fd64f51c28a97c40fe448969cf1b7d" +sha256 = "613a26a0d1b0042d9dad60dfc00f3e78208d84ab9f78cec792a407b6a0a75c24" [[packages]] name = "requests" @@ -2484,9 +2485,10 @@ sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f" [tool.nab] nab-version = "0.0.22" -created-at = 2026-10-23 21:41:56.960744+00:00 +created-at = 2026-10-24 06:08:31.473006+00:00 command-line = [ "nab", + "-vv", "lock", "--no-cache", "--groups", @@ -2504,6 +2506,7 @@ command-line = [ "release-hook-check", "--output", "pylock.toml", + "--upgrade", ] input-path = "pyproject.toml" mode = "universal" diff --git a/pyproject.toml b/pyproject.toml index 83f641c..3507510 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,14 +11,14 @@ license-files = [ "LICENSE" ] build = [ "Cython>=3.0", "setuptools>=80" ] lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ] lock-reader = [ "certifi>=2026.7.22", "packaging>=26.3" ] -release-notes = [ "release-creator>=0.6.7" ] -release-news = [ "release-creator>=0.6.7", "towncrier>=26.9" ] -release-prepare = [ "release-creator>=0.6.7", "towncrier>=26.9", "prek>=0.2" ] -release-validate = [ "release-creator>=0.6.7", "prek>=0.2" ] +release-notes = [ "release-creator>=1.0.0" ] +release-news = [ "release-creator>=1.0.0", "towncrier>=26.9" ] +release-prepare = [ "release-creator>=1.0.0", "towncrier>=26.9", "prek>=0.2" ] +release-validate = [ "release-creator>=1.0.0", "prek>=0.2" ] release-check = [ "prek>=0.2" ] release-attestations = [ "certifi>=2026.7.22", "pypi-attestations>=0.0.30" ] release-shell = [ "prek>=0.2", "yq>=4.4.5" ] -release-hooks = [ "release-creator>=0.6.7", "prek>=0.2" ] +release-hooks = [ "release-creator>=1.0.0", "prek>=0.2" ] release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ] test = [ "certifi", @@ -68,7 +68,7 @@ uploaded-prior-to = "2026-10-18T05:45:09Z" uploaded-prior-to = "2026-10-18T05:45:09Z" [tool.nab.packages.release-creator] -uploaded-prior-to = "2026-10-23T21:40:06Z" +uploaded-prior-to = "2026-10-24T06:07:46Z" [tool.ruff] line-length = 120 diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index ed4553a..0f1c045 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -76,6 +76,7 @@ def test_expire_cutoffs_removes_empty_package_table(cutoff_project: Path) -> Non pytest.param("2026-10-23T19:54:50Z", id="0.6.5"), pytest.param("2026-10-23T20:48:24Z", id="0.6.6"), pytest.param("2026-10-23T21:40:06Z", id="0.6.7"), + pytest.param("2026-10-24T06:07:46Z", id="1.0.0"), ], ) def test_expire_cutoffs_removes_each_reviewed_release_creator_floor(reviewed: str) -> None: @@ -340,6 +341,8 @@ def test_check_nab_lock_rejects_forged_upload_time( pytest.param("release-creator", "2026-10-23T20:48:25Z", False, id="extended-release-creator-0.6.6"), pytest.param("release-creator", "2026-10-23T21:40:06Z", True, id="reviewed-release-creator-0.6.7"), pytest.param("release-creator", "2026-10-23T21:40:07Z", False, id="extended-release-creator-0.6.7"), + pytest.param("release-creator", "2026-10-24T06:07:46Z", True, id="reviewed-release-creator-1.0.0"), + pytest.param("release-creator", "2026-10-24T06:07:47Z", False, id="extended-release-creator-1.0.0"), pytest.param("unreviewed", "2026-10-22T00:00:00Z", False, id="new-package"), ], ) diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py index 6f2ce09..3448e3d 100644 --- a/tools/nab_cutoffs.py +++ b/tools/nab_cutoffs.py @@ -23,6 +23,7 @@ ("release-creator", "0.6.5"): datetime(2026, 10, 23, 19, 54, 50, tzinfo=UTC), ("release-creator", "0.6.6"): datetime(2026, 10, 23, 20, 48, 24, tzinfo=UTC), ("release-creator", "0.6.7"): datetime(2026, 10, 23, 21, 40, 6, tzinfo=UTC), + ("release-creator", "1.0.0"): datetime(2026, 10, 24, 6, 7, 46, tzinfo=UTC), ("uv-build", "0.13.4"): datetime(2026, 10, 21, tzinfo=UTC), } _REVIEWED_EXCEPTIONS: Final = { From 3aec0b7e079eeb20ad76a92fa5d5e7e7ad61e3e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 23:11:52 -0700 Subject: [PATCH 55/56] =?UTF-8?q?=F0=9F=90=9B=20fix(locks):=20retry=20stal?= =?UTF-8?q?e=20PyPI=20listings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+pypi-listing-lag.bugfix.md | 1 + tests/install_locked_tools/test_metadata.py | 31 +++++++++++++++++++-- tools/check_lock_groups.py | 25 ++++++++++++++--- 3 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 changelog.d/+pypi-listing-lag.bugfix.md diff --git a/changelog.d/+pypi-listing-lag.bugfix.md b/changelog.d/+pypi-listing-lag.bugfix.md new file mode 100644 index 0000000..e2a06db --- /dev/null +++ b/changelog.d/+pypi-listing-lag.bugfix.md @@ -0,0 +1 @@ +Retry a PyPI artifact listing that lags a new release before rejecting a matching release lock. diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index eb53732..856b89f 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -99,7 +99,10 @@ def test_lock_metadata_rejects_yanked_artifact( capsys: pytest.CaptureFixture[str], ) -> None: pypi_listing["files"][0]["yanked"] = True - mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) + mocker.patch( + "urllib.request.urlopen", side_effect=lambda *_args, **_kwargs: BytesIO(json.dumps(pypi_listing).encode()) + ) + mocker.patch("time.sleep") with pytest.raises(SystemExit, match="2"): _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) @@ -122,6 +125,22 @@ def test_lock_metadata_retries_transient_network_error( sleep.assert_called_once_with(1) +def test_lock_metadata_retries_stale_listing( + lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + stale = {**pypi_listing, "files": []} + request = mocker.patch( + "urllib.request.urlopen", + side_effect=[BytesIO(json.dumps(stale).encode()), BytesIO(json.dumps(pypi_listing).encode())], + ) + sleep = mocker.patch("time.sleep") + + _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) + + assert request.call_count == 2 + sleep.assert_called_once_with(1) + + def test_lock_metadata_stops_after_three_network_errors( lock_case: tuple[Path, Path], mocker: MockerFixture, @@ -374,7 +393,10 @@ def test_lock_metadata_checks_each_duplicate_artifact( f"{head}[[packages]]{package.replace(pypi_listing['files'][0]['hashes']['sha256'], '0' * 64)}" f"[[packages]]{package}" ) - mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) + mocker.patch( + "urllib.request.urlopen", side_effect=lambda *_args, **_kwargs: BytesIO(json.dumps(pypi_listing).encode()) + ) + mocker.patch("time.sleep") with pytest.raises(SystemExit, match="2"): _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) @@ -408,7 +430,10 @@ def test_lock_metadata_rejects_pypi_mismatch( lock.write_text(lock.read_text().replace("2021-05-05T14:18:17.237677Z", "2020-05-05T14:18:17.237677Z")) else: file["url"] = "https://files.pythonhosted.org/other.whl" - mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) + mocker.patch( + "urllib.request.urlopen", side_effect=lambda *_args, **_kwargs: BytesIO(json.dumps(pypi_listing).encode()) + ) + mocker.patch("time.sleep") with pytest.raises(SystemExit, match="2"): _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index fe5034c..acbaf5b 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -114,10 +114,27 @@ def _verify_pypi(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> ) ) with ThreadPoolExecutor(max_workers=8) as pool: - for name, actual in pool.map(_pypi_files, expected): - for filename, url, digest, upload_time in expected[name]: - if actual.get(filename) != (url, digest, upload_time): - raise ValueError(f"release lock differs from PyPI metadata: {name}: {filename}") + for _ in pool.map(_verify_package_metadata, expected.items()): + pass + + +def _verify_package_metadata(item: tuple[str, list[tuple[str, str | None, str, datetime | None]]]) -> None: + name, artifacts = item + for attempt in range(5): + _, actual = _pypi_files(name) + mismatch = next( + ( + filename + for filename, url, digest, upload_time in artifacts + if actual.get(filename) != (url, digest, upload_time) + ), + None, + ) + if mismatch is None: + return + if attempt < 4: + time.sleep(2**attempt) + raise ValueError(f"release lock differs from PyPI metadata: {name}: {mismatch}") def _selected_packages(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> Iterator[Package]: From 4bcaf9978385f1897136643428dc999a0704c632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 23:16:27 -0700 Subject: [PATCH 56/56] =?UTF-8?q?=F0=9F=90=9B=20fix(locks):=20retry=20fres?= =?UTF-8?q?h=20PyPI=20404s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+pypi-listing-lag.bugfix.md | 2 +- tests/install_locked_tools/test_metadata.py | 11 +++++++---- tools/check_lock_groups.py | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/changelog.d/+pypi-listing-lag.bugfix.md b/changelog.d/+pypi-listing-lag.bugfix.md index e2a06db..b10815f 100644 --- a/changelog.d/+pypi-listing-lag.bugfix.md +++ b/changelog.d/+pypi-listing-lag.bugfix.md @@ -1 +1 @@ -Retry a PyPI artifact listing that lags a new release before rejecting a matching release lock. +Retry a PyPI artifact listing or transient 404 that lags a new release before rejecting a matching release lock. diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index 856b89f..32be4e1 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -141,7 +141,7 @@ def test_lock_metadata_retries_stale_listing( sleep.assert_called_once_with(1) -def test_lock_metadata_stops_after_three_network_errors( +def test_lock_metadata_stops_after_five_network_errors( lock_case: tuple[Path, Path], mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, @@ -153,11 +153,14 @@ def test_lock_metadata_stops_after_three_network_errors( with pytest.raises(SystemExit, match="2"): _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) - assert request.call_count == 3 + assert request.call_count == 5 assert "handshake timed out" in capsys.readouterr().err -@pytest.mark.parametrize("status", [pytest.param(429, id="rate-limit"), pytest.param(503, id="server-error")]) +@pytest.mark.parametrize( + "status", + [pytest.param(404, id="not-yet-listed"), pytest.param(429, id="rate-limit"), pytest.param(503, id="server-error")], +) def test_lock_metadata_retries_transient_http_error( lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, @@ -181,7 +184,7 @@ def test_lock_metadata_retries_transient_http_error( @pytest.mark.parametrize( ("status", "calls"), - [pytest.param(404, 1, id="not-found"), pytest.param(503, 3, id="repeated-server-error")], + [pytest.param(404, 5, id="not-found"), pytest.param(503, 5, id="repeated-server-error")], ) def test_lock_metadata_rejects_http_error( lock_case: tuple[Path, Path], diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index acbaf5b..492fb95 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -195,10 +195,10 @@ def _pypi_files(name: str) -> tuple[str, dict[str, tuple[str | None, str | None, listing = json.load(response) break except HTTPError as error: - if (error.code != 429 and error.code < 500) or attempt == 2: + if (error.code not in (404, 429) and error.code < 500) or attempt == 4: raise except IncompleteRead, TimeoutError, URLError: - if attempt == 2: + if attempt == 4: raise time.sleep(2**attempt) attempt += 1