diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml
index b95d043..72af74e 100644
--- a/.github/workflows/check.yaml
+++ b/.github/workflows/check.yaml
@@ -29,6 +29,7 @@ jobs:
version: latest-known
python-version: "3.15"
enable-cache: true
+ cache-suffix: ${{ github.job }}
cache-dependency-glob: |
pylock.toml
pyproject.toml
@@ -58,6 +59,7 @@ jobs:
version: latest-known
python-version: "3.15"
enable-cache: true
+ cache-suffix: ${{ github.job }}
cache-dependency-glob: |
pylock.toml
pyproject.toml
@@ -99,6 +101,7 @@ jobs:
version: latest-known
python-version: "3.15"
enable-cache: true
+ cache-suffix: ${{ github.job }}
cache-dependency-glob: |
pylock.toml
pyproject.toml
@@ -127,6 +130,7 @@ jobs:
version: latest-known
python-version: "3.15"
enable-cache: true
+ cache-suffix: ${{ github.job }}
cache-dependency-glob: |
pylock.toml
pyproject.toml
@@ -145,6 +149,7 @@ jobs:
version: latest-known
python-version: "3.15"
enable-cache: true
+ cache-suffix: ${{ github.job }}
cache-dependency-glob: |
pylock.toml
pyproject.toml
@@ -178,6 +183,7 @@ jobs:
version: latest-known
python-version: "3.15"
enable-cache: true
+ cache-suffix: ${{ github.job }}
cache-dependency-glob: |
pylock.toml
pyproject.toml
diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml
index 142ea12..3668ce8 100644
--- a/.github/workflows/upgrade-release-tools.yaml
+++ b/.github/workflows/upgrade-release-tools.yaml
@@ -14,6 +14,10 @@ on:
description: Groups for the secondary lock; empty selects build requirements
type: string
default: ""
+ additional-lock-selection:
+ description: Select project dependencies and configured groups for the secondary lock
+ type: string
+ default: build-or-groups
additional-matrix-python:
description: Python range for the secondary lock
type: string
@@ -67,6 +71,7 @@ jobs:
lock-groups: ${{ inputs.lock-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 }}
diff --git a/README.md b/README.md
index a460f71..95e4349 100644
--- a/README.md
+++ b/README.md
@@ -157,12 +157,17 @@ jobs:
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]`.
-For a separately checked-in build matrix lock, also pass `additional-lock` with an existing `pylock.*.toml` basename,
-`additional-matrix-python` with its Python range, and `additional-matrix-platforms` and
+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. For a separately checked-in build matrix lock, also pass `additional-lock` with an existing
+`pylock.*.toml` basename, `additional-matrix-python` with its Python range, and `additional-matrix-platforms` and
`additional-matrix-implementations` as space-separated Nab values. The secondary lock keeps its specified environment
matrix during refresh. `additional-lock-groups` selects dependency groups for it; when empty, Nab locks the project's
-build requirements. These inputs have no effect when `additional-lock` is absent, and matrix inputs without that lock
-fail. The writer accepts only the named secondary lock path.
+build requirements. Set `additional-lock-selection: project` to include the project's runtime dependencies and any
+configured build group. The consumer must select its runtime group when reading that lock. This selection cannot be
+combined with `additional-lock-groups`. Secondary-lock inputs without `additional-lock` fail. The writer accepts only
+the named secondary lock path. Use `project` only when those runtime dependencies become part of the published artifact,
+such as a bundled zipapp; tests still install runtime dependencies afresh.
For a repository that already commits `pylock.build.toml`, the complete caller is:
diff --git a/changelog.d/+lock-cutoff.security.md b/changelog.d/+lock-cutoff.security.md
new file mode 100644
index 0000000..5e32fef
--- /dev/null
+++ b/changelog.d/+lock-cutoff.security.md
@@ -0,0 +1 @@
+Enforce the seven-day Nab cutoff for global and package rules, including disabled or shortened cutoffs.
diff --git a/changelog.d/+release-asset-retry.bugfix.md b/changelog.d/+release-asset-retry.bugfix.md
new file mode 100644
index 0000000..6ab5b05
--- /dev/null
+++ b/changelog.d/+release-asset-retry.bugfix.md
@@ -0,0 +1 @@
+Remove partial GitHub Release assets before retrying an upload in the same run.
diff --git a/changelog.d/+release-tool-setup.bugfix.md b/changelog.d/+release-tool-setup.bugfix.md
new file mode 100644
index 0000000..d06c8e4
--- /dev/null
+++ b/changelog.d/+release-tool-setup.bugfix.md
@@ -0,0 +1 @@
+Allow repeated lock-tool setup on Windows and check caller release-creator versions against the selected groups.
diff --git a/changelog.d/+runtime-secondary-lock.feature.md b/changelog.d/+runtime-secondary-lock.feature.md
new file mode 100644
index 0000000..6829949
--- /dev/null
+++ b/changelog.d/+runtime-secondary-lock.feature.md
@@ -0,0 +1 @@
+Refresh a secondary pylock for project runtime dependencies, including a separate Python and platform matrix.
diff --git a/install-nab/action.yml b/install-nab/action.yml
index e2d0788..eea0a4a 100644
--- a/install-nab/action.yml
+++ b/install-nab/action.yml
@@ -5,10 +5,7 @@ runs:
steps:
- name: Create lock-tool environment
shell: bash
- run: |
- if [[ ! -x "$RUNNER_TEMP/lock-tools/bin/python" ]]; then
- uv venv --python 3.15 "$RUNNER_TEMP/lock-tools"
- fi
+ run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-tools"
- name: Install lock reader
shell: bash
run: |
diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml
index 352cf94..f313aac 100644
--- a/publish-github-release/action.yml
+++ b/publish-github-release/action.yml
@@ -172,9 +172,21 @@ runs:
if jq -e '.[0].draft' "$RUNNER_TEMP/release-current.json" >/dev/null; then
for asset in "$ASSETS_DIRECTORY"/*; do
name=${asset##*/}
- if ! jq -e --arg name "$name" 'any(.[0].assets[]; .name == $name)' \
+ if ! jq -e --arg name "$name" --slurpfile assets "$RUNNER_TEMP/release-assets.json" '
+ any(.[0].assets[]; .name == $name and .state == "uploaded" and
+ (.digest as $digest | any($assets[0][]; .name == $name and .digest == $digest)))
+ ' \
"$RUNNER_TEMP/release-current.json" >/dev/null; then
for attempt in {1..6}; do
+ if ((attempt > 1)); then
+ release=$(gh api "repos/$GH_REPO/releases/$release_id")
+ asset_ids=$(jq -r --arg name "$name" \
+ '.assets[] | select(.name == $name and .state != "uploaded") | .id' <<<"$release")
+ while IFS= read -r asset_id; do
+ [[ -n "$asset_id" ]] || continue
+ gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id" >/dev/null
+ done <<<"$asset_ids"
+ fi
if gh release upload "$TAG" "$asset" --repo "$GH_REPO"; then
break
fi
diff --git a/pylock.toml b/pylock.toml
index 198455d..35bfd47 100644
--- a/pylock.toml
+++ b/pylock.toml
@@ -2335,18 +2335,17 @@ command-line = [
"--groups",
"build",
"lock-tools",
- "release-attestations",
- "release-check",
- "release-hook-check",
- "release-hooks",
- "release-news",
"release-notes",
+ "release-news",
"release-prepare",
- "release-shell",
"release-validate",
+ "release-check",
+ "release-attestations",
+ "release-shell",
+ "release-hooks",
+ "release-hook-check",
"--output",
"pylock.toml",
- "--upgrade",
]
input-path = "pyproject.toml"
mode = "universal"
diff --git a/pyproject.toml b/pyproject.toml
index b435dec..054f645 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,6 +20,8 @@ release-shell = [ "prek>=0.2", "yq>=4.4.5" ]
release-hooks = [ "release-creator>=0.6.0", "prek>=0.2" ]
release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ]
test = [
+ "certifi",
+ "check-wheel-contents>=0.6.3",
"hatchling>=1.28",
"pytest>=9",
"pytest-httpserver",
@@ -29,6 +31,7 @@ test = [
"tox>=4.64.7",
"tox-uv>=1.36.1",
"trustme",
+ "twine>=7",
"ty",
]
diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml
index f35f3ad..3b8480c 100644
--- a/refresh-release-lock/action.yml
+++ b/refresh-release-lock/action.yml
@@ -10,6 +10,9 @@ inputs:
additional-lock-groups:
description: Groups for the secondary lock; empty selects build requirements
default: ""
+ additional-lock-selection:
+ description: Select project dependencies and configured groups for the secondary lock
+ default: build-or-groups
additional-matrix-python:
description: Python range for the secondary lock
default: ""
@@ -48,6 +51,7 @@ runs:
MODE: ${{ inputs.mode }}
ADDITIONAL_LOCK: ${{ inputs.additional-lock }}
ADDITIONAL_GROUPS: ${{ inputs.additional-lock-groups }}
+ ADDITIONAL_SELECTION: ${{ inputs.additional-lock-selection }}
MATRIX_PYTHON: ${{ inputs.additional-matrix-python }}
MATRIX_PLATFORMS: ${{ inputs.additional-matrix-platforms }}
MATRIX_IMPLEMENTATIONS: ${{ inputs.additional-matrix-implementations }}
@@ -80,7 +84,8 @@ runs:
"$RUNNER_TEMP/pylock-before-check.toml" pylock.toml
fi
if [[ -z "$ADDITIONAL_LOCK" ]]; then
- if [[ -n "$ADDITIONAL_GROUPS$MATRIX_PYTHON$MATRIX_PLATFORMS$MATRIX_IMPLEMENTATIONS" ]]; then
+ if [[ "$ADDITIONAL_SELECTION" != build-or-groups ||
+ -n "$ADDITIONAL_GROUPS$MATRIX_PYTHON$MATRIX_PLATFORMS$MATRIX_IMPLEMENTATIONS" ]]; then
echo '::error::secondary matrix inputs require additional-lock' >&2
exit 1
fi
@@ -111,8 +116,23 @@ runs:
echo '::error::secondary matrix needs platforms and implementations' >&2
exit 1
fi
- selection=(--build-requirements)
- if ((${#selected_groups[@]})); then selection=(--groups "${selected_groups[@]}"); fi
+ case "$ADDITIONAL_SELECTION" in
+ build-or-groups)
+ selection=(--build-requirements)
+ if ((${#selected_groups[@]})); then selection=(--groups "${selected_groups[@]}"); fi
+ ;;
+ project)
+ if ((${#selected_groups[@]})); then
+ echo '::error::project secondary lock cannot select dependency groups' >&2
+ exit 1
+ fi
+ selection=()
+ ;;
+ *)
+ echo '::error::unknown secondary lock selection' >&2
+ exit 1
+ ;;
+ esac
cp "$ADDITIONAL_LOCK" "$RUNNER_TEMP/additional-lock-before.toml"
uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock pyproject.toml "${selection[@]}" \
--project-matrix-python "$MATRIX_PYTHON" \
diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py
index 76322fd..97f7255 100644
--- a/tests/build_python_distribution/test_build.py
+++ b/tests/build_python_distribution/test_build.py
@@ -11,8 +11,11 @@
@pytest.fixture
-def build_repository(source_project: Path, nab_executable: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+def build_repository(
+ source_project: Path, nab_executable: Path, test_package_index: str, monkeypatch: pytest.MonkeyPatch
+) -> Path:
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
+ monkeypatch.setenv("UV_INDEX_URL", test_package_index)
project = source_project / "pyproject.toml"
release_requirement = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text())[
"dependency-groups"
@@ -21,6 +24,7 @@ def build_repository(source_project: Path, nab_executable: Path, monkeypatch: py
project.read_text()
+ f"\n[dependency-groups]\nrelease-notes = [{json.dumps(release_requirement)}]\n"
+ '\n[tool.nab]\nbase-group = "project-runtime"\nbuild-group = "build"\n'
+ + f'[[tool.nab.indexes]]\nname = "fixture"\nurl = "{test_package_index}"\nserialization = "html"\n'
)
run(
source_project,
diff --git a/tests/conftest.py b/tests/conftest.py
index c21ef57..d7c5851 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -5,14 +5,21 @@
import ssl
import subprocess
import sys
+import tomllib
from collections.abc import Iterator, Mapping
+from hashlib import sha256
+from html import escape
from pathlib import Path
from textwrap import dedent
from typing import Final
+from urllib.parse import urlsplit
+from urllib.request import urlopen
+import certifi
import pytest
import trustme
import yaml
+from packaging.utils import canonicalize_name
from pytest_httpserver import HTTPServer
from werkzeug.wrappers import Request, Response
@@ -27,8 +34,7 @@ def github_api(tmp_path: Path) -> Iterator[tuple[HTTPServer, dict[str, str]]]:
server.start()
bundle = tmp_path / "ca.pem"
authority.cert_pem.write_to_path(bundle)
- if system_certs := ssl.get_default_verify_paths().cafile:
- bundle.write_bytes(bundle.read_bytes() + Path(system_certs).read_bytes())
+ bundle.write_bytes(bundle.read_bytes() + Path(certifi.where()).read_bytes())
yield (
server,
{
@@ -80,6 +86,63 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path:
return scripts
+@pytest.fixture(scope="session")
+def test_package_lock(tmp_path_factory: pytest.TempPathFactory, nab_executable: Path) -> Path:
+ fixture = tmp_path_factory.mktemp("fixture-lock")
+ (fixture / "pyproject.toml").write_text(
+ '[project]\nname = "release-fixture"\nversion = "0.1.0"\nrequires-python = ">=3.15,<3.16"\n'
+ '[dependency-groups]\nfixture = ["check-wheel-contents>=0.6.3", "hatchling>=1.28", "twine>=7"]\n'
+ '[tool.nab]\nmode = "universal"\nbase-group = "project-runtime"\n'
+ 'default-groups = []\n[tool.nab.matrix]\npython = ">=3.15,<3.16"\nplatforms = ["linux_x86_64"]\n'
+ )
+ subprocess.run(
+ [str(nab_executable), "lock", "--groups", "fixture", "--output", "pylock.toml"],
+ cwd=fixture,
+ check=True,
+ capture_output=True,
+ )
+ return fixture / "pylock.toml"
+
+
+@pytest.fixture
+def test_package_index(httpserver: HTTPServer, test_package_lock: Path, artifact_cache: dict[str, bytes]) -> str:
+ packages = [
+ *tomllib.loads((Path(__file__).resolve().parents[1] / "pylock.toml").read_text())["packages"],
+ *tomllib.loads(test_package_lock.read_text())["packages"],
+ ]
+ listings: dict[str, list[str]] = {}
+ artifacts: dict[str, tuple[str, str]] = {}
+ for package in packages:
+ links = 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]
+ digest = artifact["hashes"]["sha256"]
+ artifacts[filename] = artifact["url"], digest
+ links.append(
+ f'{escape(filename)}'
+ )
+ for name, links in listings.items():
+ httpserver.expect_request(f"/simple/{name}/").respond_with_data("\n".join(links), content_type="text/html")
+ for filename, (url, digest) in artifacts.items():
+
+ def serve(_request: Request, *, source: str = url, expected: str = digest) -> Response:
+ if expected not in artifact_cache:
+ with urlopen(source, timeout=30) as stream:
+ artifact_cache[expected] = stream.read()
+ content = artifact_cache[expected]
+ assert sha256(content).hexdigest() == expected
+ return Response(content, mimetype="application/octet-stream")
+
+ httpserver.expect_request(f"/files/{filename}").respond_with_handler(serve)
+ return httpserver.url_for("/simple/")
+
+
+@pytest.fixture(scope="session")
+def artifact_cache() -> dict[str, bytes]:
+ return {}
+
+
@pytest.fixture(scope="session")
def nab_executable(tmp_path_factory: pytest.TempPathFactory) -> Path:
root = Path(__file__).resolve().parents[1]
@@ -389,6 +452,7 @@ def _safe_environment() -> dict[str, str]:
"LANG",
"PATH",
"UV_CACHE_DIR",
+ "UV_INDEX_URL",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py
index 3cc274b..1854e1f 100644
--- a/tests/install_locked_tools/test_install.py
+++ b/tests/install_locked_tools/test_install.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import os
import shutil
import subprocess
import tomllib
@@ -110,6 +111,29 @@ def test_install_lock_tools_rejects_hash_mismatch(tmp_path: Path, action_scripts
assert "Hash mismatch" in result.stderr
+def test_install_nab_reuses_existing_environment(tmp_path: Path, action_scripts: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ environment = {"GITHUB_ACTION_PATH": str(source / "install-nab")}
+ first = action(source, tmp_path, action_scripts, "install-nab", version="", **environment)
+ second = action(source, tmp_path, action_scripts, "install-nab", version="", **environment)
+ assert first.returncode == second.returncode == 0, first.stderr + second.stderr
+ assert (tmp_path / "lock-tools" / ("Scripts/nab.exe" if os.name == "nt" else "bin/nab")).is_file()
+
+
+def test_install_nab_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()
+ result = subprocess.run(
+ ["bash", str(action_scripts / "install-nab-0.sh")],
+ env=os.environ | {"RUNNER_TEMP": str(tmp_path)},
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "lock-tools/pyvenv.cfg").is_file()
+
+
def test_install_locked_tools_rejects_empty_groups(tmp_path: Path, action_scripts: Path) -> None:
source = Path(__file__).resolve().parents[2]
result = install(
@@ -158,6 +182,59 @@ 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:
+ source = Path(__file__).resolve().parents[2]
+ project = tomllib.loads((source / "pylock.toml").read_text())
+ package = next(item for item in project["packages"] if item["name"] == "release-creator")
+ lock = (source / "pylock.toml").read_text().replace(f'version = "{package["version"]}"', "", 1)
+ (tmp_path / "pylock.toml").write_text(lock)
+ result = subprocess.run(
+ [
+ "python",
+ str(source / "tools/check_lock_groups.py"),
+ str(tmp_path / "pylock.toml"),
+ "release-prepare",
+ "--shared-lock",
+ str(source / "pylock.toml"),
+ "--local-tool-source",
+ ],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+
+
+def test_caller_version_check_uses_shared_package_marker(tmp_path: Path) -> None:
+ source = Path(__file__).resolve().parents[2]
+ shared = tmp_path / "shared.toml"
+ lock = (source / "pylock.toml").read_text()
+ marker = next(
+ package["marker"] for package in tomllib.loads(lock)["packages"] if package["name"] == "release-creator"
+ )
+ shared.write_text(lock.replace(marker.replace('"', '\\"'), '\\"release-shell\\" in dependency_groups'))
+ 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'
+ )
+ 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_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)
@@ -270,12 +347,13 @@ def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_pat
def test_install_locked_tools_accepts_lock_without_build_group(
- tmp_path: Path, action_scripts: Path, nab_executable: Path
+ tmp_path: Path, action_scripts: Path, nab_executable: Path, test_package_index: str
) -> None:
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "tool-only"\nversion = "0.1.0"\nrequires-python = ">=3.15"\n'
'[dependency-groups]\nrelease-check = ["prek>=0.2"]\n'
- '[tool.nab]\nbase-group = "project-runtime"\ndefault-groups = []\n',
+ '[tool.nab]\nbase-group = "project-runtime"\ndefault-groups = []\n'
+ f'[[tool.nab.indexes]]\nname = "fixture"\nurl = "{test_package_index}"\nserialization = "html"\n',
)
generated = subprocess.run(
[str(nab_executable), "lock", "--groups", "release-check", "--output", "pylock.toml"],
diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py
index 1822fa4..ba0e4d8 100644
--- a/tests/publish_github_release/test_publish.py
+++ b/tests/publish_github_release/test_publish.py
@@ -436,6 +436,99 @@ def test_draft_release_replaces_incomplete_asset(
assert [request.method for request, _ in server.log] == ["GET", "GET", "DELETE", "GET", "GET", "GET"]
+def test_failed_upload_deletes_partial_asset_before_retry(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ assets = tmp_path / "assets"
+ assets.mkdir()
+ asset_path = assets / "fixture.zip"
+ asset_path.write_bytes(b"release archive")
+ draft = _release(notes, draft=True)
+ partial = _release(
+ notes, draft=True, assets=[{"id": "21", "name": asset_path.name, "digest": "", "state": "starter"}]
+ )
+ complete = _release(
+ notes,
+ draft=True,
+ assets=[
+ {
+ "name": asset_path.name,
+ "digest": f"sha256:{sha256(asset_path.read_bytes()).hexdigest()}",
+ "state": "uploaded",
+ }
+ ],
+ )
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit})
+ server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json(
+ [draft]
+ )
+ for release in (draft, partial, partial):
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(release)
+ server.expect_ordered_request(f"{prefix}/releases/assets/21", method="DELETE").respond_with_json({})
+ for _ in range(3):
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(complete)
+
+ result = _request(
+ repository,
+ tmp_path,
+ action_scripts,
+ notes,
+ commit,
+ environment | release_cli | {"GH_UPLOAD_FAILURE": "once"},
+ assets=assets,
+ mode="stage",
+ )
+ assert result.returncode == 0, result.stderr
+ assert [request.method for request, _ in server.log] == ["GET"] * 5 + ["DELETE"] + ["GET"] * 2
+ assert _release_calls(tmp_path) == [
+ ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"),
+ ("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture"),
+ ]
+
+
+def test_failed_asset_lookup_stops_retry(
+ repository: Path,
+ tmp_path: Path,
+ action_scripts: Path,
+ github_api: tuple[HTTPServer, dict[str, str]],
+ release_cli: dict[str, str],
+) -> None:
+ server, environment = github_api
+ notes, commit = _notes_and_commit(repository, tmp_path)
+ assets = tmp_path / "assets"
+ assets.mkdir()
+ asset_path = assets / "fixture.zip"
+ asset_path.write_bytes(b"release archive")
+ draft = _release(notes, draft=True)
+ prefix = "/api/v3/repos/tox-dev/fixture"
+ server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json({"sha": commit})
+ server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json(
+ [draft]
+ )
+ for _ in range(2):
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft)
+ server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_data(status=500)
+ result = _request(
+ repository,
+ tmp_path,
+ action_scripts,
+ notes,
+ commit,
+ environment | release_cli | {"GH_UPLOAD_FAILURE": "once"},
+ assets=assets,
+ mode="stage",
+ )
+ assert result.returncode != 0
+ assert _release_calls(tmp_path) == [("release", "upload", "0.1.0", str(asset_path), "--repo", "tox-dev/fixture")]
+
+
def test_failed_asset_upload_leaves_draft_unpublished(
repository: Path,
tmp_path: Path,
@@ -454,7 +547,7 @@ def test_failed_asset_upload_leaves_draft_unpublished(
server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json(
[draft]
)
- for _ in range(7):
+ for _ in range(14):
server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft)
result = _request(
repository,
@@ -466,7 +559,7 @@ def test_failed_asset_upload_leaves_draft_unpublished(
assets=assets,
)
assert result.returncode != 0
- assert [request.method for request, _ in server.log] == ["GET"] * 9
+ assert [request.method for request, _ in server.log] == ["GET"] * 14
assert (
_release_calls(tmp_path)
== [("release", "upload", "0.1.0", str(assets / "fixture.zip"), "--repo", "tox-dev/fixture")] * 6
diff --git a/tests/recheck_hook_update_cooldown/test_recheck.py b/tests/recheck_hook_update_cooldown/test_recheck.py
index af4ef0e..62f8920 100644
--- a/tests/recheck_hook_update_cooldown/test_recheck.py
+++ b/tests/recheck_hook_update_cooldown/test_recheck.py
@@ -22,14 +22,16 @@ def test_recheck_schedule_has_only_required_permissions() -> None:
@pytest.mark.parametrize(
- ("observed_age", "job_age", "job_conclusion", "job_name", "reruns"),
+ ("observed_age", "job_age", "job_conclusion", "job_name", "minimum_days", "reruns"),
[
- pytest.param(8, 8, "failure", "hook-cooldown", 1, id="matured-failure"),
- pytest.param(8, 8, "failure", "Hook update cooldown", 1, id="named-job"),
- pytest.param(6, 6, "failure", "hook-cooldown", 0, id="recent-pull"),
- pytest.param(8, 0, "failure", "hook-cooldown", 1, id="failed-rerun"),
- pytest.param(31, 31, "failure", "hook-cooldown", 0, id="expired-run"),
- pytest.param(8, 8, "success", "hook-cooldown", 0, id="successful-job"),
+ 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(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.mark.parametrize(
@@ -44,6 +46,7 @@ def test_recheck_reruns_failed_job(
job_age: int,
job_conclusion: str,
job_name: str,
+ minimum_days: int,
reruns: int,
bot_login: str,
) -> None:
@@ -85,7 +88,7 @@ def test_recheck_reruns_failed_job(
]
}
)
- if 7 <= observed_age < 30:
+ if minimum_days <= observed_age < 30:
server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42/jobs", method="GET").respond_with_json(
{
"jobs": [
@@ -114,6 +117,7 @@ def test_recheck_reruns_failed_job(
version="",
GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"],
JOB_NAME=job_name,
+ MINIMUM_DAYS=str(minimum_days),
**environment,
)
assert result.returncode == 0, result.stderr
diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py
index cd42887..212ae23 100644
--- a/tests/refresh_release_lock/test_cutoffs.py
+++ b/tests/refresh_release_lock/test_cutoffs.py
@@ -163,7 +163,112 @@ def test_recent_cutoff_requires_review(package: str, timestamp: str, accepted: b
project = (
f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.{package}]\nuploaded-prior-to = "{timestamp}"\n'
)
+ result = _check_recent_cutoffs(project)
+ assert (result.returncode == 0) == accepted
+ if not accepted:
+ assert "unreviewed recent Nab cutoff" in result.stderr
+
+
+@pytest.mark.parametrize(
+ ("cutoff", "message"),
+ [
+ pytest.param('uploaded-prior-to = "P0D"', "global Nab cutoff", id="zero-days"),
+ pytest.param('uploaded-prior-to = "P6D"', "global Nab cutoff", id="six-days"),
+ pytest.param("uploaded-prior-to = false", "global Nab cutoff", id="disabled"),
+ ],
+)
+def test_recent_cutoff_rejects_weak_global_policy(cutoff: str, message: str) -> None:
+ result = _check_recent_cutoffs(f"[tool.nab]\n{cutoff}\n")
+ assert result.returncode != 0
+ assert message in result.stderr
+
+
+@pytest.mark.parametrize(
+ ("value", "accepted"),
+ [
+ pytest.param('"P0D"', False, id="zero-days"),
+ pytest.param('"P6D"', False, id="six-days"),
+ pytest.param("false", False, id="disabled"),
+ pytest.param('"P7D"', True, id="seven-days"),
+ pytest.param('"P14D"', True, id="fourteen-days"),
+ ],
+)
+def test_recent_cutoff_checks_package_duration(value: str, accepted: bool) -> None:
+ project = f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.example]\nuploaded-prior-to = {value}\n'
+ result = _check_recent_cutoffs(project)
+ assert (result.returncode == 0) is accepted
+ if not accepted:
+ assert "weakens the seven-day policy" in result.stderr
+
+
+@pytest.mark.parametrize("package", ["nab", "release-creator", "uv-build"], ids=["nab", "creator", "uv-build"])
+@pytest.mark.parametrize("value", ['"P0D"', "false"], ids=["zero-days", "disabled"])
+def test_reviewed_cutoff_rejects_unbounded_override(package: str, value: str) -> None:
+ project = f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.{package}]\nuploaded-prior-to = {value}\n'
+ result = _check_recent_cutoffs(project)
+ assert result.returncode != 0
+ assert "weakens the seven-day policy" in result.stderr
+
+
+def test_recent_cutoff_rejects_index_override() -> None:
+ result = _check_recent_cutoffs(
+ '[tool.nab]\nuploaded-prior-to = "P7D"\n'
+ '[[tool.nab.indexes]]\nname = "pypi"\nurl = "https://pypi.org/simple/"\n'
+ "[tool.nab.index.pypi]\nuploaded-prior-to = false\n"
+ )
+ assert result.returncode != 0
+ assert "Nab index cutoff for pypi" 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,
+ )
+ assert result.returncode != 0
+ assert "project nab.toml can override" in result.stderr
+
+
+def test_verify_cutoff_patch_reports_invalid_boolean(cutoff_project: Path) -> None:
+ cutoff_project.write_text(cutoff_project.read_text().replace('"2026-01-01T00:00:00Z"', "true", 1))
+ subprocess.run(["git", "add", "pyproject.toml"], cwd=cutoff_project.parent, check=True, capture_output=True)
+ subprocess.run(
+ ["git", "-c", "user.name=Release test", "-c", "user.email=release@example.org", "commit", "-m", "Invalid"],
+ cwd=cutoff_project.parent,
+ check=True,
+ capture_output=True,
+ )
result = subprocess.run(
+ [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/verify_cutoff_patch.py")],
+ cwd=cutoff_project.parent,
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode != 0
+ assert "invalid Nab cutoff: True" in result.stderr
+
+
+def test_expire_cutoffs_preserves_disabled_package(cutoff_project: Path) -> None:
+ cutoff_project.write_text(cutoff_project.read_text().replace('"2999-01-01T00:00:00Z"', "false"))
+ source = Path(__file__).resolve().parents[2] / "tools"
+ result = subprocess.run(
+ [sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+ assert tomllib.loads(cutoff_project.read_text())["tool"]["nab"]["packages"]["new"]["uploaded-prior-to"] is False
+
+
+def _check_recent_cutoffs(project: str) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
[
sys.executable,
"-c",
@@ -178,6 +283,3 @@ def test_recent_cutoff_requires_review(package: str, timestamp: str, accepted: b
text=True,
check=False,
)
- assert (result.returncode == 0) == accepted
- if not accepted:
- assert "unreviewed recent Nab cutoff" in result.stderr
diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py
index dcff75e..6c58d70 100644
--- a/tests/upgrade_release_tools/test_upgrade.py
+++ b/tests/upgrade_release_tools/test_upgrade.py
@@ -57,7 +57,7 @@ def test_upgrade_pull_request_uses_repository_app() -> None:
@pytest.fixture
-def lock_repository(tmp_path: Path) -> Path:
+def lock_repository(tmp_path: Path, test_package_index: str) -> Path:
repository = tmp_path / "repository"
repository.mkdir()
(repository / "pyproject.toml").write_text(
@@ -66,7 +66,8 @@ def lock_repository(tmp_path: Path) -> 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',
+ 'platforms = ["linux_x86_64", "linux_aarch64", "macos_arm64", "windows_amd64"]\n'
+ f'[[tool.nab.indexes]]\nname = "fixture"\nurl = "{test_package_index}"\nserialization = "html"\n',
)
run(repository, "git", "init", "-b", "main")
run(repository, "git", "config", "user.name", "Release test")
@@ -96,7 +97,10 @@ def test_upgrade_locks_only_selected_groups(lock_repository: Path, tmp_path: Pat
def test_upgrade_check_mode_preserves_lock_bytes(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None:
project = lock_repository / "pyproject.toml"
- project.write_text(project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]'))
+ project.write_text(
+ project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]')
+ + '[tool.nab.packages.old]\nuploaded-prior-to = "2020-01-01T00:00:00Z"\n'
+ )
installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="")
assert installed.returncode == 0, installed.stderr
refreshed = execute(
@@ -108,30 +112,42 @@ def test_upgrade_check_mode_preserves_lock_bytes(lock_repository: Path, tmp_path
)
assert refreshed.returncode == 0, refreshed.stderr
expected = (lock_repository / "pylock.toml").read_bytes()
- checked = execute(
+ project_bytes = project.read_bytes()
+ run(lock_repository, "git", "add", ".")
+ run(lock_repository, "git", "commit", "-m", "Record lock")
+ (lock_repository / "pylock.toml").write_bytes(expected.replace(b'"release-check"', b'"release-news"', 1))
+ stale = (lock_repository / "pylock.toml").read_bytes()
+ checked = action(
lock_repository,
- upgrade_scripts / "refresh-release-lock-3.sh",
tmp_path,
+ upgrade_scripts,
+ "refresh-release-lock",
+ version="",
LOCK_GROUPS="release-check",
MODE="check",
)
- assert checked.returncode == 0, checked.stderr
- assert (lock_repository / "pylock.toml").read_bytes() == expected
+ assert checked.returncode != 0
+ assert "regenerated Nab lock differs" in checked.stderr
+ assert (lock_repository / "pylock.toml").read_bytes() != stale
+ assert project.read_bytes() == project_bytes
@pytest.mark.parametrize(
- ("groups", "selection"),
+ ("groups", "selection", "source"),
[
- pytest.param("", "--build-requirements", id="build-only"),
- pytest.param("release-check", "--groups", id="selected-group"),
+ pytest.param("", "--build-requirements", "build-or-groups", id="build-only"),
+ pytest.param("release-check", "--groups", "build-or-groups", id="selected-group"),
+ pytest.param("", "", "project", id="project-runtime"),
],
)
def test_upgrade_preserves_secondary_lock_matrix(
- lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, groups: str, selection: str
+ lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, groups: str, selection: str, source: str
) -> None:
project = lock_repository / "pyproject.toml"
project.write_text(
- project.read_text().replace("release = []", 'release-check = ["prek>=0.2"]')
+ project.read_text()
+ .replace('version = "0.1.0"', 'version = "0.1.0"\ndependencies = ["packaging>=26"]')
+ .replace("release = []", 'release-check = ["prek>=0.2"]')
+ '[build-system]\nrequires = ["setuptools>=80"]\nbuild-backend = "setuptools.build_meta"\n',
)
installed = action(lock_repository, tmp_path, upgrade_scripts, "install-nab", version="")
@@ -154,8 +170,9 @@ def test_upgrade_preserves_secondary_lock_matrix(
"nab",
"lock",
"pyproject.toml",
- selection,
]
+ if selection:
+ command.append(selection)
if groups:
command.append(groups)
command.extend(
@@ -179,6 +196,7 @@ def test_upgrade_preserves_secondary_lock_matrix(
"MODE": "check",
"ADDITIONAL_LOCK": additional.name,
"ADDITIONAL_GROUPS": groups,
+ "ADDITIONAL_SELECTION": source,
"MATRIX_PYTHON": ">=3.15,<3.16",
"MATRIX_PLATFORMS": "linux_x86_64 macos_arm64",
"MATRIX_IMPLEMENTATIONS": "cpython",
@@ -186,6 +204,10 @@ def test_upgrade_preserves_secondary_lock_matrix(
checked = execute(lock_repository, upgrade_scripts / "refresh-release-lock-3.sh", tmp_path, **environment)
assert checked.returncode == 0, checked.stderr
assert additional.read_bytes() == expected
+ if source == "project":
+ names = {package["name"] for package in tomllib.loads(expected.decode())["packages"]}
+ assert "packaging" in names
+ assert "setuptools" not in names
def test_upgrade_rejects_secondary_matrix_change(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None:
@@ -471,6 +493,7 @@ def execute(repository: Path, script: Path, tmp_path: Path, **environment: str)
"GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "refresh-release-lock"),
"ADDITIONAL_LOCK": "",
"ADDITIONAL_GROUPS": "",
+ "ADDITIONAL_SELECTION": "build-or-groups",
"MATRIX_PYTHON": "",
"MATRIX_PLATFORMS": "",
"MATRIX_IMPLEMENTATIONS": "",
diff --git a/tests/validate_python_distribution/test_distribution.py b/tests/validate_python_distribution/test_distribution.py
index 48edea9..c124fc8 100644
--- a/tests/validate_python_distribution/test_distribution.py
+++ b/tests/validate_python_distribution/test_distribution.py
@@ -9,11 +9,15 @@
@pytest.fixture
-def distribution(source_project: Path, nab_executable: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]:
+def distribution(
+ source_project: Path, nab_executable: Path, test_package_index: str, monkeypatch: pytest.MonkeyPatch
+) -> tuple[Path, Path]:
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
project = source_project / "pyproject.toml"
project.write_text(
- project.read_text() + '\n[dependency-groups]\npkg-meta = ["check-wheel-contents>=0.6.3", "twine>=7"]\n',
+ project.read_text()
+ + '\n[dependency-groups]\npkg-meta = ["check-wheel-contents>=0.6.3", "twine>=7"]\n'
+ + f'[[tool.nab.indexes]]\nname = "fixture"\nurl = "{test_package_index}"\nserialization = "html"\n',
)
run(
source_project,
diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py
index f6341f4..9a73156 100644
--- a/tools/check_lock_groups.py
+++ b/tools/check_lock_groups.py
@@ -7,7 +7,7 @@
from pathlib import Path
from packaging.pylock import Pylock, PylockValidationError
-from packaging.version import Version
+from packaging.version import InvalidVersion, Version
def main() -> None:
@@ -18,29 +18,35 @@ def main() -> None:
parser.add_argument("--local-tool-source", action="store_true")
args = parser.parse_args()
try:
+ groups = set(args.groups)
with args.lock.open("rb") as stream:
lock = Pylock.from_dict(tomllib.load(stream))
- if missing := set(args.groups) - set(lock.dependency_groups or ()):
+ if missing := groups - set(lock.dependency_groups or ()):
parser.error(f"missing lock dependency groups: {', '.join(sorted(missing))}")
if args.shared_lock:
with args.shared_lock.open("rb") as stream:
shared = Pylock.from_dict(tomllib.load(stream))
- required = next(package.version for package in shared.packages if package.name == "release-creator")
- installed = next((package.version for package in lock.packages if package.name == "release-creator"), None)
- needs_release_creator = bool(
- set(args.groups)
- & {"release-news", "release-notes", "release-prepare", "release-validate", "release-hooks"}
- )
- if (
- needs_release_creator
- and not args.local_tool_source
- and (installed is None or Version(str(installed)) < Version(str(required)))
- ):
- parser.error(f"relock: release-creator {required} required")
- except (OSError, PylockValidationError) as error:
+ required = _selected_release_creator(shared, groups)
+ if required is not None and not args.local_tool_source:
+ 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) as error:
parser.error(str(error))
+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,
+ )
+
+
if __name__ == "__main__":
main()
diff --git a/tools/check_recent_cutoffs.py b/tools/check_recent_cutoffs.py
index a1b6cec..b9b816d 100644
--- a/tools/check_recent_cutoffs.py
+++ b/tools/check_recent_cutoffs.py
@@ -14,8 +14,10 @@ def main() -> None:
parser.add_argument("project", type=Path)
args = parser.parse_args()
try:
+ if (args.project.parent / "nab.toml").exists():
+ parser.error("project nab.toml can override the reviewed cutoff policy")
validate_recent_cutoffs(args.project.read_text(), datetime.now(UTC) - timedelta(days=7))
- except (OSError, KeyError, ValueError) as error:
+ except (OSError, KeyError, TypeError, ValueError) as error:
parser.error(str(error))
diff --git a/tools/expire_cutoffs.py b/tools/expire_cutoffs.py
index 7b26a44..3ed9857 100644
--- a/tools/expire_cutoffs.py
+++ b/tools/expire_cutoffs.py
@@ -19,7 +19,7 @@ def main() -> None:
updated = remove_expired_cutoffs(source, datetime.now(UTC) - timedelta(days=7))
if source != updated:
args.project.write_text(updated)
- except (OSError, KeyError, ValueError, ParseError) as error:
+ except (OSError, KeyError, TypeError, ValueError, ParseError) as error:
parser.error(str(error))
diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py
index d65df7f..a4a3907 100644
--- a/tools/nab_cutoffs.py
+++ b/tools/nab_cutoffs.py
@@ -1,7 +1,19 @@
from __future__ import annotations
+import re
import tomllib
from datetime import UTC, datetime
+from typing import Final
+
+_DURATION: Final = re.compile(
+ r"""
+ ^P # ISO duration prefix
+ (?P[0-9]+) # Nab supports whole days
+ D$ # Day unit and end
+ """,
+ re.VERBOSE,
+)
+_MINIMUM_DAYS: Final = 7
_REVIEWED_EXCEPTIONS = {
**dict.fromkeys(
@@ -9,11 +21,17 @@
datetime(2026, 10, 19, tzinfo=UTC),
),
"release-creator": datetime(2026, 10, 23, 2, 44, 40, tzinfo=UTC),
+ "uv-build": datetime(2026, 10, 21, tzinfo=UTC), # toml-fmt requires 0.13.4 until the cutoff matures.
}
def validate_recent_cutoffs(project: str, threshold: datetime) -> None:
nab = tomllib.loads(project)["tool"]["nab"]
+ global_cutoff = nab.get("uploaded-prior-to")
+ if not isinstance(global_cutoff, str) or (match := _DURATION.fullmatch(global_cutoff)) is None:
+ raise ValueError("global Nab cutoff must be a duration of at least P7D")
+ if int(match["days"]) < _MINIMUM_DAYS:
+ raise ValueError("global Nab cutoff must be a duration of at least P7D")
candidates = [
(name, settings["uploaded-prior-to"])
for name, settings in nab.get("packages", {}).items()
@@ -25,9 +43,18 @@ def validate_recent_cutoffs(project: str, threshold: datetime) -> None:
if "uploaded-prior-to" in rule
for name in rule["match"]
)
+ 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")
for name, value in candidates:
- if isinstance(value, str) and value.startswith("P"):
+ if value is False:
+ raise ValueError(f"Nab cutoff for {name} weakens the seven-day policy: {value}")
+ if isinstance(value, str) and (match := _DURATION.fullmatch(value)) is not None:
+ if int(match["days"]) < _MINIMUM_DAYS:
+ raise ValueError(f"Nab cutoff for {name} weakens the seven-day policy: {value}")
continue
+ if isinstance(value, bool):
+ raise TypeError(f"invalid Nab cutoff for {name}: {value}")
timestamp = datetime.fromisoformat(value) if isinstance(value, str) else value
if timestamp.tzinfo is None:
raise ValueError(f"Nab package cutoff has no timezone: {value}")
@@ -66,7 +93,7 @@ def remove_cutoffs(nab: dict, names: set[str], positions: set[int]) -> None:
def expired_cutoffs(project: str, threshold: datetime) -> tuple[set[str], set[int]]:
nab = tomllib.loads(project)["tool"]["nab"]
packages = {
- name
+ str(name)
for name, settings in nab.get("packages", {}).items()
if (value := settings.get("uploaded-prior-to")) is not None and _expired(value, threshold)
}
@@ -78,9 +105,11 @@ def expired_cutoffs(project: str, threshold: datetime) -> tuple[set[str], set[in
return packages, rules
-def _expired(value: str | datetime, threshold: datetime) -> bool:
- if isinstance(value, str) and value.startswith("P"):
+def _expired(value: str | datetime | bool, threshold: datetime) -> bool:
+ if value is False or isinstance(value, str) and _DURATION.fullmatch(value) is not None:
return False
+ if isinstance(value, bool):
+ raise TypeError(f"invalid Nab cutoff: {value}")
timestamp = datetime.fromisoformat(value) if isinstance(value, str) else value
if timestamp.tzinfo is None:
raise ValueError(f"Nab package cutoff has no timezone: {value}")
diff --git a/tools/verify_cutoff_patch.py b/tools/verify_cutoff_patch.py
index 57c57d5..1b6c525 100644
--- a/tools/verify_cutoff_patch.py
+++ b/tools/verify_cutoff_patch.py
@@ -36,7 +36,7 @@ def main() -> None:
remove_cutoffs(before, removed_names, removed_positions)
if original != actual:
parser.error("pyproject.toml has changes beyond expired Nab cutoffs")
- except (OSError, KeyError, ValueError, subprocess.CalledProcessError, tomllib.TOMLDecodeError) as error:
+ except (OSError, KeyError, TypeError, ValueError, subprocess.CalledProcessError, tomllib.TOMLDecodeError) as error:
parser.error(str(error))