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():