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