From 62b6b4616962d7173bbfb112d75cbbad41b0be83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 23 Oct 2026 14:04:48 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(release):=20preserve=20runne?= =?UTF-8?q?r=20tool=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A locked tool environment on GITHUB_PATH shadows the runner's Python and package installers. Expose only release command launchers. Copy Windows launchers so this also works when the workspace and runner temp are on different volumes. Retry truncated PyPI responses, reject empty yank reasons, and correct the 1.0.0 migration record. Remove reviewed cutoffs for release-creator versions below the shared lock floor. --- CHANGELOG.md | 13 +++- README.md | 14 +++-- changelog.d/+empty-yank.bugfix.md | 1 + changelog.d/+install-tool-options.doc.md | 2 + changelog.d/+locked-cli-path.bugfix.md | 1 + changelog.d/+pypi-incomplete-read.bugfix.md | 2 + install-locked-tools/action.yml | 7 +-- tests/install_locked_tools/test_install.py | 70 +++++++++++++++++++++ tests/refresh_release_lock/test_cutoffs.py | 6 +- tests/verify_pypi_files/test_verify.py | 35 +++++++++-- tools/expose_locked_commands.py | 36 +++++++++++ tools/nab_cutoffs.py | 2 - tools/verify_pypi_files.py | 7 ++- 13 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 changelog.d/+empty-yank.bugfix.md create mode 100644 changelog.d/+install-tool-options.doc.md create mode 100644 changelog.d/+locked-cli-path.bugfix.md create mode 100644 changelog.d/+pypi-incomplete-read.bugfix.md create mode 100644 tools/expose_locked_commands.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f7dbdf..d08aa0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,9 @@ ([#6](https://github.com/tox-dev/github-workflows/pull/6)) by [@gaborbernat](https://github.com/gaborbernat). - Run release-tool checks and build-constraint export outside the job that builds distribution files ([#6](https://github.com/tox-dev/github-workflows/pull/6)) by [@gaborbernat](https://github.com/gaborbernat). +- Add `constraints-lock`, `target-platform`, and `targets` inputs and the `build-constraints` output to + `install-locked-tools` ([#6](https://github.com/tox-dev/github-workflows/pull/6)) by + [@gaborbernat](https://github.com/gaborbernat). @@ -63,9 +66,13 @@ - Remove the `install-nab`, `label-bot-news`, `refresh-hook-pins`, `resolve-pr-from-run`, and `validate-python-distribution` Actions. `select-previous-tag` no longer accepts `package`, `executable`, or - `stable-only`; `validate-release-patch` no longer accepts `expected-patch-sha`; and `render-release-notes` no longer - emits `previous`. The release validation workflow no longer uploads the `release-validation-notes` artifact - ([#6](https://github.com/tox-dev/github-workflows/pull/6)) by [@gaborbernat](https://github.com/gaborbernat). + `stable-only`; `reconcile-pypi-artifacts` no longer accepts `local-tool-source`; `validate-release-patch` no longer + accepts `expected-patch-sha`; and `render-release-notes` no longer emits `previous`. The release validation workflow + no longer uploads the `release-validation-notes` artifact ([#6](https://github.com/tox-dev/github-workflows/pull/6)) + by [@gaborbernat](https://github.com/gaborbernat). +- `install-locked-tools` now adds its selected virtual environment to `GITHUB_PATH`, so later bare `python` commands + resolve to that environment ([#6](https://github.com/tox-dev/github-workflows/pull/6)) by + [@gaborbernat](https://github.com/gaborbernat). diff --git a/README.md b/README.md index 4a16ef3..aad4266 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,16 @@ constraints generated from the lock's `build` group under the runner's temporary selected lock and applies hashed build constraints. - `local-tool-source`: set to `true` only when release-creator builds and installs its own wheel after this Action. The caller's lock then needs the selected groups but does not need a release-creator package entry. - -The `build-constraints` output names the hashed constraints file. The Action adds installed commands to `GITHUB_PATH` -for later steps. Run `actions/checkout` and `astral-sh/setup-uv` with Python 3.15 before it. It bootstraps Nab from the -shared, hash-checked lock. +- `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. +- `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. + +The `build-constraints` output names the hashed constraints file. The Action adds installed command launchers to +`GITHUB_PATH` for later steps while leaving the runner's `python`, `pip`, and `uv` commands on their original paths. Run +`actions/checkout` and `astral-sh/setup-uv` with Python 3.15 before it. It bootstraps Nab from the shared, hash-checked +lock. ## Check pull request news diff --git a/changelog.d/+empty-yank.bugfix.md b/changelog.d/+empty-yank.bugfix.md new file mode 100644 index 0000000..1433f0e --- /dev/null +++ b/changelog.d/+empty-yank.bugfix.md @@ -0,0 +1 @@ +Reject a yanked PyPI file even when its yank reason is empty. diff --git a/changelog.d/+install-tool-options.doc.md b/changelog.d/+install-tool-options.doc.md new file mode 100644 index 0000000..3ad606d --- /dev/null +++ b/changelog.d/+install-tool-options.doc.md @@ -0,0 +1,2 @@ +Document the build-constraint inputs of `install-locked-tools` and correct the 1.0.0 migration notes for removed inputs +and PATH behavior. diff --git a/changelog.d/+locked-cli-path.bugfix.md b/changelog.d/+locked-cli-path.bugfix.md new file mode 100644 index 0000000..3ed3e4e --- /dev/null +++ b/changelog.d/+locked-cli-path.bugfix.md @@ -0,0 +1 @@ +Expose locked release commands without replacing the runner's Python, pip, or uv executables in later workflow steps. diff --git a/changelog.d/+pypi-incomplete-read.bugfix.md b/changelog.d/+pypi-incomplete-read.bugfix.md new file mode 100644 index 0000000..c5a2efb --- /dev/null +++ b/changelog.d/+pypi-incomplete-read.bugfix.md @@ -0,0 +1,2 @@ +Retry incomplete PyPI listing responses while waiting for published files, then report a concise error if the wait +expires. diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index b05a4e1..dd9ad1b 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -151,8 +151,5 @@ runs: env: VENV: ${{ inputs.venv }} run: | - if [[ -d "$VENV/Scripts" ]]; then - (cd "$VENV/Scripts" && pwd) >>"$GITHUB_PATH" - else - (cd "$VENV/bin" && pwd) >>"$GITHUB_PATH" - fi + uv run --no-project --python "$RUNNER_TEMP/lock-tools" 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 d31737c..d4689c5 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -3,6 +3,7 @@ import os import shutil import subprocess +import sys import tomllib from datetime import UTC, datetime, timedelta from pathlib import Path @@ -121,6 +122,44 @@ 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_exposes_release_commands(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + environment = tmp_path / "tools" + result = install(source, action_scripts, tmp_path, groups="release-check", venv=environment) + + assert result.returncode == 0, result.stderr + overlay = Path((tmp_path / "github-path").read_text().splitlines()[-1]) + suffix = ".exe" if os.name == "nt" else "" + assert (overlay / f"prek{suffix}").is_file() + command = subprocess.run([str(overlay / f"prek{suffix}"), "--version"], capture_output=True, text=True, check=False) + assert command.returncode == 0, command.stderr + + +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") + + assert result.returncode == 0, result.stderr + overlay = Path((tmp_path / "github-path").read_text().splitlines()[-1]) + assert not any(command.name.casefold().startswith(("python", "pip", "uv")) for command in overlay.iterdir()) + original = shutil.which(Path(sys.executable).name, path=f"{overlay}{os.pathsep}{Path(sys.executable).parent}") + assert original is not None + assert Path(original).resolve() == Path(sys.executable).resolve() + + +def test_install_locked_tools_exposes_separate_environments(tmp_path: Path, action_scripts: Path) -> None: + source = Path(__file__).resolve().parents[2] + for group in ("release-check", "release-news"): + result = install(source, action_scripts, tmp_path, groups=group, venv=tmp_path / group) + assert result.returncode == 0, result.stderr + + overlays = [Path(line) for line in (tmp_path / "github-path").read_text().splitlines()] + suffix = ".exe" if os.name == "nt" else "" + assert overlays[0] != overlays[1] + assert (overlays[0] / f"prek{suffix}").is_file() + assert (overlays[1] / f"towncrier{suffix}").is_file() + + def test_install_locked_tools_reuses_windows_shaped_environment(tmp_path: Path, action_scripts: Path) -> None: subprocess.run(["uv", "venv", "--python", "3.15", str(tmp_path / "lock-tools")], check=True, capture_output=True) (tmp_path / "lock-tools" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")).unlink() @@ -359,6 +398,37 @@ def test_install_locked_tools_rejects_unreviewed_version_under_reviewed_floor(tm assert "uncooled artifact" in result.stderr +@pytest.mark.parametrize("version", ["0.6.0", "0.6.4"]) +def test_install_locked_tools_rejects_caller_below_shared_floor(tmp_path: Path, version: str) -> 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"] == "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["upload-time"] = datetime(2026, 10, 15, tzinfo=UTC) + 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-news", + "--shared-lock", + str(source / "pylock.toml"), + ], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "relock: release-creator 0.6.5 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 / "pylock.toml").write_text( diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index 388c3df..b426794 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -67,8 +67,6 @@ def test_expire_cutoffs_removes_empty_package_table(cutoff_project: Path) -> Non @pytest.mark.parametrize( "reviewed", [ - pytest.param("2026-10-23T02:44:40Z", id="0.6.0"), - pytest.param("2026-10-23T16:52:06Z", id="0.6.4"), pytest.param("2026-10-23T19:54:50Z", id="0.6.5"), pytest.param("2026-10-23T20:48:24Z", id="0.6.6"), ], @@ -205,8 +203,8 @@ def test_check_nab_lock_rejects_forged_upload_time( pytest.param("nab", "2026-10-18T05:45:09Z", True, id="reviewed-nab"), pytest.param("nab", "2026-10-19T00:00:00Z", False, id="unreviewed-nab"), pytest.param("nab", "2026-10-20T00:00:00Z", False, id="extended-nab"), - pytest.param("release-creator", "2026-10-23T02:44:40Z", True, id="reviewed-release-creator"), - pytest.param("release-creator", "2026-10-23T16:52:06Z", True, id="reviewed-release-creator-0.6.4"), + pytest.param("release-creator", "2026-10-23T02:44:40Z", False, id="stale-release-creator-0.6.0"), + pytest.param("release-creator", "2026-10-23T16:52:06Z", False, id="stale-release-creator-0.6.4"), pytest.param("release-creator", "2026-10-23T16:52:07Z", False, id="extended-release-creator"), pytest.param("release-creator", "2026-10-23T19:54:50Z", True, id="reviewed-release-creator-0.6.5"), pytest.param("release-creator", "2026-10-23T19:54:51Z", False, id="extended-release-creator-0.6.5"), diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py index be88a21..c6b37b2 100644 --- a/tests/verify_pypi_files/test_verify.py +++ b/tests/verify_pypi_files/test_verify.py @@ -5,6 +5,7 @@ import io import json from email.message import Message +from http.client import IncompleteRead from pathlib import Path from socket import gaierror from typing import TYPE_CHECKING, TypedDict, cast @@ -29,7 +30,7 @@ class _ListingFile(TypedDict): filename: str hashes: dict[str, str] - yanked: bool + yanked: bool | str @pytest.fixture @@ -79,6 +80,7 @@ def test_verify_files_ignores_legacy_egg_from_longer_version(distribution: Path, pytest.param("extra", "unexpected files", id="extra"), pytest.param("digest", "different SHA256", id="digest"), pytest.param("yanked", "yanked PyPI file", id="yanked"), + pytest.param("yanked-empty", "yanked PyPI file", id="yanked-empty-reason"), pytest.param("missing", "missing files", id="missing"), pytest.param("duplicate", "duplicate or yanked", id="duplicate"), pytest.param("invalid-name", "unrecognized distribution filename", id="invalid-name"), @@ -141,10 +143,11 @@ def test_verify_files_waits_for_incomplete_listing(distribution: Path, mocker: M pytest.param(URLError(gaierror("DNS failure")), id="dns"), pytest.param(ConnectionResetError("connection reset"), id="reset"), pytest.param(TimeoutError("read timeout"), id="read-timeout"), + pytest.param(IncompleteRead(b"partial", 10), id="incomplete-read"), ], ) def test_verify_files_retries_transient_listing_failure( - distribution: Path, mocker: MockerFixture, failure: OSError + distribution: Path, mocker: MockerFixture, failure: OSError | IncompleteRead ) -> None: response = mocker.patch.object(_MODULE, "urlopen", side_effect=[failure, _body(_listing(distribution))]) mocker.patch.object(_MODULE.time, "sleep") @@ -156,9 +159,18 @@ def test_verify_files_retries_transient_listing_failure( assert response.call_count == 2 -def test_verify_files_retries_response_read_timeout(distribution: Path, mocker: MockerFixture) -> None: +@pytest.mark.parametrize( + "failure", + [ + pytest.param(TimeoutError("read timeout"), id="timeout"), + pytest.param(IncompleteRead(b"partial", 10), id="incomplete-read"), + ], +) +def test_verify_files_retries_response_read_failure( + distribution: Path, mocker: MockerFixture, failure: TimeoutError | IncompleteRead +) -> None: response = mocker.MagicMock() - response.__enter__.return_value.read.side_effect = TimeoutError("read timeout") + response.__enter__.return_value.read.side_effect = failure mocker.patch.object(_MODULE, "urlopen", side_effect=[response, _body(_listing(distribution))]) mocker.patch.object(_MODULE.time, "sleep") @@ -168,6 +180,19 @@ def test_verify_files_retries_response_read_timeout(distribution: Path, mocker: ] +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"]) + + with pytest.raises(SystemExit) as raised: + cast("Callable[[], None]", _MODULE.main)() + + assert raised.value.code == 2 + assert "IncompleteRead" in capsys.readouterr().err + + def test_verify_files_backs_off_between_retries(distribution: Path, mocker: MockerFixture) -> None: mocker.patch.object( _MODULE, @@ -193,6 +218,8 @@ def _serve(mocker: MockerFixture, distribution: Path, *, change: str = "") -> No files[0]["hashes"]["sha256"] = "0" * 64 elif change == "yanked": files[0]["yanked"] = True + elif change == "yanked-empty": + files[0]["yanked"] = "" elif change == "missing": files.pop() elif change == "duplicate": diff --git a/tools/expose_locked_commands.py b/tools/expose_locked_commands.py new file mode 100644 index 0000000..be65c4c --- /dev/null +++ b/tools/expose_locked_commands.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import os +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Final + + +def main() -> None: + overlay: Final[Path] = Path(tempfile.mkdtemp(prefix="locked-cli-", dir=os.environ["RUNNER_TEMP"])) + for command in (Path(sys.argv[1]).resolve() / ("Scripts" if os.name == "nt" else "bin")).iterdir(): + name = command.stem.casefold() if os.name == "nt" else command.name.casefold() + if ( + command.is_file() + 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"} + ): + if os.name == "nt": + shutil.copy2(command, overlay / command.name) + else: + os.symlink(command, overlay / command.name) + with Path(os.environ["GITHUB_PATH"]).open("a", encoding="utf-8") as stream: + print(overlay, file=stream) + + +if __name__ == "__main__": + main() + + +__all__ = [ + "main", +] diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py index 9a8f03b..7fb3366 100644 --- a/tools/nab_cutoffs.py +++ b/tools/nab_cutoffs.py @@ -20,8 +20,6 @@ (name, "0.0.22"): datetime(2026, 10, 18, 5, 45, 9, tzinfo=UTC) for name in ("nab", "nab-index", "nab-markersets", "nab-project", "nab-provider", "nab-resolver") }, - ("release-creator", "0.6.0"): datetime(2026, 10, 23, 2, 44, 40, tzinfo=UTC), - ("release-creator", "0.6.4"): datetime(2026, 10, 23, 16, 52, 6, tzinfo=UTC), ("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), ("uv-build", "0.13.4"): datetime(2026, 10, 21, tzinfo=UTC), diff --git a/tools/verify_pypi_files.py b/tools/verify_pypi_files.py index ae74ae3..de74f99 100644 --- a/tools/verify_pypi_files.py +++ b/tools/verify_pypi_files.py @@ -6,6 +6,7 @@ import hashlib import json import time +from http.client import IncompleteRead from pathlib import Path from typing import NotRequired, TypedDict, cast from urllib.error import HTTPError, URLError @@ -37,7 +38,7 @@ def main() -> None: try: for path in verify_files(args.project, args.version, args.dist, args.wait_seconds): print(path) - except (OSError, TypeError, ValueError) as error: + except (OSError, TypeError, ValueError, IncompleteRead) as error: parser.error(str(error)) @@ -76,7 +77,7 @@ def verify_files(project: str, version: str, dist: Path, wait_seconds: int) -> l if deadline - time.monotonic() <= 0: raise files = [] - except TimeoutError, ConnectionError, URLError: + except TimeoutError, ConnectionError, URLError, IncompleteRead: if deadline - time.monotonic() <= 0: raise files = [] @@ -94,7 +95,7 @@ def verify_files(project: str, version: str, dist: Path, wait_seconds: int) -> l if identity != (name, release): continue filename = file["filename"] - if filename in remote or file.get("yanked"): + if filename in remote or file.get("yanked", False) is not False: raise ValueError(f"duplicate or yanked PyPI file: {filename}") remote[filename] = file["hashes"]["sha256"] if extra := remote.keys() - local.keys():