Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<a name="release-1-0-0-fixed"></a>

Expand All @@ -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).

<a name="release-0-4-1"></a>

Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions changelog.d/+empty-yank.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reject a yanked PyPI file even when its yank reason is empty.
2 changes: 2 additions & 0 deletions changelog.d/+install-tool-options.doc.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog.d/+locked-cli-path.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Expose locked release commands without replacing the runner's Python, pip, or uv executables in later workflow steps.
2 changes: 2 additions & 0 deletions changelog.d/+pypi-incomplete-read.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Retry incomplete PyPI listing responses while waiting for published files, then report a concise error if the wait
expires.
7 changes: 2 additions & 5 deletions install-locked-tools/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
70 changes: 70 additions & 0 deletions tests/install_locked_tools/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import shutil
import subprocess
import sys
import tomllib
from datetime import UTC, datetime, timedelta
from pathlib import Path
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 2 additions & 4 deletions tests/refresh_release_lock/test_cutoffs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
],
Expand Down Expand Up @@ -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"),
Expand Down
35 changes: 31 additions & 4 deletions tests/verify_pypi_files/test_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,7 +30,7 @@
class _ListingFile(TypedDict):
filename: str
hashes: dict[str, str]
yanked: bool
yanked: bool | str


@pytest.fixture
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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")
Expand All @@ -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")

Expand All @@ -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,
Expand All @@ -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":
Expand Down
36 changes: 36 additions & 0 deletions tools/expose_locked_commands.py
Original file line number Diff line number Diff line change
@@ -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",
]
2 changes: 0 additions & 2 deletions tools/nab_cutoffs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
7 changes: 4 additions & 3 deletions tools/verify_pypi_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))


Expand Down Expand Up @@ -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 = []
Expand All @@ -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():
Expand Down
Loading